Files
mokosh/.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md

32 KiB

Phase 1: Stabilize Video Pipeline — Pattern Map

Mapped: 2026-05-15 Files analyzed: 12 (4 NEW, 4 MODIFIED, 4 DELETED) Analogs found: 11 / 12

NOTE on line numbers cited in CONTEXT.md: The CONTEXT.md narrative references some "decimal-style" approximate line ranges that did not match the live file when I read it. The numbers used below in Pattern Assignments are the verified line numbers from the files as they exist on disk on 2026-05-15.

File Classification

New / Modified / Deleted File Role Data Flow Closest Analog Match Quality
src/offscreen/recorder.ts (NEW) offscreen recorder module streaming + port + event-driven offscreen/index.ts (dead) + inline string at vite.config.ts:35-213 + ring-buffer structure at src/background/index.ts:26-75 role-match (compose 3 analogs)
src/offscreen/index.html (NEW) bundle entry HTML static asset offscreen/index.html (current) + src/popup/index.html (script tag) exact
src/background/index.ts (MODIFIED, heavy shrink) service worker coordinator request-response + port-host + event-driven itself (current file) — refactor in place exact
vite.config.ts (MODIFIED, heavy reduction) build config config crxjs discussion #919 pattern (cited in RESEARCH.md Example B) role-match
manifest.json (MODIFIED, permission swap) manifest config itself exact
src/shared/types.ts (MODIFIED, light) shared types type-only itself (current file) — add/verify message types exact
tests/offscreen/ring-buffer.test.ts (NEW) unit test request-response none in codebase — Vitest default; structural cousin: src/background/index.ts:47-75 cleanupVideoBuffer (subject under test) role-only
tests/offscreen/codec-check.test.ts (NEW) unit test request-response none role-only
tests/offscreen/handshake.test.ts (NEW) unit test event-driven none role-only
tests/offscreen/port.test.ts (NEW) unit test port/event-driven none role-only
vitest.config.ts (NEW) test runner config config vite.config.ts (defineConfig + plugin shape) + tsconfig.json (path/aliases) role-only
offscreen/index.ts (DELETED)
offscreen/index.html (DELETED)
vite.config.ts:11-217 inline plugin (DELETED)
src/background/index.ts:156-165, 128, 457-473, 482-520 (DELETED in-place)

Pattern Assignments

src/offscreen/recorder.ts (NEW — offscreen recorder; streaming + port + event-driven)

Analogs (composed):

  • offscreen/index.ts (lines 1-60) — the dead-but-correctly-shaped skeleton: module-level let mediaRecorder, chrome.runtime.onMessage.addListener with a switch (message.type), MediaRecorder.ondataavailable push pattern. Phase 1 KEEPS the shape and REWRITES the body.
  • Inline JS string at vite.config.ts:35-213 — the live offscreen at runtime. This is the source-of-truth for the current behavior but is the explicit DELETE target. Use it ONLY as a reference for what NOT to copy (the codec fallback chain at lines 151-172, the IndexedDB plumbing at lines 43-107, the let mediaRecorder shadow at line 158).
  • src/background/index.ts:25-75 — ring-buffer structural pattern (currently SW-side). MOVE this to offscreen.
  • src/shared/logger.ts:1-25Logger class shape. Phase 1 adds an OffscreenLogger mirroring this ([OS:...] prefix) OR reuses Logger with prefix Offscreen:Main.

Skeleton pattern (KEEP shape; rewrite body) — from offscreen/index.ts:1-60:

// offscreen/index.ts:1-2 — module-level state declaration (KEEP shape; rename
// `mediaRecorder` → `videoRecorder` per RESEARCH.md anti-pattern fix)
let mediaRecorder: MediaRecorder | null = null;
let videoChunks: Blob[] = [];

// offscreen/index.ts:45-59 — message-listener-with-switch shape (KEEP)
chrome.runtime.onMessage.addListener((message) => {
  switch (message.type) {
    case 'START_RECORDING':
      startRecording(message.streamId);
      break;
    case 'STOP_RECORDING':
      stopRecording();
      break;
    case 'GET_CHUNKS':
      chrome.runtime.sendMessage({
        type: 'CHUNKS_RESPONSE',
        chunks: getChunks()
      });
      break;
  }
});

