@spotify-confidence/csr-common 0.18.10 → 0.18.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/confidence-worker.js +53 -14
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/{types-C3mO_2YH.d.cts → types-CdvFpcEN.d.cts} +7 -6
- package/dist/{types-C3mO_2YH.d.ts → types-CdvFpcEN.d.ts} +7 -6
- package/dist/uploader/index.cjs +6 -1
- package/dist/uploader/index.d.cts +5 -2
- package/dist/uploader/index.d.ts +5 -2
- package/dist/uploader/index.js +6 -2
- package/package.json +1 -1
- package/src/test-utils/mock-ws-server.ts +18 -2
- package/src/uploader/create-uploader.test.ts +105 -0
- package/src/uploader/create-uploader.ts +8 -0
- package/src/uploader/index.ts +1 -0
- package/src/uploader/types.ts +7 -6
- package/src/uploader/worker/core.test.ts +146 -4
- package/src/uploader/worker/core.ts +6 -1
- package/src/uploader/worker/csr-client.test.ts +33 -18
- package/src/uploader/worker/csr-client.ts +13 -4
- package/src/uploader/worker/web-socket-transport.test.ts +71 -4
- package/src/uploader/worker/web-socket-transport.ts +30 -15
- package/src/uploader/worker/websocket-auth.test.ts +29 -0
- package/src/uploader/worker/websocket-auth.ts +15 -0
- package/src/uploader/worker/worker-script.test.ts +205 -0
- package/src/uploader/worker/worker-script.ts +1 -1
- package/src/uploader/worker-hash.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.18.11](https://github.com/spotify/confidence-sdk-js/compare/csr-common-v0.18.10...csr-common-v0.18.11) (2026-09-11)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ✨ New Features
|
|
7
|
+
|
|
8
|
+
* **csr:** authenticate recording WebSockets with subprotocols ([#455](https://github.com/spotify/confidence-sdk-js/issues/455)) ([250342f](https://github.com/spotify/confidence-sdk-js/commit/250342f015a70ba12e92ff9725d3c48180047786))
|
|
9
|
+
* **csr:** log version drift between SDK and worker ([#415](https://github.com/spotify/confidence-sdk-js/issues/415)) ([4ce14ee](https://github.com/spotify/confidence-sdk-js/commit/4ce14eee2704fef6e514b1b5a3b1976705ea22ba))
|
|
10
|
+
|
|
3
11
|
## [0.18.10](https://github.com/spotify/confidence-sdk-js/compare/csr-common-v0.18.9...csr-common-v0.18.10) (2026-09-09)
|
|
4
12
|
|
|
5
13
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
globalThis.__WORKER_HASH__ = '1774b236fa1eb46a';
|
|
1
2
|
//#region src/uploader/worker/web-socket-transport.ts
|
|
2
3
|
/**
|
|
3
4
|
* WebSocket-backed Transport. Internal retry policy: clean server-initiated close after the
|
|
@@ -19,8 +20,10 @@ var WebSocketTransport = class {
|
|
|
19
20
|
/** Frames buffered while a (re)connect is in progress. */
|
|
20
21
|
pending = [];
|
|
21
22
|
readyPromise;
|
|
22
|
-
|
|
23
|
+
protocols;
|
|
24
|
+
constructor(url, protocols = []) {
|
|
23
25
|
this.url = url;
|
|
26
|
+
this.protocols = [...protocols];
|
|
24
27
|
this.readyPromise = new Promise((resolve, reject) => {
|
|
25
28
|
this.connect(false, resolve, reject);
|
|
26
29
|
});
|
|
@@ -45,10 +48,22 @@ var WebSocketTransport = class {
|
|
|
45
48
|
this.onStateChangeCb = cb;
|
|
46
49
|
}
|
|
47
50
|
connect(isReconnect, onReady, onReadyFail) {
|
|
48
|
-
|
|
51
|
+
let ws;
|
|
52
|
+
try {
|
|
53
|
+
ws = new WebSocket(this.url, [...this.protocols]);
|
|
54
|
+
} catch (_error) {
|
|
55
|
+
this.failConnection(isReconnect, onReadyFail);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
49
58
|
this.ws = ws;
|
|
50
59
|
let opened = false;
|
|
51
60
|
ws.onopen = () => {
|
|
61
|
+
const expectedProtocol = this.protocols[0];
|
|
62
|
+
if (expectedProtocol !== void 0 && ws.protocol !== expectedProtocol) {
|
|
63
|
+
this.failConnection(isReconnect, onReadyFail);
|
|
64
|
+
ws.close(1e3, "protocol-mismatch");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
52
67
|
opened = true;
|
|
53
68
|
onReady?.();
|
|
54
69
|
if (isReconnect) this.onStateChangeCb?.({ connected: true });
|
|
@@ -58,13 +73,9 @@ var WebSocketTransport = class {
|
|
|
58
73
|
}
|
|
59
74
|
};
|
|
60
75
|
ws.onclose = (event) => {
|
|
61
|
-
if (this.intentionallyClosed) return;
|
|
76
|
+
if (this.intentionallyClosed || this.dead) return;
|
|
62
77
|
if (!opened) {
|
|
63
|
-
|
|
64
|
-
if (onReadyFail) {
|
|
65
|
-
onReadyFail(new Error(reason));
|
|
66
|
-
this.dead = true;
|
|
67
|
-
} else this.die(reason);
|
|
78
|
+
this.failConnection(isReconnect, onReadyFail);
|
|
68
79
|
return;
|
|
69
80
|
}
|
|
70
81
|
if (event.wasClean && (event.code === 1e3 || event.code === 1001)) {
|
|
@@ -73,12 +84,29 @@ var WebSocketTransport = class {
|
|
|
73
84
|
} else this.die(`close code=${event.code} wasClean=${event.wasClean}`);
|
|
74
85
|
};
|
|
75
86
|
}
|
|
87
|
+
failConnection(isReconnect, onReadyFail) {
|
|
88
|
+
const reason = isReconnect ? "reconnect-failed" : "initial-failed";
|
|
89
|
+
if (onReadyFail) {
|
|
90
|
+
this.dead = true;
|
|
91
|
+
onReadyFail(new Error(reason));
|
|
92
|
+
} else this.die(reason);
|
|
93
|
+
}
|
|
76
94
|
die(reason) {
|
|
77
95
|
this.dead = true;
|
|
78
96
|
this.onCloseCb?.({ reason });
|
|
79
97
|
}
|
|
80
98
|
};
|
|
81
99
|
//#endregion
|
|
100
|
+
//#region src/uploader/worker/websocket-auth.ts
|
|
101
|
+
const RECORDING_PROTOCOL = "recording.v1";
|
|
102
|
+
const AUTH_PROTOCOL_PREFIX = "auth.";
|
|
103
|
+
const MAX_TOKEN_LENGTH = 4096;
|
|
104
|
+
function recordingProtocols(sessionToken) {
|
|
105
|
+
if (sessionToken.length > MAX_TOKEN_LENGTH) throw new Error("Session token is too long for WebSocket authentication");
|
|
106
|
+
if (!sessionToken || /[^A-Za-z0-9._-]/.test(sessionToken)) throw new Error("Invalid session token for WebSocket authentication");
|
|
107
|
+
return [RECORDING_PROTOCOL, `${AUTH_PROTOCOL_PREFIX}${sessionToken}`];
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
82
110
|
//#region src/uploader/worker/csr-client.ts
|
|
83
111
|
/**
|
|
84
112
|
* Single Client implementation that talks to the recording backend's REST + WS protocol.
|
|
@@ -122,9 +150,16 @@ var CsrClient = class {
|
|
|
122
150
|
}
|
|
123
151
|
async openTransport(sessionToken) {
|
|
124
152
|
const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
153
|
+
let parsedUrl;
|
|
154
|
+
try {
|
|
155
|
+
parsedUrl = new URL(wsBase);
|
|
156
|
+
} catch (_error) {
|
|
157
|
+
throw new Error("Invalid WebSocket URL");
|
|
158
|
+
}
|
|
159
|
+
if (parsedUrl.searchParams.has("session_token")) throw new Error("WebSocket URL must not include a session token");
|
|
160
|
+
const protocols = recordingProtocols(sessionToken);
|
|
161
|
+
this.log(`WebSocket connect ${wsBase}`);
|
|
162
|
+
const transport = new WebSocketTransport(wsBase, protocols);
|
|
128
163
|
await transport.ready();
|
|
129
164
|
return transport;
|
|
130
165
|
}
|
|
@@ -139,6 +174,7 @@ var CsrClient = class {
|
|
|
139
174
|
};
|
|
140
175
|
//#endregion
|
|
141
176
|
//#region src/uploader/worker/core.ts
|
|
177
|
+
const WORKER_HASH = globalThis.__WORKER_HASH__;
|
|
142
178
|
const IDLE_GRACE_MS = 5e3;
|
|
143
179
|
let state = { phase: "init" };
|
|
144
180
|
const ports = [];
|
|
@@ -231,7 +267,7 @@ function onHello(handle) {
|
|
|
231
267
|
websocketUrl: handle.hello.websocketUrl,
|
|
232
268
|
clientSecret: handle.hello.clientSecret
|
|
233
269
|
};
|
|
234
|
-
log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl
|
|
270
|
+
log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ? "(configured)" : "(derive)"} sessionIdHint=${handle.hello.sessionIdHint ?? "(none)"}`);
|
|
235
271
|
state = { phase: "initializing" };
|
|
236
272
|
initializeSession(handle.hello).then(flushPendingWelcomes);
|
|
237
273
|
return;
|
|
@@ -254,7 +290,8 @@ function onHello(handle) {
|
|
|
254
290
|
}
|
|
255
291
|
handle.port.postMessage({
|
|
256
292
|
type: "welcome",
|
|
257
|
-
result: { skipRecording: true }
|
|
293
|
+
result: { skipRecording: true },
|
|
294
|
+
workerHash: WORKER_HASH
|
|
258
295
|
});
|
|
259
296
|
return;
|
|
260
297
|
case "dead":
|
|
@@ -370,7 +407,8 @@ function flushPendingWelcomes() {
|
|
|
370
407
|
if (state.phase === "active") sendActiveWelcome(handle, state.sessionId, state.sessionToken);
|
|
371
408
|
else if (state.phase === "skipping") handle.port.postMessage({
|
|
372
409
|
type: "welcome",
|
|
373
|
-
result: { skipRecording: true }
|
|
410
|
+
result: { skipRecording: true },
|
|
411
|
+
workerHash: WORKER_HASH
|
|
374
412
|
});
|
|
375
413
|
else if (state.phase === "dead") handle.port.postMessage({
|
|
376
414
|
type: "dead",
|
|
@@ -388,6 +426,7 @@ function sendActiveWelcome(handle, currentSessionId, currentSessionToken) {
|
|
|
388
426
|
sessionId: currentSessionId,
|
|
389
427
|
sessionToken: currentSessionToken
|
|
390
428
|
},
|
|
429
|
+
workerHash: WORKER_HASH,
|
|
391
430
|
adoptedFromSessionId: adopted ? hint : void 0,
|
|
392
431
|
newTabId,
|
|
393
432
|
resetCounter: adopted || newTabId !== void 0
|
package/dist/index.d.cts
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -41,11 +41,12 @@ interface CreateUploaderOptions {
|
|
|
41
41
|
apiUrl: string;
|
|
42
42
|
/**
|
|
43
43
|
* URL of the WebSocket ingest endpoint, including the path (e.g.
|
|
44
|
-
* `wss://recording-ws.confidence.dev/sessions/stream`)
|
|
45
|
-
* the
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
44
|
+
* `wss://recording-ws.confidence.dev/sessions/stream`). The worker sends the session
|
|
45
|
+
* token through the WebSocket subprotocol header. A `session_token` query parameter is
|
|
46
|
+
* rejected. Other query parameters are retained. Optional: when omitted the worker
|
|
47
|
+
* derives one from `apiUrl` by swapping `http(s)://` → `ws(s)://` and appending
|
|
48
|
+
* `/sessions/stream`. Set this when the init endpoint and the WS ingest live on different
|
|
49
|
+
* hosts (e.g. prod).
|
|
49
50
|
*/
|
|
50
51
|
websocketUrl?: string;
|
|
51
52
|
/** Per-tenant secret. Hashed to scope the SharedWorker so different secrets never share a session, and sent in the `initSession` request body. */
|
|
@@ -88,7 +89,7 @@ interface CreateUploaderOptions {
|
|
|
88
89
|
}) => void;
|
|
89
90
|
/**
|
|
90
91
|
* Optional verbose tracer. Called on key tab- and worker-side events
|
|
91
|
-
* (hello/welcome, init-session URL, ws connect URL, retries, transitions).
|
|
92
|
+
* (hello/welcome, init-session URL, credential-free ws connect URL, retries, transitions).
|
|
92
93
|
* Worker messages are forwarded over the port and tagged so you can tell them apart.
|
|
93
94
|
*/
|
|
94
95
|
debugLogger?: (msg: string) => void;
|
|
@@ -41,11 +41,12 @@ interface CreateUploaderOptions {
|
|
|
41
41
|
apiUrl: string;
|
|
42
42
|
/**
|
|
43
43
|
* URL of the WebSocket ingest endpoint, including the path (e.g.
|
|
44
|
-
* `wss://recording-ws.confidence.dev/sessions/stream`)
|
|
45
|
-
* the
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
44
|
+
* `wss://recording-ws.confidence.dev/sessions/stream`). The worker sends the session
|
|
45
|
+
* token through the WebSocket subprotocol header. A `session_token` query parameter is
|
|
46
|
+
* rejected. Other query parameters are retained. Optional: when omitted the worker
|
|
47
|
+
* derives one from `apiUrl` by swapping `http(s)://` → `ws(s)://` and appending
|
|
48
|
+
* `/sessions/stream`. Set this when the init endpoint and the WS ingest live on different
|
|
49
|
+
* hosts (e.g. prod).
|
|
49
50
|
*/
|
|
50
51
|
websocketUrl?: string;
|
|
51
52
|
/** Per-tenant secret. Hashed to scope the SharedWorker so different secrets never share a session, and sent in the `initSession` request body. */
|
|
@@ -88,7 +89,7 @@ interface CreateUploaderOptions {
|
|
|
88
89
|
}) => void;
|
|
89
90
|
/**
|
|
90
91
|
* Optional verbose tracer. Called on key tab- and worker-side events
|
|
91
|
-
* (hello/welcome, init-session URL, ws connect URL, retries, transitions).
|
|
92
|
+
* (hello/welcome, init-session URL, credential-free ws connect URL, retries, transitions).
|
|
92
93
|
* Worker messages are forwarded over the port and tagged so you can tell them apart.
|
|
93
94
|
*/
|
|
94
95
|
debugLogger?: (msg: string) => void;
|
package/dist/uploader/index.cjs
CHANGED
|
@@ -51,7 +51,10 @@ function collectUserAgentContext() {
|
|
|
51
51
|
}
|
|
52
52
|
//#endregion
|
|
53
53
|
//#region src/uploader/worker/worker-script.ts
|
|
54
|
-
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
|
+
const workerScript = "//#region src/uploader/worker/web-socket-transport.ts\n/**\n* WebSocket-backed Transport. Internal retry policy: clean server-initiated close after the\n* first successful open → reconnect and resume; abrupt close (or any close before the first\n* open) → fire `onClose` and stop. Frames received while a (re)connect is in progress are\n* buffered and flushed on open.\n*\n* `ready()` resolves on the first successful open and rejects on close-before-open. Callers\n* should await it before treating the Transport as live, so a failure to open can be caught\n* (e.g. 4404 unknown session) and recovered from.\n*/\nvar WebSocketTransport = class {\n url;\n ws = null;\n onCloseCb = null;\n onStateChangeCb = null;\n intentionallyClosed = false;\n dead = false;\n /** Frames buffered while a (re)connect is in progress. */\n pending = [];\n readyPromise;\n protocols;\n constructor(url, protocols = []) {\n this.url = url;\n this.protocols = [...protocols];\n this.readyPromise = new Promise((resolve, reject) => {\n this.connect(false, resolve, reject);\n });\n this.readyPromise.catch(() => {});\n }\n ready() {\n return this.readyPromise;\n }\n send(frame) {\n if (this.dead || this.intentionallyClosed) return;\n if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));\n else this.pending.push(frame);\n }\n close(reason = \"transport-close\") {\n this.intentionallyClosed = true;\n this.ws?.close(1e3, reason);\n }\n onClose(cb) {\n this.onCloseCb = cb;\n }\n onStateChange(cb) {\n this.onStateChangeCb = cb;\n }\n connect(isReconnect, onReady, onReadyFail) {\n let ws;\n try {\n ws = new WebSocket(this.url, [...this.protocols]);\n } catch (_error) {\n this.failConnection(isReconnect, onReadyFail);\n return;\n }\n this.ws = ws;\n let opened = false;\n ws.onopen = () => {\n const expectedProtocol = this.protocols[0];\n if (expectedProtocol !== void 0 && ws.protocol !== expectedProtocol) {\n this.failConnection(isReconnect, onReadyFail);\n ws.close(1e3, \"protocol-mismatch\");\n return;\n }\n opened = true;\n onReady?.();\n if (isReconnect) this.onStateChangeCb?.({ connected: true });\n while (this.pending.length > 0) {\n const f = this.pending.shift();\n ws.send(JSON.stringify(f));\n }\n };\n ws.onclose = (event) => {\n if (this.intentionallyClosed || this.dead) return;\n if (!opened) {\n this.failConnection(isReconnect, onReadyFail);\n return;\n }\n if (event.wasClean && (event.code === 1e3 || event.code === 1001)) {\n this.onStateChangeCb?.({ connected: false });\n this.connect(true);\n } else this.die(`close code=${event.code} wasClean=${event.wasClean}`);\n };\n }\n failConnection(isReconnect, onReadyFail) {\n const reason = isReconnect ? \"reconnect-failed\" : \"initial-failed\";\n if (onReadyFail) {\n this.dead = true;\n onReadyFail(new Error(reason));\n } else this.die(reason);\n }\n die(reason) {\n this.dead = true;\n this.onCloseCb?.({ reason });\n }\n};\n//#endregion\n//#region src/uploader/worker/websocket-auth.ts\nconst RECORDING_PROTOCOL = \"recording.v1\";\nconst AUTH_PROTOCOL_PREFIX = \"auth.\";\nconst MAX_TOKEN_LENGTH = 4096;\nfunction recordingProtocols(sessionToken) {\n if (sessionToken.length > MAX_TOKEN_LENGTH) throw new Error(\"Session token is too long for WebSocket authentication\");\n if (!sessionToken || /[^A-Za-z0-9._-]/.test(sessionToken)) throw new Error(\"Invalid session token for WebSocket authentication\");\n return [RECORDING_PROTOCOL, `${AUTH_PROTOCOL_PREFIX}${sessionToken}`];\n}\n//#endregion\n//#region src/uploader/worker/csr-client.ts\n/**\n* Single Client implementation that talks to the recording backend's REST + WS protocol.\n* Both dev-server and prod implement the same protocol, so we don't need polymorphism here yet.\n*/\nvar CsrClient = class {\n apiUrl;\n clientSecret;\n context;\n websocketUrl;\n log;\n forceRecord;\n constructor(apiUrl, clientSecret, context, websocketUrl, log = () => {}, forceRecord) {\n this.apiUrl = apiUrl;\n this.clientSecret = clientSecret;\n this.context = context;\n this.websocketUrl = websocketUrl;\n this.log = log;\n this.forceRecord = forceRecord;\n }\n async initSession() {\n const url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;\n this.log(`fetch POST ${url}`);\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n clientSecret: this.clientSecret,\n ...this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {},\n ...this.forceRecord ? { forceRecord: true } : {}\n })\n });\n if (!res.ok) throw new Error(`init-session failed: HTTP ${res.status}`);\n const data = await res.json();\n if (data.skipRecording) return { skipRecording: true };\n if (!data.sessionId || !data.sessionToken) throw new Error(\"init-session response missing sessionId or sessionToken\");\n return {\n sessionId: data.sessionId,\n sessionToken: data.sessionToken\n };\n }\n async openTransport(sessionToken) {\n const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;\n let parsedUrl;\n try {\n parsedUrl = new URL(wsBase);\n } catch (_error) {\n throw new Error(\"Invalid WebSocket URL\");\n }\n if (parsedUrl.searchParams.has(\"session_token\")) throw new Error(\"WebSocket URL must not include a session token\");\n const protocols = recordingProtocols(sessionToken);\n this.log(`WebSocket connect ${wsBase}`);\n const transport = new WebSocketTransport(wsBase, protocols);\n await transport.ready();\n return transport;\n }\n trimSlash(s) {\n return s.endsWith(\"/\") ? s.slice(0, -1) : s;\n }\n toWsScheme(base) {\n if (base.startsWith(\"https://\")) return `wss://${base.slice(8)}`;\n if (base.startsWith(\"http://\")) return `ws://${base.slice(7)}`;\n return base;\n }\n};\n//#endregion\n//#region src/uploader/worker/core.ts\nconst WORKER_HASH = globalThis.__WORKER_HASH__;\nconst IDLE_GRACE_MS = 5e3;\nlet state = { phase: \"init\" };\nconst ports = [];\nlet idleTimer = null;\nfunction cancelIdleTimer() {\n if (idleTimer !== null) {\n clearTimeout(idleTimer);\n idleTimer = null;\n }\n}\nfunction log(msg) {\n for (const handle of ports) if (handle.debugLogs) handle.port.postMessage({\n type: \"log\",\n msg\n });\n}\n/**\n* The first hello \"locks in\" the session's apiUrl/clientSecret. Any later tab arriving\n* with different values is misconfigured — we reject it rather than silently using the\n* locked values. In SharedWorker mode the `name = hash(clientSecret)` scoping already\n* prevents secret-mismatch from sharing a worker, but this defends against the dedicated\n* path and against future bugs.\n*/\nlet lockedConfig = null;\nfunction registerPort(adapter) {\n cancelIdleTimer();\n const handle = {\n port: adapter,\n hello: null,\n debugLogs: false\n };\n ports.push(handle);\n adapter.onmessage((data) => {\n handleMessage(handle, data);\n });\n}\nfunction handleMessage(handle, message) {\n switch (message.type) {\n case \"hello\":\n handle.hello = message;\n handle.debugLogs = message.debugLogs ?? false;\n if (rejectIfIncompatible(handle)) return;\n if (state.phase !== \"dead\" && state.phase !== \"skipping\") detectDuplicateTab(handle);\n onHello(handle);\n return;\n case \"frame\":\n onFrame(message.frame);\n return;\n case \"bye\":\n onBye(handle);\n return;\n default: break;\n }\n}\n/**\n* Reject hellos whose `apiUrl`/`clientSecret` don't match the values established by the\n* first hello. Returns true if the port was rejected (caller should not continue\n* processing this hello).\n*/\nfunction rejectIfIncompatible(handle) {\n if (lockedConfig === null) return false;\n const incoming = handle.hello;\n if (incoming.apiUrl === lockedConfig.apiUrl && incoming.websocketUrl === lockedConfig.websocketUrl && incoming.clientSecret === lockedConfig.clientSecret) return false;\n handle.port.postMessage({\n type: \"dead\",\n reason: \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\"\n });\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n return true;\n}\n/**\n* If another already-connected port has the same `tabId`, this hello is from a duplicate\n* tab (browser \"Duplicate\" command clones sessionStorage). Mint a fresh `tabId` so the two\n* tabs don't collide on the same `(sessionId, tabId)` Recording. The new tabId is returned\n* to the tab in `welcome` so it can update its own state and sessionStorage.\n*/\nfunction detectDuplicateTab(handle) {\n const tabId = handle.hello.tabId;\n if (!ports.some((p) => p !== handle && p.hello?.tabId === tabId)) return;\n const fresh = crypto.randomUUID();\n handle.newTabId = fresh;\n handle.hello.tabId = fresh;\n}\nfunction onHello(handle) {\n switch (state.phase) {\n case \"init\":\n lockedConfig = {\n apiUrl: handle.hello.apiUrl,\n websocketUrl: handle.hello.websocketUrl,\n clientSecret: handle.hello.clientSecret\n };\n log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ? \"(configured)\" : \"(derive)\"} sessionIdHint=${handle.hello.sessionIdHint ?? \"(none)\"}`);\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n case \"initializing\": return;\n case \"active\":\n sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n return;\n case \"idle\": {\n const { client, sessionId, sessionToken } = state;\n state = { phase: \"initializing\" };\n resumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);\n return;\n }\n case \"skipping\":\n if (handle.hello.forceRecord) {\n log(\"forceRecord set; re-initializing from skipping state\");\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n }\n handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true },\n workerHash: WORKER_HASH\n });\n return;\n case \"dead\":\n handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n return;\n default: break;\n }\n}\nasync function initializeSession(firstHello) {\n const client = new CsrClient(firstHello.apiUrl, firstHello.clientSecret, firstHello.context, firstHello.websocketUrl, log, firstHello.forceRecord);\n if (firstHello.sessionIdHint && firstHello.sessionTokenHint) {\n log(`adopting sessionIdHint=${firstHello.sessionIdHint}`);\n try {\n const transport = await client.openTransport(firstHello.sessionTokenHint);\n wireTransport(transport);\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: firstHello.sessionIdHint,\n sessionToken: firstHello.sessionTokenHint\n };\n log(\"hint adopted; transport open\");\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n return;\n } catch (err) {\n log(`hint rejected (${String(err)}); falling back to fresh init`);\n }\n }\n let result;\n try {\n result = await client.initSession();\n } catch (err) {\n log(`init-session threw: ${String(err)}`);\n transitionToDead(`init-session-failed: ${String(err)}`);\n return;\n }\n if (\"skipRecording\" in result) {\n log(\"init-session: skipRecording\");\n state = { phase: \"skipping\" };\n return;\n }\n log(`init-session ok sessionId=${result.sessionId}`);\n let transport;\n try {\n transport = await client.openTransport(result.sessionToken);\n } catch (err) {\n log(`openTransport threw: ${String(err)}`);\n transitionToDead(`open-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport open; session active\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: result.sessionId,\n sessionToken: result.sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nasync function resumeTransport(client, sessionId, sessionToken) {\n log(\"resuming transport from idle\");\n let transport;\n try {\n transport = await client.openTransport(sessionToken);\n } catch (err) {\n log(`resume-transport threw: ${String(err)}`);\n transitionToDead(`resume-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport resumed\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId,\n sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nfunction wireTransport(transport) {\n transport.onClose((info) => {\n if (state.phase !== \"active\") return;\n transitionToDead(info.reason);\n });\n transport.onStateChange((info) => {\n if (state.phase !== \"active\") return;\n for (const handle of ports) handle.port.postMessage({\n type: \"state\",\n connected: info.connected\n });\n });\n}\nfunction transitionToDead(reason) {\n state = {\n phase: \"dead\",\n reason\n };\n for (const handle of ports) handle.port.postMessage({\n type: \"dead\",\n reason\n });\n}\nfunction flushPendingWelcomes() {\n for (const handle of ports) {\n if (handle.hello === null) continue;\n if (state.phase === \"active\") sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n else if (state.phase === \"skipping\") handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true },\n workerHash: WORKER_HASH\n });\n else if (state.phase === \"dead\") handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n }\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n const hint = handle.hello?.sessionIdHint;\n const adopted = hint !== void 0 && hint !== currentSessionId;\n const newTabId = handle.newTabId;\n handle.port.postMessage({\n type: \"welcome\",\n result: {\n sessionId: currentSessionId,\n sessionToken: currentSessionToken\n },\n workerHash: WORKER_HASH,\n adoptedFromSessionId: adopted ? hint : void 0,\n newTabId,\n resetCounter: adopted || newTabId !== void 0\n });\n}\nfunction onFrame(frame) {\n if (state.phase !== \"active\") return;\n state.transport.send(frame);\n}\nfunction onBye(handle) {\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n if (ports.length === 0 && state.phase === \"active\") {\n log(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);\n idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n }\n}\nfunction enterIdle() {\n idleTimer = null;\n if (state.phase !== \"active\" || ports.length > 0) return;\n log(\"idle timeout; closing transport\");\n state.transport.close(\"idle\");\n state = {\n phase: \"idle\",\n client: state.client,\n sessionId: state.sessionId,\n sessionToken: state.sessionToken\n };\n}\n//#endregion\n//#region src/uploader/worker/entry.ts\nconst SharedWorkerScopeCtor = globalThis.SharedWorkerGlobalScope;\nif (typeof SharedWorkerScopeCtor === \"function\" && self instanceof SharedWorkerScopeCtor) self.onconnect = (event) => {\n const port = event.ports[0];\n port.start();\n registerPort(adaptMessagePort(port));\n};\nelse registerPort(adaptDedicatedSelf());\nfunction adaptMessagePort(port) {\n return {\n postMessage: (message) => port.postMessage(message),\n onmessage: (cb) => {\n port.onmessage = (e) => cb(e.data);\n }\n };\n}\nfunction adaptDedicatedSelf() {\n const ws = self;\n return {\n postMessage: (message) => ws.postMessage(message),\n onmessage: (cb) => {\n ws.onmessage = (e) => cb(e.data);\n }\n };\n}\n//#endregion\n";
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/uploader/worker-hash.ts
|
|
57
|
+
const WORKER_HASH = "1774b236fa1eb46a";
|
|
55
58
|
//#endregion
|
|
56
59
|
//#region src/uploader/create-uploader.ts
|
|
57
60
|
const STORAGE_TAB_ID = "csr:tabId";
|
|
@@ -141,6 +144,7 @@ async function createUploader(opts) {
|
|
|
141
144
|
return msg;
|
|
142
145
|
}), welcomeDeadline]);
|
|
143
146
|
log?.(welcome.type === "welcome" ? `tab: welcome (${"sessionId" in welcome.result ? `sessionId=${welcome.result.sessionId}` : "skipRecording"})` : `tab: dead reason=${welcome.reason}`);
|
|
147
|
+
if (welcome.type === "welcome" && urlScheme === "custom" && welcome.workerHash !== "1774b236fa1eb46a") log?.("tab: WORKER MISMATCH — the self-hosted confidence-worker.js does not match the installed SDK. Copy the updated file from node_modules/@spotify-confidence/session-recording/dist/confidence-worker.js");
|
|
144
148
|
if (welcome.type === "dead") throw new Error(`uploader: ${welcome.reason}`);
|
|
145
149
|
if ("skipRecording" in welcome.result) {
|
|
146
150
|
if (opts.forceRecord) log?.("tab: forceRecord was set but backend still skipped — backend may not support forceRecord yet");
|
|
@@ -349,6 +353,7 @@ function writeCounter(counter) {
|
|
|
349
353
|
sessionStorage.setItem(STORAGE_COUNTER, String(counter));
|
|
350
354
|
}
|
|
351
355
|
//#endregion
|
|
356
|
+
exports.WORKER_HASH = WORKER_HASH;
|
|
352
357
|
exports.collectUserAgentContext = collectUserAgentContext;
|
|
353
358
|
exports.createUploader = createUploader;
|
|
354
359
|
exports.workerScript = workerScript;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as ContextValue, i as ClientContext, o as UserAgentContext, r as Uploader, s as collectUserAgentContext, t as CreateUploaderOptions } from "../types-
|
|
1
|
+
import { a as ContextValue, i as ClientContext, o as UserAgentContext, r as Uploader, s as collectUserAgentContext, t as CreateUploaderOptions } from "../types-CdvFpcEN.cjs";
|
|
2
2
|
|
|
3
3
|
//#region src/uploader/create-uploader.d.ts
|
|
4
4
|
declare function createUploader(opts: CreateUploaderOptions): Promise<Uploader | null>;
|
|
@@ -6,4 +6,7 @@ declare function createUploader(opts: CreateUploaderOptions): Promise<Uploader |
|
|
|
6
6
|
//#region src/uploader/worker/worker-script.d.ts
|
|
7
7
|
declare const workerScript: string;
|
|
8
8
|
//#endregion
|
|
9
|
-
|
|
9
|
+
//#region src/uploader/worker-hash.d.ts
|
|
10
|
+
declare const WORKER_HASH = "1774b236fa1eb46a";
|
|
11
|
+
//#endregion
|
|
12
|
+
export { type ClientContext, type ContextValue, type CreateUploaderOptions, type Uploader, type UserAgentContext, WORKER_HASH, collectUserAgentContext, createUploader, workerScript };
|
package/dist/uploader/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as ContextValue, i as ClientContext, o as UserAgentContext, r as Uploader, s as collectUserAgentContext, t as CreateUploaderOptions } from "../types-
|
|
1
|
+
import { a as ContextValue, i as ClientContext, o as UserAgentContext, r as Uploader, s as collectUserAgentContext, t as CreateUploaderOptions } from "../types-CdvFpcEN.js";
|
|
2
2
|
|
|
3
3
|
//#region src/uploader/create-uploader.d.ts
|
|
4
4
|
declare function createUploader(opts: CreateUploaderOptions): Promise<Uploader | null>;
|
|
@@ -6,4 +6,7 @@ declare function createUploader(opts: CreateUploaderOptions): Promise<Uploader |
|
|
|
6
6
|
//#region src/uploader/worker/worker-script.d.ts
|
|
7
7
|
declare const workerScript: string;
|
|
8
8
|
//#endregion
|
|
9
|
-
|
|
9
|
+
//#region src/uploader/worker-hash.d.ts
|
|
10
|
+
declare const WORKER_HASH = "1774b236fa1eb46a";
|
|
11
|
+
//#endregion
|
|
12
|
+
export { type ClientContext, type ContextValue, type CreateUploaderOptions, type Uploader, type UserAgentContext, WORKER_HASH, collectUserAgentContext, createUploader, workerScript };
|
package/dist/uploader/index.js
CHANGED
|
@@ -27,7 +27,10 @@ function collectUserAgentContext() {
|
|
|
27
27
|
}
|
|
28
28
|
//#endregion
|
|
29
29
|
//#region src/uploader/worker/worker-script.ts
|
|
30
|
-
const workerScript = "//#region src/uploader/worker/web-socket-transport.ts\n/**\n* WebSocket-backed Transport. Internal retry policy: clean server-initiated close after the\n* first successful open → reconnect and resume; abrupt close (or any close before the first\n* open) → fire `onClose` and stop. Frames received while a (re)connect is in progress are\n* buffered and flushed on open.\n*\n* `ready()` resolves on the first successful open and rejects on close-before-open. Callers\n* should await it before treating the Transport as live, so a failure to open can be caught\n* (e.g. 4404 unknown session) and recovered from.\n*/\nvar WebSocketTransport = class {\n url;\n ws = null;\n onCloseCb = null;\n onStateChangeCb = null;\n intentionallyClosed = false;\n dead = false;\n /** Frames buffered while a (re)connect is in progress. */\n pending = [];\n readyPromise;\n constructor(url) {\n this.url = url;\n this.readyPromise = new Promise((resolve, reject) => {\n this.connect(false, resolve, reject);\n });\n this.readyPromise.catch(() => {});\n }\n ready() {\n return this.readyPromise;\n }\n send(frame) {\n if (this.dead || this.intentionallyClosed) return;\n if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));\n else this.pending.push(frame);\n }\n close(reason = \"transport-close\") {\n this.intentionallyClosed = true;\n this.ws?.close(1e3, reason);\n }\n onClose(cb) {\n this.onCloseCb = cb;\n }\n onStateChange(cb) {\n this.onStateChangeCb = cb;\n }\n connect(isReconnect, onReady, onReadyFail) {\n const ws = new WebSocket(this.url);\n this.ws = ws;\n let opened = false;\n ws.onopen = () => {\n opened = true;\n onReady?.();\n if (isReconnect) this.onStateChangeCb?.({ connected: true });\n while (this.pending.length > 0) {\n const f = this.pending.shift();\n ws.send(JSON.stringify(f));\n }\n };\n ws.onclose = (event) => {\n if (this.intentionallyClosed) return;\n if (!opened) {\n const reason = `${isReconnect ? \"reconnect\" : \"initial\"}-failed code=${event.code}`;\n if (onReadyFail) {\n onReadyFail(new Error(reason));\n this.dead = true;\n } else this.die(reason);\n return;\n }\n if (event.wasClean && (event.code === 1e3 || event.code === 1001)) {\n this.onStateChangeCb?.({ connected: false });\n this.connect(true);\n } else this.die(`close code=${event.code} wasClean=${event.wasClean}`);\n };\n }\n die(reason) {\n this.dead = true;\n this.onCloseCb?.({ reason });\n }\n};\n//#endregion\n//#region src/uploader/worker/csr-client.ts\n/**\n* Single Client implementation that talks to the recording backend's REST + WS protocol.\n* Both dev-server and prod implement the same protocol, so we don't need polymorphism here yet.\n*/\nvar CsrClient = class {\n apiUrl;\n clientSecret;\n context;\n websocketUrl;\n log;\n forceRecord;\n constructor(apiUrl, clientSecret, context, websocketUrl, log = () => {}, forceRecord) {\n this.apiUrl = apiUrl;\n this.clientSecret = clientSecret;\n this.context = context;\n this.websocketUrl = websocketUrl;\n this.log = log;\n this.forceRecord = forceRecord;\n }\n async initSession() {\n const url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;\n this.log(`fetch POST ${url}`);\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n clientSecret: this.clientSecret,\n ...this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {},\n ...this.forceRecord ? { forceRecord: true } : {}\n })\n });\n if (!res.ok) throw new Error(`init-session failed: HTTP ${res.status}`);\n const data = await res.json();\n if (data.skipRecording) return { skipRecording: true };\n if (!data.sessionId || !data.sessionToken) throw new Error(\"init-session response missing sessionId or sessionToken\");\n return {\n sessionId: data.sessionId,\n sessionToken: data.sessionToken\n };\n }\n async openTransport(sessionToken) {\n const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;\n const url = `${wsBase}${wsBase.includes(\"?\") ? \"&\" : \"?\"}session_token=${encodeURIComponent(sessionToken)}`;\n this.log(`WebSocket connect ${url.replace(/session_token=[^&]*/, \"session_token=[REDACTED]\")}`);\n const transport = new WebSocketTransport(url);\n await transport.ready();\n return transport;\n }\n trimSlash(s) {\n return s.endsWith(\"/\") ? s.slice(0, -1) : s;\n }\n toWsScheme(base) {\n if (base.startsWith(\"https://\")) return `wss://${base.slice(8)}`;\n if (base.startsWith(\"http://\")) return `ws://${base.slice(7)}`;\n return base;\n }\n};\n//#endregion\n//#region src/uploader/worker/core.ts\nconst IDLE_GRACE_MS = 5e3;\nlet state = { phase: \"init\" };\nconst ports = [];\nlet idleTimer = null;\nfunction cancelIdleTimer() {\n if (idleTimer !== null) {\n clearTimeout(idleTimer);\n idleTimer = null;\n }\n}\nfunction log(msg) {\n for (const handle of ports) if (handle.debugLogs) handle.port.postMessage({\n type: \"log\",\n msg\n });\n}\n/**\n* The first hello \"locks in\" the session's apiUrl/clientSecret. Any later tab arriving\n* with different values is misconfigured — we reject it rather than silently using the\n* locked values. In SharedWorker mode the `name = hash(clientSecret)` scoping already\n* prevents secret-mismatch from sharing a worker, but this defends against the dedicated\n* path and against future bugs.\n*/\nlet lockedConfig = null;\nfunction registerPort(adapter) {\n cancelIdleTimer();\n const handle = {\n port: adapter,\n hello: null,\n debugLogs: false\n };\n ports.push(handle);\n adapter.onmessage((data) => {\n handleMessage(handle, data);\n });\n}\nfunction handleMessage(handle, message) {\n switch (message.type) {\n case \"hello\":\n handle.hello = message;\n handle.debugLogs = message.debugLogs ?? false;\n if (rejectIfIncompatible(handle)) return;\n if (state.phase !== \"dead\" && state.phase !== \"skipping\") detectDuplicateTab(handle);\n onHello(handle);\n return;\n case \"frame\":\n onFrame(message.frame);\n return;\n case \"bye\":\n onBye(handle);\n return;\n default: break;\n }\n}\n/**\n* Reject hellos whose `apiUrl`/`clientSecret` don't match the values established by the\n* first hello. Returns true if the port was rejected (caller should not continue\n* processing this hello).\n*/\nfunction rejectIfIncompatible(handle) {\n if (lockedConfig === null) return false;\n const incoming = handle.hello;\n if (incoming.apiUrl === lockedConfig.apiUrl && incoming.websocketUrl === lockedConfig.websocketUrl && incoming.clientSecret === lockedConfig.clientSecret) return false;\n handle.port.postMessage({\n type: \"dead\",\n reason: \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\"\n });\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n return true;\n}\n/**\n* If another already-connected port has the same `tabId`, this hello is from a duplicate\n* tab (browser \"Duplicate\" command clones sessionStorage). Mint a fresh `tabId` so the two\n* tabs don't collide on the same `(sessionId, tabId)` Recording. The new tabId is returned\n* to the tab in `welcome` so it can update its own state and sessionStorage.\n*/\nfunction detectDuplicateTab(handle) {\n const tabId = handle.hello.tabId;\n if (!ports.some((p) => p !== handle && p.hello?.tabId === tabId)) return;\n const fresh = crypto.randomUUID();\n handle.newTabId = fresh;\n handle.hello.tabId = fresh;\n}\nfunction onHello(handle) {\n switch (state.phase) {\n case \"init\":\n lockedConfig = {\n apiUrl: handle.hello.apiUrl,\n websocketUrl: handle.hello.websocketUrl,\n clientSecret: handle.hello.clientSecret\n };\n log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ?? \"(derive)\"} sessionIdHint=${handle.hello.sessionIdHint ?? \"(none)\"}`);\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n case \"initializing\": return;\n case \"active\":\n sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n return;\n case \"idle\": {\n const { client, sessionId, sessionToken } = state;\n state = { phase: \"initializing\" };\n resumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);\n return;\n }\n case \"skipping\":\n if (handle.hello.forceRecord) {\n log(\"forceRecord set; re-initializing from skipping state\");\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n }\n handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n return;\n case \"dead\":\n handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n return;\n default: break;\n }\n}\nasync function initializeSession(firstHello) {\n const client = new CsrClient(firstHello.apiUrl, firstHello.clientSecret, firstHello.context, firstHello.websocketUrl, log, firstHello.forceRecord);\n if (firstHello.sessionIdHint && firstHello.sessionTokenHint) {\n log(`adopting sessionIdHint=${firstHello.sessionIdHint}`);\n try {\n const transport = await client.openTransport(firstHello.sessionTokenHint);\n wireTransport(transport);\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: firstHello.sessionIdHint,\n sessionToken: firstHello.sessionTokenHint\n };\n log(\"hint adopted; transport open\");\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n return;\n } catch (err) {\n log(`hint rejected (${String(err)}); falling back to fresh init`);\n }\n }\n let result;\n try {\n result = await client.initSession();\n } catch (err) {\n log(`init-session threw: ${String(err)}`);\n transitionToDead(`init-session-failed: ${String(err)}`);\n return;\n }\n if (\"skipRecording\" in result) {\n log(\"init-session: skipRecording\");\n state = { phase: \"skipping\" };\n return;\n }\n log(`init-session ok sessionId=${result.sessionId}`);\n let transport;\n try {\n transport = await client.openTransport(result.sessionToken);\n } catch (err) {\n log(`openTransport threw: ${String(err)}`);\n transitionToDead(`open-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport open; session active\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: result.sessionId,\n sessionToken: result.sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nasync function resumeTransport(client, sessionId, sessionToken) {\n log(\"resuming transport from idle\");\n let transport;\n try {\n transport = await client.openTransport(sessionToken);\n } catch (err) {\n log(`resume-transport threw: ${String(err)}`);\n transitionToDead(`resume-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport resumed\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId,\n sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nfunction wireTransport(transport) {\n transport.onClose((info) => {\n if (state.phase !== \"active\") return;\n transitionToDead(info.reason);\n });\n transport.onStateChange((info) => {\n if (state.phase !== \"active\") return;\n for (const handle of ports) handle.port.postMessage({\n type: \"state\",\n connected: info.connected\n });\n });\n}\nfunction transitionToDead(reason) {\n state = {\n phase: \"dead\",\n reason\n };\n for (const handle of ports) handle.port.postMessage({\n type: \"dead\",\n reason\n });\n}\nfunction flushPendingWelcomes() {\n for (const handle of ports) {\n if (handle.hello === null) continue;\n if (state.phase === \"active\") sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n else if (state.phase === \"skipping\") handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n else if (state.phase === \"dead\") handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n }\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n const hint = handle.hello?.sessionIdHint;\n const adopted = hint !== void 0 && hint !== currentSessionId;\n const newTabId = handle.newTabId;\n handle.port.postMessage({\n type: \"welcome\",\n result: {\n sessionId: currentSessionId,\n sessionToken: currentSessionToken\n },\n adoptedFromSessionId: adopted ? hint : void 0,\n newTabId,\n resetCounter: adopted || newTabId !== void 0\n });\n}\nfunction onFrame(frame) {\n if (state.phase !== \"active\") return;\n state.transport.send(frame);\n}\nfunction onBye(handle) {\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n if (ports.length === 0 && state.phase === \"active\") {\n log(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);\n idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n }\n}\nfunction enterIdle() {\n idleTimer = null;\n if (state.phase !== \"active\" || ports.length > 0) return;\n log(\"idle timeout; closing transport\");\n state.transport.close(\"idle\");\n state = {\n phase: \"idle\",\n client: state.client,\n sessionId: state.sessionId,\n sessionToken: state.sessionToken\n };\n}\n//#endregion\n//#region src/uploader/worker/entry.ts\nconst SharedWorkerScopeCtor = globalThis.SharedWorkerGlobalScope;\nif (typeof SharedWorkerScopeCtor === \"function\" && self instanceof SharedWorkerScopeCtor) self.onconnect = (event) => {\n const port = event.ports[0];\n port.start();\n registerPort(adaptMessagePort(port));\n};\nelse registerPort(adaptDedicatedSelf());\nfunction adaptMessagePort(port) {\n return {\n postMessage: (message) => port.postMessage(message),\n onmessage: (cb) => {\n port.onmessage = (e) => cb(e.data);\n }\n };\n}\nfunction adaptDedicatedSelf() {\n const ws = self;\n return {\n postMessage: (message) => ws.postMessage(message),\n onmessage: (cb) => {\n ws.onmessage = (e) => cb(e.data);\n }\n };\n}\n//#endregion\n";
|
|
30
|
+
const workerScript = "//#region src/uploader/worker/web-socket-transport.ts\n/**\n* WebSocket-backed Transport. Internal retry policy: clean server-initiated close after the\n* first successful open → reconnect and resume; abrupt close (or any close before the first\n* open) → fire `onClose` and stop. Frames received while a (re)connect is in progress are\n* buffered and flushed on open.\n*\n* `ready()` resolves on the first successful open and rejects on close-before-open. Callers\n* should await it before treating the Transport as live, so a failure to open can be caught\n* (e.g. 4404 unknown session) and recovered from.\n*/\nvar WebSocketTransport = class {\n url;\n ws = null;\n onCloseCb = null;\n onStateChangeCb = null;\n intentionallyClosed = false;\n dead = false;\n /** Frames buffered while a (re)connect is in progress. */\n pending = [];\n readyPromise;\n protocols;\n constructor(url, protocols = []) {\n this.url = url;\n this.protocols = [...protocols];\n this.readyPromise = new Promise((resolve, reject) => {\n this.connect(false, resolve, reject);\n });\n this.readyPromise.catch(() => {});\n }\n ready() {\n return this.readyPromise;\n }\n send(frame) {\n if (this.dead || this.intentionallyClosed) return;\n if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));\n else this.pending.push(frame);\n }\n close(reason = \"transport-close\") {\n this.intentionallyClosed = true;\n this.ws?.close(1e3, reason);\n }\n onClose(cb) {\n this.onCloseCb = cb;\n }\n onStateChange(cb) {\n this.onStateChangeCb = cb;\n }\n connect(isReconnect, onReady, onReadyFail) {\n let ws;\n try {\n ws = new WebSocket(this.url, [...this.protocols]);\n } catch (_error) {\n this.failConnection(isReconnect, onReadyFail);\n return;\n }\n this.ws = ws;\n let opened = false;\n ws.onopen = () => {\n const expectedProtocol = this.protocols[0];\n if (expectedProtocol !== void 0 && ws.protocol !== expectedProtocol) {\n this.failConnection(isReconnect, onReadyFail);\n ws.close(1e3, \"protocol-mismatch\");\n return;\n }\n opened = true;\n onReady?.();\n if (isReconnect) this.onStateChangeCb?.({ connected: true });\n while (this.pending.length > 0) {\n const f = this.pending.shift();\n ws.send(JSON.stringify(f));\n }\n };\n ws.onclose = (event) => {\n if (this.intentionallyClosed || this.dead) return;\n if (!opened) {\n this.failConnection(isReconnect, onReadyFail);\n return;\n }\n if (event.wasClean && (event.code === 1e3 || event.code === 1001)) {\n this.onStateChangeCb?.({ connected: false });\n this.connect(true);\n } else this.die(`close code=${event.code} wasClean=${event.wasClean}`);\n };\n }\n failConnection(isReconnect, onReadyFail) {\n const reason = isReconnect ? \"reconnect-failed\" : \"initial-failed\";\n if (onReadyFail) {\n this.dead = true;\n onReadyFail(new Error(reason));\n } else this.die(reason);\n }\n die(reason) {\n this.dead = true;\n this.onCloseCb?.({ reason });\n }\n};\n//#endregion\n//#region src/uploader/worker/websocket-auth.ts\nconst RECORDING_PROTOCOL = \"recording.v1\";\nconst AUTH_PROTOCOL_PREFIX = \"auth.\";\nconst MAX_TOKEN_LENGTH = 4096;\nfunction recordingProtocols(sessionToken) {\n if (sessionToken.length > MAX_TOKEN_LENGTH) throw new Error(\"Session token is too long for WebSocket authentication\");\n if (!sessionToken || /[^A-Za-z0-9._-]/.test(sessionToken)) throw new Error(\"Invalid session token for WebSocket authentication\");\n return [RECORDING_PROTOCOL, `${AUTH_PROTOCOL_PREFIX}${sessionToken}`];\n}\n//#endregion\n//#region src/uploader/worker/csr-client.ts\n/**\n* Single Client implementation that talks to the recording backend's REST + WS protocol.\n* Both dev-server and prod implement the same protocol, so we don't need polymorphism here yet.\n*/\nvar CsrClient = class {\n apiUrl;\n clientSecret;\n context;\n websocketUrl;\n log;\n forceRecord;\n constructor(apiUrl, clientSecret, context, websocketUrl, log = () => {}, forceRecord) {\n this.apiUrl = apiUrl;\n this.clientSecret = clientSecret;\n this.context = context;\n this.websocketUrl = websocketUrl;\n this.log = log;\n this.forceRecord = forceRecord;\n }\n async initSession() {\n const url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;\n this.log(`fetch POST ${url}`);\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n clientSecret: this.clientSecret,\n ...this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {},\n ...this.forceRecord ? { forceRecord: true } : {}\n })\n });\n if (!res.ok) throw new Error(`init-session failed: HTTP ${res.status}`);\n const data = await res.json();\n if (data.skipRecording) return { skipRecording: true };\n if (!data.sessionId || !data.sessionToken) throw new Error(\"init-session response missing sessionId or sessionToken\");\n return {\n sessionId: data.sessionId,\n sessionToken: data.sessionToken\n };\n }\n async openTransport(sessionToken) {\n const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;\n let parsedUrl;\n try {\n parsedUrl = new URL(wsBase);\n } catch (_error) {\n throw new Error(\"Invalid WebSocket URL\");\n }\n if (parsedUrl.searchParams.has(\"session_token\")) throw new Error(\"WebSocket URL must not include a session token\");\n const protocols = recordingProtocols(sessionToken);\n this.log(`WebSocket connect ${wsBase}`);\n const transport = new WebSocketTransport(wsBase, protocols);\n await transport.ready();\n return transport;\n }\n trimSlash(s) {\n return s.endsWith(\"/\") ? s.slice(0, -1) : s;\n }\n toWsScheme(base) {\n if (base.startsWith(\"https://\")) return `wss://${base.slice(8)}`;\n if (base.startsWith(\"http://\")) return `ws://${base.slice(7)}`;\n return base;\n }\n};\n//#endregion\n//#region src/uploader/worker/core.ts\nconst WORKER_HASH = globalThis.__WORKER_HASH__;\nconst IDLE_GRACE_MS = 5e3;\nlet state = { phase: \"init\" };\nconst ports = [];\nlet idleTimer = null;\nfunction cancelIdleTimer() {\n if (idleTimer !== null) {\n clearTimeout(idleTimer);\n idleTimer = null;\n }\n}\nfunction log(msg) {\n for (const handle of ports) if (handle.debugLogs) handle.port.postMessage({\n type: \"log\",\n msg\n });\n}\n/**\n* The first hello \"locks in\" the session's apiUrl/clientSecret. Any later tab arriving\n* with different values is misconfigured — we reject it rather than silently using the\n* locked values. In SharedWorker mode the `name = hash(clientSecret)` scoping already\n* prevents secret-mismatch from sharing a worker, but this defends against the dedicated\n* path and against future bugs.\n*/\nlet lockedConfig = null;\nfunction registerPort(adapter) {\n cancelIdleTimer();\n const handle = {\n port: adapter,\n hello: null,\n debugLogs: false\n };\n ports.push(handle);\n adapter.onmessage((data) => {\n handleMessage(handle, data);\n });\n}\nfunction handleMessage(handle, message) {\n switch (message.type) {\n case \"hello\":\n handle.hello = message;\n handle.debugLogs = message.debugLogs ?? false;\n if (rejectIfIncompatible(handle)) return;\n if (state.phase !== \"dead\" && state.phase !== \"skipping\") detectDuplicateTab(handle);\n onHello(handle);\n return;\n case \"frame\":\n onFrame(message.frame);\n return;\n case \"bye\":\n onBye(handle);\n return;\n default: break;\n }\n}\n/**\n* Reject hellos whose `apiUrl`/`clientSecret` don't match the values established by the\n* first hello. Returns true if the port was rejected (caller should not continue\n* processing this hello).\n*/\nfunction rejectIfIncompatible(handle) {\n if (lockedConfig === null) return false;\n const incoming = handle.hello;\n if (incoming.apiUrl === lockedConfig.apiUrl && incoming.websocketUrl === lockedConfig.websocketUrl && incoming.clientSecret === lockedConfig.clientSecret) return false;\n handle.port.postMessage({\n type: \"dead\",\n reason: \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\"\n });\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n return true;\n}\n/**\n* If another already-connected port has the same `tabId`, this hello is from a duplicate\n* tab (browser \"Duplicate\" command clones sessionStorage). Mint a fresh `tabId` so the two\n* tabs don't collide on the same `(sessionId, tabId)` Recording. The new tabId is returned\n* to the tab in `welcome` so it can update its own state and sessionStorage.\n*/\nfunction detectDuplicateTab(handle) {\n const tabId = handle.hello.tabId;\n if (!ports.some((p) => p !== handle && p.hello?.tabId === tabId)) return;\n const fresh = crypto.randomUUID();\n handle.newTabId = fresh;\n handle.hello.tabId = fresh;\n}\nfunction onHello(handle) {\n switch (state.phase) {\n case \"init\":\n lockedConfig = {\n apiUrl: handle.hello.apiUrl,\n websocketUrl: handle.hello.websocketUrl,\n clientSecret: handle.hello.clientSecret\n };\n log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ? \"(configured)\" : \"(derive)\"} sessionIdHint=${handle.hello.sessionIdHint ?? \"(none)\"}`);\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n case \"initializing\": return;\n case \"active\":\n sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n return;\n case \"idle\": {\n const { client, sessionId, sessionToken } = state;\n state = { phase: \"initializing\" };\n resumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);\n return;\n }\n case \"skipping\":\n if (handle.hello.forceRecord) {\n log(\"forceRecord set; re-initializing from skipping state\");\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n }\n handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true },\n workerHash: WORKER_HASH\n });\n return;\n case \"dead\":\n handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n return;\n default: break;\n }\n}\nasync function initializeSession(firstHello) {\n const client = new CsrClient(firstHello.apiUrl, firstHello.clientSecret, firstHello.context, firstHello.websocketUrl, log, firstHello.forceRecord);\n if (firstHello.sessionIdHint && firstHello.sessionTokenHint) {\n log(`adopting sessionIdHint=${firstHello.sessionIdHint}`);\n try {\n const transport = await client.openTransport(firstHello.sessionTokenHint);\n wireTransport(transport);\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: firstHello.sessionIdHint,\n sessionToken: firstHello.sessionTokenHint\n };\n log(\"hint adopted; transport open\");\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n return;\n } catch (err) {\n log(`hint rejected (${String(err)}); falling back to fresh init`);\n }\n }\n let result;\n try {\n result = await client.initSession();\n } catch (err) {\n log(`init-session threw: ${String(err)}`);\n transitionToDead(`init-session-failed: ${String(err)}`);\n return;\n }\n if (\"skipRecording\" in result) {\n log(\"init-session: skipRecording\");\n state = { phase: \"skipping\" };\n return;\n }\n log(`init-session ok sessionId=${result.sessionId}`);\n let transport;\n try {\n transport = await client.openTransport(result.sessionToken);\n } catch (err) {\n log(`openTransport threw: ${String(err)}`);\n transitionToDead(`open-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport open; session active\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: result.sessionId,\n sessionToken: result.sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nasync function resumeTransport(client, sessionId, sessionToken) {\n log(\"resuming transport from idle\");\n let transport;\n try {\n transport = await client.openTransport(sessionToken);\n } catch (err) {\n log(`resume-transport threw: ${String(err)}`);\n transitionToDead(`resume-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport resumed\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId,\n sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nfunction wireTransport(transport) {\n transport.onClose((info) => {\n if (state.phase !== \"active\") return;\n transitionToDead(info.reason);\n });\n transport.onStateChange((info) => {\n if (state.phase !== \"active\") return;\n for (const handle of ports) handle.port.postMessage({\n type: \"state\",\n connected: info.connected\n });\n });\n}\nfunction transitionToDead(reason) {\n state = {\n phase: \"dead\",\n reason\n };\n for (const handle of ports) handle.port.postMessage({\n type: \"dead\",\n reason\n });\n}\nfunction flushPendingWelcomes() {\n for (const handle of ports) {\n if (handle.hello === null) continue;\n if (state.phase === \"active\") sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n else if (state.phase === \"skipping\") handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true },\n workerHash: WORKER_HASH\n });\n else if (state.phase === \"dead\") handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n }\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n const hint = handle.hello?.sessionIdHint;\n const adopted = hint !== void 0 && hint !== currentSessionId;\n const newTabId = handle.newTabId;\n handle.port.postMessage({\n type: \"welcome\",\n result: {\n sessionId: currentSessionId,\n sessionToken: currentSessionToken\n },\n workerHash: WORKER_HASH,\n adoptedFromSessionId: adopted ? hint : void 0,\n newTabId,\n resetCounter: adopted || newTabId !== void 0\n });\n}\nfunction onFrame(frame) {\n if (state.phase !== \"active\") return;\n state.transport.send(frame);\n}\nfunction onBye(handle) {\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n if (ports.length === 0 && state.phase === \"active\") {\n log(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);\n idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n }\n}\nfunction enterIdle() {\n idleTimer = null;\n if (state.phase !== \"active\" || ports.length > 0) return;\n log(\"idle timeout; closing transport\");\n state.transport.close(\"idle\");\n state = {\n phase: \"idle\",\n client: state.client,\n sessionId: state.sessionId,\n sessionToken: state.sessionToken\n };\n}\n//#endregion\n//#region src/uploader/worker/entry.ts\nconst SharedWorkerScopeCtor = globalThis.SharedWorkerGlobalScope;\nif (typeof SharedWorkerScopeCtor === \"function\" && self instanceof SharedWorkerScopeCtor) self.onconnect = (event) => {\n const port = event.ports[0];\n port.start();\n registerPort(adaptMessagePort(port));\n};\nelse registerPort(adaptDedicatedSelf());\nfunction adaptMessagePort(port) {\n return {\n postMessage: (message) => port.postMessage(message),\n onmessage: (cb) => {\n port.onmessage = (e) => cb(e.data);\n }\n };\n}\nfunction adaptDedicatedSelf() {\n const ws = self;\n return {\n postMessage: (message) => ws.postMessage(message),\n onmessage: (cb) => {\n ws.onmessage = (e) => cb(e.data);\n }\n };\n}\n//#endregion\n";
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/uploader/worker-hash.ts
|
|
33
|
+
const WORKER_HASH = "1774b236fa1eb46a";
|
|
31
34
|
//#endregion
|
|
32
35
|
//#region src/uploader/create-uploader.ts
|
|
33
36
|
const STORAGE_TAB_ID = "csr:tabId";
|
|
@@ -117,6 +120,7 @@ async function createUploader(opts) {
|
|
|
117
120
|
return msg;
|
|
118
121
|
}), welcomeDeadline]);
|
|
119
122
|
log?.(welcome.type === "welcome" ? `tab: welcome (${"sessionId" in welcome.result ? `sessionId=${welcome.result.sessionId}` : "skipRecording"})` : `tab: dead reason=${welcome.reason}`);
|
|
123
|
+
if (welcome.type === "welcome" && urlScheme === "custom" && welcome.workerHash !== "1774b236fa1eb46a") log?.("tab: WORKER MISMATCH — the self-hosted confidence-worker.js does not match the installed SDK. Copy the updated file from node_modules/@spotify-confidence/session-recording/dist/confidence-worker.js");
|
|
120
124
|
if (welcome.type === "dead") throw new Error(`uploader: ${welcome.reason}`);
|
|
121
125
|
if ("skipRecording" in welcome.result) {
|
|
122
126
|
if (opts.forceRecord) log?.("tab: forceRecord was set but backend still skipped — backend may not support forceRecord yet");
|
|
@@ -325,4 +329,4 @@ function writeCounter(counter) {
|
|
|
325
329
|
sessionStorage.setItem(STORAGE_COUNTER, String(counter));
|
|
326
330
|
}
|
|
327
331
|
//#endregion
|
|
328
|
-
export { collectUserAgentContext, createUploader, workerScript };
|
|
332
|
+
export { WORKER_HASH, collectUserAgentContext, createUploader, workerScript };
|
package/package.json
CHANGED
|
@@ -14,15 +14,30 @@ import { onTestFinished, vi } from 'vitest';
|
|
|
14
14
|
* Must be called from within a test (or a helper called from one) so vitest
|
|
15
15
|
* has a test context to attach the cleanup to.
|
|
16
16
|
*/
|
|
17
|
-
|
|
17
|
+
interface MockWsServerOptions {
|
|
18
|
+
selectProtocol?: (protocols: string[], connectionIndex: number) => string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function installMockWsServer(
|
|
22
|
+
url: string,
|
|
23
|
+
options: MockWsServerOptions = {},
|
|
24
|
+
): {
|
|
18
25
|
server: Server;
|
|
19
26
|
connections: Client[];
|
|
20
27
|
messages: string[];
|
|
28
|
+
protocolOffers: string[][];
|
|
21
29
|
waitForConnection: () => Promise<Client>;
|
|
22
30
|
nextMessage: () => Promise<string>;
|
|
23
31
|
nextMessages: (n: number) => Promise<string[]>;
|
|
24
32
|
} {
|
|
25
|
-
const
|
|
33
|
+
const protocolOffers: string[][] = [];
|
|
34
|
+
const server = new Server(url, {
|
|
35
|
+
selectProtocol: protocols => {
|
|
36
|
+
const offer = [...protocols];
|
|
37
|
+
protocolOffers.push(offer);
|
|
38
|
+
return options.selectProtocol?.(offer, protocolOffers.length - 1) ?? offer[0] ?? '';
|
|
39
|
+
},
|
|
40
|
+
});
|
|
26
41
|
const connections: Client[] = [];
|
|
27
42
|
const messages: string[] = [];
|
|
28
43
|
let nextConnIndex = 0;
|
|
@@ -71,6 +86,7 @@ export function installMockWsServer(url: string): {
|
|
|
71
86
|
server,
|
|
72
87
|
connections,
|
|
73
88
|
messages,
|
|
89
|
+
protocolOffers,
|
|
74
90
|
waitForConnection,
|
|
75
91
|
nextMessage,
|
|
76
92
|
nextMessages,
|
|
@@ -97,6 +97,7 @@ describe('createUploader', () => {
|
|
|
97
97
|
|
|
98
98
|
async function loadCreateUploader() {
|
|
99
99
|
vi.resetModules();
|
|
100
|
+
vi.doMock('./worker-hash', () => ({ WORKER_HASH: 'expected-hash' }));
|
|
100
101
|
// .js extension is required by Node16 module resolution for dynamic imports
|
|
101
102
|
// (static `import` lines work without it because the package is CJS — see package.json).
|
|
102
103
|
// eslint-disable-next-line es/no-dynamic-import
|
|
@@ -309,6 +310,110 @@ describe('createUploader', () => {
|
|
|
309
310
|
});
|
|
310
311
|
});
|
|
311
312
|
|
|
313
|
+
describe('worker hash mismatch', () => {
|
|
314
|
+
beforeEach(() => {
|
|
315
|
+
(globalThis as Record<string, unknown>).window = { addEventListener: vi.fn() };
|
|
316
|
+
(globalThis as Record<string, unknown>).document = { addEventListener: vi.fn() };
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
afterEach(() => {
|
|
320
|
+
delete (globalThis as Record<string, unknown>).window;
|
|
321
|
+
delete (globalThis as Record<string, unknown>).document;
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
function workerThatReplies(welcomeOverrides: Record<string, unknown> = {}) {
|
|
325
|
+
return class {
|
|
326
|
+
onerror: ((e: Event) => void) | null = null;
|
|
327
|
+
onmessage: ((e: MessageEvent) => void) | null = null;
|
|
328
|
+
postMessage(m: unknown) {
|
|
329
|
+
const msg = m as { type: string };
|
|
330
|
+
if (msg.type === 'hello') {
|
|
331
|
+
setTimeout(
|
|
332
|
+
() =>
|
|
333
|
+
this.onmessage?.({
|
|
334
|
+
data: {
|
|
335
|
+
type: 'welcome',
|
|
336
|
+
result: { sessionId: 'sess-1', sessionToken: 'tok-1' },
|
|
337
|
+
workerHash: 'stale-hash',
|
|
338
|
+
...welcomeOverrides,
|
|
339
|
+
},
|
|
340
|
+
} as MessageEvent),
|
|
341
|
+
0,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function loadCreateUploaderWithMockedContext() {
|
|
349
|
+
vi.doMock('./client-context', () => ({ collectUserAgentContext: () => null }));
|
|
350
|
+
return loadCreateUploader();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
it('logs a warning when workerUrl is set and hashes mismatch', async () => {
|
|
354
|
+
(globalThis as Record<string, unknown>).Worker = workerThatReplies();
|
|
355
|
+
delete (globalThis as Record<string, unknown>).SharedWorker;
|
|
356
|
+
|
|
357
|
+
const createUploader = await loadCreateUploaderWithMockedContext();
|
|
358
|
+
const logs: string[] = [];
|
|
359
|
+
await createUploader({
|
|
360
|
+
...DEFAULTS,
|
|
361
|
+
workerMode: 'dedicated',
|
|
362
|
+
workerUrl: '/confidence-worker.js',
|
|
363
|
+
debugLogger: m => logs.push(m),
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
expect(logs.some(l => l.includes('WORKER MISMATCH'))).toBe(true);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('does not warn when using data: URL (inlined worker)', async () => {
|
|
370
|
+
(globalThis as Record<string, unknown>).Worker = workerThatReplies();
|
|
371
|
+
delete (globalThis as Record<string, unknown>).SharedWorker;
|
|
372
|
+
|
|
373
|
+
const createUploader = await loadCreateUploaderWithMockedContext();
|
|
374
|
+
const logs: string[] = [];
|
|
375
|
+
await createUploader({
|
|
376
|
+
...DEFAULTS,
|
|
377
|
+
workerMode: 'dedicated',
|
|
378
|
+
debugLogger: m => logs.push(m),
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
expect(logs.some(l => l.includes('WORKER MISMATCH'))).toBe(false);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('does not warn when hashes match', async () => {
|
|
385
|
+
(globalThis as Record<string, unknown>).Worker = workerThatReplies({ workerHash: 'expected-hash' });
|
|
386
|
+
delete (globalThis as Record<string, unknown>).SharedWorker;
|
|
387
|
+
|
|
388
|
+
const createUploader = await loadCreateUploaderWithMockedContext();
|
|
389
|
+
const logs: string[] = [];
|
|
390
|
+
await createUploader({
|
|
391
|
+
...DEFAULTS,
|
|
392
|
+
workerMode: 'dedicated',
|
|
393
|
+
workerUrl: '/confidence-worker.js',
|
|
394
|
+
debugLogger: m => logs.push(m),
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
expect(logs.some(l => l.includes('WORKER MISMATCH'))).toBe(false);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('warns when workerHash is absent (old worker)', async () => {
|
|
401
|
+
(globalThis as Record<string, unknown>).Worker = workerThatReplies({ workerHash: undefined });
|
|
402
|
+
delete (globalThis as Record<string, unknown>).SharedWorker;
|
|
403
|
+
|
|
404
|
+
const createUploader = await loadCreateUploaderWithMockedContext();
|
|
405
|
+
const logs: string[] = [];
|
|
406
|
+
await createUploader({
|
|
407
|
+
...DEFAULTS,
|
|
408
|
+
workerMode: 'dedicated',
|
|
409
|
+
workerUrl: '/confidence-worker.js',
|
|
410
|
+
debugLogger: m => logs.push(m),
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
expect(logs.some(l => l.includes('WORKER MISMATCH'))).toBe(true);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
312
417
|
describe('welcome timeout', () => {
|
|
313
418
|
it('rejects when the worker never sends a welcome', async () => {
|
|
314
419
|
(globalThis as Record<string, unknown>).Worker = class {
|