// Generated by scripts/build-worker.mjs at build time. Do not edit. // Run `yarn workspace @spotify-confidence/csr-common build:worker` to regenerate. export const workerScript: string = "//#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\tprotocols;\n\tconstructor(url, protocols = []) {\n\t\tthis.url = url;\n\t\tthis.protocols = [...protocols];\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\tlet ws;\n\t\ttry {\n\t\t\tws = new WebSocket(this.url, [...this.protocols]);\n\t\t} catch (_error) {\n\t\t\tthis.failConnection(isReconnect, onReadyFail);\n\t\t\treturn;\n\t\t}\n\t\tthis.ws = ws;\n\t\tlet opened = false;\n\t\tws.onopen = () => {\n\t\t\tconst expectedProtocol = this.protocols[0];\n\t\t\tif (expectedProtocol !== void 0 && ws.protocol !== expectedProtocol) {\n\t\t\t\tthis.failConnection(isReconnect, onReadyFail);\n\t\t\t\tws.close(1e3, \"protocol-mismatch\");\n\t\t\t\treturn;\n\t\t\t}\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 || this.dead) return;\n\t\t\tif (!opened) {\n\t\t\t\tthis.failConnection(isReconnect, onReadyFail);\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\tfailConnection(isReconnect, onReadyFail) {\n\t\tconst reason = isReconnect ? \"reconnect-failed\" : \"initial-failed\";\n\t\tif (onReadyFail) {\n\t\t\tthis.dead = true;\n\t\t\tonReadyFail(new Error(reason));\n\t\t} else this.die(reason);\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/websocket-auth.ts\nconst RECORDING_PROTOCOL = \"recording.v1\";\nconst AUTH_PROTOCOL_PREFIX = \"auth.\";\nconst MAX_TOKEN_LENGTH = 4096;\nfunction recordingProtocols(sessionToken) {\n\tif (sessionToken.length > MAX_TOKEN_LENGTH) throw new Error(\"Session token is too long for WebSocket authentication\");\n\tif (!sessionToken || /[^A-Za-z0-9._-]/.test(sessionToken)) throw new Error(\"Invalid session token for WebSocket authentication\");\n\treturn [RECORDING_PROTOCOL, `${AUTH_PROTOCOL_PREFIX}${sessionToken}`];\n}\n//#endregion\n//#region src/uploader/worker/csr-client.ts\n/**\n* Single Client implementation that talks to the recording backend's REST + WS protocol.\n* Both dev-server and prod implement the same protocol, so we don't need polymorphism here yet.\n*/\nvar CsrClient = class {\n\tapiUrl;\n\tclientSecret;\n\tcontext;\n\twebsocketUrl;\n\tlog;\n\tconstructor(apiUrl, clientSecret, context, websocketUrl, log = () => {}) {\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}\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})\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\tlet parsedUrl;\n\t\ttry {\n\t\t\tparsedUrl = new URL(wsBase);\n\t\t} catch (_error) {\n\t\t\tthrow new Error(\"Invalid WebSocket URL\");\n\t\t}\n\t\tif (parsedUrl.searchParams.has(\"session_token\")) throw new Error(\"WebSocket URL must not include a session token\");\n\t\tconst protocols = recordingProtocols(sessionToken);\n\t\tthis.log(`WebSocket connect ${wsBase}`);\n\t\tconst transport = new WebSocketTransport(wsBase, protocols);\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 WORKER_HASH = globalThis.__WORKER_HASH__;\nconst IDLE_GRACE_MS = 5e3;\nconst RECOVERY_RETRY_MS = 5e3;\nconst RECOVERY_RETRY_MAX_MS = 5 * 6e4;\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\tanswered: false,\n\t\tawaitingReset: 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(handle, 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\tsendDead(handle, \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\");\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 ? \"(configured)\" : \"(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 \"interrupted\": {\n\t\t\tconst { client, previousSessionId, nextRetryDelayMs } = state;\n\t\t\tstate = {\n\t\t\t\tphase: \"restarting\",\n\t\t\t\tclient,\n\t\t\t\tpreviousSessionId\n\t\t\t};\n\t\t\trestartSession(client, previousSessionId, nextRetryDelayMs).then(flushPendingWelcomes);\n\t\t\treturn;\n\t\t}\n\t\tcase \"restarting\": return;\n\t\tcase \"skipping\":\n\t\t\tsendSkipWelcome(handle);\n\t\t\treturn;\n\t\tcase \"dead\":\n\t\t\tsendDead(handle, state.reason);\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);\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\tstate = {\n\t\t\tphase: \"restarting\",\n\t\t\tclient,\n\t\t\tpreviousSessionId: sessionId\n\t\t};\n\t\tawait restartSession(client, sessionId);\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}\nasync function restartSession(client, previousSessionId, nextRetryDelayMs = RECOVERY_RETRY_MS) {\n\tlog(`restarting after session ${previousSessionId} ended`);\n\tlet result;\n\ttry {\n\t\tresult = await client.initSession();\n\t} catch (err) {\n\t\tawaitRecoveryRetry(client, previousSessionId, `init-session failed: ${String(err)}`, nextRetryDelayMs);\n\t\treturn;\n\t}\n\tif (\"skipRecording\" in result) {\n\t\ttransitionToDead(\"session-restart-skipped\");\n\t\treturn;\n\t}\n\tlet transport;\n\ttry {\n\t\ttransport = await client.openTransport(result.sessionToken);\n\t} catch (err) {\n\t\tawaitRecoveryRetry(client, previousSessionId, `open-transport failed: ${String(err)}`, nextRetryDelayMs);\n\t\treturn;\n\t}\n\twireTransport(transport);\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\tlog(`session restarted sessionId=${result.sessionId}`);\n\tfor (const handle of ports) {\n\t\thandle.awaitingReset = handle.answered;\n\t\thandle.port.postMessage({\n\t\t\ttype: \"session-restarted\",\n\t\t\tresult,\n\t\t\tadoptedFromSessionId: previousSessionId\n\t\t});\n\t}\n}\nfunction backoffPending(state) {\n\treturn Date.now() < state.retryAt;\n}\nfunction awaitRecoveryRetry(client, previousSessionId, reason, retryDelayMs) {\n\tlog(`${reason}; waiting for user activity to retry in ${retryDelayMs}ms`);\n\tstate = {\n\t\tphase: \"interrupted\",\n\t\tclient,\n\t\tpreviousSessionId,\n\t\tretryAt: Date.now() + retryDelayMs,\n\t\tnextRetryDelayMs: Math.min(retryDelayMs * 2, RECOVERY_RETRY_MAX_MS)\n\t};\n}\nfunction wireTransport(transport) {\n\ttransport.onClose((info) => {\n\t\tif (state.phase !== \"active\" || state.transport !== transport) return;\n\t\tconst { client, sessionId } = state;\n\t\tlog(`transport ended (${info.reason}); waiting for user activity`);\n\t\tstate = {\n\t\t\tphase: \"interrupted\",\n\t\t\tclient,\n\t\t\tpreviousSessionId: sessionId,\n\t\t\tretryAt: 0,\n\t\t\tnextRetryDelayMs: RECOVERY_RETRY_MS\n\t\t};\n\t\tbroadcastConnectionState(false);\n\t});\n\ttransport.onStateChange((info) => {\n\t\tif (state.phase !== \"active\" || state.transport !== transport) return;\n\t\tbroadcastConnectionState(info.connected);\n\t});\n}\nfunction broadcastConnectionState(connected) {\n\tfor (const handle of ports) handle.port.postMessage({\n\t\ttype: \"state\",\n\t\tconnected\n\t});\n}\nfunction transitionToDead(reason) {\n\tstate = {\n\t\tphase: \"dead\",\n\t\treason\n\t};\n\tfor (const handle of ports) sendDead(handle, reason);\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\") sendSkipWelcome(handle);\n\t\telse if (state.phase === \"dead\") sendDead(handle, state.reason);\n\t\telse if (state.phase === \"interrupted\" && !handle.answered) sendDead(handle, \"session-restart-failed\");\n\t}\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n\tconst hint = handle.hello?.sessionIdHint;\n\tconst adopted = 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\tworkerHash: WORKER_HASH,\n\t\tadoptedFromSessionId: adopted ? hint : void 0,\n\t\tnewTabId,\n\t\tresetCounter: adopted || newTabId !== void 0\n\t});\n\thandle.answered = true;\n}\nfunction sendSkipWelcome(handle) {\n\thandle.port.postMessage({\n\t\ttype: \"welcome\",\n\t\tresult: { skipRecording: true },\n\t\tworkerHash: WORKER_HASH\n\t});\n\thandle.answered = true;\n}\nfunction sendDead(handle, reason) {\n\thandle.port.postMessage({\n\t\ttype: \"dead\",\n\t\treason\n\t});\n\thandle.answered = true;\n}\nfunction onFrame(handle, frame) {\n\tif (state.phase === \"active\") {\n\t\tif (handle.awaitingReset) {\n\t\t\tif (frame.eventCounter !== 0) return;\n\t\t\thandle.awaitingReset = false;\n\t\t}\n\t\tstate.transport.send(frame);\n\t\treturn;\n\t}\n\tif (state.phase !== \"interrupted\" || frame.userActivity !== true || backoffPending(state)) return;\n\tconst { client, previousSessionId, nextRetryDelayMs } = state;\n\tstate = {\n\t\tphase: \"restarting\",\n\t\tclient,\n\t\tpreviousSessionId\n\t};\n\trestartSession(client, previousSessionId, nextRetryDelayMs).then(flushPendingWelcomes);\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";