dataavailable pattern (KEEP behavior; chunk Blob is now pushed to ring buffer with timestamp+isHeader, NOT forwarded via chrome.runtime.sendMessage) — from offscreen/index.ts:18-27:

mediaRecorder.ondataavailable = (event) => {
  if (event.data && event.data.size > 0) {
    videoChunks.push(event.data);
    chrome.runtime.sendMessage({  // ← REMOVE: this is the broken-Blob-over-sendMessage path
      type: 'VIDEO_CHUNK',
      data: event.data,
      timestamp: Date.now()
    });
  }
};

Ring-buffer pattern (MOVE from SW to offscreen, verbatim) — from src/background/index.ts:47-75:

function cleanupVideoBuffer() {
  const now = Date.now();
  const beforeCount = videoBuffer.length;

  logger.log(`Cleaning up buffer, current size: ${beforeCount}`);

  // Всегда сохраняем первый чанк (WebM заголовок, помечен как isFirst)
  // Остальные чанки фильтруем по времени (старше 30 секунд удаляем)
  videoBuffer = videoBuffer.filter(chunk => {
    // Всегда оставляем первый чанк (заголовок)
    if (chunk.isFirst) {
      return true;
    }
    // Остальные - только если моложе 30 секунд
    const age = now - chunk.timestamp;
    const keep = age < VIDEO_BUFFER_DURATION_MS;
    if (!keep) {
      logger.log(`Removing chunk, age: ${age}ms, limit: ${VIDEO_BUFFER_DURATION_MS}ms`);
    }
    return keep;
  });

  const removed = beforeCount - videoBuffer.length;
  if (removed > 0) {
    logger.log(`Removed ${removed} old video chunks, buffer: ${videoBuffer.length}`);
  } else {
    logger.log(`No chunks removed, buffer: ${videoBuffer.length}`);
  }
}

Header-pin add pattern (MOVE from SW to offscreen) — from src/background/index.ts:26-45:

function addVideoChunkFromBlob(blob: Blob) {
  logger.log(`Processing video chunk from Blob, size: ${blob.size} bytes, type: ${blob.type}`);

  const chunk: VideoChunk = {
    data: blob,
    timestamp: Date.now(),
    isFirst: !firstChunkSaved   // Первый чанк помечаем как isFirst
  };

  if (!firstChunkSaved) {
    firstChunkSaved = true;
    logger.log(`This is the FIRST video chunk (WebM header), size: ${blob.size} bytes`);
  }

  videoBuffer.push(chunk);
  cleanupVideoBuffer();
}

Constants pattern — from src/background/index.ts:12-13:

// Константы
const VIDEO_BUFFER_DURATION_MS = 30 * 1000; // 30 секунд

Add for the new module:

const TIMESLICE_MS = 2000;             // CON-video-codec / SPEC §4.1 (was 200ms)
const VIDEO_MIME = 'video/webm;codecs=vp9';
const VIDEO_BITRATE = 400_000;         // CON-video-codec
const PORT_NAME = 'video-keepalive';
const PORT_PING_MS = 25_000;           // < 30s SW idle threshold
const PORT_RECONNECT_MS = 290_000;     // pre-empt the ~5min port cap

getDisplayMedia call pattern — from RESEARCH.md Example A (canonical):

async function startRecording(): Promise<void> {
  const stream = await navigator.mediaDevices.getDisplayMedia({
    video: true,
    audio: false,        // SPEC §9 — Phase 2/CAP-01 territory
  });
  // Codec strict-mode — RESEARCH.md Example E
  if (!MediaRecorder.isTypeSupported(VIDEO_MIME)) {
    const err = `vp9 unsupported. UA=${navigator.userAgent}`;
    chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: err });
    throw new Error(err);
  }
  videoRecorder = new MediaRecorder(stream, {
    mimeType: VIDEO_MIME,
    videoBitsPerSecond: VIDEO_BITRATE,
  });
  videoRecorder.ondataavailable = onDataAvailable;
  videoRecorder.start(TIMESLICE_MS);
  // RESEARCH.md Example F — track.ended for "Stop sharing" recovery
  stream.getTracks().forEach((track) => {
    track.addEventListener('ended', onUserStoppedSharing, { once: true });
  });
}

