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..6555f8cb 100644 --- a/csr/csr-common/scripts/build-worker.mjs +++ b/csr/csr-common/scripts/build-worker.mjs @@ -21,10 +21,12 @@ 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}`); 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/README.md b/csr/session-recording/README.md index fcfbb70b..c028a798 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,112 @@ 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 and a cross-platform command for copying it to your static assets. + +**Vite** + +Let Vite emit and version the worker as a build asset: + +```typescript +import confidenceWorkerUrl from '@spotify-confidence/session-recording/confidence-worker.js?url'; +import { initSessionRecorder } from '@spotify-confidence/session-recording'; + +const recorder = initSessionRecorder({ + clientSecret: '', + workerUrl: confidenceWorkerUrl, +}); +``` + +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`. + +**Next.js: copy to `public`** + +Run the package-owned command before development and production builds: + +```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({ + clientSecret: '', + workerUrl: '/confidence-worker.js', +}); +``` + +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. + +### 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/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 55a67a7d..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", + "build": "yarn run -T tsdown && node scripts/emit-worker-file.mjs", "typecheck": "tsc --noEmit" }, "files": [ + "bin", "dist", "src" ], @@ -31,7 +35,13 @@ "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" + }, + "./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 new file mode 100644 index 00000000..ead0927d --- /dev/null +++ b/csr/session-recording/scripts/emit-worker-file.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +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, '..'); + +const src = resolve(pkgRoot, '..', 'csr-common', 'dist', 'confidence-worker.js'); +const distDir = resolve(pkgRoot, 'dist'); +mkdirSync(distDir, { recursive: true }); +const dest = resolve(distDir, 'confidence-worker.js'); +copyFileSync(src, dest); +console.log(`emit-worker-file: copied confidence-worker.js from csr-common`); 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/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(() => {}); 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', 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