@spotify-confidence/session-recording 0.18.12 → 0.18.14
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 +25 -0
- package/README.md +2 -1
- package/dist/confidence-worker.js +53 -14
- package/dist/index.cjs +5 -4
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -4
- package/dist/{uploader-B6-mcWdA.cjs → uploader-B_gHcMnM.cjs} +15 -2
- package/dist/{uploader-vqAzevKI.js → uploader-Bc6m-jT-.js} +10 -3
- package/dist/worker.cjs +1 -1
- package/dist/worker.js +1 -1
- package/package.json +3 -3
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.18.14](https://github.com/spotify/confidence-sdk-js/compare/session-recording-v0.18.13...session-recording-v0.18.14) (2026-09-11)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ✨ New Features
|
|
7
|
+
|
|
8
|
+
* **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))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Dependencies
|
|
12
|
+
|
|
13
|
+
* The following workspace dependencies were updated
|
|
14
|
+
* dependencies
|
|
15
|
+
* @spotify-confidence/csr-common bumped to 0.18.11
|
|
16
|
+
* @spotify-confidence/csr-recorder bumped to 0.17.19
|
|
17
|
+
|
|
18
|
+
## [0.18.13](https://github.com/spotify/confidence-sdk-js/compare/session-recording-v0.18.12...session-recording-v0.18.13) (2026-09-09)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Dependencies
|
|
22
|
+
|
|
23
|
+
* The following workspace dependencies were updated
|
|
24
|
+
* dependencies
|
|
25
|
+
* @spotify-confidence/csr-common bumped to 0.18.10
|
|
26
|
+
* @spotify-confidence/csr-recorder bumped to 0.17.18
|
|
27
|
+
|
|
3
28
|
## [0.18.12](https://github.com/spotify/confidence-sdk-js/compare/session-recording-v0.18.11...session-recording-v0.18.12) (2026-09-07)
|
|
4
29
|
|
|
5
30
|
|
package/README.md
CHANGED
|
@@ -202,7 +202,7 @@ const recorder = initSessionRecorder({
|
|
|
202
202
|
|
|
203
203
|
Your CSP only needs `worker-src 'self'` with this setup.
|
|
204
204
|
|
|
205
|
-
> **Note:** The worker
|
|
205
|
+
> **Note:** The worker file must stay in sync with the SDK. After upgrading `@spotify-confidence/session-recording`, re-copy or re-deploy the worker file. If they're out of sync, the SDK logs a `WORKER MISMATCH` warning via `debugLogger`.
|
|
206
206
|
|
|
207
207
|
### Troubleshooting
|
|
208
208
|
|
|
@@ -210,6 +210,7 @@ If recording silently fails, open DevTools and look for:
|
|
|
210
210
|
|
|
211
211
|
- A `SecurityError` mentioning `worker-src` — your CSP blocks the worker. Use one of the options above.
|
|
212
212
|
- A blocked `connect-src` request to `recording.confidence.dev` — add the hosts to your CSP.
|
|
213
|
+
- A `WORKER MISMATCH` log — your self-hosted `confidence-worker.js` is outdated. Re-copy it from `node_modules/@spotify-confidence/session-recording/dist/`.
|
|
213
214
|
|
|
214
215
|
Enable [debug logging](#debug-logging) to see the full lifecycle and pinpoint where it fails.
|
|
215
216
|
|
|
@@ -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.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_uploader = require("./uploader-
|
|
2
|
+
const require_uploader = require("./uploader-B_gHcMnM.cjs");
|
|
3
3
|
const VALID_KEY_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
|
4
4
|
function validateKey(key) {
|
|
5
5
|
if (typeof key !== "string" || key.length === 0) return "key is empty";
|
|
@@ -234,12 +234,13 @@ var Recorder = class Recorder {
|
|
|
234
234
|
};
|
|
235
235
|
}
|
|
236
236
|
parameterizeHref(href) {
|
|
237
|
+
const strippedHref = require_uploader.stripUrlQueryAndHash(href);
|
|
237
238
|
try {
|
|
238
|
-
const url = new URL(
|
|
239
|
+
const url = new URL(strippedHref);
|
|
239
240
|
url.pathname = this.parameterizeRoute(url.pathname);
|
|
240
241
|
return url.toString();
|
|
241
242
|
} catch (_e) {
|
|
242
|
-
return this.parameterizeRoute(
|
|
243
|
+
return this.parameterizeRoute(strippedHref);
|
|
243
244
|
}
|
|
244
245
|
}
|
|
245
246
|
emitRouteChange(from, to, trigger) {
|
|
@@ -11326,7 +11327,7 @@ function observeFlags(onFlagWrite) {
|
|
|
11326
11327
|
}
|
|
11327
11328
|
//#endregion
|
|
11328
11329
|
//#region src/version.ts
|
|
11329
|
-
const SDK_VERSION = "0.18.
|
|
11330
|
+
const SDK_VERSION = "0.18.14";
|
|
11330
11331
|
//#endregion
|
|
11331
11332
|
//#region src/index.ts
|
|
11332
11333
|
const DEFAULT_API_URL = "https://recording.confidence.dev";
|
package/dist/index.d.cts
CHANGED
|
@@ -25,7 +25,7 @@ type UserAgentContext = {
|
|
|
25
25
|
screenWidth?: number;
|
|
26
26
|
screenHeight?: number;
|
|
27
27
|
devicePixelRatio?: number; /** Initial document URI — without query/hash to avoid leaking PII. */
|
|
28
|
-
uri?: string;
|
|
28
|
+
uri?: string; /** Document referrer — without query/hash to avoid leaking PII. */
|
|
29
29
|
referrer?: string;
|
|
30
30
|
};
|
|
31
31
|
interface ClientContext {
|
package/dist/index.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ type UserAgentContext = {
|
|
|
25
25
|
screenWidth?: number;
|
|
26
26
|
screenHeight?: number;
|
|
27
27
|
devicePixelRatio?: number; /** Initial document URI — without query/hash to avoid leaking PII. */
|
|
28
|
-
uri?: string;
|
|
28
|
+
uri?: string; /** Document referrer — without query/hash to avoid leaking PII. */
|
|
29
29
|
referrer?: string;
|
|
30
30
|
};
|
|
31
31
|
interface ClientContext {
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { i as RecordingPluginName, r as stripUrlQueryAndHash, t as createUploader } from "./uploader-Bc6m-jT-.js";
|
|
2
2
|
const VALID_KEY_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
|
3
3
|
function validateKey(key) {
|
|
4
4
|
if (typeof key !== "string" || key.length === 0) return "key is empty";
|
|
@@ -233,12 +233,13 @@ var Recorder = class Recorder {
|
|
|
233
233
|
};
|
|
234
234
|
}
|
|
235
235
|
parameterizeHref(href) {
|
|
236
|
+
const strippedHref = stripUrlQueryAndHash(href);
|
|
236
237
|
try {
|
|
237
|
-
const url = new URL(
|
|
238
|
+
const url = new URL(strippedHref);
|
|
238
239
|
url.pathname = this.parameterizeRoute(url.pathname);
|
|
239
240
|
return url.toString();
|
|
240
241
|
} catch (_e) {
|
|
241
|
-
return this.parameterizeRoute(
|
|
242
|
+
return this.parameterizeRoute(strippedHref);
|
|
242
243
|
}
|
|
243
244
|
}
|
|
244
245
|
emitRouteChange(from, to, trigger) {
|
|
@@ -11307,7 +11308,7 @@ function observeFlags(onFlagWrite) {
|
|
|
11307
11308
|
}
|
|
11308
11309
|
//#endregion
|
|
11309
11310
|
//#region src/version.ts
|
|
11310
|
-
const SDK_VERSION = "0.18.
|
|
11311
|
+
const SDK_VERSION = "0.18.14";
|
|
11311
11312
|
//#endregion
|
|
11312
11313
|
//#region src/index.ts
|
|
11313
11314
|
const DEFAULT_API_URL = "https://recording.confidence.dev";
|
|
@@ -50,6 +50,12 @@ const RecordingPluginName = {
|
|
|
50
50
|
FlagEvaluation: "csr:flagEvaluation"
|
|
51
51
|
};
|
|
52
52
|
//#endregion
|
|
53
|
+
//#region ../csr-common/src/url.ts
|
|
54
|
+
/** Remove query strings and fragments, including from relative or malformed URLs. */
|
|
55
|
+
function stripUrlQueryAndHash(url) {
|
|
56
|
+
return url.split(/[?#]/, 1)[0];
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
53
59
|
//#region ../csr-common/src/metrics.ts
|
|
54
60
|
const RecordingMetricKey = {
|
|
55
61
|
Click: "clicks",
|
|
@@ -1845,12 +1851,12 @@ function collectUserAgentContext() {
|
|
|
1845
1851
|
screenHeight: window.screen.height,
|
|
1846
1852
|
devicePixelRatio: window.devicePixelRatio,
|
|
1847
1853
|
uri: `${window.location.origin}${window.location.pathname}`,
|
|
1848
|
-
referrer: document.referrer
|
|
1854
|
+
referrer: stripUrlQueryAndHash(document.referrer)
|
|
1849
1855
|
};
|
|
1850
1856
|
}
|
|
1851
1857
|
//#endregion
|
|
1852
1858
|
//#region ../csr-common/src/uploader/worker/worker-script.ts
|
|
1853
|
-
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";
|
|
1859
|
+
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";
|
|
1854
1860
|
//#endregion
|
|
1855
1861
|
//#region ../csr-common/src/uploader/create-uploader.ts
|
|
1856
1862
|
const STORAGE_TAB_ID = "csr:tabId";
|
|
@@ -1940,6 +1946,7 @@ async function createUploader(opts) {
|
|
|
1940
1946
|
return msg;
|
|
1941
1947
|
}), welcomeDeadline]);
|
|
1942
1948
|
log?.(welcome.type === "welcome" ? `tab: welcome (${"sessionId" in welcome.result ? `sessionId=${welcome.result.sessionId}` : "skipRecording"})` : `tab: dead reason=${welcome.reason}`);
|
|
1949
|
+
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");
|
|
1943
1950
|
if (welcome.type === "dead") throw new Error(`uploader: ${welcome.reason}`);
|
|
1944
1951
|
if ("skipRecording" in welcome.result) {
|
|
1945
1952
|
if (opts.forceRecord) log?.("tab: forceRecord was set but backend still skipped — backend may not support forceRecord yet");
|
|
@@ -2160,6 +2167,12 @@ Object.defineProperty(exports, "createUploader", {
|
|
|
2160
2167
|
return createUploader;
|
|
2161
2168
|
}
|
|
2162
2169
|
});
|
|
2170
|
+
Object.defineProperty(exports, "stripUrlQueryAndHash", {
|
|
2171
|
+
enumerable: true,
|
|
2172
|
+
get: function() {
|
|
2173
|
+
return stripUrlQueryAndHash;
|
|
2174
|
+
}
|
|
2175
|
+
});
|
|
2163
2176
|
Object.defineProperty(exports, "workerScript", {
|
|
2164
2177
|
enumerable: true,
|
|
2165
2178
|
get: function() {
|
|
@@ -50,6 +50,12 @@ const RecordingPluginName = {
|
|
|
50
50
|
FlagEvaluation: "csr:flagEvaluation"
|
|
51
51
|
};
|
|
52
52
|
//#endregion
|
|
53
|
+
//#region ../csr-common/src/url.ts
|
|
54
|
+
/** Remove query strings and fragments, including from relative or malformed URLs. */
|
|
55
|
+
function stripUrlQueryAndHash(url) {
|
|
56
|
+
return url.split(/[?#]/, 1)[0];
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
53
59
|
//#region ../csr-common/src/metrics.ts
|
|
54
60
|
const RecordingMetricKey = {
|
|
55
61
|
Click: "clicks",
|
|
@@ -1845,12 +1851,12 @@ function collectUserAgentContext() {
|
|
|
1845
1851
|
screenHeight: window.screen.height,
|
|
1846
1852
|
devicePixelRatio: window.devicePixelRatio,
|
|
1847
1853
|
uri: `${window.location.origin}${window.location.pathname}`,
|
|
1848
|
-
referrer: document.referrer
|
|
1854
|
+
referrer: stripUrlQueryAndHash(document.referrer)
|
|
1849
1855
|
};
|
|
1850
1856
|
}
|
|
1851
1857
|
//#endregion
|
|
1852
1858
|
//#region ../csr-common/src/uploader/worker/worker-script.ts
|
|
1853
|
-
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";
|
|
1859
|
+
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";
|
|
1854
1860
|
//#endregion
|
|
1855
1861
|
//#region ../csr-common/src/uploader/create-uploader.ts
|
|
1856
1862
|
const STORAGE_TAB_ID = "csr:tabId";
|
|
@@ -1940,6 +1946,7 @@ async function createUploader(opts) {
|
|
|
1940
1946
|
return msg;
|
|
1941
1947
|
}), welcomeDeadline]);
|
|
1942
1948
|
log?.(welcome.type === "welcome" ? `tab: welcome (${"sessionId" in welcome.result ? `sessionId=${welcome.result.sessionId}` : "skipRecording"})` : `tab: dead reason=${welcome.reason}`);
|
|
1949
|
+
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");
|
|
1943
1950
|
if (welcome.type === "dead") throw new Error(`uploader: ${welcome.reason}`);
|
|
1944
1951
|
if ("skipRecording" in welcome.result) {
|
|
1945
1952
|
if (opts.forceRecord) log?.("tab: forceRecord was set but backend still skipped — backend may not support forceRecord yet");
|
|
@@ -2148,4 +2155,4 @@ function writeCounter(counter) {
|
|
|
2148
2155
|
sessionStorage.setItem(STORAGE_COUNTER, String(counter));
|
|
2149
2156
|
}
|
|
2150
2157
|
//#endregion
|
|
2151
|
-
export { workerScript as n,
|
|
2158
|
+
export { RecordingPluginName as i, workerScript as n, stripUrlQueryAndHash as r, createUploader as t };
|
package/dist/worker.cjs
CHANGED
package/dist/worker.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as workerScript } from "./uploader-
|
|
1
|
+
import { n as workerScript } from "./uploader-Bc6m-jT-.js";
|
|
2
2
|
export { workerScript };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spotify-confidence/session-recording",
|
|
3
3
|
"license": "Apache-2.0",
|
|
4
|
-
"version": "0.18.
|
|
4
|
+
"version": "0.18.14",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/spotify/confidence-sdk-js.git",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
}
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@spotify-confidence/csr-common": "0.18.
|
|
45
|
-
"@spotify-confidence/csr-recorder": "0.17.
|
|
44
|
+
"@spotify-confidence/csr-common": "0.18.11",
|
|
45
|
+
"@spotify-confidence/csr-recorder": "0.17.19"
|
|
46
46
|
},
|
|
47
47
|
"module": "./dist/index.js",
|
|
48
48
|
"exports": {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '0.18.
|
|
1
|
+
export const SDK_VERSION = '0.18.14';
|