OFFSCREEN_READY handshake (Pattern 4) — from RESEARCH.md (matches src/shared/types.ts:18 declared but unused message type):

// at the bottom of recorder.ts, after the onMessage listener is registered:
chrome.runtime.onMessage.addListener((msg) => { /* ... */ });
chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' });

Port keepalive pattern (Pattern 5) — from RESEARCH.md lines 600-628:

const port = chrome.runtime.connect({ name: PORT_NAME });
setInterval(() => port.postMessage({ type: 'PING' }), PORT_PING_MS);
port.onMessage.addListener((msg) => {
  if (msg.type === 'REQUEST_BUFFER') {
    port.postMessage({ type: 'BUFFER', chunks: videoBuffer });
  }
});
port.onDisconnect.addListener(() => {
  // Pitfall 4 — 5-minute port cap, reconnect
  reconnectPort();
});

Russian-comment style (KEEP) — observed throughout src/background/index.ts:12, 25, 47, 52-54, 156:

// Константы
// Кольцевой буфер видео
// Очистка старых событий
// Keepalive для предотвращения выгрузки Service Worker

New code in src/offscreen/recorder.ts SHOULD include Russian section headers (matches project provenance from CONTEXT.md "Established patterns").

Imports pattern (KEEP project shape) — from src/background/index.ts:1-8:

import { Logger } from '../shared/logger';
import type {
  Message,
  VideoChunk,
  SessionMetadata,
  VideoBufferResponse
} from '../shared/types';
import JSZip from 'jszip';

For the new offscreen recorder:

import { Logger } from '../shared/logger';
import type { Message, VideoChunk } from '../shared/types';

src/offscreen/index.html (NEW — bundle entry HTML, static asset)

Analog: offscreen/index.html (current) — DELETE and recreate.

Current patternoffscreen/index.html:1-10:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Offscreen Page</title>
</head>
<body>
  <script src="./index.ts" type="module"></script>
</body>
</html>

Popup-side analog (same shape, references TS via crxjs)src/popup/index.html:1-21:

<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>AI Call Recorder</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  ...
  <script type="module" src="index.ts"></script>
</body>
</html>

Pattern to copy — RESEARCH.md Example A (crxjs discussion #919):

<!doctype html>
<html>
<head><meta charset="UTF-8"><title>Mokosh Recorder</title></head>
<body>
  <script type="module" src="./recorder.ts"></script>
</body>
</html>

src/background/index.ts (MODIFIED — service worker coordinator; shrinks substantially)

Analog: itself. Refactor in place.

DELETE blocks (verified line numbers as of 2026-05-15):

Block Current Lines Reason
addVideoChunkFromBlob 26-45 MOVE to offscreen (D-16)
cleanupVideoBuffer 47-75 MOVE to offscreen (D-16)
getMediaStreamId call inside startVideoCapture 126-131 Replaced by getDisplayMedia in offscreen (D-01)
setupKeepalive function 156-165 Replaced by port keepalive (D-18)
VIDEO_CHUNK case in onMessage 457-466 Buffer no longer travels via sendMessage (D-19)
VIDEO_CHUNK_SAVED case in onMessage 468-473 IndexedDB path is dead (D-19)
loadChunkFromIndexedDB function 482-505 IndexedDB path is dead (D-19)
openIndexedDB function 507-520 IndexedDB path is dead (D-19)
setupKeepalive() call inside initialize 525 Paired with deletion of setupKeepalive

Auth/Guard pattern (sender.id validation — RESEARCH.md security recommendation) — currently MISSING: The SW's onMessage listener at src/background/index.ts:427-479 does NOT validate sender.id. Phase 1 SHOULD add if (_sender.id !== chrome.runtime.id) return false; at the top of the new port and onMessage handlers (low-effort hardening; per RESEARCH.md security domain table). Rename _sendersender since it is now used.

onMessage handler pattern (KEEP shape, modify cases) — from src/background/index.ts:427-479:

chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) => {
  logger.log('Received message:', message.type, message);

  switch (message.type) {
    case 'REQUEST_PERMISSIONS':
      checkPermissions().then(async (hasPermissions) => {
        if (hasPermissions) {
          if (!isRecording) {
            await startVideoCapture();
          }
          sendResponse({ granted: true });
        } else {
          requestPermissions().then(granted => {
            sendResponse({ granted });
          });
        }
      });
      return true;     // ← async response convention

    case 'GET_VIDEO_BUFFER':
      sendResponse(getVideoBuffer());
      return false;    // ← sync response convention

    case 'SAVE_ARCHIVE':
      saveArchive().then(result => {
        sendResponse(result);
      });
      return true;

    default:
      logger.warn('Unknown message type:', message.type);
      return false;
  }
});

