@spotify-confidence/csr-common 0.0.0 → 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,309 @@
1
+ import type { CreateUploaderOptions, Frame, Uploader } from './types';
2
+ import { ClientContext, collectUserAgentContext } from './client-context';
3
+ import { workerScript } from './worker/worker-script';
4
+
5
+ const STORAGE_TAB_ID = 'csr:tabId';
6
+ const STORAGE_SESSION = 'csr:session';
7
+ const STORAGE_COUNTER = 'csr:counter';
8
+ const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
9
+
10
+ interface PortLike {
11
+ postMessage(m: unknown): void;
12
+ setHandler(cb: (data: unknown) => void): void;
13
+ }
14
+
15
+ interface WelcomeMessage {
16
+ type: 'welcome';
17
+ result: { sessionId: string; sessionToken: string } | { skipRecording: true };
18
+ adoptedFromSessionId?: string;
19
+ /** Worker-assigned fresh tabId because this tab is a duplicate of another live one. */
20
+ newTabId?: string;
21
+ resetCounter?: boolean;
22
+ }
23
+ interface DeadMessage {
24
+ type: 'dead';
25
+ reason: string;
26
+ }
27
+ interface StateMessage {
28
+ type: 'state';
29
+ connected: boolean;
30
+ }
31
+ interface LogMessage {
32
+ type: 'log';
33
+ msg: string;
34
+ }
35
+ type WelcomeOrDead = WelcomeMessage | DeadMessage;
36
+ type IncomingMessage = WelcomeMessage | DeadMessage | StateMessage | LogMessage;
37
+
38
+ export async function createUploader(opts: CreateUploaderOptions): Promise<Uploader | null> {
39
+ const log = opts.debugLogger;
40
+ const sessionTtlMs = opts.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
41
+ const tabId = readOrMintTabId();
42
+ const sessionHint = readSessionHint(sessionTtlMs);
43
+ const counterHint = readCounter();
44
+ const mode = resolveMode(opts.workerMode ?? 'auto');
45
+
46
+ log?.(
47
+ `tab: createUploader mode=${mode} tabId=${tabId} sessionHint=${
48
+ sessionHint?.id ?? '(none)'
49
+ } counterHint=${counterHint}`,
50
+ );
51
+
52
+ const port = await openWorkerPort(mode, opts.clientSecret, opts.workerUrl);
53
+
54
+ // State accessible to both the message handler (for post-welcome state/dead messages)
55
+ // and the post-welcome setup code below. Mutable so welcome can populate them.
56
+ let phase: 'awaiting-welcome' | 'active' | 'dead' = 'awaiting-welcome';
57
+ let sessionId: string | null = null;
58
+ let sessionToken: string | null = null;
59
+ let effectiveTabId: string = tabId;
60
+ let resolveWelcome!: (msg: WelcomeOrDead) => void;
61
+ const welcomePromise = new Promise<WelcomeOrDead>(res => {
62
+ resolveWelcome = res;
63
+ });
64
+
65
+ port.setHandler(data => {
66
+ const msg = data as IncomingMessage;
67
+ if (msg.type === 'log') {
68
+ log?.(`worker: ${msg.msg}`);
69
+ return;
70
+ }
71
+ if (phase === 'awaiting-welcome') {
72
+ if (msg.type === 'welcome' || msg.type === 'dead') {
73
+ phase = msg.type === 'welcome' ? 'active' : 'dead';
74
+ resolveWelcome(msg);
75
+ }
76
+ return;
77
+ }
78
+ if (msg.type === 'state' && sessionId !== null) {
79
+ opts.onStateChange?.({
80
+ sessionId,
81
+ tabId: effectiveTabId,
82
+ connected: msg.connected,
83
+ sessionToken,
84
+ });
85
+ return;
86
+ }
87
+ if (msg.type === 'dead') {
88
+ phase = 'dead';
89
+ if (sessionId !== null) {
90
+ opts.onStateChange?.({
91
+ sessionId,
92
+ tabId: effectiveTabId,
93
+ connected: false,
94
+ sessionToken,
95
+ });
96
+ }
97
+ opts.onTerminate?.({ reason: msg.reason });
98
+ }
99
+ });
100
+
101
+ const autoUA = collectUserAgentContext();
102
+ const context: ClientContext = {
103
+ ...(autoUA ? { userAgent: autoUA } : {}),
104
+ ...(opts.context ?? {}),
105
+ };
106
+
107
+ port.postMessage({
108
+ type: 'hello',
109
+ apiUrl: opts.apiUrl,
110
+ websocketUrl: opts.websocketUrl,
111
+ clientSecret: opts.clientSecret,
112
+ targetingKey: opts.targetingKey,
113
+ context,
114
+ forceRecord: opts.forceRecord,
115
+ sessionIdHint: sessionHint?.id,
116
+ sessionTokenHint: sessionHint?.token,
117
+ tabId,
118
+ debugLogs: log !== undefined,
119
+ });
120
+ log?.('tab: hello sent, awaiting welcome');
121
+
122
+ const welcome = await welcomePromise;
123
+ log?.(
124
+ welcome.type === 'welcome'
125
+ ? `tab: welcome (${'sessionId' in welcome.result ? `sessionId=${welcome.result.sessionId}` : 'skipRecording'})`
126
+ : `tab: dead reason=${welcome.reason}`,
127
+ );
128
+ if (welcome.type === 'dead') {
129
+ // Worker died before establishing a session — surface the reason instead of swallowing it as `null`.
130
+ throw new Error(`uploader: ${welcome.reason}`);
131
+ }
132
+ if ('skipRecording' in welcome.result) {
133
+ if (opts.forceRecord) {
134
+ log?.('tab: forceRecord was set but backend still skipped — backend may not support forceRecord yet');
135
+ }
136
+ return null;
137
+ }
138
+
139
+ sessionId = welcome.result.sessionId;
140
+ sessionToken = welcome.result.sessionToken;
141
+ writeSession(welcome.result.sessionId, welcome.result.sessionToken);
142
+
143
+ // If the worker minted a fresh tabId (we're a duplicate of another live tab), adopt it.
144
+ if (welcome.newTabId !== undefined) {
145
+ effectiveTabId = welcome.newTabId;
146
+ sessionStorage.setItem(STORAGE_TAB_ID, effectiveTabId);
147
+ }
148
+
149
+ let counter = welcome.resetCounter ? 0 : counterHint;
150
+ let nextAdoptionMeta: Pick<Frame, 'adoptedFromSessionId' | 'adoptedAt'> | undefined =
151
+ welcome.adoptedFromSessionId !== undefined
152
+ ? {
153
+ adoptedFromSessionId: welcome.adoptedFromSessionId,
154
+ adoptedAt: Date.now(),
155
+ }
156
+ : undefined;
157
+
158
+ opts.onStateChange?.({
159
+ sessionId,
160
+ tabId: effectiveTabId,
161
+ connected: true,
162
+ sessionToken,
163
+ });
164
+
165
+ // Persist counter on pagehide; visibilitychange→hidden as a backup for mobile/BFCache.
166
+ const flush = () => {
167
+ writeCounter(counter);
168
+ };
169
+ window.addEventListener('pagehide', () => {
170
+ flush();
171
+ try {
172
+ port.postMessage({ type: 'bye', reason: 'pagehide' });
173
+ } catch (_e) {
174
+ // ignore
175
+ }
176
+ });
177
+ document.addEventListener('visibilitychange', () => {
178
+ if (document.visibilityState === 'hidden') flush();
179
+ });
180
+
181
+ const uploader = ((event: unknown) => {
182
+ if (phase === 'dead') {
183
+ throw new Error('uploader: terminated');
184
+ }
185
+ const frame: Frame = {
186
+ tabId: effectiveTabId,
187
+ eventCounter: counter,
188
+ data: event,
189
+ ...(nextAdoptionMeta ?? {}),
190
+ };
191
+ counter += 1;
192
+ nextAdoptionMeta = undefined;
193
+ port.postMessage({ type: 'frame', frame });
194
+ }) as Uploader;
195
+
196
+ uploader.close = () => {
197
+ flush();
198
+ try {
199
+ port.postMessage({ type: 'bye', reason: 'stop' });
200
+ } catch (_e) {
201
+ // ignore
202
+ }
203
+ };
204
+
205
+ return uploader;
206
+ }
207
+
208
+ function resolveMode(mode: 'shared' | 'dedicated' | 'auto'): 'shared' | 'dedicated' {
209
+ if (mode === 'auto') {
210
+ return typeof SharedWorker !== 'undefined' ? 'shared' : 'dedicated';
211
+ }
212
+ return mode;
213
+ }
214
+
215
+ async function openWorkerPort(
216
+ mode: 'shared' | 'dedicated',
217
+ clientSecret: string,
218
+ workerUrl: string | undefined,
219
+ ): Promise<PortLike> {
220
+ // Default to a content-derived data URL: identical across tabs (so SharedWorker
221
+ // sharing works) and self-contained (no infrastructure for SDK consumers).
222
+ const url = workerUrl ?? toDataUrl(workerScript);
223
+
224
+ if (mode === 'shared') {
225
+ const name = await hashSecret(clientSecret);
226
+ // `extendedLifetime` keeps the SharedWorker alive across top-level navigations on
227
+ // Chrome 139+. No-op everywhere else. Cast required because the option isn't in the
228
+ // standard SharedWorker type yet.
229
+ const options = {
230
+ name,
231
+ type: 'module',
232
+ extendedLifetime: true,
233
+ } as WorkerOptions;
234
+ const worker = new SharedWorker(url, options);
235
+ worker.port.start();
236
+ return {
237
+ postMessage: m => worker.port.postMessage(m),
238
+ setHandler: cb => {
239
+ worker.port.onmessage = (e: MessageEvent) => cb(e.data);
240
+ },
241
+ };
242
+ }
243
+
244
+ const worker = new Worker(url, { type: 'module' });
245
+ return {
246
+ postMessage: m => worker.postMessage(m),
247
+ setHandler: cb => {
248
+ worker.onmessage = (e: MessageEvent) => cb(e.data);
249
+ },
250
+ };
251
+ }
252
+
253
+ function toDataUrl(script: string): string {
254
+ // UTF-8-safe base64. The worker bundle is ASCII today but esbuild may emit non-ASCII
255
+ // identifiers if the source ever contains them; this avoids `btoa` choking.
256
+ const bytes = new TextEncoder().encode(script);
257
+ let binary = '';
258
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
259
+ return `data:application/javascript;base64,${btoa(binary)}`;
260
+ }
261
+
262
+ async function hashSecret(secret: string): Promise<string> {
263
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));
264
+ return Array.from(new Uint8Array(buf))
265
+ .slice(0, 8)
266
+ .map(b => b.toString(16).padStart(2, '0'))
267
+ .join('');
268
+ }
269
+
270
+ function readOrMintTabId(): string {
271
+ let id = sessionStorage.getItem(STORAGE_TAB_ID);
272
+ if (!id) {
273
+ id = crypto.randomUUID();
274
+ sessionStorage.setItem(STORAGE_TAB_ID, id);
275
+ }
276
+ return id;
277
+ }
278
+
279
+ function readSessionHint(ttlMs: number): { id: string; token: string } | null {
280
+ const raw = sessionStorage.getItem(STORAGE_SESSION);
281
+ if (!raw) return null;
282
+ try {
283
+ const parsed = JSON.parse(raw) as {
284
+ id: string;
285
+ token?: string;
286
+ ts: number;
287
+ };
288
+ if (Date.now() - parsed.ts > ttlMs) return null;
289
+ if (!parsed.token) return null;
290
+ return { id: parsed.id, token: parsed.token };
291
+ } catch (_e) {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ function writeSession(sessionId: string, sessionToken: string): void {
297
+ sessionStorage.setItem(STORAGE_SESSION, JSON.stringify({ id: sessionId, token: sessionToken, ts: Date.now() }));
298
+ }
299
+
300
+ function readCounter(): number {
301
+ const raw = sessionStorage.getItem(STORAGE_COUNTER);
302
+ if (!raw) return 0;
303
+ const n = Number(raw);
304
+ return Number.isFinite(n) ? n : 0;
305
+ }
306
+
307
+ function writeCounter(counter: number): void {
308
+ sessionStorage.setItem(STORAGE_COUNTER, String(counter));
309
+ }
@@ -0,0 +1,15 @@
1
+ export { createUploader } from './create-uploader';
2
+ export { type Uploader, type CreateUploaderOptions } from './types';
3
+ export {
4
+ collectUserAgentContext,
5
+ type ClientContext,
6
+ type UserAgentContext,
7
+ type ContextValue,
8
+ } from './client-context';
9
+
10
+ /**
11
+ * Bundled worker source. Re-exported so consumers can serve it at a stable same-origin
12
+ * URL and pass that URL via `createUploader({ workerUrl })` — required for cross-tab
13
+ * SharedWorker sharing (per-document blob URLs defeat sharing).
14
+ */
15
+ export { workerScript } from './worker/worker-script';
@@ -0,0 +1,101 @@
1
+ import type { ClientContext } from './client-context';
2
+
3
+ export interface Uploader {
4
+ (event: unknown): void;
5
+ close(): void;
6
+ }
7
+
8
+ export interface CreateUploaderOptions {
9
+ /** Base URL of the recording backend (serves `/v1/sessions:initSession` and, by default, the WS ingest endpoint). */
10
+ apiUrl: string;
11
+ /**
12
+ * URL of the WebSocket ingest endpoint, including the path (e.g.
13
+ * `wss://recording-ws.confidence.dev/sessions/stream`) but **without** any query —
14
+ * the worker appends `?session_token=…`. Optional: when omitted the worker derives
15
+ * one from `apiUrl` by swapping `http(s)://` → `ws(s)://` and appending
16
+ * `/sessions/stream`. Set this when the init endpoint and the WS ingest live on
17
+ * different hosts (e.g. prod).
18
+ */
19
+ websocketUrl?: string;
20
+ /** Per-tenant secret. Hashed to scope the SharedWorker so different secrets never share a session, and sent in the `initSession` request body. */
21
+ clientSecret: string;
22
+ /** End-user identifier (visitor / device ID). Forwarded in the `initSession` body for sampling and eligibility. */
23
+ targetingKey?: string;
24
+ /**
25
+ * Session context sent in the InitSession request. The SDK auto-populates
26
+ * `userAgent` with browser/OS/screen metadata. Pass any extra keys as
27
+ * custom dimensions.
28
+ */
29
+ context?: ClientContext;
30
+ /** Force a worker mode for testing. Default `"auto"`. */
31
+ workerMode?: 'shared' | 'dedicated' | 'auto';
32
+ /**
33
+ * Optional override for the worker script URL. By default the bundled worker is loaded
34
+ * from a `data:` URL — self-contained, no infrastructure setup, works across tabs
35
+ * (`SharedWorker` is keyed by `(scriptURL, name)` and identical content yields identical
36
+ * data: URLs). Provide this only if your `worker-src` CSP forbids `data:`; you can serve
37
+ * the bundled worker yourself by re-exporting `workerScript` from
38
+ * `csr-common/uploader` and pointing `workerUrl` at the route.
39
+ */
40
+ workerUrl?: string;
41
+ /** Force recording regardless of backend sampling and targeting rules. Included in the `initSession` request body. */
42
+ forceRecord?: boolean;
43
+ /** Tab-side hint expiry; cached `sessionId`s older than this are discarded on init. */
44
+ sessionTtlMs?: number;
45
+ onStateChange?: (state: {
46
+ sessionId: string | null;
47
+ tabId: string | null;
48
+ connected: boolean;
49
+ /**
50
+ * Token issued by `initSession`. Null until the session is established.
51
+ * Most consumers should ignore this; it's exposed for tooling that needs
52
+ * to call session-lifecycle endpoints directly (e.g. `closeSession`).
53
+ */
54
+ sessionToken: string | null;
55
+ }) => void;
56
+ /** Called once when recording is permanently dead. SDK should dismantle the recorder. */
57
+ onTerminate?: (info: { reason: string }) => void;
58
+ /**
59
+ * Optional verbose tracer. Called on key tab- and worker-side events
60
+ * (hello/welcome, init-session URL, ws connect URL, retries, transitions).
61
+ * Worker messages are forwarded over the port and tagged so you can tell them apart.
62
+ */
63
+ debugLogger?: (msg: string) => void;
64
+ }
65
+
66
+ // --- Internal types: used across the worker / tab pieces, not part of the public API. ---
67
+
68
+ /** Internal: backend-protocol adapter inside the worker bundle. */
69
+ export interface Client {
70
+ initSession(): Promise<{ sessionId: string; sessionToken: string } | { skipRecording: true }>;
71
+ /** Opens the data-plane connection. Rejects if `sessionToken` is stale/closed. */
72
+ openTransport(sessionToken: string): Promise<Transport>;
73
+ }
74
+
75
+ /**
76
+ * Internal: one backend-protocol connection. Owns its own retry/reconnect policy.
77
+ * `onClose` fires only when delivery is permanently impossible.
78
+ * `onStateChange` fires when the underlying connection flaps — e.g. a graceful drain
79
+ * triggers reconnect (`connected: false` then `connected: true` once reconnected).
80
+ * Used to surface mid-session state to consumers wiring `onStateChange` for debugging /
81
+ * tests; production consumers typically only care about `onTerminate` (driven by `onClose`).
82
+ */
83
+ export interface Transport {
84
+ send(frame: Frame): void;
85
+ close(reason?: string): void;
86
+ onClose(cb: (info: { reason: string }) => void): void;
87
+ onStateChange(cb: (info: { connected: boolean }) => void): void;
88
+ }
89
+
90
+ /** Internal: wire-level frame. Session-id is implicit (Transport is session-bound at open). */
91
+ export interface Frame {
92
+ tabId: string;
93
+ /** Monotonic, 0-based per Recording. Resets on adoption. */
94
+ eventCounter: number;
95
+ /** Opaque payload from the recorder. */
96
+ data: unknown;
97
+ /** Set only on the first frame emitted after the tab was adopted into a different session. */
98
+ adoptedFromSessionId?: string;
99
+ /** Epoch millis of the adoption event. */
100
+ adoptedAt?: number;
101
+ }
@@ -0,0 +1,247 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createMockPort, installMockFetch, installMockWsServer, jsonResponse } from '../../test-utils';
3
+
4
+ const API_URL = 'https://api.example';
5
+ const WS_URL = 'wss://api.example/sessions/stream?session_token=tok-1';
6
+
7
+ async function loadCore() {
8
+ vi.resetModules();
9
+ // .js extension is required by Node16 module resolution for dynamic imports
10
+ // (static `import` lines work without it because the package is CJS — see package.json).
11
+ // eslint-disable-next-line es/no-dynamic-import
12
+ return await import('./core.js');
13
+ }
14
+
15
+ function helloMessage(overrides: Record<string, unknown> = {}) {
16
+ return {
17
+ type: 'hello' as const,
18
+ apiUrl: API_URL,
19
+ clientSecret: 'secret',
20
+ tabId: 'tab-A',
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ const isType = (type: string) => (m: unknown) => (m as { type: string }).type === type;
26
+
27
+ interface WelcomeMessage {
28
+ type: 'welcome';
29
+ result: { sessionId: string; sessionToken: string } | { skipRecording: true };
30
+ newTabId?: string;
31
+ resetCounter?: boolean;
32
+ adoptedFromSessionId?: string;
33
+ }
34
+ interface DeadMessage {
35
+ type: 'dead';
36
+ reason: string;
37
+ }
38
+
39
+ describe('worker/core', () => {
40
+ function setupBackend(initBody: unknown = { sessionId: 'sess-1', sessionToken: 'tok-1' }) {
41
+ const fetchHarness = installMockFetch(() => jsonResponse(initBody));
42
+ const wsHarness = installMockWsServer(WS_URL);
43
+ return { fetchHarness, wsHarness };
44
+ }
45
+
46
+ describe('first hello', () => {
47
+ it('runs initSession + openTransport, then sends welcome', async () => {
48
+ const { fetchHarness } = setupBackend();
49
+ const { registerPort } = await loadCore();
50
+ const port = createMockPort();
51
+ registerPort(port.adapter);
52
+
53
+ port.tabSends(helloMessage());
54
+ const welcome = await port.next<WelcomeMessage>(isType('welcome'));
55
+
56
+ expect(fetchHarness.calls).toHaveLength(1);
57
+ expect(fetchHarness.calls[0].url).toBe(`${API_URL}/v1/sessions:initSession`);
58
+ expect(welcome.result).toEqual({
59
+ sessionId: 'sess-1',
60
+ sessionToken: 'tok-1',
61
+ });
62
+ });
63
+
64
+ it('replies with skipRecording when the backend opts out', async () => {
65
+ setupBackend({ skipRecording: true });
66
+ const { registerPort } = await loadCore();
67
+ const port = createMockPort();
68
+ registerPort(port.adapter);
69
+
70
+ port.tabSends(helloMessage());
71
+ const welcome = await port.next<WelcomeMessage>(isType('welcome'));
72
+
73
+ expect(welcome.result).toEqual({ skipRecording: true });
74
+ });
75
+
76
+ it('transitions to dead when initSession throws', async () => {
77
+ installMockFetch(() => new Response(null, { status: 500 }));
78
+
79
+ const { registerPort } = await loadCore();
80
+ const port = createMockPort();
81
+ registerPort(port.adapter);
82
+
83
+ port.tabSends(helloMessage());
84
+ const dead = await port.next<DeadMessage>(isType('dead'));
85
+
86
+ expect(dead.reason).toMatch(/init-session-failed/);
87
+ });
88
+ });
89
+
90
+ describe('multiple ports', () => {
91
+ it('queues a second hello during initialization and welcomes it after', async () => {
92
+ setupBackend();
93
+ const { registerPort } = await loadCore();
94
+
95
+ const portA = createMockPort();
96
+ const portB = createMockPort();
97
+ registerPort(portA.adapter);
98
+ registerPort(portB.adapter);
99
+
100
+ // Both ports race in before the worker has finished init.
101
+ portA.tabSends(helloMessage({ tabId: 'tab-A' }));
102
+ portB.tabSends(helloMessage({ tabId: 'tab-B' }));
103
+
104
+ const [aWelcome, bWelcome] = await Promise.all([
105
+ portA.next<WelcomeMessage>(isType('welcome')),
106
+ portB.next<WelcomeMessage>(isType('welcome')),
107
+ ]);
108
+ expect(aWelcome.result).toEqual({
109
+ sessionId: 'sess-1',
110
+ sessionToken: 'tok-1',
111
+ });
112
+ expect(bWelcome.result).toEqual({
113
+ sessionId: 'sess-1',
114
+ sessionToken: 'tok-1',
115
+ });
116
+ });
117
+
118
+ it('rejects a second hello with a different clientSecret', async () => {
119
+ setupBackend();
120
+ const { registerPort } = await loadCore();
121
+
122
+ const portA = createMockPort();
123
+ const portB = createMockPort();
124
+ registerPort(portA.adapter);
125
+ registerPort(portB.adapter);
126
+
127
+ portA.tabSends(helloMessage());
128
+ await portA.next<WelcomeMessage>(isType('welcome'));
129
+
130
+ portB.tabSends(helloMessage({ clientSecret: 'different-secret', tabId: 'tab-B' }));
131
+ const dead = await portB.next<DeadMessage>(isType('dead'));
132
+
133
+ expect(dead.reason).toMatch(/incompatible-options/);
134
+ });
135
+
136
+ it('mints a fresh tabId when a duplicate tab connects', async () => {
137
+ setupBackend();
138
+ const { registerPort } = await loadCore();
139
+
140
+ const portA = createMockPort();
141
+ const portB = createMockPort();
142
+ registerPort(portA.adapter);
143
+ registerPort(portB.adapter);
144
+
145
+ portA.tabSends(helloMessage({ tabId: 'shared-tab' }));
146
+ await portA.next<WelcomeMessage>(isType('welcome'));
147
+ portB.tabSends(helloMessage({ tabId: 'shared-tab' }));
148
+ const bWelcome = await portB.next<WelcomeMessage>(isType('welcome'));
149
+
150
+ expect(bWelcome.newTabId).toBeDefined();
151
+ expect(bWelcome.newTabId).not.toBe('shared-tab');
152
+ expect(bWelcome.resetCounter).toBe(true);
153
+ });
154
+ });
155
+
156
+ describe('frame routing', () => {
157
+ it('forwards frames over the open transport once active', async () => {
158
+ const { wsHarness } = setupBackend();
159
+ const { registerPort } = await loadCore();
160
+ const port = createMockPort();
161
+ registerPort(port.adapter);
162
+
163
+ port.tabSends(helloMessage());
164
+ await port.next<WelcomeMessage>(isType('welcome'));
165
+
166
+ port.tabSends({
167
+ type: 'frame',
168
+ frame: { tabId: 'tab-A', eventCounter: 0, data: { kind: 'click' } },
169
+ });
170
+
171
+ expect(JSON.parse(await wsHarness.nextMessage())).toEqual({
172
+ tabId: 'tab-A',
173
+ eventCounter: 0,
174
+ data: { kind: 'click' },
175
+ });
176
+ });
177
+
178
+ it('drops frames received before the worker is active', async () => {
179
+ const { wsHarness } = setupBackend();
180
+ const { registerPort } = await loadCore();
181
+ const port = createMockPort();
182
+ registerPort(port.adapter);
183
+
184
+ // Send a frame before hello — phase is still 'init'. Should be silently dropped.
185
+ port.tabSends({
186
+ type: 'frame',
187
+ frame: { tabId: 'tab-A', eventCounter: 0, data: 'too-early' },
188
+ });
189
+
190
+ // Hello + welcome opens the transport. If the early frame had leaked it'd be
191
+ // buffered in the transport's pending queue and flushed first on open.
192
+ port.tabSends(helloMessage());
193
+ await port.next<WelcomeMessage>(isType('welcome'));
194
+
195
+ // Send a real frame and assert it's the *first* message the server sees —
196
+ // the early one would have been ahead of it in the queue had it leaked.
197
+ port.tabSends({
198
+ type: 'frame',
199
+ frame: { tabId: 'tab-A', eventCounter: 0, data: 'real' },
200
+ });
201
+ const message = await wsHarness.nextMessage();
202
+ expect(JSON.parse(message).data).toBe('real');
203
+ });
204
+ });
205
+
206
+ describe('debug log forwarding', () => {
207
+ it('only forwards log messages to ports that opted in', async () => {
208
+ setupBackend();
209
+ const { registerPort } = await loadCore();
210
+
211
+ const debug = createMockPort();
212
+ const quiet = createMockPort();
213
+ registerPort(debug.adapter);
214
+ registerPort(quiet.adapter);
215
+
216
+ debug.tabSends(helloMessage({ debugLogs: true }));
217
+ quiet.tabSends(helloMessage({ tabId: 'tab-B', debugLogs: false }));
218
+
219
+ // Both ports get welcome — wait for that as the synchronization point.
220
+ await Promise.all([debug.next<WelcomeMessage>(isType('welcome')), quiet.next<WelcomeMessage>(isType('welcome'))]);
221
+
222
+ expect(debug.received.some(isType('log'))).toBe(true);
223
+ expect(quiet.received.some(isType('log'))).toBe(false);
224
+ });
225
+ });
226
+
227
+ describe('lifecycle after active', () => {
228
+ it('broadcasts dead to all ports when the transport closes abruptly', async () => {
229
+ const { wsHarness } = setupBackend();
230
+ const { registerPort } = await loadCore();
231
+
232
+ const portA = createMockPort();
233
+ const portB = createMockPort();
234
+ registerPort(portA.adapter);
235
+ registerPort(portB.adapter);
236
+
237
+ portA.tabSends(helloMessage());
238
+ portB.tabSends(helloMessage({ tabId: 'tab-B' }));
239
+ await Promise.all([portA.next<WelcomeMessage>(isType('welcome')), portB.next<WelcomeMessage>(isType('welcome'))]);
240
+
241
+ const ws = await wsHarness.waitForConnection();
242
+ ws.close({ code: 1011, reason: 'server crash', wasClean: false });
243
+
244
+ await Promise.all([portA.next<DeadMessage>(isType('dead')), portB.next<DeadMessage>(isType('dead'))]);
245
+ });
246
+ });
247
+ });