@spotify-confidence/csr-common 0.18.9 → 0.18.11
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.
- package/CHANGELOG.md +15 -0
- package/dist/confidence-worker.js +53 -14
- package/dist/index.cjs +3 -15
- package/dist/index.d.cts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -15
- package/dist/{session-activity-HfuXKqGd.cjs → session-activity-B8swR5Wg.cjs} +29 -0
- package/dist/{session-activity-CJ1wae06.js → session-activity-tshS9Hua.js} +18 -1
- package/dist/{types-Bg0UKTVQ.d.cts → types-CdvFpcEN.d.cts} +8 -7
- package/dist/{types-Bg0UKTVQ.d.ts → types-CdvFpcEN.d.ts} +8 -7
- package/dist/uploader/index.cjs +8 -3
- package/dist/uploader/index.d.cts +5 -2
- package/dist/uploader/index.d.ts +5 -2
- package/dist/uploader/index.js +8 -4
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/test-utils/mock-ws-server.ts +18 -2
- package/src/uploader/client-context.test.ts +27 -0
- package/src/uploader/client-context.ts +3 -1
- package/src/uploader/create-uploader.test.ts +105 -0
- package/src/uploader/create-uploader.ts +8 -0
- package/src/uploader/index.ts +1 -0
- package/src/uploader/types.ts +7 -6
- package/src/uploader/worker/core.test.ts +146 -4
- package/src/uploader/worker/core.ts +6 -1
- package/src/uploader/worker/csr-client.test.ts +33 -18
- package/src/uploader/worker/csr-client.ts +13 -4
- package/src/uploader/worker/web-socket-transport.test.ts +71 -4
- package/src/uploader/worker/web-socket-transport.ts +30 -15
- package/src/uploader/worker/websocket-auth.test.ts +29 -0
- package/src/uploader/worker/websocket-auth.ts +15 -0
- package/src/uploader/worker/worker-script.test.ts +205 -0
- package/src/uploader/worker/worker-script.ts +1 -1
- package/src/uploader/worker-hash.ts +2 -0
- package/src/url.ts +5 -0
package/dist/uploader/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as isSessionActivityEvent } from "../session-activity-
|
|
1
|
+
import { o as stripUrlQueryAndHash, t as isSessionActivityEvent } from "../session-activity-tshS9Hua.js";
|
|
2
2
|
import Bowser from "bowser";
|
|
3
3
|
//#region src/uploader/client-context.ts
|
|
4
4
|
function collectUserAgentContext() {
|
|
@@ -22,12 +22,15 @@ function collectUserAgentContext() {
|
|
|
22
22
|
screenHeight: window.screen.height,
|
|
23
23
|
devicePixelRatio: window.devicePixelRatio,
|
|
24
24
|
uri: `${window.location.origin}${window.location.pathname}`,
|
|
25
|
-
referrer: document.referrer
|
|
25
|
+
referrer: stripUrlQueryAndHash(document.referrer)
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
//#endregion
|
|
29
29
|
//#region src/uploader/worker/worker-script.ts
|
|
30
|
-
const workerScript = "//#region src/uploader/worker/web-socket-transport.ts\n/**\n* WebSocket-backed Transport. Internal retry policy: clean server-initiated close after the\n* first successful open → reconnect and resume; abrupt close (or any close before the first\n* open) → fire `onClose` and stop. Frames received while a (re)connect is in progress are\n* buffered and flushed on open.\n*\n* `ready()` resolves on the first successful open and rejects on close-before-open. Callers\n* should await it before treating the Transport as live, so a failure to open can be caught\n* (e.g. 4404 unknown session) and recovered from.\n*/\nvar WebSocketTransport = class {\n url;\n ws = null;\n onCloseCb = null;\n onStateChangeCb = null;\n intentionallyClosed = false;\n dead = false;\n /** Frames buffered while a (re)connect is in progress. */\n pending = [];\n readyPromise;\n constructor(url) {\n this.url = url;\n this.readyPromise = new Promise((resolve, reject) => {\n this.connect(false, resolve, reject);\n });\n this.readyPromise.catch(() => {});\n }\n ready() {\n return this.readyPromise;\n }\n send(frame) {\n if (this.dead || this.intentionallyClosed) return;\n if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));\n else this.pending.push(frame);\n }\n close(reason = \"transport-close\") {\n this.intentionallyClosed = true;\n this.ws?.close(1e3, reason);\n }\n onClose(cb) {\n this.onCloseCb = cb;\n }\n onStateChange(cb) {\n this.onStateChangeCb = cb;\n }\n connect(isReconnect, onReady, onReadyFail) {\n const ws = new WebSocket(this.url);\n this.ws = ws;\n let opened = false;\n ws.onopen = () => {\n opened = true;\n onReady?.();\n if (isReconnect) this.onStateChangeCb?.({ connected: true });\n while (this.pending.length > 0) {\n const f = this.pending.shift();\n ws.send(JSON.stringify(f));\n }\n };\n ws.onclose = (event) => {\n if (this.intentionallyClosed) return;\n if (!opened) {\n const reason = `${isReconnect ? \"reconnect\" : \"initial\"}-failed code=${event.code}`;\n if (onReadyFail) {\n onReadyFail(new Error(reason));\n this.dead = true;\n } else this.die(reason);\n return;\n }\n if (event.wasClean && (event.code === 1e3 || event.code === 1001)) {\n this.onStateChangeCb?.({ connected: false });\n this.connect(true);\n } else this.die(`close code=${event.code} wasClean=${event.wasClean}`);\n };\n }\n die(reason) {\n this.dead = true;\n this.onCloseCb?.({ reason });\n }\n};\n//#endregion\n//#region src/uploader/worker/csr-client.ts\n/**\n* Single Client implementation that talks to the recording backend's REST + WS protocol.\n* Both dev-server and prod implement the same protocol, so we don't need polymorphism here yet.\n*/\nvar CsrClient = class {\n apiUrl;\n clientSecret;\n context;\n websocketUrl;\n log;\n forceRecord;\n constructor(apiUrl, clientSecret, context, websocketUrl, log = () => {}, forceRecord) {\n this.apiUrl = apiUrl;\n this.clientSecret = clientSecret;\n this.context = context;\n this.websocketUrl = websocketUrl;\n this.log = log;\n this.forceRecord = forceRecord;\n }\n async initSession() {\n const url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;\n this.log(`fetch POST ${url}`);\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n clientSecret: this.clientSecret,\n ...this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {},\n ...this.forceRecord ? { forceRecord: true } : {}\n })\n });\n if (!res.ok) throw new Error(`init-session failed: HTTP ${res.status}`);\n const data = await res.json();\n if (data.skipRecording) return { skipRecording: true };\n if (!data.sessionId || !data.sessionToken) throw new Error(\"init-session response missing sessionId or sessionToken\");\n return {\n sessionId: data.sessionId,\n sessionToken: data.sessionToken\n };\n }\n async openTransport(sessionToken) {\n const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;\n const url = `${wsBase}${wsBase.includes(\"?\") ? \"&\" : \"?\"}session_token=${encodeURIComponent(sessionToken)}`;\n this.log(`WebSocket connect ${url.replace(/session_token=[^&]*/, \"session_token=[REDACTED]\")}`);\n const transport = new WebSocketTransport(url);\n await transport.ready();\n return transport;\n }\n trimSlash(s) {\n return s.endsWith(\"/\") ? s.slice(0, -1) : s;\n }\n toWsScheme(base) {\n if (base.startsWith(\"https://\")) return `wss://${base.slice(8)}`;\n if (base.startsWith(\"http://\")) return `ws://${base.slice(7)}`;\n return base;\n }\n};\n//#endregion\n//#region src/uploader/worker/core.ts\nconst IDLE_GRACE_MS = 5e3;\nlet state = { phase: \"init\" };\nconst ports = [];\nlet idleTimer = null;\nfunction cancelIdleTimer() {\n if (idleTimer !== null) {\n clearTimeout(idleTimer);\n idleTimer = null;\n }\n}\nfunction log(msg) {\n for (const handle of ports) if (handle.debugLogs) handle.port.postMessage({\n type: \"log\",\n msg\n });\n}\n/**\n* The first hello \"locks in\" the session's apiUrl/clientSecret. Any later tab arriving\n* with different values is misconfigured — we reject it rather than silently using the\n* locked values. In SharedWorker mode the `name = hash(clientSecret)` scoping already\n* prevents secret-mismatch from sharing a worker, but this defends against the dedicated\n* path and against future bugs.\n*/\nlet lockedConfig = null;\nfunction registerPort(adapter) {\n cancelIdleTimer();\n const handle = {\n port: adapter,\n hello: null,\n debugLogs: false\n };\n ports.push(handle);\n adapter.onmessage((data) => {\n handleMessage(handle, data);\n });\n}\nfunction handleMessage(handle, message) {\n switch (message.type) {\n case \"hello\":\n handle.hello = message;\n handle.debugLogs = message.debugLogs ?? false;\n if (rejectIfIncompatible(handle)) return;\n if (state.phase !== \"dead\" && state.phase !== \"skipping\") detectDuplicateTab(handle);\n onHello(handle);\n return;\n case \"frame\":\n onFrame(message.frame);\n return;\n case \"bye\":\n onBye(handle);\n return;\n default: break;\n }\n}\n/**\n* Reject hellos whose `apiUrl`/`clientSecret` don't match the values established by the\n* first hello. Returns true if the port was rejected (caller should not continue\n* processing this hello).\n*/\nfunction rejectIfIncompatible(handle) {\n if (lockedConfig === null) return false;\n const incoming = handle.hello;\n if (incoming.apiUrl === lockedConfig.apiUrl && incoming.websocketUrl === lockedConfig.websocketUrl && incoming.clientSecret === lockedConfig.clientSecret) return false;\n handle.port.postMessage({\n type: \"dead\",\n reason: \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\"\n });\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n return true;\n}\n/**\n* If another already-connected port has the same `tabId`, this hello is from a duplicate\n* tab (browser \"Duplicate\" command clones sessionStorage). Mint a fresh `tabId` so the two\n* tabs don't collide on the same `(sessionId, tabId)` Recording. The new tabId is returned\n* to the tab in `welcome` so it can update its own state and sessionStorage.\n*/\nfunction detectDuplicateTab(handle) {\n const tabId = handle.hello.tabId;\n if (!ports.some((p) => p !== handle && p.hello?.tabId === tabId)) return;\n const fresh = crypto.randomUUID();\n handle.newTabId = fresh;\n handle.hello.tabId = fresh;\n}\nfunction onHello(handle) {\n switch (state.phase) {\n case \"init\":\n lockedConfig = {\n apiUrl: handle.hello.apiUrl,\n websocketUrl: handle.hello.websocketUrl,\n clientSecret: handle.hello.clientSecret\n };\n log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ?? \"(derive)\"} sessionIdHint=${handle.hello.sessionIdHint ?? \"(none)\"}`);\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n case \"initializing\": return;\n case \"active\":\n sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n return;\n case \"idle\": {\n const { client, sessionId, sessionToken } = state;\n state = { phase: \"initializing\" };\n resumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);\n return;\n }\n case \"skipping\":\n if (handle.hello.forceRecord) {\n log(\"forceRecord set; re-initializing from skipping state\");\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n }\n handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n return;\n case \"dead\":\n handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n return;\n default: break;\n }\n}\nasync function initializeSession(firstHello) {\n const client = new CsrClient(firstHello.apiUrl, firstHello.clientSecret, firstHello.context, firstHello.websocketUrl, log, firstHello.forceRecord);\n if (firstHello.sessionIdHint && firstHello.sessionTokenHint) {\n log(`adopting sessionIdHint=${firstHello.sessionIdHint}`);\n try {\n const transport = await client.openTransport(firstHello.sessionTokenHint);\n wireTransport(transport);\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: firstHello.sessionIdHint,\n sessionToken: firstHello.sessionTokenHint\n };\n log(\"hint adopted; transport open\");\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n return;\n } catch (err) {\n log(`hint rejected (${String(err)}); falling back to fresh init`);\n }\n }\n let result;\n try {\n result = await client.initSession();\n } catch (err) {\n log(`init-session threw: ${String(err)}`);\n transitionToDead(`init-session-failed: ${String(err)}`);\n return;\n }\n if (\"skipRecording\" in result) {\n log(\"init-session: skipRecording\");\n state = { phase: \"skipping\" };\n return;\n }\n log(`init-session ok sessionId=${result.sessionId}`);\n let transport;\n try {\n transport = await client.openTransport(result.sessionToken);\n } catch (err) {\n log(`openTransport threw: ${String(err)}`);\n transitionToDead(`open-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport open; session active\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: result.sessionId,\n sessionToken: result.sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nasync function resumeTransport(client, sessionId, sessionToken) {\n log(\"resuming transport from idle\");\n let transport;\n try {\n transport = await client.openTransport(sessionToken);\n } catch (err) {\n log(`resume-transport threw: ${String(err)}`);\n transitionToDead(`resume-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport resumed\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId,\n sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nfunction wireTransport(transport) {\n transport.onClose((info) => {\n if (state.phase !== \"active\") return;\n transitionToDead(info.reason);\n });\n transport.onStateChange((info) => {\n if (state.phase !== \"active\") return;\n for (const handle of ports) handle.port.postMessage({\n type: \"state\",\n connected: info.connected\n });\n });\n}\nfunction transitionToDead(reason) {\n state = {\n phase: \"dead\",\n reason\n };\n for (const handle of ports) handle.port.postMessage({\n type: \"dead\",\n reason\n });\n}\nfunction flushPendingWelcomes() {\n for (const handle of ports) {\n if (handle.hello === null) continue;\n if (state.phase === \"active\") sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n else if (state.phase === \"skipping\") handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n else if (state.phase === \"dead\") handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n }\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n const hint = handle.hello?.sessionIdHint;\n const adopted = hint !== void 0 && hint !== currentSessionId;\n const newTabId = handle.newTabId;\n handle.port.postMessage({\n type: \"welcome\",\n result: {\n sessionId: currentSessionId,\n sessionToken: currentSessionToken\n },\n adoptedFromSessionId: adopted ? hint : void 0,\n newTabId,\n resetCounter: adopted || newTabId !== void 0\n });\n}\nfunction onFrame(frame) {\n if (state.phase !== \"active\") return;\n state.transport.send(frame);\n}\nfunction onBye(handle) {\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n if (ports.length === 0 && state.phase === \"active\") {\n log(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);\n idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n }\n}\nfunction enterIdle() {\n idleTimer = null;\n if (state.phase !== \"active\" || ports.length > 0) return;\n log(\"idle timeout; closing transport\");\n state.transport.close(\"idle\");\n state = {\n phase: \"idle\",\n client: state.client,\n sessionId: state.sessionId,\n sessionToken: state.sessionToken\n };\n}\n//#endregion\n//#region src/uploader/worker/entry.ts\nconst SharedWorkerScopeCtor = globalThis.SharedWorkerGlobalScope;\nif (typeof SharedWorkerScopeCtor === \"function\" && self instanceof SharedWorkerScopeCtor) self.onconnect = (event) => {\n const port = event.ports[0];\n port.start();\n registerPort(adaptMessagePort(port));\n};\nelse registerPort(adaptDedicatedSelf());\nfunction adaptMessagePort(port) {\n return {\n postMessage: (message) => port.postMessage(message),\n onmessage: (cb) => {\n port.onmessage = (e) => cb(e.data);\n }\n };\n}\nfunction adaptDedicatedSelf() {\n const ws = self;\n return {\n postMessage: (message) => ws.postMessage(message),\n onmessage: (cb) => {\n ws.onmessage = (e) => cb(e.data);\n }\n };\n}\n//#endregion\n";
|
|
30
|
+
const workerScript = "//#region src/uploader/worker/web-socket-transport.ts\n/**\n* WebSocket-backed Transport. Internal retry policy: clean server-initiated close after the\n* first successful open → reconnect and resume; abrupt close (or any close before the first\n* open) → fire `onClose` and stop. Frames received while a (re)connect is in progress are\n* buffered and flushed on open.\n*\n* `ready()` resolves on the first successful open and rejects on close-before-open. Callers\n* should await it before treating the Transport as live, so a failure to open can be caught\n* (e.g. 4404 unknown session) and recovered from.\n*/\nvar WebSocketTransport = class {\n url;\n ws = null;\n onCloseCb = null;\n onStateChangeCb = null;\n intentionallyClosed = false;\n dead = false;\n /** Frames buffered while a (re)connect is in progress. */\n pending = [];\n readyPromise;\n protocols;\n constructor(url, protocols = []) {\n this.url = url;\n this.protocols = [...protocols];\n this.readyPromise = new Promise((resolve, reject) => {\n this.connect(false, resolve, reject);\n });\n this.readyPromise.catch(() => {});\n }\n ready() {\n return this.readyPromise;\n }\n send(frame) {\n if (this.dead || this.intentionallyClosed) return;\n if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));\n else this.pending.push(frame);\n }\n close(reason = \"transport-close\") {\n this.intentionallyClosed = true;\n this.ws?.close(1e3, reason);\n }\n onClose(cb) {\n this.onCloseCb = cb;\n }\n onStateChange(cb) {\n this.onStateChangeCb = cb;\n }\n connect(isReconnect, onReady, onReadyFail) {\n let ws;\n try {\n ws = new WebSocket(this.url, [...this.protocols]);\n } catch (_error) {\n this.failConnection(isReconnect, onReadyFail);\n return;\n }\n this.ws = ws;\n let opened = false;\n ws.onopen = () => {\n const expectedProtocol = this.protocols[0];\n if (expectedProtocol !== void 0 && ws.protocol !== expectedProtocol) {\n this.failConnection(isReconnect, onReadyFail);\n ws.close(1e3, \"protocol-mismatch\");\n return;\n }\n opened = true;\n onReady?.();\n if (isReconnect) this.onStateChangeCb?.({ connected: true });\n while (this.pending.length > 0) {\n const f = this.pending.shift();\n ws.send(JSON.stringify(f));\n }\n };\n ws.onclose = (event) => {\n if (this.intentionallyClosed || this.dead) return;\n if (!opened) {\n this.failConnection(isReconnect, onReadyFail);\n return;\n }\n if (event.wasClean && (event.code === 1e3 || event.code === 1001)) {\n this.onStateChangeCb?.({ connected: false });\n this.connect(true);\n } else this.die(`close code=${event.code} wasClean=${event.wasClean}`);\n };\n }\n failConnection(isReconnect, onReadyFail) {\n const reason = isReconnect ? \"reconnect-failed\" : \"initial-failed\";\n if (onReadyFail) {\n this.dead = true;\n onReadyFail(new Error(reason));\n } else this.die(reason);\n }\n die(reason) {\n this.dead = true;\n this.onCloseCb?.({ reason });\n }\n};\n//#endregion\n//#region src/uploader/worker/websocket-auth.ts\nconst RECORDING_PROTOCOL = \"recording.v1\";\nconst AUTH_PROTOCOL_PREFIX = \"auth.\";\nconst MAX_TOKEN_LENGTH = 4096;\nfunction recordingProtocols(sessionToken) {\n if (sessionToken.length > MAX_TOKEN_LENGTH) throw new Error(\"Session token is too long for WebSocket authentication\");\n if (!sessionToken || /[^A-Za-z0-9._-]/.test(sessionToken)) throw new Error(\"Invalid session token for WebSocket authentication\");\n return [RECORDING_PROTOCOL, `${AUTH_PROTOCOL_PREFIX}${sessionToken}`];\n}\n//#endregion\n//#region src/uploader/worker/csr-client.ts\n/**\n* Single Client implementation that talks to the recording backend's REST + WS protocol.\n* Both dev-server and prod implement the same protocol, so we don't need polymorphism here yet.\n*/\nvar CsrClient = class {\n apiUrl;\n clientSecret;\n context;\n websocketUrl;\n log;\n forceRecord;\n constructor(apiUrl, clientSecret, context, websocketUrl, log = () => {}, forceRecord) {\n this.apiUrl = apiUrl;\n this.clientSecret = clientSecret;\n this.context = context;\n this.websocketUrl = websocketUrl;\n this.log = log;\n this.forceRecord = forceRecord;\n }\n async initSession() {\n const url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;\n this.log(`fetch POST ${url}`);\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n clientSecret: this.clientSecret,\n ...this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {},\n ...this.forceRecord ? { forceRecord: true } : {}\n })\n });\n if (!res.ok) throw new Error(`init-session failed: HTTP ${res.status}`);\n const data = await res.json();\n if (data.skipRecording) return { skipRecording: true };\n if (!data.sessionId || !data.sessionToken) throw new Error(\"init-session response missing sessionId or sessionToken\");\n return {\n sessionId: data.sessionId,\n sessionToken: data.sessionToken\n };\n }\n async openTransport(sessionToken) {\n const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;\n let parsedUrl;\n try {\n parsedUrl = new URL(wsBase);\n } catch (_error) {\n throw new Error(\"Invalid WebSocket URL\");\n }\n if (parsedUrl.searchParams.has(\"session_token\")) throw new Error(\"WebSocket URL must not include a session token\");\n const protocols = recordingProtocols(sessionToken);\n this.log(`WebSocket connect ${wsBase}`);\n const transport = new WebSocketTransport(wsBase, protocols);\n await transport.ready();\n return transport;\n }\n trimSlash(s) {\n return s.endsWith(\"/\") ? s.slice(0, -1) : s;\n }\n toWsScheme(base) {\n if (base.startsWith(\"https://\")) return `wss://${base.slice(8)}`;\n if (base.startsWith(\"http://\")) return `ws://${base.slice(7)}`;\n return base;\n }\n};\n//#endregion\n//#region src/uploader/worker/core.ts\nconst WORKER_HASH = globalThis.__WORKER_HASH__;\nconst IDLE_GRACE_MS = 5e3;\nlet state = { phase: \"init\" };\nconst ports = [];\nlet idleTimer = null;\nfunction cancelIdleTimer() {\n if (idleTimer !== null) {\n clearTimeout(idleTimer);\n idleTimer = null;\n }\n}\nfunction log(msg) {\n for (const handle of ports) if (handle.debugLogs) handle.port.postMessage({\n type: \"log\",\n msg\n });\n}\n/**\n* The first hello \"locks in\" the session's apiUrl/clientSecret. Any later tab arriving\n* with different values is misconfigured — we reject it rather than silently using the\n* locked values. In SharedWorker mode the `name = hash(clientSecret)` scoping already\n* prevents secret-mismatch from sharing a worker, but this defends against the dedicated\n* path and against future bugs.\n*/\nlet lockedConfig = null;\nfunction registerPort(adapter) {\n cancelIdleTimer();\n const handle = {\n port: adapter,\n hello: null,\n debugLogs: false\n };\n ports.push(handle);\n adapter.onmessage((data) => {\n handleMessage(handle, data);\n });\n}\nfunction handleMessage(handle, message) {\n switch (message.type) {\n case \"hello\":\n handle.hello = message;\n handle.debugLogs = message.debugLogs ?? false;\n if (rejectIfIncompatible(handle)) return;\n if (state.phase !== \"dead\" && state.phase !== \"skipping\") detectDuplicateTab(handle);\n onHello(handle);\n return;\n case \"frame\":\n onFrame(message.frame);\n return;\n case \"bye\":\n onBye(handle);\n return;\n default: break;\n }\n}\n/**\n* Reject hellos whose `apiUrl`/`clientSecret` don't match the values established by the\n* first hello. Returns true if the port was rejected (caller should not continue\n* processing this hello).\n*/\nfunction rejectIfIncompatible(handle) {\n if (lockedConfig === null) return false;\n const incoming = handle.hello;\n if (incoming.apiUrl === lockedConfig.apiUrl && incoming.websocketUrl === lockedConfig.websocketUrl && incoming.clientSecret === lockedConfig.clientSecret) return false;\n handle.port.postMessage({\n type: \"dead\",\n reason: \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\"\n });\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n return true;\n}\n/**\n* If another already-connected port has the same `tabId`, this hello is from a duplicate\n* tab (browser \"Duplicate\" command clones sessionStorage). Mint a fresh `tabId` so the two\n* tabs don't collide on the same `(sessionId, tabId)` Recording. The new tabId is returned\n* to the tab in `welcome` so it can update its own state and sessionStorage.\n*/\nfunction detectDuplicateTab(handle) {\n const tabId = handle.hello.tabId;\n if (!ports.some((p) => p !== handle && p.hello?.tabId === tabId)) return;\n const fresh = crypto.randomUUID();\n handle.newTabId = fresh;\n handle.hello.tabId = fresh;\n}\nfunction onHello(handle) {\n switch (state.phase) {\n case \"init\":\n lockedConfig = {\n apiUrl: handle.hello.apiUrl,\n websocketUrl: handle.hello.websocketUrl,\n clientSecret: handle.hello.clientSecret\n };\n log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ? \"(configured)\" : \"(derive)\"} sessionIdHint=${handle.hello.sessionIdHint ?? \"(none)\"}`);\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n case \"initializing\": return;\n case \"active\":\n sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n return;\n case \"idle\": {\n const { client, sessionId, sessionToken } = state;\n state = { phase: \"initializing\" };\n resumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);\n return;\n }\n case \"skipping\":\n if (handle.hello.forceRecord) {\n log(\"forceRecord set; re-initializing from skipping state\");\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n }\n handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true },\n workerHash: WORKER_HASH\n });\n return;\n case \"dead\":\n handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n return;\n default: break;\n }\n}\nasync function initializeSession(firstHello) {\n const client = new CsrClient(firstHello.apiUrl, firstHello.clientSecret, firstHello.context, firstHello.websocketUrl, log, firstHello.forceRecord);\n if (firstHello.sessionIdHint && firstHello.sessionTokenHint) {\n log(`adopting sessionIdHint=${firstHello.sessionIdHint}`);\n try {\n const transport = await client.openTransport(firstHello.sessionTokenHint);\n wireTransport(transport);\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: firstHello.sessionIdHint,\n sessionToken: firstHello.sessionTokenHint\n };\n log(\"hint adopted; transport open\");\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n return;\n } catch (err) {\n log(`hint rejected (${String(err)}); falling back to fresh init`);\n }\n }\n let result;\n try {\n result = await client.initSession();\n } catch (err) {\n log(`init-session threw: ${String(err)}`);\n transitionToDead(`init-session-failed: ${String(err)}`);\n return;\n }\n if (\"skipRecording\" in result) {\n log(\"init-session: skipRecording\");\n state = { phase: \"skipping\" };\n return;\n }\n log(`init-session ok sessionId=${result.sessionId}`);\n let transport;\n try {\n transport = await client.openTransport(result.sessionToken);\n } catch (err) {\n log(`openTransport threw: ${String(err)}`);\n transitionToDead(`open-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport open; session active\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: result.sessionId,\n sessionToken: result.sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nasync function resumeTransport(client, sessionId, sessionToken) {\n log(\"resuming transport from idle\");\n let transport;\n try {\n transport = await client.openTransport(sessionToken);\n } catch (err) {\n log(`resume-transport threw: ${String(err)}`);\n transitionToDead(`resume-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport resumed\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId,\n sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nfunction wireTransport(transport) {\n transport.onClose((info) => {\n if (state.phase !== \"active\") return;\n transitionToDead(info.reason);\n });\n transport.onStateChange((info) => {\n if (state.phase !== \"active\") return;\n for (const handle of ports) handle.port.postMessage({\n type: \"state\",\n connected: info.connected\n });\n });\n}\nfunction transitionToDead(reason) {\n state = {\n phase: \"dead\",\n reason\n };\n for (const handle of ports) handle.port.postMessage({\n type: \"dead\",\n reason\n });\n}\nfunction flushPendingWelcomes() {\n for (const handle of ports) {\n if (handle.hello === null) continue;\n if (state.phase === \"active\") sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n else if (state.phase === \"skipping\") handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true },\n workerHash: WORKER_HASH\n });\n else if (state.phase === \"dead\") handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n }\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n const hint = handle.hello?.sessionIdHint;\n const adopted = hint !== void 0 && hint !== currentSessionId;\n const newTabId = handle.newTabId;\n handle.port.postMessage({\n type: \"welcome\",\n result: {\n sessionId: currentSessionId,\n sessionToken: currentSessionToken\n },\n workerHash: WORKER_HASH,\n adoptedFromSessionId: adopted ? hint : void 0,\n newTabId,\n resetCounter: adopted || newTabId !== void 0\n });\n}\nfunction onFrame(frame) {\n if (state.phase !== \"active\") return;\n state.transport.send(frame);\n}\nfunction onBye(handle) {\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n if (ports.length === 0 && state.phase === \"active\") {\n log(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);\n idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n }\n}\nfunction enterIdle() {\n idleTimer = null;\n if (state.phase !== \"active\" || ports.length > 0) return;\n log(\"idle timeout; closing transport\");\n state.transport.close(\"idle\");\n state = {\n phase: \"idle\",\n client: state.client,\n sessionId: state.sessionId,\n sessionToken: state.sessionToken\n };\n}\n//#endregion\n//#region src/uploader/worker/entry.ts\nconst SharedWorkerScopeCtor = globalThis.SharedWorkerGlobalScope;\nif (typeof SharedWorkerScopeCtor === \"function\" && self instanceof SharedWorkerScopeCtor) self.onconnect = (event) => {\n const port = event.ports[0];\n port.start();\n registerPort(adaptMessagePort(port));\n};\nelse registerPort(adaptDedicatedSelf());\nfunction adaptMessagePort(port) {\n return {\n postMessage: (message) => port.postMessage(message),\n onmessage: (cb) => {\n port.onmessage = (e) => cb(e.data);\n }\n };\n}\nfunction adaptDedicatedSelf() {\n const ws = self;\n return {\n postMessage: (message) => ws.postMessage(message),\n onmessage: (cb) => {\n ws.onmessage = (e) => cb(e.data);\n }\n };\n}\n//#endregion\n";
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/uploader/worker-hash.ts
|
|
33
|
+
const WORKER_HASH = "1774b236fa1eb46a";
|
|
31
34
|
//#endregion
|
|
32
35
|
//#region src/uploader/create-uploader.ts
|
|
33
36
|
const STORAGE_TAB_ID = "csr:tabId";
|
|
@@ -117,6 +120,7 @@ async function createUploader(opts) {
|
|
|
117
120
|
return msg;
|
|
118
121
|
}), welcomeDeadline]);
|
|
119
122
|
log?.(welcome.type === "welcome" ? `tab: welcome (${"sessionId" in welcome.result ? `sessionId=${welcome.result.sessionId}` : "skipRecording"})` : `tab: dead reason=${welcome.reason}`);
|
|
123
|
+
if (welcome.type === "welcome" && urlScheme === "custom" && welcome.workerHash !== "1774b236fa1eb46a") 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");
|
|
120
124
|
if (welcome.type === "dead") throw new Error(`uploader: ${welcome.reason}`);
|
|
121
125
|
if ("skipRecording" in welcome.result) {
|
|
122
126
|
if (opts.forceRecord) log?.("tab: forceRecord was set but backend still skipped — backend may not support forceRecord yet");
|
|
@@ -325,4 +329,4 @@ function writeCounter(counter) {
|
|
|
325
329
|
sessionStorage.setItem(STORAGE_COUNTER, String(counter));
|
|
326
330
|
}
|
|
327
331
|
//#endregion
|
|
328
|
-
export { collectUserAgentContext, createUploader, workerScript };
|
|
332
|
+
export { WORKER_HASH, collectUserAgentContext, createUploader, workerScript };
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -14,15 +14,30 @@ import { onTestFinished, vi } from 'vitest';
|
|
|
14
14
|
* Must be called from within a test (or a helper called from one) so vitest
|
|
15
15
|
* has a test context to attach the cleanup to.
|
|
16
16
|
*/
|
|
17
|
-
|
|
17
|
+
interface MockWsServerOptions {
|
|
18
|
+
selectProtocol?: (protocols: string[], connectionIndex: number) => string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function installMockWsServer(
|
|
22
|
+
url: string,
|
|
23
|
+
options: MockWsServerOptions = {},
|
|
24
|
+
): {
|
|
18
25
|
server: Server;
|
|
19
26
|
connections: Client[];
|
|
20
27
|
messages: string[];
|
|
28
|
+
protocolOffers: string[][];
|
|
21
29
|
waitForConnection: () => Promise<Client>;
|
|
22
30
|
nextMessage: () => Promise<string>;
|
|
23
31
|
nextMessages: (n: number) => Promise<string[]>;
|
|
24
32
|
} {
|
|
25
|
-
const
|
|
33
|
+
const protocolOffers: string[][] = [];
|
|
34
|
+
const server = new Server(url, {
|
|
35
|
+
selectProtocol: protocols => {
|
|
36
|
+
const offer = [...protocols];
|
|
37
|
+
protocolOffers.push(offer);
|
|
38
|
+
return options.selectProtocol?.(offer, protocolOffers.length - 1) ?? offer[0] ?? '';
|
|
39
|
+
},
|
|
40
|
+
});
|
|
26
41
|
const connections: Client[] = [];
|
|
27
42
|
const messages: string[] = [];
|
|
28
43
|
let nextConnIndex = 0;
|
|
@@ -71,6 +86,7 @@ export function installMockWsServer(url: string): {
|
|
|
71
86
|
server,
|
|
72
87
|
connections,
|
|
73
88
|
messages,
|
|
89
|
+
protocolOffers,
|
|
74
90
|
waitForConnection,
|
|
75
91
|
nextMessage,
|
|
76
92
|
nextMessages,
|
|
@@ -84,6 +84,33 @@ describe('collectUserAgentContext', () => {
|
|
|
84
84
|
});
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
it.each([
|
|
88
|
+
['https://referrer.example/path?email=alice@example.com#token', 'https://referrer.example/path'],
|
|
89
|
+
['https://referrer.example/path#token?email=alice@example.com', 'https://referrer.example/path'],
|
|
90
|
+
['https://referrer.example/path?email=alice@example.com', 'https://referrer.example/path'],
|
|
91
|
+
['https://referrer.example/path#token', 'https://referrer.example/path'],
|
|
92
|
+
['', ''],
|
|
93
|
+
['invalid-url?email=alice@example.com#token', 'invalid-url'],
|
|
94
|
+
])('strips query/hash from referrer %s', (referrer, expected) => {
|
|
95
|
+
stubBrowser({ document: { referrer } });
|
|
96
|
+
expect(collectUserAgentContext()?.referrer).toBe(expected);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('excludes query/hash from the initial document URI', () => {
|
|
100
|
+
stubBrowser({
|
|
101
|
+
window: {
|
|
102
|
+
location: {
|
|
103
|
+
origin: 'https://example.com',
|
|
104
|
+
pathname: '/path',
|
|
105
|
+
search: '?email=alice@example.com',
|
|
106
|
+
hash: '#token',
|
|
107
|
+
href: 'https://example.com/path?email=alice@example.com#token',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
expect(collectUserAgentContext()?.uri).toBe('https://example.com/path');
|
|
112
|
+
});
|
|
113
|
+
|
|
87
114
|
it('flags `mobile` platform type as mobile=true', () => {
|
|
88
115
|
parse.mockReturnValueOnce({
|
|
89
116
|
os: {},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Bowser from 'bowser';
|
|
2
|
+
import { stripUrlQueryAndHash } from '../url';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* JSON-shaped values accepted in a Context — matches `google.protobuf.Struct`.
|
|
@@ -31,6 +32,7 @@ export type UserAgentContext = {
|
|
|
31
32
|
devicePixelRatio?: number;
|
|
32
33
|
/** Initial document URI — without query/hash to avoid leaking PII. */
|
|
33
34
|
uri?: string;
|
|
35
|
+
/** Document referrer — without query/hash to avoid leaking PII. */
|
|
34
36
|
referrer?: string;
|
|
35
37
|
};
|
|
36
38
|
|
|
@@ -65,6 +67,6 @@ export function collectUserAgentContext(): UserAgentContext | undefined {
|
|
|
65
67
|
screenHeight: window.screen.height,
|
|
66
68
|
devicePixelRatio: window.devicePixelRatio,
|
|
67
69
|
uri: `${window.location.origin}${window.location.pathname}`,
|
|
68
|
-
referrer: document.referrer,
|
|
70
|
+
referrer: stripUrlQueryAndHash(document.referrer),
|
|
69
71
|
};
|
|
70
72
|
}
|
|
@@ -97,6 +97,7 @@ describe('createUploader', () => {
|
|
|
97
97
|
|
|
98
98
|
async function loadCreateUploader() {
|
|
99
99
|
vi.resetModules();
|
|
100
|
+
vi.doMock('./worker-hash', () => ({ WORKER_HASH: 'expected-hash' }));
|
|
100
101
|
// .js extension is required by Node16 module resolution for dynamic imports
|
|
101
102
|
// (static `import` lines work without it because the package is CJS — see package.json).
|
|
102
103
|
// eslint-disable-next-line es/no-dynamic-import
|
|
@@ -309,6 +310,110 @@ describe('createUploader', () => {
|
|
|
309
310
|
});
|
|
310
311
|
});
|
|
311
312
|
|
|
313
|
+
describe('worker hash mismatch', () => {
|
|
314
|
+
beforeEach(() => {
|
|
315
|
+
(globalThis as Record<string, unknown>).window = { addEventListener: vi.fn() };
|
|
316
|
+
(globalThis as Record<string, unknown>).document = { addEventListener: vi.fn() };
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
afterEach(() => {
|
|
320
|
+
delete (globalThis as Record<string, unknown>).window;
|
|
321
|
+
delete (globalThis as Record<string, unknown>).document;
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
function workerThatReplies(welcomeOverrides: Record<string, unknown> = {}) {
|
|
325
|
+
return class {
|
|
326
|
+
onerror: ((e: Event) => void) | null = null;
|
|
327
|
+
onmessage: ((e: MessageEvent) => void) | null = null;
|
|
328
|
+
postMessage(m: unknown) {
|
|
329
|
+
const msg = m as { type: string };
|
|
330
|
+
if (msg.type === 'hello') {
|
|
331
|
+
setTimeout(
|
|
332
|
+
() =>
|
|
333
|
+
this.onmessage?.({
|
|
334
|
+
data: {
|
|
335
|
+
type: 'welcome',
|
|
336
|
+
result: { sessionId: 'sess-1', sessionToken: 'tok-1' },
|
|
337
|
+
workerHash: 'stale-hash',
|
|
338
|
+
...welcomeOverrides,
|
|
339
|
+
},
|
|
340
|
+
} as MessageEvent),
|
|
341
|
+
0,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function loadCreateUploaderWithMockedContext() {
|
|
349
|
+
vi.doMock('./client-context', () => ({ collectUserAgentContext: () => null }));
|
|
350
|
+
return loadCreateUploader();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
it('logs a warning when workerUrl is set and hashes mismatch', async () => {
|
|
354
|
+
(globalThis as Record<string, unknown>).Worker = workerThatReplies();
|
|
355
|
+
delete (globalThis as Record<string, unknown>).SharedWorker;
|
|
356
|
+
|
|
357
|
+
const createUploader = await loadCreateUploaderWithMockedContext();
|
|
358
|
+
const logs: string[] = [];
|
|
359
|
+
await createUploader({
|
|
360
|
+
...DEFAULTS,
|
|
361
|
+
workerMode: 'dedicated',
|
|
362
|
+
workerUrl: '/confidence-worker.js',
|
|
363
|
+
debugLogger: m => logs.push(m),
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
expect(logs.some(l => l.includes('WORKER MISMATCH'))).toBe(true);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('does not warn when using data: URL (inlined worker)', async () => {
|
|
370
|
+
(globalThis as Record<string, unknown>).Worker = workerThatReplies();
|
|
371
|
+
delete (globalThis as Record<string, unknown>).SharedWorker;
|
|
372
|
+
|
|
373
|
+
const createUploader = await loadCreateUploaderWithMockedContext();
|
|
374
|
+
const logs: string[] = [];
|
|
375
|
+
await createUploader({
|
|
376
|
+
...DEFAULTS,
|
|
377
|
+
workerMode: 'dedicated',
|
|
378
|
+
debugLogger: m => logs.push(m),
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
expect(logs.some(l => l.includes('WORKER MISMATCH'))).toBe(false);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('does not warn when hashes match', async () => {
|
|
385
|
+
(globalThis as Record<string, unknown>).Worker = workerThatReplies({ workerHash: 'expected-hash' });
|
|
386
|
+
delete (globalThis as Record<string, unknown>).SharedWorker;
|
|
387
|
+
|
|
388
|
+
const createUploader = await loadCreateUploaderWithMockedContext();
|
|
389
|
+
const logs: string[] = [];
|
|
390
|
+
await createUploader({
|
|
391
|
+
...DEFAULTS,
|
|
392
|
+
workerMode: 'dedicated',
|
|
393
|
+
workerUrl: '/confidence-worker.js',
|
|
394
|
+
debugLogger: m => logs.push(m),
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
expect(logs.some(l => l.includes('WORKER MISMATCH'))).toBe(false);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('warns when workerHash is absent (old worker)', async () => {
|
|
401
|
+
(globalThis as Record<string, unknown>).Worker = workerThatReplies({ workerHash: undefined });
|
|
402
|
+
delete (globalThis as Record<string, unknown>).SharedWorker;
|
|
403
|
+
|
|
404
|
+
const createUploader = await loadCreateUploaderWithMockedContext();
|
|
405
|
+
const logs: string[] = [];
|
|
406
|
+
await createUploader({
|
|
407
|
+
...DEFAULTS,
|
|
408
|
+
workerMode: 'dedicated',
|
|
409
|
+
workerUrl: '/confidence-worker.js',
|
|
410
|
+
debugLogger: m => logs.push(m),
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
expect(logs.some(l => l.includes('WORKER MISMATCH'))).toBe(true);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
312
417
|
describe('welcome timeout', () => {
|
|
313
418
|
it('rejects when the worker never sends a welcome', async () => {
|
|
314
419
|
(globalThis as Record<string, unknown>).Worker = class {
|
|
@@ -2,6 +2,7 @@ import type { CreateUploaderOptions, Frame, Uploader } from './types';
|
|
|
2
2
|
import { ClientContext, collectUserAgentContext } from './client-context';
|
|
3
3
|
import { isSessionActivityEvent } from '../session-activity';
|
|
4
4
|
import { workerScript } from './worker/worker-script';
|
|
5
|
+
import { WORKER_HASH } from './worker-hash';
|
|
5
6
|
|
|
6
7
|
const STORAGE_TAB_ID = 'csr:tabId';
|
|
7
8
|
const STORAGE_SESSION = 'csr:session';
|
|
@@ -18,6 +19,7 @@ interface PortLike {
|
|
|
18
19
|
interface WelcomeMessage {
|
|
19
20
|
type: 'welcome';
|
|
20
21
|
result: { sessionId: string; sessionToken: string } | { skipRecording: true };
|
|
22
|
+
workerHash?: string;
|
|
21
23
|
adoptedFromSessionId?: string;
|
|
22
24
|
/** Worker-assigned fresh tabId because this tab is a duplicate of another live one. */
|
|
23
25
|
newTabId?: string;
|
|
@@ -154,6 +156,12 @@ export async function createUploader(opts: CreateUploaderOptions): Promise<Uploa
|
|
|
154
156
|
? `tab: welcome (${'sessionId' in welcome.result ? `sessionId=${welcome.result.sessionId}` : 'skipRecording'})`
|
|
155
157
|
: `tab: dead reason=${welcome.reason}`,
|
|
156
158
|
);
|
|
159
|
+
if (welcome.type === 'welcome' && urlScheme === 'custom' && welcome.workerHash !== WORKER_HASH) {
|
|
160
|
+
log?.(
|
|
161
|
+
'tab: WORKER MISMATCH — the self-hosted confidence-worker.js does not match the installed SDK. ' +
|
|
162
|
+
'Copy the updated file from node_modules/@spotify-confidence/session-recording/dist/confidence-worker.js',
|
|
163
|
+
);
|
|
164
|
+
}
|
|
157
165
|
if (welcome.type === 'dead') {
|
|
158
166
|
throw new Error(`uploader: ${welcome.reason}`);
|
|
159
167
|
}
|
package/src/uploader/index.ts
CHANGED
package/src/uploader/types.ts
CHANGED
|
@@ -10,11 +10,12 @@ export interface CreateUploaderOptions {
|
|
|
10
10
|
apiUrl: string;
|
|
11
11
|
/**
|
|
12
12
|
* URL of the WebSocket ingest endpoint, including the path (e.g.
|
|
13
|
-
* `wss://recording-ws.confidence.dev/sessions/stream`)
|
|
14
|
-
* the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* `wss://recording-ws.confidence.dev/sessions/stream`). The worker sends the session
|
|
14
|
+
* token through the WebSocket subprotocol header. A `session_token` query parameter is
|
|
15
|
+
* rejected. Other query parameters are retained. Optional: when omitted the worker
|
|
16
|
+
* derives one from `apiUrl` by swapping `http(s)://` → `ws(s)://` and appending
|
|
17
|
+
* `/sessions/stream`. Set this when the init endpoint and the WS ingest live on different
|
|
18
|
+
* hosts (e.g. prod).
|
|
18
19
|
*/
|
|
19
20
|
websocketUrl?: string;
|
|
20
21
|
/** Per-tenant secret. Hashed to scope the SharedWorker so different secrets never share a session, and sent in the `initSession` request body. */
|
|
@@ -55,7 +56,7 @@ export interface CreateUploaderOptions {
|
|
|
55
56
|
onTerminate?: (info: { reason: string }) => void;
|
|
56
57
|
/**
|
|
57
58
|
* Optional verbose tracer. Called on key tab- and worker-side events
|
|
58
|
-
* (hello/welcome, init-session URL, ws connect URL, retries, transitions).
|
|
59
|
+
* (hello/welcome, init-session URL, credential-free ws connect URL, retries, transitions).
|
|
59
60
|
* Worker messages are forwarded over the port and tagged so you can tell them apart.
|
|
60
61
|
*/
|
|
61
62
|
debugLogger?: (msg: string) => void;
|
|
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
import { createMockPort, installMockFetch, installMockWsServer, jsonResponse } from '../../test-utils';
|
|
3
3
|
|
|
4
4
|
const API_URL = 'https://api.example';
|
|
5
|
-
const WS_URL = 'wss://api.example/sessions/stream
|
|
5
|
+
const WS_URL = 'wss://api.example/sessions/stream';
|
|
6
6
|
|
|
7
7
|
async function loadCore() {
|
|
8
8
|
vi.resetModules();
|
|
@@ -27,6 +27,7 @@ const isType = (type: string) => (m: unknown) => (m as { type: string }).type ==
|
|
|
27
27
|
interface WelcomeMessage {
|
|
28
28
|
type: 'welcome';
|
|
29
29
|
result: { sessionId: string; sessionToken: string } | { skipRecording: true };
|
|
30
|
+
workerHash?: string;
|
|
30
31
|
newTabId?: string;
|
|
31
32
|
resetCounter?: boolean;
|
|
32
33
|
adoptedFromSessionId?: string;
|
|
@@ -37,15 +38,18 @@ interface DeadMessage {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
describe('worker/core', () => {
|
|
40
|
-
function setupBackend(
|
|
41
|
+
function setupBackend(
|
|
42
|
+
initBody: unknown = { sessionId: 'sess-1', sessionToken: 'tok-1' },
|
|
43
|
+
selectProtocol?: (protocols: string[], connectionIndex: number) => string,
|
|
44
|
+
) {
|
|
41
45
|
const fetchHarness = installMockFetch(() => jsonResponse(initBody));
|
|
42
|
-
const wsHarness = installMockWsServer(WS_URL);
|
|
46
|
+
const wsHarness = installMockWsServer(WS_URL, { selectProtocol });
|
|
43
47
|
return { fetchHarness, wsHarness };
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
describe('first hello', () => {
|
|
47
51
|
it('runs initSession + openTransport, then sends welcome', async () => {
|
|
48
|
-
const { fetchHarness } = setupBackend();
|
|
52
|
+
const { fetchHarness, wsHarness } = setupBackend();
|
|
49
53
|
const { registerPort } = await loadCore();
|
|
50
54
|
const port = createMockPort();
|
|
51
55
|
registerPort(port.adapter);
|
|
@@ -59,6 +63,34 @@ describe('worker/core', () => {
|
|
|
59
63
|
sessionId: 'sess-1',
|
|
60
64
|
sessionToken: 'tok-1',
|
|
61
65
|
});
|
|
66
|
+
expect(wsHarness.connections[0].url).toBe(WS_URL);
|
|
67
|
+
expect(wsHarness.protocolOffers).toEqual([['recording.v1', 'auth.tok-1']]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('includes workerHash in welcome when set on globalThis', async () => {
|
|
71
|
+
(globalThis as Record<string, unknown>).__WORKER_HASH__ = 'abc123';
|
|
72
|
+
setupBackend();
|
|
73
|
+
const { registerPort } = await loadCore();
|
|
74
|
+
const port = createMockPort();
|
|
75
|
+
registerPort(port.adapter);
|
|
76
|
+
|
|
77
|
+
port.tabSends(helloMessage());
|
|
78
|
+
const welcome = await port.next<WelcomeMessage>(isType('welcome'));
|
|
79
|
+
|
|
80
|
+
expect(welcome.workerHash).toBe('abc123');
|
|
81
|
+
delete (globalThis as Record<string, unknown>).__WORKER_HASH__;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('workerHash is undefined when not set on globalThis', async () => {
|
|
85
|
+
setupBackend();
|
|
86
|
+
const { registerPort } = await loadCore();
|
|
87
|
+
const port = createMockPort();
|
|
88
|
+
registerPort(port.adapter);
|
|
89
|
+
|
|
90
|
+
port.tabSends(helloMessage());
|
|
91
|
+
const welcome = await port.next<WelcomeMessage>(isType('welcome'));
|
|
92
|
+
|
|
93
|
+
expect(welcome.workerHash).toBeUndefined();
|
|
62
94
|
});
|
|
63
95
|
|
|
64
96
|
it('replies with skipRecording when the backend opts out', async () => {
|
|
@@ -222,9 +254,119 @@ describe('worker/core', () => {
|
|
|
222
254
|
expect(debug.received.some(isType('log'))).toBe(true);
|
|
223
255
|
expect(quiet.received.some(isType('log'))).toBe(false);
|
|
224
256
|
});
|
|
257
|
+
|
|
258
|
+
it('does not expose a session token through configured URLs', async () => {
|
|
259
|
+
const token = 'leaky-sensitive';
|
|
260
|
+
setupBackend({ sessionId: 'session-1', sessionToken: token });
|
|
261
|
+
const { registerPort } = await loadCore();
|
|
262
|
+
const port = createMockPort();
|
|
263
|
+
registerPort(port.adapter);
|
|
264
|
+
|
|
265
|
+
port.tabSends(
|
|
266
|
+
helloMessage({
|
|
267
|
+
websocketUrl: `${WS_URL}?session_token=${token}`,
|
|
268
|
+
sessionIdHint: 'session-1',
|
|
269
|
+
sessionTokenHint: token,
|
|
270
|
+
debugLogs: true,
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
await port.next<DeadMessage>(isType('dead'));
|
|
274
|
+
|
|
275
|
+
const logs = port.received.filter(isType('log')).map(message => (message as { msg: string }).msg);
|
|
276
|
+
expect(logs.length).toBeGreaterThan(0);
|
|
277
|
+
for (const log of logs) {
|
|
278
|
+
expect(log).not.toContain(token);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
225
281
|
});
|
|
226
282
|
|
|
227
283
|
describe('lifecycle after active', () => {
|
|
284
|
+
it('uses a fresh header credential when a session hint fails protocol negotiation', async () => {
|
|
285
|
+
const { fetchHarness, wsHarness } = setupBackend(
|
|
286
|
+
{ sessionId: 'fresh-session', sessionToken: 'fresh-sensitive' },
|
|
287
|
+
(protocols, connectionIndex) => (connectionIndex === 0 ? '' : protocols[0]),
|
|
288
|
+
);
|
|
289
|
+
const { registerPort } = await loadCore();
|
|
290
|
+
const port = createMockPort();
|
|
291
|
+
registerPort(port.adapter);
|
|
292
|
+
|
|
293
|
+
port.tabSends(
|
|
294
|
+
helloMessage({
|
|
295
|
+
sessionIdHint: 'stale-session',
|
|
296
|
+
sessionTokenHint: 'stale-sensitive',
|
|
297
|
+
debugLogs: true,
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
const welcome = await port.next<WelcomeMessage>(isType('welcome'));
|
|
301
|
+
|
|
302
|
+
expect(welcome.result).toEqual({
|
|
303
|
+
sessionId: 'fresh-session',
|
|
304
|
+
sessionToken: 'fresh-sensitive',
|
|
305
|
+
});
|
|
306
|
+
expect(fetchHarness.calls).toHaveLength(1);
|
|
307
|
+
expect(wsHarness.protocolOffers).toEqual([
|
|
308
|
+
['recording.v1', 'auth.stale-sensitive'],
|
|
309
|
+
['recording.v1', 'auth.fresh-sensitive'],
|
|
310
|
+
]);
|
|
311
|
+
expect(wsHarness.connections.every(connection => connection.url === WS_URL)).toBe(true);
|
|
312
|
+
const logs = port.received.filter(isType('log')).map(message => (message as { msg: string }).msg);
|
|
313
|
+
expect(logs.some(log => log.includes('sessionIdHint=stale-session'))).toBe(true);
|
|
314
|
+
expect(logs).toContain('adopting sessionIdHint=stale-session');
|
|
315
|
+
expect(logs).toContain('init-session ok sessionId=fresh-session');
|
|
316
|
+
for (const log of logs) {
|
|
317
|
+
expect(log).not.toContain('stale-sensitive');
|
|
318
|
+
expect(log).not.toContain('c3RhbGUtc2Vuc2l0aXZl');
|
|
319
|
+
expect(log).not.toContain('fresh-sensitive');
|
|
320
|
+
expect(log).not.toContain('ZnJlc2gtc2Vuc2l0aXZl');
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('stops when a hinted session upgrades with recording.v1 then immediately closes with 4401', async () => {
|
|
325
|
+
const { fetchHarness, wsHarness } = setupBackend();
|
|
326
|
+
// The API checks session state after the upgrade. A valid token for a missing or
|
|
327
|
+
// closed session therefore opens successfully before the server rejects it.
|
|
328
|
+
wsHarness.server.on('connection', () => {
|
|
329
|
+
setTimeout(() => wsHarness.server.close({ code: 4401, reason: 'Unauthorized', wasClean: true }), 0);
|
|
330
|
+
});
|
|
331
|
+
const { registerPort } = await loadCore();
|
|
332
|
+
const port = createMockPort();
|
|
333
|
+
registerPort(port.adapter);
|
|
334
|
+
|
|
335
|
+
port.tabSends(helloMessage({ sessionIdHint: 'stale-session', sessionTokenHint: 'stale-sensitive' }));
|
|
336
|
+
const welcome = await port.next<WelcomeMessage>(isType('welcome'));
|
|
337
|
+
const dead = await port.next<DeadMessage>(isType('dead'));
|
|
338
|
+
|
|
339
|
+
expect(welcome.result).toEqual({ sessionId: 'stale-session', sessionToken: 'stale-sensitive' });
|
|
340
|
+
expect(dead.reason).toBe('close code=4401 wasClean=true');
|
|
341
|
+
expect(fetchHarness.calls).toHaveLength(0);
|
|
342
|
+
expect(wsHarness.protocolOffers).toEqual([['recording.v1', 'auth.stale-sensitive']]);
|
|
343
|
+
expect(wsHarness.connections).toHaveLength(1);
|
|
344
|
+
expect(wsHarness.connections[0].url).toBe(WS_URL);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('stops after one failed header-authenticated reconnect', async () => {
|
|
348
|
+
const { fetchHarness, wsHarness } = setupBackend(undefined, (protocols, connectionIndex) =>
|
|
349
|
+
connectionIndex === 0 ? protocols[0] : '',
|
|
350
|
+
);
|
|
351
|
+
const { registerPort } = await loadCore();
|
|
352
|
+
const port = createMockPort();
|
|
353
|
+
registerPort(port.adapter);
|
|
354
|
+
|
|
355
|
+
port.tabSends(helloMessage({ debugLogs: true }));
|
|
356
|
+
await port.next<WelcomeMessage>(isType('welcome'));
|
|
357
|
+
const first = await wsHarness.waitForConnection();
|
|
358
|
+
first.close({ code: 1000, reason: 'drain', wasClean: true });
|
|
359
|
+
|
|
360
|
+
const dead = await port.next<DeadMessage>(isType('dead'));
|
|
361
|
+
expect(dead.reason).toBe('reconnect-failed');
|
|
362
|
+
expect(fetchHarness.calls).toHaveLength(1);
|
|
363
|
+
expect(wsHarness.protocolOffers).toEqual([
|
|
364
|
+
['recording.v1', 'auth.tok-1'],
|
|
365
|
+
['recording.v1', 'auth.tok-1'],
|
|
366
|
+
]);
|
|
367
|
+
expect(wsHarness.connections.every(connection => connection.url === WS_URL)).toBe(true);
|
|
368
|
+
});
|
|
369
|
+
|
|
228
370
|
it('broadcasts dead to all ports when the transport closes abruptly', async () => {
|
|
229
371
|
const { wsHarness } = setupBackend();
|
|
230
372
|
const { registerPort } = await loadCore();
|
|
@@ -2,6 +2,8 @@ import type { ClientContext } from '../client-context';
|
|
|
2
2
|
import type { Client, Frame, Transport } from '../types';
|
|
3
3
|
import { CsrClient } from './csr-client';
|
|
4
4
|
|
|
5
|
+
const WORKER_HASH: string | undefined = (globalThis as Record<string, unknown>).__WORKER_HASH__ as string | undefined;
|
|
6
|
+
|
|
5
7
|
/** Adapter so this module is unaware of whether it's running in a SharedWorker or a dedicated Worker. */
|
|
6
8
|
export interface PortAdapter {
|
|
7
9
|
postMessage(message: unknown): void;
|
|
@@ -173,7 +175,7 @@ function onHello(handle: PortHandle): void {
|
|
|
173
175
|
};
|
|
174
176
|
log(
|
|
175
177
|
`hello received apiUrl=${handle.hello!.apiUrl} websocketUrl=${
|
|
176
|
-
handle.hello!.websocketUrl
|
|
178
|
+
handle.hello!.websocketUrl ? '(configured)' : '(derive)'
|
|
177
179
|
} sessionIdHint=${handle.hello!.sessionIdHint ?? '(none)'}`,
|
|
178
180
|
);
|
|
179
181
|
state = { phase: 'initializing' };
|
|
@@ -202,6 +204,7 @@ function onHello(handle: PortHandle): void {
|
|
|
202
204
|
handle.port.postMessage({
|
|
203
205
|
type: 'welcome',
|
|
204
206
|
result: { skipRecording: true },
|
|
207
|
+
workerHash: WORKER_HASH,
|
|
205
208
|
});
|
|
206
209
|
return;
|
|
207
210
|
case 'dead':
|
|
@@ -332,6 +335,7 @@ function flushPendingWelcomes(): void {
|
|
|
332
335
|
handle.port.postMessage({
|
|
333
336
|
type: 'welcome',
|
|
334
337
|
result: { skipRecording: true },
|
|
338
|
+
workerHash: WORKER_HASH,
|
|
335
339
|
});
|
|
336
340
|
} else if (state.phase === 'dead') {
|
|
337
341
|
handle.port.postMessage({ type: 'dead', reason: state.reason });
|
|
@@ -346,6 +350,7 @@ function sendActiveWelcome(handle: PortHandle, currentSessionId: string, current
|
|
|
346
350
|
handle.port.postMessage({
|
|
347
351
|
type: 'welcome',
|
|
348
352
|
result: { sessionId: currentSessionId, sessionToken: currentSessionToken },
|
|
353
|
+
workerHash: WORKER_HASH,
|
|
349
354
|
adoptedFromSessionId: adopted ? hint : undefined,
|
|
350
355
|
newTabId,
|
|
351
356
|
resetCounter: adopted || newTabId !== undefined,
|