Phase 1 modifications:

  • REQUEST_PERMISSIONS: replace startVideoCapture body with new ensureOffscreenAndStart() (offscreen + DISPLAY_MEDIA reason).
  • GET_VIDEO_BUFFER: replace getVideoBuffer() with an over-port request to offscreen (return true, resolve via port message handler).
  • SAVE_ARCHIVE: same — getVideoBuffer() inside saveArchive() (line 332) becomes a port-request to offscreen.
  • Add a new case 'OFFSCREEN_READY' to resolve the handshake Promise (Pattern 4).

ensureOffscreen pattern (REPLACE reason) — from src/background/index.ts:78-104:

async function ensureOffscreen() {
  if (offscreenCreated) {
    logger.log('Offscreen already created');
    return;
  }

  try {
    const url = chrome.runtime.getURL('offscreen/index.html');      // ← path may change to 'src/offscreen/index.html'; verify after first build
    logger.log('Creating offscreen document at:', url);

    await chrome.offscreen.createDocument({
      url: url,
      reasons: ['USER_MEDIA'] as any,         // ← REPLACE: ['DISPLAY_MEDIA']; drop the `as any` once @types/chrome is bumped
      justification: 'Need to record video from tab for error reporting'  // ← OPTIONAL: update copy to match RESEARCH.md Example C
    });
    offscreenCreated = true;
    logger.log('Offscreen document created successfully');
  } catch (error) {
    if ((error as any).message?.includes('already exists')) {
      offscreenCreated = true;
      logger.log('Offscreen document already exists');
    } else {
      logger.error('Failed to create offscreen document:', error);
      throw error;
    }
  }
}

Anti-pattern note: the as any on line 90 maps to audit P1 #13 and the CLAUDE.md "no @ts-ignore / no as any" rule. The fix is chrome.offscreen.Reason.DISPLAY_MEDIA after bumping @types/chrome from ^0.0.268 to ^0.1.42 (RESEARCH.md Standard Stack). The bump is OPTIONAL in this phase but recommended.

ADD: onConnect handler for video-keepalive port (NEW) — RESEARCH.md Pattern 5, lines 609-617:

let videoPort: chrome.runtime.Port | null = null;
chrome.runtime.onConnect.addListener((p) => {
  if (p.name !== 'video-keepalive') return;
  videoPort = p;
  p.onMessage.addListener((msg) => {
    if (msg.type === 'BUFFER') { /* resolve pending export */ }
  });
  p.onDisconnect.addListener(() => { videoPort = null; });
});

ADD: one-shot deletion of orphaned IndexedDB on install — RESEARCH.md Runtime State Inventory: The chrome.runtime.onInstalled.addListener at src/background/index.ts:530-533 is the natural site to call indexedDB.deleteDatabase('VideoRecorderDB') to clean up old browser profiles. Add inside that listener.

saveArchive pattern (MODIFY to fetch buffer via port)src/background/index.ts:314-387:

async function saveArchive() {
  // ... unchanged up to line 332 ...
  const videoBuffer = getVideoBuffer();    // ← REPLACE: await getVideoBufferFromOffscreen()
  // ... unchanged thereafter ...
}

