@spotify-confidence/csr-common 0.18.1 → 0.18.2
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 +12 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/uploader/index.cjs +2 -2
- package/dist/uploader/index.js +2 -2
- package/package.json +1 -1
- package/src/events.ts +1 -0
- package/src/uploader/worker/csr-client.test.ts +12 -0
- package/src/uploader/worker/csr-client.ts +1 -1
- package/src/uploader/worker/worker-script.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.18.2](https://github.com/spotify/confidence-sdk-js/compare/csr-common-v0.18.1...csr-common-v0.18.2) (2026-07-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### 🐛 Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **csr-common:** redact session_token from debug logs ([#387](https://github.com/spotify/confidence-sdk-js/issues/387)) ([114ca3d](https://github.com/spotify/confidence-sdk-js/commit/114ca3d008d8764f18515ca6b67a8b349e1fe068)), closes [#364](https://github.com/spotify/confidence-sdk-js/issues/364)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ✨ New Features
|
|
12
|
+
|
|
13
|
+
* **sdk:** add assignmentOrigin to window API ([#396](https://github.com/spotify/confidence-sdk-js/issues/396)) ([9dd17d5](https://github.com/spotify/confidence-sdk-js/commit/9dd17d5f3af787e39406785f85f0d2e79703e656))
|
|
14
|
+
|
|
3
15
|
## [0.18.1](https://github.com/spotify/confidence-sdk-js/compare/csr-common-v0.18.0...csr-common-v0.18.1) (2026-07-01)
|
|
4
16
|
|
|
5
17
|
|
package/dist/index.d.cts
CHANGED
package/dist/index.d.ts
CHANGED
package/dist/uploader/index.cjs
CHANGED
|
@@ -50,7 +50,7 @@ function collectUserAgentContext() {
|
|
|
50
50
|
}
|
|
51
51
|
//#endregion
|
|
52
52
|
//#region src/uploader/worker/worker-script.ts
|
|
53
|
-
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}`);\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";
|
|
53
|
+
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";
|
|
54
54
|
//#endregion
|
|
55
55
|
//#region src/uploader/create-uploader.ts
|
|
56
56
|
const STORAGE_TAB_ID = "csr:tabId";
|
|
@@ -196,7 +196,7 @@ function resolveMode(mode) {
|
|
|
196
196
|
return mode;
|
|
197
197
|
}
|
|
198
198
|
async function openWorkerPort(mode, clientSecret, workerUrl) {
|
|
199
|
-
const url = workerUrl ?? toDataUrl("//#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}`);\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");
|
|
199
|
+
const url = workerUrl ?? toDataUrl("//#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");
|
|
200
200
|
if (mode === "shared") {
|
|
201
201
|
const options = {
|
|
202
202
|
name: await hashSecret(clientSecret),
|
package/dist/uploader/index.js
CHANGED
|
@@ -26,7 +26,7 @@ function collectUserAgentContext() {
|
|
|
26
26
|
}
|
|
27
27
|
//#endregion
|
|
28
28
|
//#region src/uploader/worker/worker-script.ts
|
|
29
|
-
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}`);\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";
|
|
29
|
+
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
30
|
//#endregion
|
|
31
31
|
//#region src/uploader/create-uploader.ts
|
|
32
32
|
const STORAGE_TAB_ID = "csr:tabId";
|
|
@@ -172,7 +172,7 @@ function resolveMode(mode) {
|
|
|
172
172
|
return mode;
|
|
173
173
|
}
|
|
174
174
|
async function openWorkerPort(mode, clientSecret, workerUrl) {
|
|
175
|
-
const url = workerUrl ?? toDataUrl("//#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}`);\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");
|
|
175
|
+
const url = workerUrl ?? toDataUrl("//#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");
|
|
176
176
|
if (mode === "shared") {
|
|
177
177
|
const options = {
|
|
178
178
|
name: await hashSecret(clientSecret),
|
package/package.json
CHANGED
package/src/events.ts
CHANGED
|
@@ -95,6 +95,18 @@ describe('CsrClient.openTransport', () => {
|
|
|
95
95
|
await expect(client.openTransport('tok')).resolves.toBeDefined();
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
it('redacts session_token from debug log', async () => {
|
|
99
|
+
installMockWsServer('wss://api/sessions/stream?session_token=secret-tok');
|
|
100
|
+
|
|
101
|
+
const logs: string[] = [];
|
|
102
|
+
const client = new CsrClient('https://api', 'secret', undefined, undefined, msg => logs.push(msg));
|
|
103
|
+
await client.openTransport('secret-tok');
|
|
104
|
+
|
|
105
|
+
expect(logs).toHaveLength(1);
|
|
106
|
+
expect(logs[0]).toContain('session_token=[REDACTED]');
|
|
107
|
+
expect(logs[0]).not.toContain('secret-tok');
|
|
108
|
+
});
|
|
109
|
+
|
|
98
110
|
it('URL-encodes the session token', async () => {
|
|
99
111
|
installMockWsServer('wss://api/sessions/stream?session_token=tok%2Fwith%3Dspecials');
|
|
100
112
|
|
|
@@ -47,7 +47,7 @@ export class CsrClient implements Client {
|
|
|
47
47
|
const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;
|
|
48
48
|
const sep = wsBase.includes('?') ? '&' : '?';
|
|
49
49
|
const url = `${wsBase}${sep}session_token=${encodeURIComponent(sessionToken)}`;
|
|
50
|
-
this.log(`WebSocket connect ${url}`);
|
|
50
|
+
this.log(`WebSocket connect ${url.replace(/session_token=[^&]*/, 'session_token=[REDACTED]')}`);
|
|
51
51
|
const transport = new WebSocketTransport(url);
|
|
52
52
|
await transport.ready();
|
|
53
53
|
return transport;
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Generated by scripts/build-worker.mjs at build time. Do not edit.
|
|
2
2
|
// Run `yarn workspace @spotify-confidence/csr-common build:worker` to regenerate.
|
|
3
|
-
export const workerScript: string = "//#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\turl;\n\tws = null;\n\tonCloseCb = null;\n\tonStateChangeCb = null;\n\tintentionallyClosed = false;\n\tdead = false;\n\t/** Frames buffered while a (re)connect is in progress. */\n\tpending = [];\n\treadyPromise;\n\tconstructor(url) {\n\t\tthis.url = url;\n\t\tthis.readyPromise = new Promise((resolve, reject) => {\n\t\t\tthis.connect(false, resolve, reject);\n\t\t});\n\t\tthis.readyPromise.catch(() => {});\n\t}\n\tready() {\n\t\treturn this.readyPromise;\n\t}\n\tsend(frame) {\n\t\tif (this.dead || this.intentionallyClosed) return;\n\t\tif (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));\n\t\telse this.pending.push(frame);\n\t}\n\tclose(reason = \"transport-close\") {\n\t\tthis.intentionallyClosed = true;\n\t\tthis.ws?.close(1e3, reason);\n\t}\n\tonClose(cb) {\n\t\tthis.onCloseCb = cb;\n\t}\n\tonStateChange(cb) {\n\t\tthis.onStateChangeCb = cb;\n\t}\n\tconnect(isReconnect, onReady, onReadyFail) {\n\t\tconst ws = new WebSocket(this.url);\n\t\tthis.ws = ws;\n\t\tlet opened = false;\n\t\tws.onopen = () => {\n\t\t\topened = true;\n\t\t\tonReady?.();\n\t\t\tif (isReconnect) this.onStateChangeCb?.({ connected: true });\n\t\t\twhile (this.pending.length > 0) {\n\t\t\t\tconst f = this.pending.shift();\n\t\t\t\tws.send(JSON.stringify(f));\n\t\t\t}\n\t\t};\n\t\tws.onclose = (event) => {\n\t\t\tif (this.intentionallyClosed) return;\n\t\t\tif (!opened) {\n\t\t\t\tconst reason = `${isReconnect ? \"reconnect\" : \"initial\"}-failed code=${event.code}`;\n\t\t\t\tif (onReadyFail) {\n\t\t\t\t\tonReadyFail(new Error(reason));\n\t\t\t\t\tthis.dead = true;\n\t\t\t\t} else this.die(reason);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (event.wasClean && (event.code === 1e3 || event.code === 1001)) {\n\t\t\t\tthis.onStateChangeCb?.({ connected: false });\n\t\t\t\tthis.connect(true);\n\t\t\t} else this.die(`close code=${event.code} wasClean=${event.wasClean}`);\n\t\t};\n\t}\n\tdie(reason) {\n\t\tthis.dead = true;\n\t\tthis.onCloseCb?.({ reason });\n\t}\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\tapiUrl;\n\tclientSecret;\n\tcontext;\n\twebsocketUrl;\n\tlog;\n\tforceRecord;\n\tconstructor(apiUrl, clientSecret, context, websocketUrl, log = () => {}, forceRecord) {\n\t\tthis.apiUrl = apiUrl;\n\t\tthis.clientSecret = clientSecret;\n\t\tthis.context = context;\n\t\tthis.websocketUrl = websocketUrl;\n\t\tthis.log = log;\n\t\tthis.forceRecord = forceRecord;\n\t}\n\tasync initSession() {\n\t\tconst url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;\n\t\tthis.log(`fetch POST ${url}`);\n\t\tconst res = await fetch(url, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\tbody: JSON.stringify({\n\t\t\t\tclientSecret: this.clientSecret,\n\t\t\t\t...this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {},\n\t\t\t\t...this.forceRecord ? { forceRecord: true } : {}\n\t\t\t})\n\t\t});\n\t\tif (!res.ok) throw new Error(`init-session failed: HTTP ${res.status}`);\n\t\tconst data = await res.json();\n\t\tif (data.skipRecording) return { skipRecording: true };\n\t\tif (!data.sessionId || !data.sessionToken) throw new Error(\"init-session response missing sessionId or sessionToken\");\n\t\treturn {\n\t\t\tsessionId: data.sessionId,\n\t\t\tsessionToken: data.sessionToken\n\t\t};\n\t}\n\tasync openTransport(sessionToken) {\n\t\tconst wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;\n\t\tconst url = `${wsBase}${wsBase.includes(\"?\") ? \"&\" : \"?\"}session_token=${encodeURIComponent(sessionToken)}`;\n\t\tthis.log(`WebSocket connect ${url}`);\n\t\tconst transport = new WebSocketTransport(url);\n\t\tawait transport.ready();\n\t\treturn transport;\n\t}\n\ttrimSlash(s) {\n\t\treturn s.endsWith(\"/\") ? s.slice(0, -1) : s;\n\t}\n\ttoWsScheme(base) {\n\t\tif (base.startsWith(\"https://\")) return `wss://${base.slice(8)}`;\n\t\tif (base.startsWith(\"http://\")) return `ws://${base.slice(7)}`;\n\t\treturn base;\n\t}\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\tif (idleTimer !== null) {\n\t\tclearTimeout(idleTimer);\n\t\tidleTimer = null;\n\t}\n}\nfunction log(msg) {\n\tfor (const handle of ports) if (handle.debugLogs) handle.port.postMessage({\n\t\ttype: \"log\",\n\t\tmsg\n\t});\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\tcancelIdleTimer();\n\tconst handle = {\n\t\tport: adapter,\n\t\thello: null,\n\t\tdebugLogs: false\n\t};\n\tports.push(handle);\n\tadapter.onmessage((data) => {\n\t\thandleMessage(handle, data);\n\t});\n}\nfunction handleMessage(handle, message) {\n\tswitch (message.type) {\n\t\tcase \"hello\":\n\t\t\thandle.hello = message;\n\t\t\thandle.debugLogs = message.debugLogs ?? false;\n\t\t\tif (rejectIfIncompatible(handle)) return;\n\t\t\tif (state.phase !== \"dead\" && state.phase !== \"skipping\") detectDuplicateTab(handle);\n\t\t\tonHello(handle);\n\t\t\treturn;\n\t\tcase \"frame\":\n\t\t\tonFrame(message.frame);\n\t\t\treturn;\n\t\tcase \"bye\":\n\t\t\tonBye(handle);\n\t\t\treturn;\n\t\tdefault: break;\n\t}\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\tif (lockedConfig === null) return false;\n\tconst incoming = handle.hello;\n\tif (incoming.apiUrl === lockedConfig.apiUrl && incoming.websocketUrl === lockedConfig.websocketUrl && incoming.clientSecret === lockedConfig.clientSecret) return false;\n\thandle.port.postMessage({\n\t\ttype: \"dead\",\n\t\treason: \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\"\n\t});\n\tconst idx = ports.indexOf(handle);\n\tif (idx >= 0) ports.splice(idx, 1);\n\treturn 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\tconst tabId = handle.hello.tabId;\n\tif (!ports.some((p) => p !== handle && p.hello?.tabId === tabId)) return;\n\tconst fresh = crypto.randomUUID();\n\thandle.newTabId = fresh;\n\thandle.hello.tabId = fresh;\n}\nfunction onHello(handle) {\n\tswitch (state.phase) {\n\t\tcase \"init\":\n\t\t\tlockedConfig = {\n\t\t\t\tapiUrl: handle.hello.apiUrl,\n\t\t\t\twebsocketUrl: handle.hello.websocketUrl,\n\t\t\t\tclientSecret: handle.hello.clientSecret\n\t\t\t};\n\t\t\tlog(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ?? \"(derive)\"} sessionIdHint=${handle.hello.sessionIdHint ?? \"(none)\"}`);\n\t\t\tstate = { phase: \"initializing\" };\n\t\t\tinitializeSession(handle.hello).then(flushPendingWelcomes);\n\t\t\treturn;\n\t\tcase \"initializing\": return;\n\t\tcase \"active\":\n\t\t\tsendActiveWelcome(handle, state.sessionId, state.sessionToken);\n\t\t\treturn;\n\t\tcase \"idle\": {\n\t\t\tconst { client, sessionId, sessionToken } = state;\n\t\t\tstate = { phase: \"initializing\" };\n\t\t\tresumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);\n\t\t\treturn;\n\t\t}\n\t\tcase \"skipping\":\n\t\t\tif (handle.hello.forceRecord) {\n\t\t\t\tlog(\"forceRecord set; re-initializing from skipping state\");\n\t\t\t\tstate = { phase: \"initializing\" };\n\t\t\t\tinitializeSession(handle.hello).then(flushPendingWelcomes);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\thandle.port.postMessage({\n\t\t\t\ttype: \"welcome\",\n\t\t\t\tresult: { skipRecording: true }\n\t\t\t});\n\t\t\treturn;\n\t\tcase \"dead\":\n\t\t\thandle.port.postMessage({\n\t\t\t\ttype: \"dead\",\n\t\t\t\treason: state.reason\n\t\t\t});\n\t\t\treturn;\n\t\tdefault: break;\n\t}\n}\nasync function initializeSession(firstHello) {\n\tconst client = new CsrClient(firstHello.apiUrl, firstHello.clientSecret, firstHello.context, firstHello.websocketUrl, log, firstHello.forceRecord);\n\tif (firstHello.sessionIdHint && firstHello.sessionTokenHint) {\n\t\tlog(`adopting sessionIdHint=${firstHello.sessionIdHint}`);\n\t\ttry {\n\t\t\tconst transport = await client.openTransport(firstHello.sessionTokenHint);\n\t\t\twireTransport(transport);\n\t\t\tstate = {\n\t\t\t\tphase: \"active\",\n\t\t\t\tclient,\n\t\t\t\ttransport,\n\t\t\t\tsessionId: firstHello.sessionIdHint,\n\t\t\t\tsessionToken: firstHello.sessionTokenHint\n\t\t\t};\n\t\t\tlog(\"hint adopted; transport open\");\n\t\t\tif (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n\t\t\treturn;\n\t\t} catch (err) {\n\t\t\tlog(`hint rejected (${String(err)}); falling back to fresh init`);\n\t\t}\n\t}\n\tlet result;\n\ttry {\n\t\tresult = await client.initSession();\n\t} catch (err) {\n\t\tlog(`init-session threw: ${String(err)}`);\n\t\ttransitionToDead(`init-session-failed: ${String(err)}`);\n\t\treturn;\n\t}\n\tif (\"skipRecording\" in result) {\n\t\tlog(\"init-session: skipRecording\");\n\t\tstate = { phase: \"skipping\" };\n\t\treturn;\n\t}\n\tlog(`init-session ok sessionId=${result.sessionId}`);\n\tlet transport;\n\ttry {\n\t\ttransport = await client.openTransport(result.sessionToken);\n\t} catch (err) {\n\t\tlog(`openTransport threw: ${String(err)}`);\n\t\ttransitionToDead(`open-transport-failed: ${String(err)}`);\n\t\treturn;\n\t}\n\twireTransport(transport);\n\tlog(\"transport open; session active\");\n\tstate = {\n\t\tphase: \"active\",\n\t\tclient,\n\t\ttransport,\n\t\tsessionId: result.sessionId,\n\t\tsessionToken: result.sessionToken\n\t};\n\tif (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nasync function resumeTransport(client, sessionId, sessionToken) {\n\tlog(\"resuming transport from idle\");\n\tlet transport;\n\ttry {\n\t\ttransport = await client.openTransport(sessionToken);\n\t} catch (err) {\n\t\tlog(`resume-transport threw: ${String(err)}`);\n\t\ttransitionToDead(`resume-transport-failed: ${String(err)}`);\n\t\treturn;\n\t}\n\twireTransport(transport);\n\tlog(\"transport resumed\");\n\tstate = {\n\t\tphase: \"active\",\n\t\tclient,\n\t\ttransport,\n\t\tsessionId,\n\t\tsessionToken\n\t};\n\tif (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nfunction wireTransport(transport) {\n\ttransport.onClose((info) => {\n\t\tif (state.phase !== \"active\") return;\n\t\ttransitionToDead(info.reason);\n\t});\n\ttransport.onStateChange((info) => {\n\t\tif (state.phase !== \"active\") return;\n\t\tfor (const handle of ports) handle.port.postMessage({\n\t\t\ttype: \"state\",\n\t\t\tconnected: info.connected\n\t\t});\n\t});\n}\nfunction transitionToDead(reason) {\n\tstate = {\n\t\tphase: \"dead\",\n\t\treason\n\t};\n\tfor (const handle of ports) handle.port.postMessage({\n\t\ttype: \"dead\",\n\t\treason\n\t});\n}\nfunction flushPendingWelcomes() {\n\tfor (const handle of ports) {\n\t\tif (handle.hello === null) continue;\n\t\tif (state.phase === \"active\") sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n\t\telse if (state.phase === \"skipping\") handle.port.postMessage({\n\t\t\ttype: \"welcome\",\n\t\t\tresult: { skipRecording: true }\n\t\t});\n\t\telse if (state.phase === \"dead\") handle.port.postMessage({\n\t\t\ttype: \"dead\",\n\t\t\treason: state.reason\n\t\t});\n\t}\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n\tconst hint = handle.hello?.sessionIdHint;\n\tconst adopted = hint !== void 0 && hint !== currentSessionId;\n\tconst newTabId = handle.newTabId;\n\thandle.port.postMessage({\n\t\ttype: \"welcome\",\n\t\tresult: {\n\t\t\tsessionId: currentSessionId,\n\t\t\tsessionToken: currentSessionToken\n\t\t},\n\t\tadoptedFromSessionId: adopted ? hint : void 0,\n\t\tnewTabId,\n\t\tresetCounter: adopted || newTabId !== void 0\n\t});\n}\nfunction onFrame(frame) {\n\tif (state.phase !== \"active\") return;\n\tstate.transport.send(frame);\n}\nfunction onBye(handle) {\n\tconst idx = ports.indexOf(handle);\n\tif (idx >= 0) ports.splice(idx, 1);\n\tif (ports.length === 0 && state.phase === \"active\") {\n\t\tlog(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);\n\t\tidleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n\t}\n}\nfunction enterIdle() {\n\tidleTimer = null;\n\tif (state.phase !== \"active\" || ports.length > 0) return;\n\tlog(\"idle timeout; closing transport\");\n\tstate.transport.close(\"idle\");\n\tstate = {\n\t\tphase: \"idle\",\n\t\tclient: state.client,\n\t\tsessionId: state.sessionId,\n\t\tsessionToken: state.sessionToken\n\t};\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\tconst port = event.ports[0];\n\tport.start();\n\tregisterPort(adaptMessagePort(port));\n};\nelse registerPort(adaptDedicatedSelf());\nfunction adaptMessagePort(port) {\n\treturn {\n\t\tpostMessage: (message) => port.postMessage(message),\n\t\tonmessage: (cb) => {\n\t\t\tport.onmessage = (e) => cb(e.data);\n\t\t}\n\t};\n}\nfunction adaptDedicatedSelf() {\n\tconst ws = self;\n\treturn {\n\t\tpostMessage: (message) => ws.postMessage(message),\n\t\tonmessage: (cb) => {\n\t\t\tws.onmessage = (e) => cb(e.data);\n\t\t}\n\t};\n}\n//#endregion\n";
|
|
3
|
+
export const workerScript: string = "//#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\turl;\n\tws = null;\n\tonCloseCb = null;\n\tonStateChangeCb = null;\n\tintentionallyClosed = false;\n\tdead = false;\n\t/** Frames buffered while a (re)connect is in progress. */\n\tpending = [];\n\treadyPromise;\n\tconstructor(url) {\n\t\tthis.url = url;\n\t\tthis.readyPromise = new Promise((resolve, reject) => {\n\t\t\tthis.connect(false, resolve, reject);\n\t\t});\n\t\tthis.readyPromise.catch(() => {});\n\t}\n\tready() {\n\t\treturn this.readyPromise;\n\t}\n\tsend(frame) {\n\t\tif (this.dead || this.intentionallyClosed) return;\n\t\tif (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));\n\t\telse this.pending.push(frame);\n\t}\n\tclose(reason = \"transport-close\") {\n\t\tthis.intentionallyClosed = true;\n\t\tthis.ws?.close(1e3, reason);\n\t}\n\tonClose(cb) {\n\t\tthis.onCloseCb = cb;\n\t}\n\tonStateChange(cb) {\n\t\tthis.onStateChangeCb = cb;\n\t}\n\tconnect(isReconnect, onReady, onReadyFail) {\n\t\tconst ws = new WebSocket(this.url);\n\t\tthis.ws = ws;\n\t\tlet opened = false;\n\t\tws.onopen = () => {\n\t\t\topened = true;\n\t\t\tonReady?.();\n\t\t\tif (isReconnect) this.onStateChangeCb?.({ connected: true });\n\t\t\twhile (this.pending.length > 0) {\n\t\t\t\tconst f = this.pending.shift();\n\t\t\t\tws.send(JSON.stringify(f));\n\t\t\t}\n\t\t};\n\t\tws.onclose = (event) => {\n\t\t\tif (this.intentionallyClosed) return;\n\t\t\tif (!opened) {\n\t\t\t\tconst reason = `${isReconnect ? \"reconnect\" : \"initial\"}-failed code=${event.code}`;\n\t\t\t\tif (onReadyFail) {\n\t\t\t\t\tonReadyFail(new Error(reason));\n\t\t\t\t\tthis.dead = true;\n\t\t\t\t} else this.die(reason);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (event.wasClean && (event.code === 1e3 || event.code === 1001)) {\n\t\t\t\tthis.onStateChangeCb?.({ connected: false });\n\t\t\t\tthis.connect(true);\n\t\t\t} else this.die(`close code=${event.code} wasClean=${event.wasClean}`);\n\t\t};\n\t}\n\tdie(reason) {\n\t\tthis.dead = true;\n\t\tthis.onCloseCb?.({ reason });\n\t}\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\tapiUrl;\n\tclientSecret;\n\tcontext;\n\twebsocketUrl;\n\tlog;\n\tforceRecord;\n\tconstructor(apiUrl, clientSecret, context, websocketUrl, log = () => {}, forceRecord) {\n\t\tthis.apiUrl = apiUrl;\n\t\tthis.clientSecret = clientSecret;\n\t\tthis.context = context;\n\t\tthis.websocketUrl = websocketUrl;\n\t\tthis.log = log;\n\t\tthis.forceRecord = forceRecord;\n\t}\n\tasync initSession() {\n\t\tconst url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;\n\t\tthis.log(`fetch POST ${url}`);\n\t\tconst res = await fetch(url, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\tbody: JSON.stringify({\n\t\t\t\tclientSecret: this.clientSecret,\n\t\t\t\t...this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {},\n\t\t\t\t...this.forceRecord ? { forceRecord: true } : {}\n\t\t\t})\n\t\t});\n\t\tif (!res.ok) throw new Error(`init-session failed: HTTP ${res.status}`);\n\t\tconst data = await res.json();\n\t\tif (data.skipRecording) return { skipRecording: true };\n\t\tif (!data.sessionId || !data.sessionToken) throw new Error(\"init-session response missing sessionId or sessionToken\");\n\t\treturn {\n\t\t\tsessionId: data.sessionId,\n\t\t\tsessionToken: data.sessionToken\n\t\t};\n\t}\n\tasync openTransport(sessionToken) {\n\t\tconst wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;\n\t\tconst url = `${wsBase}${wsBase.includes(\"?\") ? \"&\" : \"?\"}session_token=${encodeURIComponent(sessionToken)}`;\n\t\tthis.log(`WebSocket connect ${url.replace(/session_token=[^&]*/, \"session_token=[REDACTED]\")}`);\n\t\tconst transport = new WebSocketTransport(url);\n\t\tawait transport.ready();\n\t\treturn transport;\n\t}\n\ttrimSlash(s) {\n\t\treturn s.endsWith(\"/\") ? s.slice(0, -1) : s;\n\t}\n\ttoWsScheme(base) {\n\t\tif (base.startsWith(\"https://\")) return `wss://${base.slice(8)}`;\n\t\tif (base.startsWith(\"http://\")) return `ws://${base.slice(7)}`;\n\t\treturn base;\n\t}\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\tif (idleTimer !== null) {\n\t\tclearTimeout(idleTimer);\n\t\tidleTimer = null;\n\t}\n}\nfunction log(msg) {\n\tfor (const handle of ports) if (handle.debugLogs) handle.port.postMessage({\n\t\ttype: \"log\",\n\t\tmsg\n\t});\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\tcancelIdleTimer();\n\tconst handle = {\n\t\tport: adapter,\n\t\thello: null,\n\t\tdebugLogs: false\n\t};\n\tports.push(handle);\n\tadapter.onmessage((data) => {\n\t\thandleMessage(handle, data);\n\t});\n}\nfunction handleMessage(handle, message) {\n\tswitch (message.type) {\n\t\tcase \"hello\":\n\t\t\thandle.hello = message;\n\t\t\thandle.debugLogs = message.debugLogs ?? false;\n\t\t\tif (rejectIfIncompatible(handle)) return;\n\t\t\tif (state.phase !== \"dead\" && state.phase !== \"skipping\") detectDuplicateTab(handle);\n\t\t\tonHello(handle);\n\t\t\treturn;\n\t\tcase \"frame\":\n\t\t\tonFrame(message.frame);\n\t\t\treturn;\n\t\tcase \"bye\":\n\t\t\tonBye(handle);\n\t\t\treturn;\n\t\tdefault: break;\n\t}\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\tif (lockedConfig === null) return false;\n\tconst incoming = handle.hello;\n\tif (incoming.apiUrl === lockedConfig.apiUrl && incoming.websocketUrl === lockedConfig.websocketUrl && incoming.clientSecret === lockedConfig.clientSecret) return false;\n\thandle.port.postMessage({\n\t\ttype: \"dead\",\n\t\treason: \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\"\n\t});\n\tconst idx = ports.indexOf(handle);\n\tif (idx >= 0) ports.splice(idx, 1);\n\treturn 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\tconst tabId = handle.hello.tabId;\n\tif (!ports.some((p) => p !== handle && p.hello?.tabId === tabId)) return;\n\tconst fresh = crypto.randomUUID();\n\thandle.newTabId = fresh;\n\thandle.hello.tabId = fresh;\n}\nfunction onHello(handle) {\n\tswitch (state.phase) {\n\t\tcase \"init\":\n\t\t\tlockedConfig = {\n\t\t\t\tapiUrl: handle.hello.apiUrl,\n\t\t\t\twebsocketUrl: handle.hello.websocketUrl,\n\t\t\t\tclientSecret: handle.hello.clientSecret\n\t\t\t};\n\t\t\tlog(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ?? \"(derive)\"} sessionIdHint=${handle.hello.sessionIdHint ?? \"(none)\"}`);\n\t\t\tstate = { phase: \"initializing\" };\n\t\t\tinitializeSession(handle.hello).then(flushPendingWelcomes);\n\t\t\treturn;\n\t\tcase \"initializing\": return;\n\t\tcase \"active\":\n\t\t\tsendActiveWelcome(handle, state.sessionId, state.sessionToken);\n\t\t\treturn;\n\t\tcase \"idle\": {\n\t\t\tconst { client, sessionId, sessionToken } = state;\n\t\t\tstate = { phase: \"initializing\" };\n\t\t\tresumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);\n\t\t\treturn;\n\t\t}\n\t\tcase \"skipping\":\n\t\t\tif (handle.hello.forceRecord) {\n\t\t\t\tlog(\"forceRecord set; re-initializing from skipping state\");\n\t\t\t\tstate = { phase: \"initializing\" };\n\t\t\t\tinitializeSession(handle.hello).then(flushPendingWelcomes);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\thandle.port.postMessage({\n\t\t\t\ttype: \"welcome\",\n\t\t\t\tresult: { skipRecording: true }\n\t\t\t});\n\t\t\treturn;\n\t\tcase \"dead\":\n\t\t\thandle.port.postMessage({\n\t\t\t\ttype: \"dead\",\n\t\t\t\treason: state.reason\n\t\t\t});\n\t\t\treturn;\n\t\tdefault: break;\n\t}\n}\nasync function initializeSession(firstHello) {\n\tconst client = new CsrClient(firstHello.apiUrl, firstHello.clientSecret, firstHello.context, firstHello.websocketUrl, log, firstHello.forceRecord);\n\tif (firstHello.sessionIdHint && firstHello.sessionTokenHint) {\n\t\tlog(`adopting sessionIdHint=${firstHello.sessionIdHint}`);\n\t\ttry {\n\t\t\tconst transport = await client.openTransport(firstHello.sessionTokenHint);\n\t\t\twireTransport(transport);\n\t\t\tstate = {\n\t\t\t\tphase: \"active\",\n\t\t\t\tclient,\n\t\t\t\ttransport,\n\t\t\t\tsessionId: firstHello.sessionIdHint,\n\t\t\t\tsessionToken: firstHello.sessionTokenHint\n\t\t\t};\n\t\t\tlog(\"hint adopted; transport open\");\n\t\t\tif (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n\t\t\treturn;\n\t\t} catch (err) {\n\t\t\tlog(`hint rejected (${String(err)}); falling back to fresh init`);\n\t\t}\n\t}\n\tlet result;\n\ttry {\n\t\tresult = await client.initSession();\n\t} catch (err) {\n\t\tlog(`init-session threw: ${String(err)}`);\n\t\ttransitionToDead(`init-session-failed: ${String(err)}`);\n\t\treturn;\n\t}\n\tif (\"skipRecording\" in result) {\n\t\tlog(\"init-session: skipRecording\");\n\t\tstate = { phase: \"skipping\" };\n\t\treturn;\n\t}\n\tlog(`init-session ok sessionId=${result.sessionId}`);\n\tlet transport;\n\ttry {\n\t\ttransport = await client.openTransport(result.sessionToken);\n\t} catch (err) {\n\t\tlog(`openTransport threw: ${String(err)}`);\n\t\ttransitionToDead(`open-transport-failed: ${String(err)}`);\n\t\treturn;\n\t}\n\twireTransport(transport);\n\tlog(\"transport open; session active\");\n\tstate = {\n\t\tphase: \"active\",\n\t\tclient,\n\t\ttransport,\n\t\tsessionId: result.sessionId,\n\t\tsessionToken: result.sessionToken\n\t};\n\tif (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nasync function resumeTransport(client, sessionId, sessionToken) {\n\tlog(\"resuming transport from idle\");\n\tlet transport;\n\ttry {\n\t\ttransport = await client.openTransport(sessionToken);\n\t} catch (err) {\n\t\tlog(`resume-transport threw: ${String(err)}`);\n\t\ttransitionToDead(`resume-transport-failed: ${String(err)}`);\n\t\treturn;\n\t}\n\twireTransport(transport);\n\tlog(\"transport resumed\");\n\tstate = {\n\t\tphase: \"active\",\n\t\tclient,\n\t\ttransport,\n\t\tsessionId,\n\t\tsessionToken\n\t};\n\tif (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nfunction wireTransport(transport) {\n\ttransport.onClose((info) => {\n\t\tif (state.phase !== \"active\") return;\n\t\ttransitionToDead(info.reason);\n\t});\n\ttransport.onStateChange((info) => {\n\t\tif (state.phase !== \"active\") return;\n\t\tfor (const handle of ports) handle.port.postMessage({\n\t\t\ttype: \"state\",\n\t\t\tconnected: info.connected\n\t\t});\n\t});\n}\nfunction transitionToDead(reason) {\n\tstate = {\n\t\tphase: \"dead\",\n\t\treason\n\t};\n\tfor (const handle of ports) handle.port.postMessage({\n\t\ttype: \"dead\",\n\t\treason\n\t});\n}\nfunction flushPendingWelcomes() {\n\tfor (const handle of ports) {\n\t\tif (handle.hello === null) continue;\n\t\tif (state.phase === \"active\") sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n\t\telse if (state.phase === \"skipping\") handle.port.postMessage({\n\t\t\ttype: \"welcome\",\n\t\t\tresult: { skipRecording: true }\n\t\t});\n\t\telse if (state.phase === \"dead\") handle.port.postMessage({\n\t\t\ttype: \"dead\",\n\t\t\treason: state.reason\n\t\t});\n\t}\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n\tconst hint = handle.hello?.sessionIdHint;\n\tconst adopted = hint !== void 0 && hint !== currentSessionId;\n\tconst newTabId = handle.newTabId;\n\thandle.port.postMessage({\n\t\ttype: \"welcome\",\n\t\tresult: {\n\t\t\tsessionId: currentSessionId,\n\t\t\tsessionToken: currentSessionToken\n\t\t},\n\t\tadoptedFromSessionId: adopted ? hint : void 0,\n\t\tnewTabId,\n\t\tresetCounter: adopted || newTabId !== void 0\n\t});\n}\nfunction onFrame(frame) {\n\tif (state.phase !== \"active\") return;\n\tstate.transport.send(frame);\n}\nfunction onBye(handle) {\n\tconst idx = ports.indexOf(handle);\n\tif (idx >= 0) ports.splice(idx, 1);\n\tif (ports.length === 0 && state.phase === \"active\") {\n\t\tlog(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);\n\t\tidleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n\t}\n}\nfunction enterIdle() {\n\tidleTimer = null;\n\tif (state.phase !== \"active\" || ports.length > 0) return;\n\tlog(\"idle timeout; closing transport\");\n\tstate.transport.close(\"idle\");\n\tstate = {\n\t\tphase: \"idle\",\n\t\tclient: state.client,\n\t\tsessionId: state.sessionId,\n\t\tsessionToken: state.sessionToken\n\t};\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\tconst port = event.ports[0];\n\tport.start();\n\tregisterPort(adaptMessagePort(port));\n};\nelse registerPort(adaptDedicatedSelf());\nfunction adaptMessagePort(port) {\n\treturn {\n\t\tpostMessage: (message) => port.postMessage(message),\n\t\tonmessage: (cb) => {\n\t\t\tport.onmessage = (e) => cb(e.data);\n\t\t}\n\t};\n}\nfunction adaptDedicatedSelf() {\n\tconst ws = self;\n\treturn {\n\t\tpostMessage: (message) => ws.postMessage(message),\n\t\tonmessage: (cb) => {\n\t\t\tws.onmessage = (e) => cb(e.data);\n\t\t}\n\t};\n}\n//#endregion\n";
|