From 74adc67b6788c2ae483a36d2ecc96bbdc96be515 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Tue, 14 Jul 2026 17:04:21 +0200 Subject: [PATCH 1/6] feat(csr): ship standalone worker file and expose workerUrl option - Add `workerUrl` to `InitSessionRecorderOptions`, plumbed to `createUploader`. - Export `workerScript` from `@spotify-confidence/session-recording/worker` so customers can serve it at a same-origin route. - Emit `dist/confidence-worker.js` as a standalone file in the npm package that can be copied directly to static assets. - Add CSP section to README with required directives, three hosting options (copy, build-tool, serve-from-route), and troubleshooting. Co-Authored-By: Claude Opus 4.6 (1M context) --- csr/session-recording/README.md | 71 +++++++++++++++++++ csr/session-recording/package.json | 7 +- .../scripts/emit-worker-file.mjs | 29 ++++++++ csr/session-recording/src/index.ts | 10 +++ csr/session-recording/src/worker.ts | 1 + csr/session-recording/tsdown.config.ts | 5 +- 6 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 csr/session-recording/scripts/emit-worker-file.mjs create mode 100644 csr/session-recording/src/worker.ts diff --git a/csr/session-recording/README.md b/csr/session-recording/README.md index fcfbb70b..7e5e373d 100644 --- a/csr/session-recording/README.md +++ b/csr/session-recording/README.md @@ -87,6 +87,9 @@ const recorder = initSessionRecorder({ // Recording mode mode: 'automatic', // 'automatic' (default) or 'manual' + // CSP: self-hosted worker (only needed when data: and blob: are blocked) + // workerUrl: '/confidence-worker.js', + // Debug debugLogger: msg => console.log(msg), // lifecycle/transport messages (default: off, or console.log when CSR_DEBUG is set in sessionStorage) }); @@ -142,6 +145,74 @@ Then reload the page. The SDK will detect the flag and log to `console.log` auto > **Tip:** We recommend enabling debug logging when first integrating the SDK. It lets you confirm that a session is established, events are flowing, and the backend is reachable — all before you open the Confidence dashboard. +## Content Security Policy (CSP) + +The SDK runs its upload logic in a Web Worker. By default it loads the worker from a `data:` URL, which requires no setup but may be blocked by strict Content Security Policies. + +### Required directives + +| Directive | Value | +| ------------ | --------------------------------------------------------------------------- | +| `worker-src` | `data:` (default), or `blob:` (automatic fallback), or `'self'` (see below) | +| `connect-src` | `https://recording.confidence.dev wss://recording-ws.confidence.dev` | + +### If `data:` is blocked + +The SDK automatically falls back to a `blob:` URL. Most CSPs already allow `blob:` in `worker-src` — if yours does, no action is needed. + +### If both `data:` and `blob:` are blocked + +Self-host the worker script. The package ships a standalone file you can copy to your static assets: + +**Option A: copy from `node_modules`** + +```bash +# After install, copy the worker to your public directory +cp node_modules/@spotify-confidence/session-recording/dist/confidence-worker.js public/ +``` + +**Option B: build-tool integration (Vite, webpack, etc.)** + +```typescript +import { workerScript } from '@spotify-confidence/session-recording/worker'; +import { writeFileSync } from 'fs'; + +// In a build plugin or script — write the worker to your output directory +writeFileSync('dist/confidence-worker.js', workerScript); +``` + +**Option C: serve via a route (Express, Next.js API route, etc.)** + +```typescript +import { workerScript } from '@spotify-confidence/session-recording/worker'; + +app.get('/confidence-worker.js', (req, res) => { + res.type('application/javascript').send(workerScript); +}); +``` + +Then pass the URL: + +```typescript +const recorder = initSessionRecorder({ + clientSecret: '', + workerUrl: '/confidence-worker.js', +}); +``` + +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. + +### 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. + +Enable [debug logging](#debug-logging) to see the full lifecycle and pinpoint where it fails. + ## Manual mode Use `manual` mode to control when recording starts — useful for gating on user consent or feature flags. diff --git a/csr/session-recording/package.json b/csr/session-recording/package.json index 55a67a7d..442bd466 100644 --- a/csr/session-recording/package.json +++ b/csr/session-recording/package.json @@ -12,7 +12,7 @@ "types": "src/index.ts", "scripts": { "prebuild": "node sync-version.mjs", - "build": "yarn run -T tsdown", + "build": "yarn run -T tsdown && node scripts/emit-worker-file.mjs", "typecheck": "tsc --noEmit" }, "files": [ @@ -31,6 +31,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./worker": { + "types": "./dist/worker.d.ts", + "import": "./dist/worker.js", + "require": "./dist/worker.cjs" } } }, diff --git a/csr/session-recording/scripts/emit-worker-file.mjs b/csr/session-recording/scripts/emit-worker-file.mjs new file mode 100644 index 00000000..da879157 --- /dev/null +++ b/csr/session-recording/scripts/emit-worker-file.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const pkgRoot = resolve(here, '..'); + +// The worker source is generated by csr-common's build-worker.mjs and stored as a +// string constant. We read it at build time and write it as a standalone JS file +// so customers can copy it to their static assets directory. +const workerScriptTs = readFileSync( + resolve(pkgRoot, '..', 'csr-common', 'src', 'uploader', 'worker', 'worker-script.ts'), + 'utf8', +); + +// Extract the string literal from: export const workerScript: string = "..."; +const match = workerScriptTs.match(/export const workerScript: string = ([\s\S]+);$/m); +if (!match) { + console.error('emit-worker-file: could not parse worker-script.ts'); + process.exit(1); +} +const code = JSON.parse(match[1]); + +const distDir = resolve(pkgRoot, 'dist'); +mkdirSync(distDir, { recursive: true }); +const outPath = resolve(distDir, 'confidence-worker.js'); +writeFileSync(outPath, code); +console.log(`emit-worker-file: wrote ${code.length} bytes to ${outPath}`); diff --git a/csr/session-recording/src/index.ts b/csr/session-recording/src/index.ts index fa0bdb03..3000824c 100644 --- a/csr/session-recording/src/index.ts +++ b/csr/session-recording/src/index.ts @@ -59,6 +59,15 @@ export interface InitSessionRecorderOptions { * `'manual'` — does nothing until `start()` is called, bypassing sampling and targeting rules. */ mode?: 'automatic' | 'manual'; + /** + * URL of a self-hosted worker script. Required when your Content Security Policy + * blocks `data:` and `blob:` in `worker-src`. Serve the file exported from + * `@spotify-confidence/session-recording/worker` at a same-origin route and pass + * its URL here. + * + * The worker version must match the SDK version — a mismatch may cause silent failures. + */ + workerUrl?: string; /** Verbose tracer for debugging — called with one-line lifecycle/transport messages. */ debugLogger?: (msg: string) => void; } @@ -129,6 +138,7 @@ export function initSessionRecorder(options: InitSessionRecorderOptions): Sessio _csr_sdk_version: SDK_VERSION, ...(options.appVersion ? { _app_version: options.appVersion } : {}), }, + workerUrl: options.workerUrl, forceRecord, debugLogger, onTerminate: ({ reason }) => { diff --git a/csr/session-recording/src/worker.ts b/csr/session-recording/src/worker.ts new file mode 100644 index 00000000..8811cc5a --- /dev/null +++ b/csr/session-recording/src/worker.ts @@ -0,0 +1 @@ +export { workerScript } from '@spotify-confidence/csr-common/uploader'; diff --git a/csr/session-recording/tsdown.config.ts b/csr/session-recording/tsdown.config.ts index a6a137dd..699692c7 100644 --- a/csr/session-recording/tsdown.config.ts +++ b/csr/session-recording/tsdown.config.ts @@ -1,7 +1,10 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: './src/index.ts', + entry: { + index: './src/index.ts', + worker: './src/worker.ts', + }, format: ['esm', 'cjs'], platform: 'browser', minify: 'dce-only', From 58bfb8775f1f0d950b5bbf51fac77e868a800cb8 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Thu, 16 Jul 2026 10:02:43 +0200 Subject: [PATCH 2/6] style(csr): format README Co-Authored-By: Claude Opus 4.6 (1M context) --- csr/session-recording/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/csr/session-recording/README.md b/csr/session-recording/README.md index 7e5e373d..9c3f4ab2 100644 --- a/csr/session-recording/README.md +++ b/csr/session-recording/README.md @@ -151,10 +151,10 @@ The SDK runs its upload logic in a Web Worker. By default it loads the worker fr ### Required directives -| Directive | Value | -| ------------ | --------------------------------------------------------------------------- | -| `worker-src` | `data:` (default), or `blob:` (automatic fallback), or `'self'` (see below) | -| `connect-src` | `https://recording.confidence.dev wss://recording-ws.confidence.dev` | +| Directive | Value | +| ------------- | --------------------------------------------------------------------------- | +| `worker-src` | `data:` (default), or `blob:` (automatic fallback), or `'self'` (see below) | +| `connect-src` | `https://recording.confidence.dev wss://recording-ws.confidence.dev` | ### If `data:` is blocked From 609bfb8756519ed8b58f97e6cf7a1cfbe4c31952 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Thu, 16 Jul 2026 16:32:36 +0200 Subject: [PATCH 3/6] refactor(csr): produce standalone worker file from csr-common build csr-common now emits dist/confidence-worker.js as a first-class build artifact. session-recording copies it instead of extracting the worker string from source. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + csr/csr-common/package.json | 2 +- csr/csr-common/scripts/build-worker.mjs | 21 ++++++++++------ csr/csr-common/scripts/emit-worker-file.mjs | 13 ++++++++++ csr/session-recording/package.json | 3 ++- .../scripts/emit-worker-file.mjs | 25 ++++--------------- 6 files changed, 35 insertions(+), 30 deletions(-) create mode 100644 csr/csr-common/scripts/emit-worker-file.mjs diff --git a/.gitignore b/.gitignore index c01bebf3..2b0cebcd 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ coverage/* # csr csr/*/build csr/*/dist +csr/csr-common/src/uploader/csr-version.ts csr/session-recording/src/version.ts # examples diff --git a/csr/csr-common/package.json b/csr/csr-common/package.json index 300ac3a8..2bb9fe1a 100644 --- a/csr/csr-common/package.json +++ b/csr/csr-common/package.json @@ -26,7 +26,7 @@ ], "scripts": { "prebuild": "node scripts/build-worker.mjs", - "build": "yarn run -T tsdown", + "build": "yarn run -T tsdown && node scripts/emit-worker-file.mjs", "typecheck": "tsc --noEmit" }, "publishConfig": { diff --git a/csr/csr-common/scripts/build-worker.mjs b/csr/csr-common/scripts/build-worker.mjs index fff93b7c..82545379 100644 --- a/csr/csr-common/scripts/build-worker.mjs +++ b/csr/csr-common/scripts/build-worker.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node import { rolldown } from 'rolldown'; -import { writeFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const pkgRoot = resolve(here, '..'); +const { version } = JSON.parse(readFileSync(resolve(pkgRoot, 'package.json'), 'utf8')); const bundle = await rolldown({ input: resolve(pkgRoot, 'src/uploader/worker/entry.ts'), @@ -21,10 +22,14 @@ const { output } = await bundle.generate({ }); const code = output[0].code; -const out = resolve(pkgRoot, 'src/uploader/worker/worker-script.ts'); -const body = `// Generated by scripts/build-worker.mjs at build time. Do not edit. -// Run \`yarn workspace @spotify-confidence/csr-common build:worker\` to regenerate. -export const workerScript: string = ${JSON.stringify(code)}; -`; -writeFileSync(out, body); -console.log(`build-worker: wrote ${code.length} bytes to ${out}`); + +const workerScriptOut = resolve(pkgRoot, 'src/uploader/worker/worker-script.ts'); +writeFileSync( + workerScriptOut, + `// Generated by scripts/build-worker.mjs at build time. Do not edit.\n// Run \`yarn workspace @spotify-confidence/csr-common build:worker\` to regenerate.\nexport const workerScript: string = ${JSON.stringify(code)};\n`, +); +console.log(`build-worker: wrote ${code.length} bytes to ${workerScriptOut}`); + +const versionOut = resolve(pkgRoot, 'src/uploader/csr-version.ts'); +writeFileSync(versionOut, `// Generated — do not edit.\nexport const CSR_VERSION = '${version}';\n`); +console.log(`build-worker: wrote csr-version ${version} to ${versionOut}`); diff --git a/csr/csr-common/scripts/emit-worker-file.mjs b/csr/csr-common/scripts/emit-worker-file.mjs new file mode 100644 index 00000000..5e283eb4 --- /dev/null +++ b/csr/csr-common/scripts/emit-worker-file.mjs @@ -0,0 +1,13 @@ +#!/usr/bin/env node +import { writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +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 outPath = resolve(pkgRoot, 'dist/confidence-worker.js'); +writeFileSync(outPath, workerScript); +console.log(`emit-worker-file: wrote ${workerScript.length} bytes to ${outPath}`); diff --git a/csr/session-recording/package.json b/csr/session-recording/package.json index 442bd466..02c9467f 100644 --- a/csr/session-recording/package.json +++ b/csr/session-recording/package.json @@ -36,7 +36,8 @@ "types": "./dist/worker.d.ts", "import": "./dist/worker.js", "require": "./dist/worker.cjs" - } + }, + "./confidence-worker.js": "./dist/confidence-worker.js" } }, "dependencies": { diff --git a/csr/session-recording/scripts/emit-worker-file.mjs b/csr/session-recording/scripts/emit-worker-file.mjs index da879157..ead0927d 100644 --- a/csr/session-recording/scripts/emit-worker-file.mjs +++ b/csr/session-recording/scripts/emit-worker-file.mjs @@ -1,29 +1,14 @@ #!/usr/bin/env node -import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { copyFileSync, mkdirSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const pkgRoot = resolve(here, '..'); -// The worker source is generated by csr-common's build-worker.mjs and stored as a -// string constant. We read it at build time and write it as a standalone JS file -// so customers can copy it to their static assets directory. -const workerScriptTs = readFileSync( - resolve(pkgRoot, '..', 'csr-common', 'src', 'uploader', 'worker', 'worker-script.ts'), - 'utf8', -); - -// Extract the string literal from: export const workerScript: string = "..."; -const match = workerScriptTs.match(/export const workerScript: string = ([\s\S]+);$/m); -if (!match) { - console.error('emit-worker-file: could not parse worker-script.ts'); - process.exit(1); -} -const code = JSON.parse(match[1]); - +const src = resolve(pkgRoot, '..', 'csr-common', 'dist', 'confidence-worker.js'); const distDir = resolve(pkgRoot, 'dist'); mkdirSync(distDir, { recursive: true }); -const outPath = resolve(distDir, 'confidence-worker.js'); -writeFileSync(outPath, code); -console.log(`emit-worker-file: wrote ${code.length} bytes to ${outPath}`); +const dest = resolve(distDir, 'confidence-worker.js'); +copyFileSync(src, dest); +console.log(`emit-worker-file: copied confidence-worker.js from csr-common`); From 1590f9a89d86e6e6009780d81587525dea213bf0 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Thu, 16 Jul 2026 16:33:54 +0200 Subject: [PATCH 4/6] test(csr): add workerUrl forwarding test Co-Authored-By: Claude Opus 4.6 (1M context) --- csr/csr-common/scripts/build-worker.mjs | 4 +++- csr/session-recording/src/index.test.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/csr/csr-common/scripts/build-worker.mjs b/csr/csr-common/scripts/build-worker.mjs index 82545379..848ebf5d 100644 --- a/csr/csr-common/scripts/build-worker.mjs +++ b/csr/csr-common/scripts/build-worker.mjs @@ -26,7 +26,9 @@ const code = output[0].code; const workerScriptOut = resolve(pkgRoot, 'src/uploader/worker/worker-script.ts'); writeFileSync( workerScriptOut, - `// Generated by scripts/build-worker.mjs at build time. Do not edit.\n// Run \`yarn workspace @spotify-confidence/csr-common build:worker\` to regenerate.\nexport const workerScript: string = ${JSON.stringify(code)};\n`, + `// Generated by scripts/build-worker.mjs at build time. Do not edit.\n// Run \`yarn workspace @spotify-confidence/csr-common build:worker\` to regenerate.\nexport const workerScript: string = ${JSON.stringify( + code, + )};\n`, ); console.log(`build-worker: wrote ${code.length} bytes to ${workerScriptOut}`); diff --git a/csr/session-recording/src/index.test.ts b/csr/session-recording/src/index.test.ts index 46cf23f7..b0b9bdf1 100644 --- a/csr/session-recording/src/index.test.ts +++ b/csr/session-recording/src/index.test.ts @@ -103,6 +103,21 @@ describe('initSessionRecorder', () => { expect(logger).toHaveBeenCalledWith(expect.stringContaining('worker-load-failed')); }); + it('forwards workerUrl to createUploader', async () => { + createUploader.mockResolvedValueOnce(mockUploader()); + record.mockReturnValueOnce(() => {}); + + initSessionRecorder({ + clientSecret: 'secret', + workerUrl: '/assets/confidence-worker.js', + }); + await flushPromises(); + + expect(createUploader.mock.calls[0][0]).toMatchObject({ + workerUrl: '/assets/confidence-worker.js', + }); + }); + it('manual mode does not init until start is called', async () => { createUploader.mockResolvedValueOnce(mockUploader()); record.mockReturnValueOnce(() => {}); From 0011703e793e84424d6c28de90f75144b456a8e3 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Fri, 17 Jul 2026 13:17:11 +0200 Subject: [PATCH 5/6] refactor(csr): move version generation to version-drift PR Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 - csr/csr-common/scripts/build-worker.mjs | 7 +------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 2b0cebcd..c01bebf3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,6 @@ coverage/* # csr csr/*/build csr/*/dist -csr/csr-common/src/uploader/csr-version.ts csr/session-recording/src/version.ts # examples diff --git a/csr/csr-common/scripts/build-worker.mjs b/csr/csr-common/scripts/build-worker.mjs index 848ebf5d..6555f8cb 100644 --- a/csr/csr-common/scripts/build-worker.mjs +++ b/csr/csr-common/scripts/build-worker.mjs @@ -1,12 +1,11 @@ #!/usr/bin/env node import { rolldown } from 'rolldown'; -import { readFileSync, writeFileSync } from 'node:fs'; +import { writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const pkgRoot = resolve(here, '..'); -const { version } = JSON.parse(readFileSync(resolve(pkgRoot, 'package.json'), 'utf8')); const bundle = await rolldown({ input: resolve(pkgRoot, 'src/uploader/worker/entry.ts'), @@ -31,7 +30,3 @@ writeFileSync( )};\n`, ); console.log(`build-worker: wrote ${code.length} bytes to ${workerScriptOut}`); - -const versionOut = resolve(pkgRoot, 'src/uploader/csr-version.ts'); -writeFileSync(versionOut, `// Generated — do not edit.\nexport const CSR_VERSION = '${version}';\n`); -console.log(`build-worker: wrote csr-version ${version} to ${versionOut}`); From 3f0a68f9e38ee777da4eb8163f3a47462ad44de2 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Mon, 17 Aug 2026 14:59:27 +0200 Subject: [PATCH 6/6] feat(csr): add worker self-hosting helpers Co-Authored-By: Codex --- csr/session-recording/README.md | 80 ++++++++++++++----- .../bin/confidence-copy-worker.mjs | 46 +++++++++++ csr/session-recording/package.json | 4 + .../src/copy-worker-cli.test.ts | 69 ++++++++++++++++ yarn.lock | 2 + 5 files changed, 180 insertions(+), 21 deletions(-) create mode 100755 csr/session-recording/bin/confidence-copy-worker.mjs create mode 100644 csr/session-recording/src/copy-worker-cli.test.ts diff --git a/csr/session-recording/README.md b/csr/session-recording/README.md index 9c3f4ab2..c028a798 100644 --- a/csr/session-recording/README.md +++ b/csr/session-recording/README.md @@ -162,36 +162,37 @@ The SDK automatically falls back to a `blob:` URL. Most CSPs already allow `blob ### If both `data:` and `blob:` are blocked -Self-host the worker script. The package ships a standalone file you can copy to your static assets: +Self-host the worker script. The package ships a standalone file and a cross-platform command for copying it to your static assets. -**Option A: copy from `node_modules`** +**Vite** -```bash -# After install, copy the worker to your public directory -cp node_modules/@spotify-confidence/session-recording/dist/confidence-worker.js public/ -``` - -**Option B: build-tool integration (Vite, webpack, etc.)** +Let Vite emit and version the worker as a build asset: ```typescript -import { workerScript } from '@spotify-confidence/session-recording/worker'; -import { writeFileSync } from 'fs'; +import confidenceWorkerUrl from '@spotify-confidence/session-recording/confidence-worker.js?url'; +import { initSessionRecorder } from '@spotify-confidence/session-recording'; -// In a build plugin or script — write the worker to your output directory -writeFileSync('dist/confidence-worker.js', workerScript); +const recorder = initSessionRecorder({ + clientSecret: '', + workerUrl: confidenceWorkerUrl, +}); ``` -**Option C: serve via a route (Express, Next.js API route, etc.)** +Vite's default asset inline limit keeps the worker as a separate file. If you increase `build.assetsInlineLimit`, make sure the worker is not emitted as a `data:` URL, since that would still require `data:` in `worker-src`. -```typescript -import { workerScript } from '@spotify-confidence/session-recording/worker'; +**Next.js: copy to `public`** -app.get('/confidence-worker.js', (req, res) => { - res.type('application/javascript').send(workerScript); -}); -``` +Run the package-owned command before development and production builds: -Then pass the URL: +```json +{ + "scripts": { + "copy:confidence-worker": "confidence-copy-worker public/confidence-worker.js", + "predev": "npm run copy:confidence-worker", + "prebuild": "npm run copy:confidence-worker" + } +} +``` ```typescript const recorder = initSessionRecorder({ @@ -200,7 +201,44 @@ const recorder = initSessionRecorder({ }); ``` -Your CSP only needs `worker-src 'self'` with this setup. +The command creates parent directories and works without relying on a `node_modules` layout. You can also detect a stale or missing copy in CI: + +```bash +confidence-copy-worker --check public/confidence-worker.js +``` + +If the app uses Next.js `basePath`, include it in `workerUrl` (for example, `/docs/confidence-worker.js`). A static filename should be served with revalidation rather than immutable caching so SDK upgrades can replace it. + +**Next.js App Router: route handler** + +As a no-copy alternative, serve the worker from a route handler: + +```typescript +import { workerScript } from '@spotify-confidence/session-recording/worker'; + +export const dynamic = 'force-static'; + +export function GET() { + return new Response(workerScript, { + headers: { + 'Content-Type': 'application/javascript; charset=utf-8', + 'Cache-Control': 'public, max-age=0, must-revalidate', + }, + }); +} +``` + +Place this in `app/confidence-worker.js/route.ts` and use `/confidence-worker.js` as above. `force-static` also emits the route during a static export; the deployment host controls response headers for exported files. + +**Other frameworks** + +Copy the worker into your framework's static assets directory: + +```bash +confidence-copy-worker path/to/static/confidence-worker.js +``` + +Then pass its same-origin public URL as `workerUrl`. 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. diff --git a/csr/session-recording/bin/confidence-copy-worker.mjs b/csr/session-recording/bin/confidence-copy-worker.mjs new file mode 100755 index 00000000..d3379db9 --- /dev/null +++ b/csr/session-recording/bin/confidence-copy-worker.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const workerPath = fileURLToPath(new URL('../dist/confidence-worker.js', import.meta.url)); +const args = process.argv.slice(2); +const check = args[0] === '--check'; +const positionalArgs = check ? args.slice(1) : args; + +if (positionalArgs.length !== 1 || positionalArgs[0].startsWith('-')) { + console.error('Usage: confidence-copy-worker [--check] '); + process.exitCode = 1; +} else { + const destination = resolve(positionalArgs[0]); + + try { + const worker = await readFile(workerPath); + + if (check) { + let installedWorker; + try { + installedWorker = await readFile(destination); + } catch (error) { + if (error.code === 'ENOENT') { + throw new Error(`Worker is missing at ${destination}`); + } + throw error; + } + + if (!worker.equals(installedWorker)) { + throw new Error(`Worker at ${destination} does not match the installed package`); + } + + console.log(`Worker is up to date at ${destination}`); + } else { + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, worker); + console.log(`Copied Confidence worker to ${destination}`); + } + } catch (error) { + console.error(`confidence-copy-worker: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/csr/session-recording/package.json b/csr/session-recording/package.json index 02c9467f..f182a9b7 100644 --- a/csr/session-recording/package.json +++ b/csr/session-recording/package.json @@ -10,12 +10,16 @@ "type": "module", "main": "src/index.ts", "types": "src/index.ts", + "bin": { + "confidence-copy-worker": "bin/confidence-copy-worker.mjs" + }, "scripts": { "prebuild": "node sync-version.mjs", "build": "yarn run -T tsdown && node scripts/emit-worker-file.mjs", "typecheck": "tsc --noEmit" }, "files": [ + "bin", "dist", "src" ], diff --git a/csr/session-recording/src/copy-worker-cli.test.ts b/csr/session-recording/src/copy-worker-cli.test.ts new file mode 100644 index 00000000..4bbbecbe --- /dev/null +++ b/csr/session-recording/src/copy-worker-cli.test.ts @@ -0,0 +1,69 @@ +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const sourceCli = join(packageRoot, 'bin/confidence-copy-worker.mjs'); + +describe('confidence-copy-worker', () => { + let fixtureRoot: string; + let cli: string; + let worker: string; + + beforeEach(() => { + fixtureRoot = mkdtempSync(join(tmpdir(), 'confidence-copy-worker-')); + cli = join(fixtureRoot, 'bin/confidence-copy-worker.mjs'); + worker = join(fixtureRoot, 'dist/confidence-worker.js'); + mkdirSync(dirname(cli), { recursive: true }); + mkdirSync(dirname(worker), { recursive: true }); + cpSync(sourceCli, cli); + writeFileSync(worker, 'self.__CONFIDENCE_WORKER__ = true;\n'); + }); + + afterEach(() => { + rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + const run = (...args: string[]) => + spawnSync(process.execPath, [cli, ...args], { + encoding: 'utf8', + }); + + it('copies the packaged worker and creates destination directories', () => { + const destination = join(fixtureRoot, 'public/assets/confidence-worker.js'); + + const result = run(destination); + + expect(result.status).toBe(0); + expect(readFileSync(destination)).toEqual(readFileSync(worker)); + }); + + it('passes --check when the destination is current', () => { + const destination = join(fixtureRoot, 'public/confidence-worker.js'); + run(destination); + + const result = run('--check', destination); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('Worker is up to date'); + }); + + it.each([ + ['missing', undefined, 'Worker is missing'], + ['stale', 'old worker contents', 'does not match the installed package'], + ])('fails --check for a %s destination', (_condition, contents, expectedError) => { + const destination = join(fixtureRoot, 'public/confidence-worker.js'); + if (contents) { + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, contents); + } + + const result = run('--check', destination); + + expect(result.status).toBe(1); + expect(result.stderr).toContain(expectedError); + }); +}); diff --git a/yarn.lock b/yarn.lock index b379c7a8..9d2dd998 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5115,6 +5115,8 @@ __metadata: dependencies: "@spotify-confidence/csr-common": "workspace:*" "@spotify-confidence/csr-recorder": "workspace:*" + bin: + confidence-copy-worker: bin/confidence-copy-worker.mjs languageName: unknown linkType: soft