vite.config.ts (MODIFIED — heavy reduction)

Analog: crxjs discussion #919 working pattern (cited in RESEARCH.md Example B).

DELETE the entire inline plugin blockvite.config.ts:13-216 (the object literal starting { name: 'copy-offscreen', generateBundle() { ... } }).

Final shape (REPLACE existing file body) — RESEARCH.md Example B:

import { defineConfig } from 'vite';
import { crx } from '@crxjs/vite-plugin';
import manifest from './manifest.json';

export default defineConfig({
  plugins: [
    crx({ manifest, contentScripts: { injectCss: false } }),
  ],
  build: {
    rollupOptions: {
      input: {
        offscreen: 'src/offscreen/index.html',
      },
    },
  },
});

Current crx invocation pattern (KEEP)vite.config.ts:5-12:

crx({
  manifest,
  contentScripts: {
    injectCss: false,
  },
}),

This invocation form is correct and is retained verbatim; only the second plugin entry (the inline copy-offscreen) is deleted, and rollupOptions.input for the offscreen HTML entry is added per RESEARCH.md Pitfall 5.

Pitfall 5 note (per RESEARCH.md): Inspect dist/ after the first npm run build to verify the emitted path matches what src/background/index.ts:85 passes to createDocument. If crxjs strips the src/ prefix, change the SW's chrome.runtime.getURL arg accordingly.


manifest.json (MODIFIED — permission swap)

Analog: itself.

Current permissions blockmanifest.json:6-14:

"permissions": [
  "tabCapture",
  "activeTab",
  "downloads",
  "scripting",
  "storage",
  "alarms",
  "offscreen"
],

Patch per D-A6:

  • Replace "tabCapture" with "desktopCapture".
  • KEEP "activeTab" (screenshot path: chrome.tabs.captureVisibleTab, see src/background/index.ts:190).
  • Drop "alarms" (no longer used after setupKeepalive deletion). [OPTIONAL but consistent with D-18.]
  • KEEP "downloads" (used at src/background/index.ts:305).
  • KEEP "storage" (used by rrweb? — not used in current code; can drop, but out of scope for this phase).
  • KEEP "scripting" (content script).
  • KEEP "offscreen" (required to call chrome.offscreen.createDocument).

Final shape:

"permissions": [
  "desktopCapture",
  "activeTab",
  "downloads",
  "scripting",
  "storage",
  "offscreen"
],

src/shared/types.ts (MODIFIED — light; verify message types)

Analog: itself.

Status checksrc/shared/types.ts:3-18:

export type MessageType =
  | 'REQUEST_PERMISSIONS'
  | 'PERMISSIONS_GRANTED'
  | 'PERMISSIONS_DENIED'
  | 'GET_VIDEO_BUFFER'
  | 'VIDEO_BUFFER_RESPONSE'
  | 'GET_RRWEB_EVENTS'
  | 'RRWEB_EVENTS_RESPONSE'
  | 'SAVE_ARCHIVE'
  | 'ARCHIVE_SAVED'
  | 'START_RECORDING'
  | 'STOP_RECORDING'
  | 'RECORDING_ERROR'
  | 'VIDEO_CHUNK'
  | 'VIDEO_CHUNK_SAVED'
  | 'OFFSCREEN_READY';
  • OFFSCREEN_READY is present (line 18). ✓
  • RECORDING_ERROR is present (line 15). ✓
  • VIDEO_CHUNK and VIDEO_CHUNK_SAVED should be REMOVED (lines 16-17) since their handlers are deleted in Phase 1.

Phase 1 additions:

  • Add port-message string types if planner chooses to type the port traffic:
    // Port message types (offscreen ↔ SW over 'video-keepalive' port)
    export type PortMessageType =
      | 'PING'
      | 'REQUEST_BUFFER'
      | 'BUFFER';
    export interface PortMessage<T = unknown> {
      type: PortMessageType;
      chunks?: VideoChunk[];
      payload?: T;
    }
    

VideoChunk interface (KEEP as-is)src/shared/types.ts:27-31:

