@spotify-confidence/csr-common 0.18.2 → 0.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/{types-D-hZGAw0.d.cts → types-CtBIVgp2.d.cts} +2 -0
- package/dist/{types-D-hZGAw0.d.ts → types-CtBIVgp2.d.ts} +2 -0
- package/dist/uploader/index.cjs +94 -17
- package/dist/uploader/index.d.cts +1 -1
- package/dist/uploader/index.d.ts +1 -1
- package/dist/uploader/index.js +94 -17
- package/package.json +1 -1
- package/src/uploader/create-uploader.test.ts +312 -0
- package/src/uploader/create-uploader.ts +132 -27
- package/src/uploader/types.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.18.3](https://github.com/spotify/confidence-sdk-js/compare/csr-common-v0.18.2...csr-common-v0.18.3) (2026-07-16)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### 🐛 Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **csr:** attempt to detect worker load failures caused by CSP ([#409](https://github.com/spotify/confidence-sdk-js/issues/409)) ([d0e38dc](https://github.com/spotify/confidence-sdk-js/commit/d0e38dc746bc571ee020d09f8922acb14ae7d7ae))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ✨ New Features
|
|
12
|
+
|
|
13
|
+
* **csr:** blob: URL fallback when data: worker is blocked by CSP ([#410](https://github.com/spotify/confidence-sdk-js/issues/410)) ([52b56db](https://github.com/spotify/confidence-sdk-js/commit/52b56db4988b72a60d92517162773fca0d01eebb))
|
|
14
|
+
|
|
3
15
|
## [0.18.2](https://github.com/spotify/confidence-sdk-js/compare/csr-common-v0.18.1...csr-common-v0.18.2) (2026-07-06)
|
|
4
16
|
|
|
5
17
|
|
package/dist/index.d.cts
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -92,6 +92,8 @@ interface CreateUploaderOptions {
|
|
|
92
92
|
* Worker messages are forwarded over the port and tagged so you can tell them apart.
|
|
93
93
|
*/
|
|
94
94
|
debugLogger?: (msg: string) => void;
|
|
95
|
+
/** @internal Override the welcome-handshake timeout for testing. */
|
|
96
|
+
_welcomeTimeoutMs?: number;
|
|
95
97
|
}
|
|
96
98
|
/** Internal: wire-level frame. Session-id is implicit (Transport is session-bound at open). */
|
|
97
99
|
interface Frame {
|
|
@@ -92,6 +92,8 @@ interface CreateUploaderOptions {
|
|
|
92
92
|
* Worker messages are forwarded over the port and tagged so you can tell them apart.
|
|
93
93
|
*/
|
|
94
94
|
debugLogger?: (msg: string) => void;
|
|
95
|
+
/** @internal Override the welcome-handshake timeout for testing. */
|
|
96
|
+
_welcomeTimeoutMs?: number;
|
|
95
97
|
}
|
|
96
98
|
/** Internal: wire-level frame. Session-id is implicit (Transport is session-bound at open). */
|
|
97
99
|
interface Frame {
|
package/dist/uploader/index.cjs
CHANGED
|
@@ -57,6 +57,7 @@ const STORAGE_TAB_ID = "csr:tabId";
|
|
|
57
57
|
const STORAGE_SESSION = "csr:session";
|
|
58
58
|
const STORAGE_COUNTER = "csr:counter";
|
|
59
59
|
const DEFAULT_SESSION_TTL_MS = 1800 * 1e3;
|
|
60
|
+
const WELCOME_TIMEOUT_MS = 1e4;
|
|
60
61
|
async function createUploader(opts) {
|
|
61
62
|
const log = opts.debugLogger;
|
|
62
63
|
const sessionTtlMs = opts.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
@@ -65,7 +66,9 @@ async function createUploader(opts) {
|
|
|
65
66
|
const counterHint = readCounter();
|
|
66
67
|
const mode = resolveMode(opts.workerMode ?? "auto");
|
|
67
68
|
log?.(`tab: createUploader mode=${mode} tabId=${tabId} sessionHint=${sessionHint?.id ?? "(none)"} counterHint=${counterHint}`);
|
|
68
|
-
const
|
|
69
|
+
const workerName = mode === "shared" ? await hashSecret(opts.clientSecret) : void 0;
|
|
70
|
+
const { port, urlScheme } = await openWorkerPort(mode, opts.workerUrl, workerName, log);
|
|
71
|
+
log?.(`tab: worker loaded via ${urlScheme}${urlScheme === "blob" ? " (fallback — cross-tab sharing unavailable)" : ""}`);
|
|
69
72
|
let phase = "awaiting-welcome";
|
|
70
73
|
let sessionId = null;
|
|
71
74
|
let sessionToken = null;
|
|
@@ -125,7 +128,17 @@ async function createUploader(opts) {
|
|
|
125
128
|
debugLogs: log !== void 0
|
|
126
129
|
});
|
|
127
130
|
log?.("tab: hello sent, awaiting welcome");
|
|
128
|
-
const
|
|
131
|
+
const timeoutMs = opts._welcomeTimeoutMs ?? WELCOME_TIMEOUT_MS;
|
|
132
|
+
let welcomeTimer;
|
|
133
|
+
const welcomeDeadline = new Promise((_, reject) => {
|
|
134
|
+
port.onError((err) => reject(err));
|
|
135
|
+
welcomeTimer = setTimeout(() => reject(/* @__PURE__ */ new Error(`uploader: welcome timeout. The worker did not respond within ${timeoutMs / 1e3}s. This may indicate a CSP policy blocking the worker script from loading. Check your browser console for errors.`)), timeoutMs);
|
|
136
|
+
});
|
|
137
|
+
welcomeDeadline.catch(() => {});
|
|
138
|
+
const welcome = await Promise.race([welcomePromise.then((msg) => {
|
|
139
|
+
clearTimeout(welcomeTimer);
|
|
140
|
+
return msg;
|
|
141
|
+
}), welcomeDeadline]);
|
|
129
142
|
log?.(welcome.type === "welcome" ? `tab: welcome (${"sessionId" in welcome.result ? `sessionId=${welcome.result.sessionId}` : "skipRecording"})` : `tab: dead reason=${welcome.reason}`);
|
|
130
143
|
if (welcome.type === "dead") throw new Error(`uploader: ${welcome.reason}`);
|
|
131
144
|
if ("skipRecording" in welcome.result) {
|
|
@@ -195,37 +208,101 @@ function resolveMode(mode) {
|
|
|
195
208
|
if (mode === "auto") return typeof SharedWorker !== "undefined" ? "shared" : "dedicated";
|
|
196
209
|
return mode;
|
|
197
210
|
}
|
|
198
|
-
async function openWorkerPort(mode,
|
|
199
|
-
const url = workerUrl ?? toDataUrl("//#region src/uploader/worker/web-socket-transport.ts\n/**\n* WebSocket-backed Transport. Internal retry policy: clean server-initiated close after the\n* first successful open → reconnect and resume; abrupt close (or any close before the first\n* open) → fire `onClose` and stop. Frames received while a (re)connect is in progress are\n* buffered and flushed on open.\n*\n* `ready()` resolves on the first successful open and rejects on close-before-open. Callers\n* should await it before treating the Transport as live, so a failure to open can be caught\n* (e.g. 4404 unknown session) and recovered from.\n*/\nvar WebSocketTransport = class {\n url;\n ws = null;\n onCloseCb = null;\n onStateChangeCb = null;\n intentionallyClosed = false;\n dead = false;\n /** Frames buffered while a (re)connect is in progress. */\n pending = [];\n readyPromise;\n constructor(url) {\n this.url = url;\n this.readyPromise = new Promise((resolve, reject) => {\n this.connect(false, resolve, reject);\n });\n this.readyPromise.catch(() => {});\n }\n ready() {\n return this.readyPromise;\n }\n send(frame) {\n if (this.dead || this.intentionallyClosed) return;\n if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));\n else this.pending.push(frame);\n }\n close(reason = \"transport-close\") {\n this.intentionallyClosed = true;\n this.ws?.close(1e3, reason);\n }\n onClose(cb) {\n this.onCloseCb = cb;\n }\n onStateChange(cb) {\n this.onStateChangeCb = cb;\n }\n connect(isReconnect, onReady, onReadyFail) {\n const ws = new WebSocket(this.url);\n this.ws = ws;\n let opened = false;\n ws.onopen = () => {\n opened = true;\n onReady?.();\n if (isReconnect) this.onStateChangeCb?.({ connected: true });\n while (this.pending.length > 0) {\n const f = this.pending.shift();\n ws.send(JSON.stringify(f));\n }\n };\n ws.onclose = (event) => {\n if (this.intentionallyClosed) return;\n if (!opened) {\n const reason = `${isReconnect ? \"reconnect\" : \"initial\"}-failed code=${event.code}`;\n if (onReadyFail) {\n onReadyFail(new Error(reason));\n this.dead = true;\n } else this.die(reason);\n return;\n }\n if (event.wasClean && (event.code === 1e3 || event.code === 1001)) {\n this.onStateChangeCb?.({ connected: false });\n this.connect(true);\n } else this.die(`close code=${event.code} wasClean=${event.wasClean}`);\n };\n }\n die(reason) {\n this.dead = true;\n this.onCloseCb?.({ reason });\n }\n};\n//#endregion\n//#region src/uploader/worker/csr-client.ts\n/**\n* Single Client implementation that talks to the recording backend's REST + WS protocol.\n* Both dev-server and prod implement the same protocol, so we don't need polymorphism here yet.\n*/\nvar CsrClient = class {\n apiUrl;\n clientSecret;\n context;\n websocketUrl;\n log;\n forceRecord;\n constructor(apiUrl, clientSecret, context, websocketUrl, log = () => {}, forceRecord) {\n this.apiUrl = apiUrl;\n this.clientSecret = clientSecret;\n this.context = context;\n this.websocketUrl = websocketUrl;\n this.log = log;\n this.forceRecord = forceRecord;\n }\n async initSession() {\n const url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;\n this.log(`fetch POST ${url}`);\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n clientSecret: this.clientSecret,\n ...this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {},\n ...this.forceRecord ? { forceRecord: true } : {}\n })\n });\n if (!res.ok) throw new Error(`init-session failed: HTTP ${res.status}`);\n const data = await res.json();\n if (data.skipRecording) return { skipRecording: true };\n if (!data.sessionId || !data.sessionToken) throw new Error(\"init-session response missing sessionId or sessionToken\");\n return {\n sessionId: data.sessionId,\n sessionToken: data.sessionToken\n };\n }\n async openTransport(sessionToken) {\n const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;\n const url = `${wsBase}${wsBase.includes(\"?\") ? \"&\" : \"?\"}session_token=${encodeURIComponent(sessionToken)}`;\n this.log(`WebSocket connect ${url.replace(/session_token=[^&]*/, \"session_token=[REDACTED]\")}`);\n const transport = new WebSocketTransport(url);\n await transport.ready();\n return transport;\n }\n trimSlash(s) {\n return s.endsWith(\"/\") ? s.slice(0, -1) : s;\n }\n toWsScheme(base) {\n if (base.startsWith(\"https://\")) return `wss://${base.slice(8)}`;\n if (base.startsWith(\"http://\")) return `ws://${base.slice(7)}`;\n return base;\n }\n};\n//#endregion\n//#region src/uploader/worker/core.ts\nconst IDLE_GRACE_MS = 5e3;\nlet state = { phase: \"init\" };\nconst ports = [];\nlet idleTimer = null;\nfunction cancelIdleTimer() {\n if (idleTimer !== null) {\n clearTimeout(idleTimer);\n idleTimer = null;\n }\n}\nfunction log(msg) {\n for (const handle of ports) if (handle.debugLogs) handle.port.postMessage({\n type: \"log\",\n msg\n });\n}\n/**\n* The first hello \"locks in\" the session's apiUrl/clientSecret. Any later tab arriving\n* with different values is misconfigured — we reject it rather than silently using the\n* locked values. In SharedWorker mode the `name = hash(clientSecret)` scoping already\n* prevents secret-mismatch from sharing a worker, but this defends against the dedicated\n* path and against future bugs.\n*/\nlet lockedConfig = null;\nfunction registerPort(adapter) {\n cancelIdleTimer();\n const handle = {\n port: adapter,\n hello: null,\n debugLogs: false\n };\n ports.push(handle);\n adapter.onmessage((data) => {\n handleMessage(handle, data);\n });\n}\nfunction handleMessage(handle, message) {\n switch (message.type) {\n case \"hello\":\n handle.hello = message;\n handle.debugLogs = message.debugLogs ?? false;\n if (rejectIfIncompatible(handle)) return;\n if (state.phase !== \"dead\" && state.phase !== \"skipping\") detectDuplicateTab(handle);\n onHello(handle);\n return;\n case \"frame\":\n onFrame(message.frame);\n return;\n case \"bye\":\n onBye(handle);\n return;\n default: break;\n }\n}\n/**\n* Reject hellos whose `apiUrl`/`clientSecret` don't match the values established by the\n* first hello. Returns true if the port was rejected (caller should not continue\n* processing this hello).\n*/\nfunction rejectIfIncompatible(handle) {\n if (lockedConfig === null) return false;\n const incoming = handle.hello;\n if (incoming.apiUrl === lockedConfig.apiUrl && incoming.websocketUrl === lockedConfig.websocketUrl && incoming.clientSecret === lockedConfig.clientSecret) return false;\n handle.port.postMessage({\n type: \"dead\",\n reason: \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\"\n });\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n return true;\n}\n/**\n* If another already-connected port has the same `tabId`, this hello is from a duplicate\n* tab (browser \"Duplicate\" command clones sessionStorage). Mint a fresh `tabId` so the two\n* tabs don't collide on the same `(sessionId, tabId)` Recording. The new tabId is returned\n* to the tab in `welcome` so it can update its own state and sessionStorage.\n*/\nfunction detectDuplicateTab(handle) {\n const tabId = handle.hello.tabId;\n if (!ports.some((p) => p !== handle && p.hello?.tabId === tabId)) return;\n const fresh = crypto.randomUUID();\n handle.newTabId = fresh;\n handle.hello.tabId = fresh;\n}\nfunction onHello(handle) {\n switch (state.phase) {\n case \"init\":\n lockedConfig = {\n apiUrl: handle.hello.apiUrl,\n websocketUrl: handle.hello.websocketUrl,\n clientSecret: handle.hello.clientSecret\n };\n log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ?? \"(derive)\"} sessionIdHint=${handle.hello.sessionIdHint ?? \"(none)\"}`);\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n case \"initializing\": return;\n case \"active\":\n sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n return;\n case \"idle\": {\n const { client, sessionId, sessionToken } = state;\n state = { phase: \"initializing\" };\n resumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);\n return;\n }\n case \"skipping\":\n if (handle.hello.forceRecord) {\n log(\"forceRecord set; re-initializing from skipping state\");\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n }\n handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n return;\n case \"dead\":\n handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n return;\n default: break;\n }\n}\nasync function initializeSession(firstHello) {\n const client = new CsrClient(firstHello.apiUrl, firstHello.clientSecret, firstHello.context, firstHello.websocketUrl, log, firstHello.forceRecord);\n if (firstHello.sessionIdHint && firstHello.sessionTokenHint) {\n log(`adopting sessionIdHint=${firstHello.sessionIdHint}`);\n try {\n const transport = await client.openTransport(firstHello.sessionTokenHint);\n wireTransport(transport);\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: firstHello.sessionIdHint,\n sessionToken: firstHello.sessionTokenHint\n };\n log(\"hint adopted; transport open\");\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n return;\n } catch (err) {\n log(`hint rejected (${String(err)}); falling back to fresh init`);\n }\n }\n let result;\n try {\n result = await client.initSession();\n } catch (err) {\n log(`init-session threw: ${String(err)}`);\n transitionToDead(`init-session-failed: ${String(err)}`);\n return;\n }\n if (\"skipRecording\" in result) {\n log(\"init-session: skipRecording\");\n state = { phase: \"skipping\" };\n return;\n }\n log(`init-session ok sessionId=${result.sessionId}`);\n let transport;\n try {\n transport = await client.openTransport(result.sessionToken);\n } catch (err) {\n log(`openTransport threw: ${String(err)}`);\n transitionToDead(`open-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport open; session active\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: result.sessionId,\n sessionToken: result.sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nasync function resumeTransport(client, sessionId, sessionToken) {\n log(\"resuming transport from idle\");\n let transport;\n try {\n transport = await client.openTransport(sessionToken);\n } catch (err) {\n log(`resume-transport threw: ${String(err)}`);\n transitionToDead(`resume-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport resumed\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId,\n sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nfunction wireTransport(transport) {\n transport.onClose((info) => {\n if (state.phase !== \"active\") return;\n transitionToDead(info.reason);\n });\n transport.onStateChange((info) => {\n if (state.phase !== \"active\") return;\n for (const handle of ports) handle.port.postMessage({\n type: \"state\",\n connected: info.connected\n });\n });\n}\nfunction transitionToDead(reason) {\n state = {\n phase: \"dead\",\n reason\n };\n for (const handle of ports) handle.port.postMessage({\n type: \"dead\",\n reason\n });\n}\nfunction flushPendingWelcomes() {\n for (const handle of ports) {\n if (handle.hello === null) continue;\n if (state.phase === \"active\") sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n else if (state.phase === \"skipping\") handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n else if (state.phase === \"dead\") handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n }\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n const hint = handle.hello?.sessionIdHint;\n const adopted = hint !== void 0 && hint !== currentSessionId;\n const newTabId = handle.newTabId;\n handle.port.postMessage({\n type: \"welcome\",\n result: {\n sessionId: currentSessionId,\n sessionToken: currentSessionToken\n },\n adoptedFromSessionId: adopted ? hint : void 0,\n newTabId,\n resetCounter: adopted || newTabId !== void 0\n });\n}\nfunction onFrame(frame) {\n if (state.phase !== \"active\") return;\n state.transport.send(frame);\n}\nfunction onBye(handle) {\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n if (ports.length === 0 && state.phase === \"active\") {\n log(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);\n idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n }\n}\nfunction enterIdle() {\n idleTimer = null;\n if (state.phase !== \"active\" || ports.length > 0) return;\n log(\"idle timeout; closing transport\");\n state.transport.close(\"idle\");\n state = {\n phase: \"idle\",\n client: state.client,\n sessionId: state.sessionId,\n sessionToken: state.sessionToken\n };\n}\n//#endregion\n//#region src/uploader/worker/entry.ts\nconst SharedWorkerScopeCtor = globalThis.SharedWorkerGlobalScope;\nif (typeof SharedWorkerScopeCtor === \"function\" && self instanceof SharedWorkerScopeCtor) self.onconnect = (event) => {\n const port = event.ports[0];\n port.start();\n registerPort(adaptMessagePort(port));\n};\nelse registerPort(adaptDedicatedSelf());\nfunction adaptMessagePort(port) {\n return {\n postMessage: (message) => port.postMessage(message),\n onmessage: (cb) => {\n port.onmessage = (e) => cb(e.data);\n }\n };\n}\nfunction adaptDedicatedSelf() {\n const ws = self;\n return {\n postMessage: (message) => ws.postMessage(message),\n onmessage: (cb) => {\n ws.onmessage = (e) => cb(e.data);\n }\n };\n}\n//#endregion\n");
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
211
|
+
async function openWorkerPort(mode, workerUrl, workerName, log) {
|
|
212
|
+
if (workerUrl) return {
|
|
213
|
+
port: createWorker(mode, workerUrl, workerName),
|
|
214
|
+
urlScheme: "custom"
|
|
215
|
+
};
|
|
216
|
+
const dataUrl = toDataUrl(workerScript);
|
|
217
|
+
try {
|
|
218
|
+
const port = createWorker(mode, dataUrl, workerName);
|
|
219
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
220
|
+
const asyncErr = port.getBufferedError();
|
|
221
|
+
if (asyncErr) throw asyncErr;
|
|
208
222
|
return {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
worker.port.onmessage = (e) => cb(e.data);
|
|
212
|
-
}
|
|
223
|
+
port,
|
|
224
|
+
urlScheme: "data"
|
|
213
225
|
};
|
|
226
|
+
} catch (_dataErr) {
|
|
227
|
+
log?.("tab: data: worker blocked, falling back to blob: URL (dedicated mode)");
|
|
228
|
+
const blobUrl = toBlobUrl(workerScript);
|
|
229
|
+
try {
|
|
230
|
+
return {
|
|
231
|
+
port: createDedicatedWorker(blobUrl),
|
|
232
|
+
urlScheme: "blob"
|
|
233
|
+
};
|
|
234
|
+
} catch (blobErr) {
|
|
235
|
+
const hint = "Your Content Security Policy blocks both `data:` and `blob:` in `worker-src`. To fix this, either add `blob:` to your `worker-src` directive, or use the `workerUrl` option to serve the worker script from your own origin.";
|
|
236
|
+
log?.(`tab: worker-load-failed: ${hint}`);
|
|
237
|
+
throw new Error(`worker-load-failed: ${hint}`, blobErr instanceof Error ? { cause: blobErr } : void 0);
|
|
238
|
+
}
|
|
214
239
|
}
|
|
240
|
+
}
|
|
241
|
+
function createWorker(mode, url, workerName) {
|
|
242
|
+
if (mode === "shared") return createSharedWorker(url, workerName);
|
|
243
|
+
return createDedicatedWorker(url);
|
|
244
|
+
}
|
|
245
|
+
function createSharedWorker(url, name) {
|
|
246
|
+
const options = {
|
|
247
|
+
name,
|
|
248
|
+
type: "module",
|
|
249
|
+
extendedLifetime: true
|
|
250
|
+
};
|
|
251
|
+
const worker = new SharedWorker(url, options);
|
|
252
|
+
let errorCb = null;
|
|
253
|
+
let bufferedError = null;
|
|
254
|
+
worker.onerror = (e) => {
|
|
255
|
+
const err = workerLoadError(e, url);
|
|
256
|
+
if (errorCb) errorCb(err);
|
|
257
|
+
else bufferedError = err;
|
|
258
|
+
};
|
|
259
|
+
worker.port.start();
|
|
260
|
+
return {
|
|
261
|
+
postMessage: (m) => worker.port.postMessage(m),
|
|
262
|
+
setHandler: (cb) => {
|
|
263
|
+
worker.port.onmessage = (e) => cb(e.data);
|
|
264
|
+
},
|
|
265
|
+
onError: (cb) => {
|
|
266
|
+
errorCb = cb;
|
|
267
|
+
if (bufferedError) cb(bufferedError);
|
|
268
|
+
},
|
|
269
|
+
getBufferedError: () => bufferedError
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function createDedicatedWorker(url) {
|
|
215
273
|
const worker = new Worker(url, { type: "module" });
|
|
274
|
+
let errorCb = null;
|
|
275
|
+
let bufferedError = null;
|
|
276
|
+
worker.onerror = (e) => {
|
|
277
|
+
const err = workerLoadError(e, url);
|
|
278
|
+
if (errorCb) errorCb(err);
|
|
279
|
+
else bufferedError = err;
|
|
280
|
+
};
|
|
216
281
|
return {
|
|
217
282
|
postMessage: (m) => worker.postMessage(m),
|
|
218
283
|
setHandler: (cb) => {
|
|
219
284
|
worker.onmessage = (e) => cb(e.data);
|
|
220
|
-
}
|
|
285
|
+
},
|
|
286
|
+
onError: (cb) => {
|
|
287
|
+
errorCb = cb;
|
|
288
|
+
if (bufferedError) cb(bufferedError);
|
|
289
|
+
},
|
|
290
|
+
getBufferedError: () => bufferedError
|
|
221
291
|
};
|
|
222
292
|
}
|
|
293
|
+
function workerLoadError(cause, url) {
|
|
294
|
+
const msg = `worker-load-failed: ${url.startsWith("data:") ? "This is likely caused by a Content Security Policy (CSP) that blocks `data:` in `worker-src`. To fix this, either add `data:` to your `worker-src` directive, or use the `workerUrl` option to serve the worker script from your own origin." : `Failed to load the worker script from ${url}. Check that the URL is reachable and that your CSP \`worker-src\` directive allows it.`}`;
|
|
295
|
+
return new Error(msg, cause instanceof Error ? { cause } : void 0);
|
|
296
|
+
}
|
|
223
297
|
function toDataUrl(script) {
|
|
224
298
|
const bytes = new TextEncoder().encode(script);
|
|
225
299
|
let binary = "";
|
|
226
300
|
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
227
301
|
return `data:application/javascript;base64,${btoa(binary)}`;
|
|
228
302
|
}
|
|
303
|
+
function toBlobUrl(script) {
|
|
304
|
+
return URL.createObjectURL(new Blob([script], { type: "application/javascript" }));
|
|
305
|
+
}
|
|
229
306
|
async function hashSecret(secret) {
|
|
230
307
|
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
|
|
231
308
|
return Array.from(new Uint8Array(buf)).slice(0, 8).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -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-CtBIVgp2.cjs";
|
|
2
2
|
|
|
3
3
|
//#region src/uploader/create-uploader.d.ts
|
|
4
4
|
declare function createUploader(opts: CreateUploaderOptions): Promise<Uploader | null>;
|
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-CtBIVgp2.js";
|
|
2
2
|
|
|
3
3
|
//#region src/uploader/create-uploader.d.ts
|
|
4
4
|
declare function createUploader(opts: CreateUploaderOptions): Promise<Uploader | null>;
|
package/dist/uploader/index.js
CHANGED
|
@@ -33,6 +33,7 @@ const STORAGE_TAB_ID = "csr:tabId";
|
|
|
33
33
|
const STORAGE_SESSION = "csr:session";
|
|
34
34
|
const STORAGE_COUNTER = "csr:counter";
|
|
35
35
|
const DEFAULT_SESSION_TTL_MS = 1800 * 1e3;
|
|
36
|
+
const WELCOME_TIMEOUT_MS = 1e4;
|
|
36
37
|
async function createUploader(opts) {
|
|
37
38
|
const log = opts.debugLogger;
|
|
38
39
|
const sessionTtlMs = opts.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
@@ -41,7 +42,9 @@ async function createUploader(opts) {
|
|
|
41
42
|
const counterHint = readCounter();
|
|
42
43
|
const mode = resolveMode(opts.workerMode ?? "auto");
|
|
43
44
|
log?.(`tab: createUploader mode=${mode} tabId=${tabId} sessionHint=${sessionHint?.id ?? "(none)"} counterHint=${counterHint}`);
|
|
44
|
-
const
|
|
45
|
+
const workerName = mode === "shared" ? await hashSecret(opts.clientSecret) : void 0;
|
|
46
|
+
const { port, urlScheme } = await openWorkerPort(mode, opts.workerUrl, workerName, log);
|
|
47
|
+
log?.(`tab: worker loaded via ${urlScheme}${urlScheme === "blob" ? " (fallback — cross-tab sharing unavailable)" : ""}`);
|
|
45
48
|
let phase = "awaiting-welcome";
|
|
46
49
|
let sessionId = null;
|
|
47
50
|
let sessionToken = null;
|
|
@@ -101,7 +104,17 @@ async function createUploader(opts) {
|
|
|
101
104
|
debugLogs: log !== void 0
|
|
102
105
|
});
|
|
103
106
|
log?.("tab: hello sent, awaiting welcome");
|
|
104
|
-
const
|
|
107
|
+
const timeoutMs = opts._welcomeTimeoutMs ?? WELCOME_TIMEOUT_MS;
|
|
108
|
+
let welcomeTimer;
|
|
109
|
+
const welcomeDeadline = new Promise((_, reject) => {
|
|
110
|
+
port.onError((err) => reject(err));
|
|
111
|
+
welcomeTimer = setTimeout(() => reject(/* @__PURE__ */ new Error(`uploader: welcome timeout. The worker did not respond within ${timeoutMs / 1e3}s. This may indicate a CSP policy blocking the worker script from loading. Check your browser console for errors.`)), timeoutMs);
|
|
112
|
+
});
|
|
113
|
+
welcomeDeadline.catch(() => {});
|
|
114
|
+
const welcome = await Promise.race([welcomePromise.then((msg) => {
|
|
115
|
+
clearTimeout(welcomeTimer);
|
|
116
|
+
return msg;
|
|
117
|
+
}), welcomeDeadline]);
|
|
105
118
|
log?.(welcome.type === "welcome" ? `tab: welcome (${"sessionId" in welcome.result ? `sessionId=${welcome.result.sessionId}` : "skipRecording"})` : `tab: dead reason=${welcome.reason}`);
|
|
106
119
|
if (welcome.type === "dead") throw new Error(`uploader: ${welcome.reason}`);
|
|
107
120
|
if ("skipRecording" in welcome.result) {
|
|
@@ -171,37 +184,101 @@ function resolveMode(mode) {
|
|
|
171
184
|
if (mode === "auto") return typeof SharedWorker !== "undefined" ? "shared" : "dedicated";
|
|
172
185
|
return mode;
|
|
173
186
|
}
|
|
174
|
-
async function openWorkerPort(mode,
|
|
175
|
-
const url = workerUrl ?? toDataUrl("//#region src/uploader/worker/web-socket-transport.ts\n/**\n* WebSocket-backed Transport. Internal retry policy: clean server-initiated close after the\n* first successful open → reconnect and resume; abrupt close (or any close before the first\n* open) → fire `onClose` and stop. Frames received while a (re)connect is in progress are\n* buffered and flushed on open.\n*\n* `ready()` resolves on the first successful open and rejects on close-before-open. Callers\n* should await it before treating the Transport as live, so a failure to open can be caught\n* (e.g. 4404 unknown session) and recovered from.\n*/\nvar WebSocketTransport = class {\n url;\n ws = null;\n onCloseCb = null;\n onStateChangeCb = null;\n intentionallyClosed = false;\n dead = false;\n /** Frames buffered while a (re)connect is in progress. */\n pending = [];\n readyPromise;\n constructor(url) {\n this.url = url;\n this.readyPromise = new Promise((resolve, reject) => {\n this.connect(false, resolve, reject);\n });\n this.readyPromise.catch(() => {});\n }\n ready() {\n return this.readyPromise;\n }\n send(frame) {\n if (this.dead || this.intentionallyClosed) return;\n if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));\n else this.pending.push(frame);\n }\n close(reason = \"transport-close\") {\n this.intentionallyClosed = true;\n this.ws?.close(1e3, reason);\n }\n onClose(cb) {\n this.onCloseCb = cb;\n }\n onStateChange(cb) {\n this.onStateChangeCb = cb;\n }\n connect(isReconnect, onReady, onReadyFail) {\n const ws = new WebSocket(this.url);\n this.ws = ws;\n let opened = false;\n ws.onopen = () => {\n opened = true;\n onReady?.();\n if (isReconnect) this.onStateChangeCb?.({ connected: true });\n while (this.pending.length > 0) {\n const f = this.pending.shift();\n ws.send(JSON.stringify(f));\n }\n };\n ws.onclose = (event) => {\n if (this.intentionallyClosed) return;\n if (!opened) {\n const reason = `${isReconnect ? \"reconnect\" : \"initial\"}-failed code=${event.code}`;\n if (onReadyFail) {\n onReadyFail(new Error(reason));\n this.dead = true;\n } else this.die(reason);\n return;\n }\n if (event.wasClean && (event.code === 1e3 || event.code === 1001)) {\n this.onStateChangeCb?.({ connected: false });\n this.connect(true);\n } else this.die(`close code=${event.code} wasClean=${event.wasClean}`);\n };\n }\n die(reason) {\n this.dead = true;\n this.onCloseCb?.({ reason });\n }\n};\n//#endregion\n//#region src/uploader/worker/csr-client.ts\n/**\n* Single Client implementation that talks to the recording backend's REST + WS protocol.\n* Both dev-server and prod implement the same protocol, so we don't need polymorphism here yet.\n*/\nvar CsrClient = class {\n apiUrl;\n clientSecret;\n context;\n websocketUrl;\n log;\n forceRecord;\n constructor(apiUrl, clientSecret, context, websocketUrl, log = () => {}, forceRecord) {\n this.apiUrl = apiUrl;\n this.clientSecret = clientSecret;\n this.context = context;\n this.websocketUrl = websocketUrl;\n this.log = log;\n this.forceRecord = forceRecord;\n }\n async initSession() {\n const url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;\n this.log(`fetch POST ${url}`);\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n clientSecret: this.clientSecret,\n ...this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {},\n ...this.forceRecord ? { forceRecord: true } : {}\n })\n });\n if (!res.ok) throw new Error(`init-session failed: HTTP ${res.status}`);\n const data = await res.json();\n if (data.skipRecording) return { skipRecording: true };\n if (!data.sessionId || !data.sessionToken) throw new Error(\"init-session response missing sessionId or sessionToken\");\n return {\n sessionId: data.sessionId,\n sessionToken: data.sessionToken\n };\n }\n async openTransport(sessionToken) {\n const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;\n const url = `${wsBase}${wsBase.includes(\"?\") ? \"&\" : \"?\"}session_token=${encodeURIComponent(sessionToken)}`;\n this.log(`WebSocket connect ${url.replace(/session_token=[^&]*/, \"session_token=[REDACTED]\")}`);\n const transport = new WebSocketTransport(url);\n await transport.ready();\n return transport;\n }\n trimSlash(s) {\n return s.endsWith(\"/\") ? s.slice(0, -1) : s;\n }\n toWsScheme(base) {\n if (base.startsWith(\"https://\")) return `wss://${base.slice(8)}`;\n if (base.startsWith(\"http://\")) return `ws://${base.slice(7)}`;\n return base;\n }\n};\n//#endregion\n//#region src/uploader/worker/core.ts\nconst IDLE_GRACE_MS = 5e3;\nlet state = { phase: \"init\" };\nconst ports = [];\nlet idleTimer = null;\nfunction cancelIdleTimer() {\n if (idleTimer !== null) {\n clearTimeout(idleTimer);\n idleTimer = null;\n }\n}\nfunction log(msg) {\n for (const handle of ports) if (handle.debugLogs) handle.port.postMessage({\n type: \"log\",\n msg\n });\n}\n/**\n* The first hello \"locks in\" the session's apiUrl/clientSecret. Any later tab arriving\n* with different values is misconfigured — we reject it rather than silently using the\n* locked values. In SharedWorker mode the `name = hash(clientSecret)` scoping already\n* prevents secret-mismatch from sharing a worker, but this defends against the dedicated\n* path and against future bugs.\n*/\nlet lockedConfig = null;\nfunction registerPort(adapter) {\n cancelIdleTimer();\n const handle = {\n port: adapter,\n hello: null,\n debugLogs: false\n };\n ports.push(handle);\n adapter.onmessage((data) => {\n handleMessage(handle, data);\n });\n}\nfunction handleMessage(handle, message) {\n switch (message.type) {\n case \"hello\":\n handle.hello = message;\n handle.debugLogs = message.debugLogs ?? false;\n if (rejectIfIncompatible(handle)) return;\n if (state.phase !== \"dead\" && state.phase !== \"skipping\") detectDuplicateTab(handle);\n onHello(handle);\n return;\n case \"frame\":\n onFrame(message.frame);\n return;\n case \"bye\":\n onBye(handle);\n return;\n default: break;\n }\n}\n/**\n* Reject hellos whose `apiUrl`/`clientSecret` don't match the values established by the\n* first hello. Returns true if the port was rejected (caller should not continue\n* processing this hello).\n*/\nfunction rejectIfIncompatible(handle) {\n if (lockedConfig === null) return false;\n const incoming = handle.hello;\n if (incoming.apiUrl === lockedConfig.apiUrl && incoming.websocketUrl === lockedConfig.websocketUrl && incoming.clientSecret === lockedConfig.clientSecret) return false;\n handle.port.postMessage({\n type: \"dead\",\n reason: \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\"\n });\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n return true;\n}\n/**\n* If another already-connected port has the same `tabId`, this hello is from a duplicate\n* tab (browser \"Duplicate\" command clones sessionStorage). Mint a fresh `tabId` so the two\n* tabs don't collide on the same `(sessionId, tabId)` Recording. The new tabId is returned\n* to the tab in `welcome` so it can update its own state and sessionStorage.\n*/\nfunction detectDuplicateTab(handle) {\n const tabId = handle.hello.tabId;\n if (!ports.some((p) => p !== handle && p.hello?.tabId === tabId)) return;\n const fresh = crypto.randomUUID();\n handle.newTabId = fresh;\n handle.hello.tabId = fresh;\n}\nfunction onHello(handle) {\n switch (state.phase) {\n case \"init\":\n lockedConfig = {\n apiUrl: handle.hello.apiUrl,\n websocketUrl: handle.hello.websocketUrl,\n clientSecret: handle.hello.clientSecret\n };\n log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ?? \"(derive)\"} sessionIdHint=${handle.hello.sessionIdHint ?? \"(none)\"}`);\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n case \"initializing\": return;\n case \"active\":\n sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n return;\n case \"idle\": {\n const { client, sessionId, sessionToken } = state;\n state = { phase: \"initializing\" };\n resumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);\n return;\n }\n case \"skipping\":\n if (handle.hello.forceRecord) {\n log(\"forceRecord set; re-initializing from skipping state\");\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n }\n handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n return;\n case \"dead\":\n handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n return;\n default: break;\n }\n}\nasync function initializeSession(firstHello) {\n const client = new CsrClient(firstHello.apiUrl, firstHello.clientSecret, firstHello.context, firstHello.websocketUrl, log, firstHello.forceRecord);\n if (firstHello.sessionIdHint && firstHello.sessionTokenHint) {\n log(`adopting sessionIdHint=${firstHello.sessionIdHint}`);\n try {\n const transport = await client.openTransport(firstHello.sessionTokenHint);\n wireTransport(transport);\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: firstHello.sessionIdHint,\n sessionToken: firstHello.sessionTokenHint\n };\n log(\"hint adopted; transport open\");\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n return;\n } catch (err) {\n log(`hint rejected (${String(err)}); falling back to fresh init`);\n }\n }\n let result;\n try {\n result = await client.initSession();\n } catch (err) {\n log(`init-session threw: ${String(err)}`);\n transitionToDead(`init-session-failed: ${String(err)}`);\n return;\n }\n if (\"skipRecording\" in result) {\n log(\"init-session: skipRecording\");\n state = { phase: \"skipping\" };\n return;\n }\n log(`init-session ok sessionId=${result.sessionId}`);\n let transport;\n try {\n transport = await client.openTransport(result.sessionToken);\n } catch (err) {\n log(`openTransport threw: ${String(err)}`);\n transitionToDead(`open-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport open; session active\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: result.sessionId,\n sessionToken: result.sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nasync function resumeTransport(client, sessionId, sessionToken) {\n log(\"resuming transport from idle\");\n let transport;\n try {\n transport = await client.openTransport(sessionToken);\n } catch (err) {\n log(`resume-transport threw: ${String(err)}`);\n transitionToDead(`resume-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport resumed\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId,\n sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nfunction wireTransport(transport) {\n transport.onClose((info) => {\n if (state.phase !== \"active\") return;\n transitionToDead(info.reason);\n });\n transport.onStateChange((info) => {\n if (state.phase !== \"active\") return;\n for (const handle of ports) handle.port.postMessage({\n type: \"state\",\n connected: info.connected\n });\n });\n}\nfunction transitionToDead(reason) {\n state = {\n phase: \"dead\",\n reason\n };\n for (const handle of ports) handle.port.postMessage({\n type: \"dead\",\n reason\n });\n}\nfunction flushPendingWelcomes() {\n for (const handle of ports) {\n if (handle.hello === null) continue;\n if (state.phase === \"active\") sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n else if (state.phase === \"skipping\") handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n else if (state.phase === \"dead\") handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n }\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n const hint = handle.hello?.sessionIdHint;\n const adopted = hint !== void 0 && hint !== currentSessionId;\n const newTabId = handle.newTabId;\n handle.port.postMessage({\n type: \"welcome\",\n result: {\n sessionId: currentSessionId,\n sessionToken: currentSessionToken\n },\n adoptedFromSessionId: adopted ? hint : void 0,\n newTabId,\n resetCounter: adopted || newTabId !== void 0\n });\n}\nfunction onFrame(frame) {\n if (state.phase !== \"active\") return;\n state.transport.send(frame);\n}\nfunction onBye(handle) {\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n if (ports.length === 0 && state.phase === \"active\") {\n log(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);\n idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n }\n}\nfunction enterIdle() {\n idleTimer = null;\n if (state.phase !== \"active\" || ports.length > 0) return;\n log(\"idle timeout; closing transport\");\n state.transport.close(\"idle\");\n state = {\n phase: \"idle\",\n client: state.client,\n sessionId: state.sessionId,\n sessionToken: state.sessionToken\n };\n}\n//#endregion\n//#region src/uploader/worker/entry.ts\nconst SharedWorkerScopeCtor = globalThis.SharedWorkerGlobalScope;\nif (typeof SharedWorkerScopeCtor === \"function\" && self instanceof SharedWorkerScopeCtor) self.onconnect = (event) => {\n const port = event.ports[0];\n port.start();\n registerPort(adaptMessagePort(port));\n};\nelse registerPort(adaptDedicatedSelf());\nfunction adaptMessagePort(port) {\n return {\n postMessage: (message) => port.postMessage(message),\n onmessage: (cb) => {\n port.onmessage = (e) => cb(e.data);\n }\n };\n}\nfunction adaptDedicatedSelf() {\n const ws = self;\n return {\n postMessage: (message) => ws.postMessage(message),\n onmessage: (cb) => {\n ws.onmessage = (e) => cb(e.data);\n }\n };\n}\n//#endregion\n");
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
187
|
+
async function openWorkerPort(mode, workerUrl, workerName, log) {
|
|
188
|
+
if (workerUrl) return {
|
|
189
|
+
port: createWorker(mode, workerUrl, workerName),
|
|
190
|
+
urlScheme: "custom"
|
|
191
|
+
};
|
|
192
|
+
const dataUrl = toDataUrl(workerScript);
|
|
193
|
+
try {
|
|
194
|
+
const port = createWorker(mode, dataUrl, workerName);
|
|
195
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
196
|
+
const asyncErr = port.getBufferedError();
|
|
197
|
+
if (asyncErr) throw asyncErr;
|
|
184
198
|
return {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
worker.port.onmessage = (e) => cb(e.data);
|
|
188
|
-
}
|
|
199
|
+
port,
|
|
200
|
+
urlScheme: "data"
|
|
189
201
|
};
|
|
202
|
+
} catch (_dataErr) {
|
|
203
|
+
log?.("tab: data: worker blocked, falling back to blob: URL (dedicated mode)");
|
|
204
|
+
const blobUrl = toBlobUrl(workerScript);
|
|
205
|
+
try {
|
|
206
|
+
return {
|
|
207
|
+
port: createDedicatedWorker(blobUrl),
|
|
208
|
+
urlScheme: "blob"
|
|
209
|
+
};
|
|
210
|
+
} catch (blobErr) {
|
|
211
|
+
const hint = "Your Content Security Policy blocks both `data:` and `blob:` in `worker-src`. To fix this, either add `blob:` to your `worker-src` directive, or use the `workerUrl` option to serve the worker script from your own origin.";
|
|
212
|
+
log?.(`tab: worker-load-failed: ${hint}`);
|
|
213
|
+
throw new Error(`worker-load-failed: ${hint}`, blobErr instanceof Error ? { cause: blobErr } : void 0);
|
|
214
|
+
}
|
|
190
215
|
}
|
|
216
|
+
}
|
|
217
|
+
function createWorker(mode, url, workerName) {
|
|
218
|
+
if (mode === "shared") return createSharedWorker(url, workerName);
|
|
219
|
+
return createDedicatedWorker(url);
|
|
220
|
+
}
|
|
221
|
+
function createSharedWorker(url, name) {
|
|
222
|
+
const options = {
|
|
223
|
+
name,
|
|
224
|
+
type: "module",
|
|
225
|
+
extendedLifetime: true
|
|
226
|
+
};
|
|
227
|
+
const worker = new SharedWorker(url, options);
|
|
228
|
+
let errorCb = null;
|
|
229
|
+
let bufferedError = null;
|
|
230
|
+
worker.onerror = (e) => {
|
|
231
|
+
const err = workerLoadError(e, url);
|
|
232
|
+
if (errorCb) errorCb(err);
|
|
233
|
+
else bufferedError = err;
|
|
234
|
+
};
|
|
235
|
+
worker.port.start();
|
|
236
|
+
return {
|
|
237
|
+
postMessage: (m) => worker.port.postMessage(m),
|
|
238
|
+
setHandler: (cb) => {
|
|
239
|
+
worker.port.onmessage = (e) => cb(e.data);
|
|
240
|
+
},
|
|
241
|
+
onError: (cb) => {
|
|
242
|
+
errorCb = cb;
|
|
243
|
+
if (bufferedError) cb(bufferedError);
|
|
244
|
+
},
|
|
245
|
+
getBufferedError: () => bufferedError
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function createDedicatedWorker(url) {
|
|
191
249
|
const worker = new Worker(url, { type: "module" });
|
|
250
|
+
let errorCb = null;
|
|
251
|
+
let bufferedError = null;
|
|
252
|
+
worker.onerror = (e) => {
|
|
253
|
+
const err = workerLoadError(e, url);
|
|
254
|
+
if (errorCb) errorCb(err);
|
|
255
|
+
else bufferedError = err;
|
|
256
|
+
};
|
|
192
257
|
return {
|
|
193
258
|
postMessage: (m) => worker.postMessage(m),
|
|
194
259
|
setHandler: (cb) => {
|
|
195
260
|
worker.onmessage = (e) => cb(e.data);
|
|
196
|
-
}
|
|
261
|
+
},
|
|
262
|
+
onError: (cb) => {
|
|
263
|
+
errorCb = cb;
|
|
264
|
+
if (bufferedError) cb(bufferedError);
|
|
265
|
+
},
|
|
266
|
+
getBufferedError: () => bufferedError
|
|
197
267
|
};
|
|
198
268
|
}
|
|
269
|
+
function workerLoadError(cause, url) {
|
|
270
|
+
const msg = `worker-load-failed: ${url.startsWith("data:") ? "This is likely caused by a Content Security Policy (CSP) that blocks `data:` in `worker-src`. To fix this, either add `data:` to your `worker-src` directive, or use the `workerUrl` option to serve the worker script from your own origin." : `Failed to load the worker script from ${url}. Check that the URL is reachable and that your CSP \`worker-src\` directive allows it.`}`;
|
|
271
|
+
return new Error(msg, cause instanceof Error ? { cause } : void 0);
|
|
272
|
+
}
|
|
199
273
|
function toDataUrl(script) {
|
|
200
274
|
const bytes = new TextEncoder().encode(script);
|
|
201
275
|
let binary = "";
|
|
202
276
|
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
203
277
|
return `data:application/javascript;base64,${btoa(binary)}`;
|
|
204
278
|
}
|
|
279
|
+
function toBlobUrl(script) {
|
|
280
|
+
return URL.createObjectURL(new Blob([script], { type: "application/javascript" }));
|
|
281
|
+
}
|
|
205
282
|
async function hashSecret(secret) {
|
|
206
283
|
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
|
|
207
284
|
return Array.from(new Uint8Array(buf)).slice(0, 8).map((b) => b.toString(16).padStart(2, "0")).join("");
|
package/package.json
CHANGED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { CreateUploaderOptions } from './types';
|
|
3
|
+
|
|
4
|
+
vi.mock('./worker/worker-script', () => ({
|
|
5
|
+
workerScript: 'console.log("worker")',
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
const DEFAULTS: CreateUploaderOptions = {
|
|
9
|
+
apiUrl: 'https://api.example',
|
|
10
|
+
clientSecret: 'secret',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function fakeSessionStorage(): Storage {
|
|
14
|
+
const store: Record<string, string> = {};
|
|
15
|
+
return {
|
|
16
|
+
getItem: (key: string) => store[key] ?? null,
|
|
17
|
+
setItem: (key: string, value: string) => {
|
|
18
|
+
store[key] = value;
|
|
19
|
+
},
|
|
20
|
+
removeItem: (key: string) => {
|
|
21
|
+
delete store[key];
|
|
22
|
+
},
|
|
23
|
+
clear: () => Object.keys(store).forEach(k => delete store[k]),
|
|
24
|
+
get length() {
|
|
25
|
+
return Object.keys(store).length;
|
|
26
|
+
},
|
|
27
|
+
key: (i: number) => Object.keys(store)[i] ?? null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class StubWorker {
|
|
32
|
+
onerror: ((e: Event) => void) | null = null;
|
|
33
|
+
onmessage: ((e: MessageEvent) => void) | null = null;
|
|
34
|
+
postMessage() {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function installWorkerStubs(opts?: { workerThrows?: boolean; sharedWorkerThrows?: boolean }) {
|
|
38
|
+
(globalThis as Record<string, unknown>).Worker = opts?.workerThrows
|
|
39
|
+
? class {
|
|
40
|
+
constructor() {
|
|
41
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
: StubWorker;
|
|
45
|
+
|
|
46
|
+
if (opts?.sharedWorkerThrows) {
|
|
47
|
+
(globalThis as Record<string, unknown>).SharedWorker = class {
|
|
48
|
+
constructor() {
|
|
49
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
} else {
|
|
53
|
+
(globalThis as Record<string, unknown>).SharedWorker = class {
|
|
54
|
+
onerror: ((e: Event) => void) | null = null;
|
|
55
|
+
port = {
|
|
56
|
+
start() {},
|
|
57
|
+
onmessage: null as ((e: MessageEvent) => void) | null,
|
|
58
|
+
postMessage() {},
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function tick() {
|
|
65
|
+
return new Promise(r => setTimeout(r, 0));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('createUploader', () => {
|
|
69
|
+
const blobUrls: string[] = [];
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
globalThis.sessionStorage = fakeSessionStorage();
|
|
73
|
+
|
|
74
|
+
if (!globalThis.crypto) {
|
|
75
|
+
(globalThis as Record<string, unknown>).crypto = {};
|
|
76
|
+
}
|
|
77
|
+
if (!globalThis.crypto.randomUUID) {
|
|
78
|
+
globalThis.crypto.randomUUID = () =>
|
|
79
|
+
'00000000-0000-0000-0000-000000000000' as `${string}-${string}-${string}-${string}-${string}`;
|
|
80
|
+
}
|
|
81
|
+
if (!globalThis.crypto.subtle?.digest) {
|
|
82
|
+
(globalThis.crypto as Record<string, unknown>).subtle = {
|
|
83
|
+
digest: async () => new ArrayBuffer(32),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
vi.spyOn(URL, 'createObjectURL').mockImplementation(() => {
|
|
88
|
+
const url = `blob:http://localhost/${blobUrls.length}`;
|
|
89
|
+
blobUrls.push(url);
|
|
90
|
+
return url;
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
vi.restoreAllMocks();
|
|
96
|
+
blobUrls.length = 0;
|
|
97
|
+
delete (globalThis as Record<string, unknown>).SharedWorker;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
async function loadCreateUploader() {
|
|
101
|
+
vi.resetModules();
|
|
102
|
+
// .js extension is required by Node16 module resolution for dynamic imports
|
|
103
|
+
// (static `import` lines work without it because the package is CJS — see package.json).
|
|
104
|
+
// eslint-disable-next-line es/no-dynamic-import
|
|
105
|
+
const mod = await import('./create-uploader.js');
|
|
106
|
+
return mod.createUploader;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe('blob: fallback', () => {
|
|
110
|
+
it('uses data: URL when Worker constructor succeeds', async () => {
|
|
111
|
+
installWorkerStubs();
|
|
112
|
+
|
|
113
|
+
const createUploader = await loadCreateUploader();
|
|
114
|
+
const logs: string[] = [];
|
|
115
|
+
createUploader({ ...DEFAULTS, workerMode: 'dedicated', debugLogger: m => logs.push(m) });
|
|
116
|
+
await tick();
|
|
117
|
+
|
|
118
|
+
expect(blobUrls).toHaveLength(0);
|
|
119
|
+
expect(logs.some(l => l.includes('via data'))).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('falls back to blob: when data: Worker throws', async () => {
|
|
123
|
+
let constructedUrl: string | undefined;
|
|
124
|
+
(globalThis as Record<string, unknown>).Worker = class {
|
|
125
|
+
onerror: ((e: Event) => void) | null = null;
|
|
126
|
+
onmessage: ((e: MessageEvent) => void) | null = null;
|
|
127
|
+
postMessage() {}
|
|
128
|
+
constructor(url: string) {
|
|
129
|
+
if (url.startsWith('data:')) {
|
|
130
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
131
|
+
}
|
|
132
|
+
constructedUrl = url;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
delete (globalThis as Record<string, unknown>).SharedWorker;
|
|
136
|
+
|
|
137
|
+
const createUploader = await loadCreateUploader();
|
|
138
|
+
const logs: string[] = [];
|
|
139
|
+
createUploader({ ...DEFAULTS, workerMode: 'dedicated', debugLogger: m => logs.push(m) });
|
|
140
|
+
await tick();
|
|
141
|
+
|
|
142
|
+
expect(blobUrls).toHaveLength(1);
|
|
143
|
+
expect(constructedUrl).toBe(blobUrls[0]);
|
|
144
|
+
expect(logs.some(l => l.includes('falling back to blob:'))).toBe(true);
|
|
145
|
+
expect(logs.some(l => l.includes('via blob'))).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('falls back to blob: dedicated worker when SharedWorker data: throws', async () => {
|
|
149
|
+
(globalThis as Record<string, unknown>).SharedWorker = class {
|
|
150
|
+
constructor() {
|
|
151
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
let constructedUrl: string | undefined;
|
|
155
|
+
(globalThis as Record<string, unknown>).Worker = class {
|
|
156
|
+
onerror: ((e: Event) => void) | null = null;
|
|
157
|
+
onmessage: ((e: MessageEvent) => void) | null = null;
|
|
158
|
+
postMessage() {}
|
|
159
|
+
constructor(url: string) {
|
|
160
|
+
constructedUrl = url;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const createUploader = await loadCreateUploader();
|
|
165
|
+
const logs: string[] = [];
|
|
166
|
+
createUploader({ ...DEFAULTS, workerMode: 'shared', debugLogger: m => logs.push(m) });
|
|
167
|
+
await tick();
|
|
168
|
+
|
|
169
|
+
expect(blobUrls).toHaveLength(1);
|
|
170
|
+
expect(constructedUrl).toBe(blobUrls[0]);
|
|
171
|
+
expect(logs.some(l => l.includes('falling back to blob:'))).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('falls back to blob: when SharedWorker onerror fires asynchronously (Chrome CSP)', async () => {
|
|
175
|
+
let constructedUrl: string | undefined;
|
|
176
|
+
(globalThis as Record<string, unknown>).SharedWorker = class {
|
|
177
|
+
onerror: ((e: Event) => void) | null = null;
|
|
178
|
+
port = {
|
|
179
|
+
start() {},
|
|
180
|
+
onmessage: null as ((e: MessageEvent) => void) | null,
|
|
181
|
+
postMessage() {},
|
|
182
|
+
};
|
|
183
|
+
constructor() {
|
|
184
|
+
setTimeout(() => this.onerror?.(new Event('error')), 0);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
(globalThis as Record<string, unknown>).Worker = class {
|
|
188
|
+
onerror: ((e: Event) => void) | null = null;
|
|
189
|
+
onmessage: ((e: MessageEvent) => void) | null = null;
|
|
190
|
+
postMessage() {}
|
|
191
|
+
constructor(url: string) {
|
|
192
|
+
constructedUrl = url;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const createUploader = await loadCreateUploader();
|
|
197
|
+
const logs: string[] = [];
|
|
198
|
+
// Await the full async chain: openWorkerPort detects the async onerror,
|
|
199
|
+
// falls back to blob, then createUploader eventually hits the welcome timeout.
|
|
200
|
+
const p = createUploader({
|
|
201
|
+
...DEFAULTS,
|
|
202
|
+
workerMode: 'shared',
|
|
203
|
+
debugLogger: m => logs.push(m),
|
|
204
|
+
_welcomeTimeoutMs: 50,
|
|
205
|
+
});
|
|
206
|
+
await expect(p).rejects.toThrow(/welcome timeout/);
|
|
207
|
+
|
|
208
|
+
expect(blobUrls).toHaveLength(1);
|
|
209
|
+
expect(constructedUrl).toBe(blobUrls[0]);
|
|
210
|
+
expect(logs.some(l => l.includes('falling back to blob:'))).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('throws with CSP hint when both data: and blob: are blocked', async () => {
|
|
214
|
+
(globalThis as Record<string, unknown>).Worker = class {
|
|
215
|
+
constructor() {
|
|
216
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
delete (globalThis as Record<string, unknown>).SharedWorker;
|
|
220
|
+
|
|
221
|
+
const createUploader = await loadCreateUploader();
|
|
222
|
+
|
|
223
|
+
await expect(createUploader({ ...DEFAULTS, workerMode: 'dedicated' })).rejects.toThrow(/worker-src/);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('logs CSP hint via debugLogger when both data: and blob: are blocked', async () => {
|
|
227
|
+
(globalThis as Record<string, unknown>).Worker = class {
|
|
228
|
+
constructor() {
|
|
229
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
delete (globalThis as Record<string, unknown>).SharedWorker;
|
|
233
|
+
|
|
234
|
+
const createUploader = await loadCreateUploader();
|
|
235
|
+
const logs: string[] = [];
|
|
236
|
+
|
|
237
|
+
await expect(
|
|
238
|
+
createUploader({ ...DEFAULTS, workerMode: 'dedicated', debugLogger: m => logs.push(m) }),
|
|
239
|
+
).rejects.toThrow();
|
|
240
|
+
expect(logs.some(l => l.includes('worker-load-failed') && l.includes('worker-src'))).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('does not fall back when a custom workerUrl is provided', async () => {
|
|
244
|
+
(globalThis as Record<string, unknown>).Worker = class {
|
|
245
|
+
constructor() {
|
|
246
|
+
throw new Error('custom URL not found');
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
delete (globalThis as Record<string, unknown>).SharedWorker;
|
|
250
|
+
|
|
251
|
+
const createUploader = await loadCreateUploader();
|
|
252
|
+
|
|
253
|
+
await expect(
|
|
254
|
+
createUploader({ ...DEFAULTS, workerMode: 'dedicated', workerUrl: 'https://cdn.example/worker.js' }),
|
|
255
|
+
).rejects.toThrow();
|
|
256
|
+
expect(blobUrls).toHaveLength(0);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('worker load failure (async error)', () => {
|
|
261
|
+
it('throws with CSP hint when dedicated Worker fires onerror', async () => {
|
|
262
|
+
(globalThis as Record<string, unknown>).Worker = class {
|
|
263
|
+
onerror: ((e: Event) => void) | null = null;
|
|
264
|
+
onmessage: ((e: MessageEvent) => void) | null = null;
|
|
265
|
+
postMessage() {}
|
|
266
|
+
constructor() {
|
|
267
|
+
setTimeout(() => this.onerror?.(new Event('error')), 5);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
delete (globalThis as Record<string, unknown>).SharedWorker;
|
|
271
|
+
|
|
272
|
+
const createUploader = await loadCreateUploader();
|
|
273
|
+
|
|
274
|
+
await expect(createUploader({ ...DEFAULTS, workerMode: 'dedicated' })).rejects.toThrow(/worker-src/);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('throws with CSP hint when SharedWorker fires onerror', async () => {
|
|
278
|
+
(globalThis as Record<string, unknown>).SharedWorker = class {
|
|
279
|
+
onerror: ((e: Event) => void) | null = null;
|
|
280
|
+
port = {
|
|
281
|
+
start() {},
|
|
282
|
+
onmessage: null as ((e: MessageEvent) => void) | null,
|
|
283
|
+
postMessage() {},
|
|
284
|
+
};
|
|
285
|
+
constructor() {
|
|
286
|
+
setTimeout(() => this.onerror?.(new Event('error')), 5);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const createUploader = await loadCreateUploader();
|
|
291
|
+
|
|
292
|
+
await expect(createUploader({ ...DEFAULTS, workerMode: 'shared' })).rejects.toThrow(/worker-src/);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('welcome timeout', () => {
|
|
297
|
+
it('rejects when the worker never sends a welcome', async () => {
|
|
298
|
+
(globalThis as Record<string, unknown>).Worker = class {
|
|
299
|
+
onerror: ((e: Event) => void) | null = null;
|
|
300
|
+
onmessage: ((e: MessageEvent) => void) | null = null;
|
|
301
|
+
postMessage() {}
|
|
302
|
+
};
|
|
303
|
+
delete (globalThis as Record<string, unknown>).SharedWorker;
|
|
304
|
+
|
|
305
|
+
const createUploader = await loadCreateUploader();
|
|
306
|
+
|
|
307
|
+
await expect(createUploader({ ...DEFAULTS, workerMode: 'dedicated', _welcomeTimeoutMs: 50 })).rejects.toThrow(
|
|
308
|
+
/welcome timeout/,
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -6,10 +6,12 @@ const STORAGE_TAB_ID = 'csr:tabId';
|
|
|
6
6
|
const STORAGE_SESSION = 'csr:session';
|
|
7
7
|
const STORAGE_COUNTER = 'csr:counter';
|
|
8
8
|
const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
|
|
9
|
+
const WELCOME_TIMEOUT_MS = 10_000;
|
|
9
10
|
|
|
10
11
|
interface PortLike {
|
|
11
12
|
postMessage(m: unknown): void;
|
|
12
13
|
setHandler(cb: (data: unknown) => void): void;
|
|
14
|
+
onError(cb: (err: Error) => void): void;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
interface WelcomeMessage {
|
|
@@ -49,7 +51,11 @@ export async function createUploader(opts: CreateUploaderOptions): Promise<Uploa
|
|
|
49
51
|
} counterHint=${counterHint}`,
|
|
50
52
|
);
|
|
51
53
|
|
|
52
|
-
const
|
|
54
|
+
const workerName = mode === 'shared' ? await hashSecret(opts.clientSecret) : undefined;
|
|
55
|
+
const { port, urlScheme } = await openWorkerPort(mode, opts.workerUrl, workerName, log);
|
|
56
|
+
log?.(
|
|
57
|
+
`tab: worker loaded via ${urlScheme}${urlScheme === 'blob' ? ' (fallback — cross-tab sharing unavailable)' : ''}`,
|
|
58
|
+
);
|
|
53
59
|
|
|
54
60
|
// State accessible to both the message handler (for post-welcome state/dead messages)
|
|
55
61
|
// and the post-welcome setup code below. Mutable so welcome can populate them.
|
|
@@ -118,14 +124,36 @@ export async function createUploader(opts: CreateUploaderOptions): Promise<Uploa
|
|
|
118
124
|
});
|
|
119
125
|
log?.('tab: hello sent, awaiting welcome');
|
|
120
126
|
|
|
121
|
-
const
|
|
127
|
+
const timeoutMs = opts._welcomeTimeoutMs ?? WELCOME_TIMEOUT_MS;
|
|
128
|
+
let welcomeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
129
|
+
const welcomeDeadline = new Promise<never>((_, reject) => {
|
|
130
|
+
port.onError(err => reject(err));
|
|
131
|
+
welcomeTimer = setTimeout(
|
|
132
|
+
() =>
|
|
133
|
+
reject(
|
|
134
|
+
new Error(
|
|
135
|
+
'uploader: welcome timeout. The worker did not respond within ' +
|
|
136
|
+
`${timeoutMs / 1000}s. This may indicate a CSP policy blocking ` +
|
|
137
|
+
'the worker script from loading. Check your browser console for errors.',
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
timeoutMs,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
welcomeDeadline.catch(() => {});
|
|
144
|
+
const welcome = await Promise.race([
|
|
145
|
+
welcomePromise.then(msg => {
|
|
146
|
+
clearTimeout(welcomeTimer);
|
|
147
|
+
return msg;
|
|
148
|
+
}),
|
|
149
|
+
welcomeDeadline,
|
|
150
|
+
]);
|
|
122
151
|
log?.(
|
|
123
152
|
welcome.type === 'welcome'
|
|
124
153
|
? `tab: welcome (${'sessionId' in welcome.result ? `sessionId=${welcome.result.sessionId}` : 'skipRecording'})`
|
|
125
154
|
: `tab: dead reason=${welcome.reason}`,
|
|
126
155
|
);
|
|
127
156
|
if (welcome.type === 'dead') {
|
|
128
|
-
// Worker died before establishing a session — surface the reason instead of swallowing it as `null`.
|
|
129
157
|
throw new Error(`uploader: ${welcome.reason}`);
|
|
130
158
|
}
|
|
131
159
|
if ('skipRecording' in welcome.result) {
|
|
@@ -211,44 +239,117 @@ function resolveMode(mode: 'shared' | 'dedicated' | 'auto'): 'shared' | 'dedicat
|
|
|
211
239
|
return mode;
|
|
212
240
|
}
|
|
213
241
|
|
|
242
|
+
type UrlScheme = 'data' | 'blob' | 'custom';
|
|
243
|
+
|
|
244
|
+
interface InternalPort extends PortLike {
|
|
245
|
+
getBufferedError(): Error | null;
|
|
246
|
+
}
|
|
247
|
+
|
|
214
248
|
async function openWorkerPort(
|
|
215
249
|
mode: 'shared' | 'dedicated',
|
|
216
|
-
clientSecret: string,
|
|
217
250
|
workerUrl: string | undefined,
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
251
|
+
workerName: string | undefined,
|
|
252
|
+
log: ((msg: string) => void) | undefined,
|
|
253
|
+
): Promise<{ port: PortLike; urlScheme: UrlScheme }> {
|
|
254
|
+
if (workerUrl) {
|
|
255
|
+
return { port: createWorker(mode, workerUrl, workerName), urlScheme: 'custom' };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const dataUrl = toDataUrl(workerScript);
|
|
259
|
+
try {
|
|
260
|
+
const port = createWorker(mode, dataUrl, workerName);
|
|
261
|
+
// Chrome's SharedWorker doesn't throw synchronously for CSP blocks — it fires
|
|
262
|
+
// onerror asynchronously. Yield one macrotask to let it fire, then check.
|
|
263
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
264
|
+
const asyncErr = port.getBufferedError();
|
|
265
|
+
if (asyncErr) throw asyncErr;
|
|
266
|
+
return { port, urlScheme: 'data' };
|
|
267
|
+
} catch (_dataErr) {
|
|
268
|
+
log?.('tab: data: worker blocked, falling back to blob: URL (dedicated mode)');
|
|
269
|
+
const blobUrl = toBlobUrl(workerScript);
|
|
270
|
+
try {
|
|
271
|
+
return { port: createDedicatedWorker(blobUrl), urlScheme: 'blob' };
|
|
272
|
+
} catch (blobErr) {
|
|
273
|
+
const hint =
|
|
274
|
+
'Your Content Security Policy blocks both `data:` and `blob:` in `worker-src`. ' +
|
|
275
|
+
'To fix this, either add `blob:` to your `worker-src` directive, or ' +
|
|
276
|
+
'use the `workerUrl` option to serve the worker script from your own origin.';
|
|
277
|
+
log?.(`tab: worker-load-failed: ${hint}`);
|
|
278
|
+
throw new Error(`worker-load-failed: ${hint}`, blobErr instanceof Error ? { cause: blobErr } : undefined);
|
|
279
|
+
}
|
|
241
280
|
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function createWorker(mode: 'shared' | 'dedicated', url: string, workerName: string | undefined): InternalPort {
|
|
284
|
+
if (mode === 'shared') return createSharedWorker(url, workerName!);
|
|
285
|
+
return createDedicatedWorker(url);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function createSharedWorker(url: string, name: string): InternalPort {
|
|
289
|
+
const options = {
|
|
290
|
+
name,
|
|
291
|
+
type: 'module',
|
|
292
|
+
extendedLifetime: true,
|
|
293
|
+
} as WorkerOptions;
|
|
294
|
+
const worker = new SharedWorker(url, options);
|
|
295
|
+
let errorCb: ((err: Error) => void) | null = null;
|
|
296
|
+
let bufferedError: Error | null = null;
|
|
242
297
|
|
|
298
|
+
worker.onerror = e => {
|
|
299
|
+
const err = workerLoadError(e, url);
|
|
300
|
+
if (errorCb) errorCb(err);
|
|
301
|
+
else bufferedError = err;
|
|
302
|
+
};
|
|
303
|
+
worker.port.start();
|
|
304
|
+
return {
|
|
305
|
+
postMessage: m => worker.port.postMessage(m),
|
|
306
|
+
setHandler: cb => {
|
|
307
|
+
worker.port.onmessage = (e: MessageEvent) => cb(e.data);
|
|
308
|
+
},
|
|
309
|
+
onError: cb => {
|
|
310
|
+
errorCb = cb;
|
|
311
|
+
if (bufferedError) cb(bufferedError);
|
|
312
|
+
},
|
|
313
|
+
getBufferedError: () => bufferedError,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function createDedicatedWorker(url: string): InternalPort {
|
|
243
318
|
const worker = new Worker(url, { type: 'module' });
|
|
319
|
+
let errorCb: ((err: Error) => void) | null = null;
|
|
320
|
+
let bufferedError: Error | null = null;
|
|
321
|
+
|
|
322
|
+
worker.onerror = e => {
|
|
323
|
+
const err = workerLoadError(e, url);
|
|
324
|
+
if (errorCb) errorCb(err);
|
|
325
|
+
else bufferedError = err;
|
|
326
|
+
};
|
|
244
327
|
return {
|
|
245
328
|
postMessage: m => worker.postMessage(m),
|
|
246
329
|
setHandler: cb => {
|
|
247
330
|
worker.onmessage = (e: MessageEvent) => cb(e.data);
|
|
248
331
|
},
|
|
332
|
+
onError: cb => {
|
|
333
|
+
errorCb = cb;
|
|
334
|
+
if (bufferedError) cb(bufferedError);
|
|
335
|
+
},
|
|
336
|
+
getBufferedError: () => bufferedError,
|
|
249
337
|
};
|
|
250
338
|
}
|
|
251
339
|
|
|
340
|
+
function workerLoadError(cause: unknown, url: string): Error {
|
|
341
|
+
const isDataUrl = url.startsWith('data:');
|
|
342
|
+
const hint = isDataUrl
|
|
343
|
+
? 'This is likely caused by a Content Security Policy (CSP) that blocks `data:` in ' +
|
|
344
|
+
'`worker-src`. To fix this, either add `data:` to your `worker-src` directive, or ' +
|
|
345
|
+
'use the `workerUrl` option to serve the worker script from your own origin.'
|
|
346
|
+
: `Failed to load the worker script from ${url}. Check that the URL is reachable and ` +
|
|
347
|
+
'that your CSP `worker-src` directive allows it.';
|
|
348
|
+
|
|
349
|
+
const msg = `worker-load-failed: ${hint}`;
|
|
350
|
+
return new Error(msg, cause instanceof Error ? { cause } : undefined);
|
|
351
|
+
}
|
|
352
|
+
|
|
252
353
|
function toDataUrl(script: string): string {
|
|
253
354
|
// UTF-8-safe base64. The worker bundle is ASCII today but esbuild may emit non-ASCII
|
|
254
355
|
// identifiers if the source ever contains them; this avoids `btoa` choking.
|
|
@@ -258,6 +359,10 @@ function toDataUrl(script: string): string {
|
|
|
258
359
|
return `data:application/javascript;base64,${btoa(binary)}`;
|
|
259
360
|
}
|
|
260
361
|
|
|
362
|
+
function toBlobUrl(script: string): string {
|
|
363
|
+
return URL.createObjectURL(new Blob([script], { type: 'application/javascript' }));
|
|
364
|
+
}
|
|
365
|
+
|
|
261
366
|
async function hashSecret(secret: string): Promise<string> {
|
|
262
367
|
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));
|
|
263
368
|
return Array.from(new Uint8Array(buf))
|
package/src/uploader/types.ts
CHANGED
|
@@ -59,6 +59,8 @@ export interface CreateUploaderOptions {
|
|
|
59
59
|
* Worker messages are forwarded over the port and tagged so you can tell them apart.
|
|
60
60
|
*/
|
|
61
61
|
debugLogger?: (msg: string) => void;
|
|
62
|
+
/** @internal Override the welcome-handshake timeout for testing. */
|
|
63
|
+
_welcomeTimeoutMs?: number;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
// --- Internal types: used across the worker / tab pieces, not part of the public API. ---
|