Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ coverage/*
# csr
csr/*/build
csr/*/dist
csr/csr-common/src/uploader/worker-hash.ts
csr/session-recording/src/version.ts

# examples
Expand Down
6 changes: 6 additions & 0 deletions csr/csr-common/scripts/build-worker.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { rolldown } from 'rolldown';
import { createHash } from 'node:crypto';
import { writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand All @@ -21,6 +22,7 @@ const { output } = await bundle.generate({
});

const code = output[0].code;
const hash = createHash('sha256').update(code).digest('hex').slice(0, 16);

const workerScriptOut = resolve(pkgRoot, 'src/uploader/worker/worker-script.ts');
writeFileSync(
Expand All @@ -30,3 +32,7 @@ writeFileSync(
)};\n`,
);
console.log(`build-worker: wrote ${code.length} bytes to ${workerScriptOut}`);

const hashOut = resolve(pkgRoot, 'src/uploader/worker-hash.ts');
writeFileSync(hashOut, `// Generated — do not edit.\nexport const WORKER_HASH = '${hash}';\n`);
console.log(`build-worker: worker hash ${hash}`);
7 changes: 4 additions & 3 deletions csr/csr-common/scripts/emit-worker-file.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const pkgRoot = resolve(here, '..');

const { workerScript } = await import(resolve(pkgRoot, 'dist/uploader/index.js'));
const { workerScript, WORKER_HASH } = await import(resolve(pkgRoot, 'dist/uploader/index.js'));

const standalone = `globalThis.__WORKER_HASH__ = '${WORKER_HASH}';\n${workerScript}`;
const outPath = resolve(pkgRoot, 'dist/confidence-worker.js');
writeFileSync(outPath, workerScript);
console.log(`emit-worker-file: wrote ${workerScript.length} bytes to ${outPath}`);
writeFileSync(outPath, standalone);
console.log(`emit-worker-file: wrote ${standalone.length} bytes to ${outPath} (hash ${WORKER_HASH})`);
105 changes: 105 additions & 0 deletions csr/csr-common/src/uploader/create-uploader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ describe('createUploader', () => {

async function loadCreateUploader() {
vi.resetModules();
vi.doMock('./worker-hash', () => ({ WORKER_HASH: 'expected-hash' }));
// .js extension is required by Node16 module resolution for dynamic imports
// (static `import` lines work without it because the package is CJS — see package.json).
// eslint-disable-next-line es/no-dynamic-import
Expand Down Expand Up @@ -293,6 +294,110 @@ describe('createUploader', () => {
});
});

describe('worker hash mismatch', () => {
beforeEach(() => {
(globalThis as Record<string, unknown>).window = { addEventListener: vi.fn() };
(globalThis as Record<string, unknown>).document = { addEventListener: vi.fn() };
});

afterEach(() => {
delete (globalThis as Record<string, unknown>).window;
delete (globalThis as Record<string, unknown>).document;
});

function workerThatReplies(welcomeOverrides: Record<string, unknown> = {}) {
return class {
onerror: ((e: Event) => void) | null = null;
onmessage: ((e: MessageEvent) => void) | null = null;
postMessage(m: unknown) {
const msg = m as { type: string };
if (msg.type === 'hello') {
setTimeout(
() =>
this.onmessage?.({
data: {
type: 'welcome',
result: { sessionId: 'sess-1', sessionToken: 'tok-1' },
workerHash: 'stale-hash',
...welcomeOverrides,
},
} as MessageEvent),
0,
);
}
}
};
}

async function loadCreateUploaderWithMockedContext() {
vi.doMock('./client-context', () => ({ collectUserAgentContext: () => null }));
return loadCreateUploader();
}

it('logs a warning when workerUrl is set and hashes mismatch', async () => {
(globalThis as Record<string, unknown>).Worker = workerThatReplies();
delete (globalThis as Record<string, unknown>).SharedWorker;

const createUploader = await loadCreateUploaderWithMockedContext();
const logs: string[] = [];
await createUploader({
...DEFAULTS,
workerMode: 'dedicated',
workerUrl: '/confidence-worker.js',
debugLogger: m => logs.push(m),
});

expect(logs.some(l => l.includes('WORKER MISMATCH'))).toBe(true);
});

it('does not warn when using data: URL (inlined worker)', async () => {
(globalThis as Record<string, unknown>).Worker = workerThatReplies();
delete (globalThis as Record<string, unknown>).SharedWorker;

const createUploader = await loadCreateUploaderWithMockedContext();
const logs: string[] = [];
await createUploader({
...DEFAULTS,
workerMode: 'dedicated',
debugLogger: m => logs.push(m),
});

expect(logs.some(l => l.includes('WORKER MISMATCH'))).toBe(false);
});

it('does not warn when hashes match', async () => {
(globalThis as Record<string, unknown>).Worker = workerThatReplies({ workerHash: 'expected-hash' });
delete (globalThis as Record<string, unknown>).SharedWorker;

const createUploader = await loadCreateUploaderWithMockedContext();
const logs: string[] = [];
await createUploader({
...DEFAULTS,
workerMode: 'dedicated',
workerUrl: '/confidence-worker.js',
debugLogger: m => logs.push(m),
});

expect(logs.some(l => l.includes('WORKER MISMATCH'))).toBe(false);
});

it('warns when workerHash is absent (old worker)', async () => {
(globalThis as Record<string, unknown>).Worker = workerThatReplies({ workerHash: undefined });
delete (globalThis as Record<string, unknown>).SharedWorker;

const createUploader = await loadCreateUploaderWithMockedContext();
const logs: string[] = [];
await createUploader({
...DEFAULTS,
workerMode: 'dedicated',
workerUrl: '/confidence-worker.js',
debugLogger: m => logs.push(m),
});

expect(logs.some(l => l.includes('WORKER MISMATCH'))).toBe(true);
});
});

describe('welcome timeout', () => {
it('rejects when the worker never sends a welcome', async () => {
(globalThis as Record<string, unknown>).Worker = class {
Expand Down
8 changes: 8 additions & 0 deletions csr/csr-common/src/uploader/create-uploader.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CreateUploaderOptions, Frame, Uploader } from './types';
import { ClientContext, collectUserAgentContext } from './client-context';
import { workerScript } from './worker/worker-script';
import { WORKER_HASH } from './worker-hash';

const STORAGE_TAB_ID = 'csr:tabId';
const STORAGE_SESSION = 'csr:session';
Expand All @@ -17,6 +18,7 @@ interface PortLike {
interface WelcomeMessage {
type: 'welcome';
result: { sessionId: string; sessionToken: string } | { skipRecording: true };
workerHash?: string;
adoptedFromSessionId?: string;
/** Worker-assigned fresh tabId because this tab is a duplicate of another live one. */
newTabId?: string;
Expand Down Expand Up @@ -153,6 +155,12 @@ export async function createUploader(opts: CreateUploaderOptions): Promise<Uploa
? `tab: welcome (${'sessionId' in welcome.result ? `sessionId=${welcome.result.sessionId}` : 'skipRecording'})`
: `tab: dead reason=${welcome.reason}`,
);
if (welcome.type === 'welcome' && urlScheme === 'custom' && welcome.workerHash !== WORKER_HASH) {
log?.(
'tab: WORKER MISMATCH — the self-hosted confidence-worker.js does not match the installed SDK. ' +
'Copy the updated file from node_modules/@spotify-confidence/session-recording/dist/confidence-worker.js',
);
}
if (welcome.type === 'dead') {
throw new Error(`uploader: ${welcome.reason}`);
}
Expand Down
1 change: 1 addition & 0 deletions csr/csr-common/src/uploader/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export {
* SharedWorker sharing (per-document blob URLs defeat sharing).
*/
export { workerScript } from './worker/worker-script';
export { WORKER_HASH } from './worker-hash';
27 changes: 27 additions & 0 deletions csr/csr-common/src/uploader/worker/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const isType = (type: string) => (m: unknown) => (m as { type: string }).type ==
interface WelcomeMessage {
type: 'welcome';
result: { sessionId: string; sessionToken: string } | { skipRecording: true };
workerHash?: string;
newTabId?: string;
resetCounter?: boolean;
adoptedFromSessionId?: string;
Expand Down Expand Up @@ -61,6 +62,32 @@ describe('worker/core', () => {
});
});