export interface VideoChunk {
  data: Blob;
  timestamp: number;
  isFirst?: boolean;
}

This shape is exactly what the offscreen ring buffer needs. Reuse unchanged.


tests/offscreen/ring-buffer.test.ts (NEW — unit test)

Analog: none in codebase (no tests directory exists yet).

Subject under test — the relocated addVideoChunkFromBlob + cleanupVideoBuffer from src/background/index.ts:26-75. Tests assert:

  1. First chunk is pinned with isFirst: true.
  2. Subsequent chunks have isFirst: false.
  3. After 30+ seconds, non-header chunks are evicted but the header survives.
  4. Buffer length stays ≤ N for N chunks added within 30 s.

Skeleton (standard Vitest) — pattern inferred from RESEARCH.md Validation Architecture:

import { describe, it, expect, beforeEach } from 'vitest';
import { addChunk, trimAged, getBuffer, resetBuffer } from '../../src/offscreen/recorder';
// NB: planner must export these pure functions from recorder.ts to make them testable.

describe('ring buffer', () => {
  beforeEach(() => resetBuffer());

  it('first chunk is header', () => {
    addChunk({ size: 1024 } as Blob, 1_000);
    expect(getBuffer()[0].isFirst).toBe(true);
  });

  it('trim 30s — keeps header, evicts aged tail', () => {
    addChunk({ size: 1024 } as Blob, 0);          // header at t=0
    addChunk({ size: 512 } as Blob, 10_000);      // t=10s
    addChunk({ size: 512 } as Blob, 35_000);      // t=35s — header now 35s old, tail 25s
    trimAged(/* now= */ 40_000);                  // header age 40s; first chunk 30s; last chunk 5s
    const buf = getBuffer();
    expect(buf[0].isFirst).toBe(true);            // header survives unconditionally
    expect(buf.length).toBeGreaterThanOrEqual(2); // header + at least the t=35s chunk
  });
});

tests/offscreen/codec-check.test.ts (NEW — unit test)

Analog: none. Subject under test = the codec strict-mode error path (RESEARCH.md Pattern 6 / Example E).

Skeleton:

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

describe('codec strict mode', () => {
  let originalIsTypeSupported: typeof MediaRecorder.isTypeSupported;
  beforeEach(() => {
    originalIsTypeSupported = (globalThis as any).MediaRecorder?.isTypeSupported;
    (globalThis as any).MediaRecorder = {
      isTypeSupported: vi.fn().mockReturnValue(false),
    };
    (globalThis as any).chrome = { runtime: { sendMessage: vi.fn() } };
  });
  afterEach(() => {
    if (originalIsTypeSupported) {
      (globalThis as any).MediaRecorder = { isTypeSupported: originalIsTypeSupported };
    }
  });

  it('throws on unsupported vp9 and emits RECORDING_ERROR', async () => {
    const { assertCodecSupported } = await import('../../src/offscreen/recorder');
    expect(() => assertCodecSupported()).toThrow(/vp9 unsupported/);
    expect((globalThis as any).chrome.runtime.sendMessage).toHaveBeenCalledWith(
      expect.objectContaining({ type: 'RECORDING_ERROR' })
    );
  });
});

tests/offscreen/handshake.test.ts (NEW — unit test)

Analog: none. Subject under test = OFFSCREEN_READY Promise resolution (RESEARCH.md Pattern 4).

Skeleton:

import { describe, it, expect, vi } from 'vitest';

describe('OFFSCREEN_READY handshake', () => {
  it('sends OFFSCREEN_READY after listener registration', async () => {
    const calls: any[] = [];
    (globalThis as any).chrome = {
      runtime: {
        sendMessage: (m: unknown) => { calls.push(m); },
        onMessage: { addListener: vi.fn() },
        connect: () => ({
          name: 'video-keepalive',
          postMessage: vi.fn(),
          onMessage: { addListener: vi.fn() },
          onDisconnect: { addListener: vi.fn() },
          disconnect: vi.fn(),
        }),
      },
    };
    await import('../../src/offscreen/recorder');
    expect(calls).toEqual(expect.arrayContaining([
      expect.objectContaining({ type: 'OFFSCREEN_READY' }),
    ]));
  });
});

tests/offscreen/port.test.ts (NEW — unit test)

Analog: none. Subject under test = port reconnect on disconnect (RESEARCH.md Pitfall 4).

Skeleton:

import { describe, it, expect, vi } from 'vitest';

describe('port reconnect', () => {
  it('reconnects when port disconnects', () => {
    const disconnectListeners: Array<() => void> = [];
    let connectCount = 0;
    (globalThis as any).chrome = {
      runtime: {
        sendMessage: vi.fn(),
        onMessage: { addListener: vi.fn() },
        connect: () => {
          connectCount++;
          return {
            name: 'video-keepalive',
            postMessage: vi.fn(),
            onMessage: { addListener: vi.fn() },
            onDisconnect: {
              addListener: (fn: () => void) => disconnectListeners.push(fn),
            },
            disconnect: vi.fn(),
          };
        },
      },
    };
    // After import, the module connects exactly once
    return import('../../src/offscreen/recorder').then(() => {
      expect(connectCount).toBe(1);
      // Fire the disconnect — module should reconnect
      disconnectListeners.forEach((fn) => fn());
      expect(connectCount).toBeGreaterThanOrEqual(2);
    });
  });
});

vitest.config.ts (NEW — test runner config)

Analogs: vite.config.ts (defineConfig shape) + tsconfig.json (compiler-options alignment).

Pattern to copy — RESEARCH.md Wave 0 Gaps + Vitest standard:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',     // node-only; Blob is shimmed by Vitest 3+ via undici
    include: ['tests/**/*.test.ts'],
    reporters: 'dot',
    typecheck: {
      enabled: false,        // tsc --noEmit runs separately in `npm run build`
    },
  },
});

package.json script ADD — companion to vitest.config.ts:

"scripts": {
  "dev": "vite",
  "build": "tsc && vite build",
  "preview": "vite preview",
  "test": "vitest run"          // ← ADD
}

Shared Patterns

Logging

Source: src/shared/logger.ts:1-25 (Logger class) and src/shared/logger.ts:28-51 (ContentLogger class).

Apply to: All new and modified source files.

// src/shared/logger.ts:1-25
export class Logger {
  private context: string;

  constructor(context: string) {
    this.context = context;
  }

  private logWithLevel(level: 'log' | 'warn' | 'error', ...args: any[]) {
    const timestamp = new Date().toISOString();
    console[level](`[SW:${this.context}] ${timestamp}`, ...args);
  }

  log(...args: any[]) { this.logWithLevel('log', ...args); }
  warn(...args: any[]) { this.logWithLevel('warn', ...args); }
  error(...args: any[]) { this.logWithLevel('error', ...args); }
}

For the new offscreen file: Either (a) reuse Logger with constructor argument 'Offscreen:Main', producing [SW:Offscreen:Main] (slightly misleading since the runtime is the offscreen, not the SW), or (b) ADD an OffscreenLogger class mirroring ContentLogger with prefix [OS:${context}]. Planner picks; CONTEXT.md "Reusable assets" section permits either.

Recommendation: add OffscreenLogger for consistency with the existing Logger / ContentLogger split. ~25 lines, matches the shape exactly:

// New OffscreenLogger class to add to src/shared/logger.ts
export class OffscreenLogger {
  private context: string;
  constructor(context: string) { this.context = context; }
  private logWithLevel(level: 'log' | 'warn' | 'error', ...args: any[]) {
    const timestamp = new Date().toISOString();
    console[level](`[OS:${this.context}] ${timestamp}`, ...args);
  }
  log(...args: any[]) { this.logWithLevel('log', ...args); }
  warn(...args: any[]) { this.logWithLevel('warn', ...args); }
  error(...args: any[]) { this.logWithLevel('error', ...args); }
}

Message handling (sender validation)

Source: RESEARCH.md Security Domain table (currently MISSING in codebase). Apply to: New chrome.runtime.onMessage and chrome.runtime.onConnect handlers in both src/background/index.ts and src/offscreen/recorder.ts.