it('includes workerHash in welcome when set on globalThis', async () => {
(globalThis as Record<string, unknown>).__WORKER_HASH__ = 'abc123';
setupBackend();
const { registerPort } = await loadCore();
const port = createMockPort();
registerPort(port.adapter);

port.tabSends(helloMessage());
const welcome = await port.next<WelcomeMessage>(isType('welcome'));

expect(welcome.workerHash).toBe('abc123');
delete (globalThis as Record<string, unknown>).__WORKER_HASH__;
});

it('workerHash is undefined when not set on globalThis', async () => {
setupBackend();
const { registerPort } = await loadCore();
const port = createMockPort();
registerPort(port.adapter);

port.tabSends(helloMessage());
const welcome = await port.next<WelcomeMessage>(isType('welcome'));

expect(welcome.workerHash).toBeUndefined();
});

it('replies with skipRecording when the backend opts out', async () => {
setupBackend({ skipRecording: true });
const { registerPort } = await loadCore();
Expand Down
5 changes: 5 additions & 0 deletions csr/csr-common/src/uploader/worker/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { ClientContext } from '../client-context';
import type { Client, Frame, Transport } from '../types';
import { CsrClient } from './csr-client';

const WORKER_HASH: string | undefined = (globalThis as Record<string, unknown>).__WORKER_HASH__ as string | undefined;

/** Adapter so this module is unaware of whether it's running in a SharedWorker or a dedicated Worker. */
export interface PortAdapter {
postMessage(message: unknown): void;
Expand Down Expand Up @@ -202,6 +204,7 @@ function onHello(handle: PortHandle): void {
handle.port.postMessage({
type: 'welcome',
result: { skipRecording: true },
workerHash: WORKER_HASH,
});
return;
case 'dead':
Expand Down Expand Up @@ -332,6 +335,7 @@ function flushPendingWelcomes(): void {
handle.port.postMessage({
type: 'welcome',
result: { skipRecording: true },
workerHash: WORKER_HASH,
});
} else if (state.phase === 'dead') {
handle.port.postMessage({ type: 'dead', reason: state.reason });
Expand All @@ -346,6 +350,7 @@ function sendActiveWelcome(handle: PortHandle, currentSessionId: string, current
handle.port.postMessage({
type: 'welcome',
result: { sessionId: currentSessionId, sessionToken: currentSessionToken },
workerHash: WORKER_HASH,
adoptedFromSessionId: adopted ? hint : undefined,
newTabId,
resetCounter: adopted || newTabId !== undefined,
Expand Down
3 changes: 2 additions & 1 deletion csr/session-recording/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,14 +202,15 @@ const recorder = initSessionRecorder({

Your CSP only needs `worker-src 'self'` with this setup.

> **Note:** The worker version must match the SDK version. After upgrading `@spotify-confidence/session-recording`, re-copy or re-deploy the worker file.
> **Note:** The worker file must stay in sync with the SDK. After upgrading `@spotify-confidence/session-recording`, re-copy or re-deploy the worker file. If they're out of sync, the SDK logs a `WORKER MISMATCH` warning via `debugLogger`.

### Troubleshooting

If recording silently fails, open DevTools and look for:

- A `SecurityError` mentioning `worker-src` — your CSP blocks the worker. Use one of the options above.
- A blocked `connect-src` request to `recording.confidence.dev` — add the hosts to your CSP.
- A `WORKER MISMATCH` log — your self-hosted `confidence-worker.js` is outdated. Re-copy it from `node_modules/@spotify-confidence/session-recording/dist/`.

Enable [debug logging](#debug-logging) to see the full lifecycle and pinpoint where it fails.

Expand Down