Current code does NOT validate sender.id. Phase 1 SHOULD add:

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (sender.id !== chrome.runtime.id) return false;
  // ... existing dispatch ...
});

chrome.runtime.onConnect.addListener((port) => {
  if (port.sender?.id !== chrome.runtime.id) {
    port.disconnect();
    return;
  }
  // ... existing handling ...
});

Async response convention

Source: src/background/index.ts:427-479. Apply to: All onMessage handlers that perform await/then before sendResponse.

The convention in the existing code is consistent and correct:

  • return true; — when the handler will call sendResponse asynchronously (after a Promise resolves).
  • return false; — when the handler returns synchronously or does not respond.

Preserve this convention verbatim in the modified background and the new offscreen recorder.

Russian inline comments

Source: src/background/index.ts:12, 25, 47, 156 and src/content/index.ts:14-18, 20-23, 27. Apply to: New src/offscreen/recorder.ts.

Project convention: Russian comments are kept as section markers / explanations for business logic. Example:

// Константы
const TIMESLICE_MS = 2000;
// Кольцевой буфер видео
let videoBuffer: VideoChunk[] = [];
// Keepalive порт (заменяет chrome.alarms)
let keepalivePort: chrome.runtime.Port | null = null;

TypeScript identifiers stay English (per CLAUDE.md naming rules); only comments are Russian.

Module-level state pattern

Source: src/background/index.ts:15-22 and src/content/index.ts:20-24. Apply to: New src/offscreen/recorder.ts.

Single-instance modules use top-level let state declarations (not classes / singletons):

// src/background/index.ts:15-22
let videoBuffer: VideoChunk[] = [];
let firstChunkSaved = false;
let isRecording = false;
let offscreenCreated = false;
let lastScreenshotTime = 0;
let cachedScreenshot: Blob | null = null;

For src/offscreen/recorder.ts, mirror this shape:

let videoRecorder: MediaRecorder | null = null;   // RENAMED from 'mediaRecorder' to break the audit P0 #1 shadow
let videoBuffer: VideoChunk[] = [];
let firstChunkSaved = false;
let mediaStream: MediaStream | null = null;
let keepalivePort: chrome.runtime.Port | null = null;
let pingIntervalId: number | null = null;

No Analog Found

File Role Data Flow Reason
tests/offscreen/*.test.ts (4 files) unit test request-response No test directory exists in the project yet. Pattern comes from Vitest defaults + RESEARCH.md Validation Architecture.

vitest.config.ts has a partial analog in vite.config.ts (defineConfig shape) and is listed under Pattern Assignments rather than here.

Metadata

Analog search scope:

  • /home/parf/projects/work/repremium/src/ (all subdirectories)
  • /home/parf/projects/work/repremium/offscreen/
  • /home/parf/projects/work/repremium/vite.config.ts
  • /home/parf/projects/work/repremium/manifest.json
  • /home/parf/projects/work/repremium/tsconfig.json
  • /home/parf/projects/work/repremium/package.json

Files scanned: 10 source files; 4 config files.

Verified line numbers (read on 2026-05-15):

  • src/background/index.ts — 536 lines total. Verified delete-target ranges: 26-45 (addVideoChunkFromBlob), 47-75 (cleanupVideoBuffer), 126-131 (getMediaStreamId), 156-165 (setupKeepalive), 457-473 (VIDEO_CHUNK + VIDEO_CHUNK_SAVED cases), 482-520 (loadChunkFromIndexedDB + openIndexedDB).
  • vite.config.ts — 227 lines total. Verified inline plugin range: 13-216 (the copy-offscreen object starts at line 13 with name: on line 14 inside a plugins array; the embedded JS string template ends at line 213; the trailing build/rollupOptions block at 218-226 also collapses per RESEARCH.md Example B).
  • offscreen/index.ts — 60 lines, intact. DELETE target.
  • offscreen/index.html — 10 lines, intact. DELETE target.
  • manifest.json:7"tabCapture" token to replace.
  • src/shared/types.ts:18'OFFSCREEN_READY' declared, ready to wire up.

Pattern extraction date: 2026-05-15