@spotify-confidence/session-recording 0.18.2 → 0.18.4
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 +35 -0
- package/README.md +30 -0
- package/dist/index.cjs +105 -22
- package/dist/index.js +105 -22
- package/package.json +3 -3
- package/src/flag-observer.test.ts +23 -14
- package/src/flag-observer.ts +9 -5
- package/src/index.test.ts +10 -0
- package/src/index.ts +2 -2
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.18.4](https://github.com/spotify/confidence-sdk-js/compare/session-recording-v0.18.3...session-recording-v0.18.4) (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
|
+
### Dependencies
|
|
12
|
+
|
|
13
|
+
* The following workspace dependencies were updated
|
|
14
|
+
* dependencies
|
|
15
|
+
* @spotify-confidence/csr-common bumped to 0.18.3
|
|
16
|
+
* @spotify-confidence/csr-recorder bumped to 0.17.9
|
|
17
|
+
|
|
18
|
+
## [0.18.3](https://github.com/spotify/confidence-sdk-js/compare/session-recording-v0.18.2...session-recording-v0.18.3) (2026-07-06)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### ✨ New Features
|
|
22
|
+
|
|
23
|
+
* **sdk:** add assignmentOrigin to window API ([#396](https://github.com/spotify/confidence-sdk-js/issues/396)) ([9dd17d5](https://github.com/spotify/confidence-sdk-js/commit/9dd17d5f3af787e39406785f85f0d2e79703e656))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### 📚 Documentation
|
|
27
|
+
|
|
28
|
+
* **csr:** add debug logging section to README ([#395](https://github.com/spotify/confidence-sdk-js/issues/395)) ([6e6d1c3](https://github.com/spotify/confidence-sdk-js/commit/6e6d1c3c28460ef222f07fe053f2dc8d35fdc365))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Dependencies
|
|
32
|
+
|
|
33
|
+
* The following workspace dependencies were updated
|
|
34
|
+
* dependencies
|
|
35
|
+
* @spotify-confidence/csr-common bumped to 0.18.2
|
|
36
|
+
* @spotify-confidence/csr-recorder bumped to 0.17.8
|
|
37
|
+
|
|
3
38
|
## [0.18.2](https://github.com/spotify/confidence-sdk-js/compare/session-recording-v0.18.1...session-recording-v0.18.2) (2026-07-01)
|
|
4
39
|
|
|
5
40
|
|
package/README.md
CHANGED
|
@@ -86,6 +86,9 @@ const recorder = initSessionRecorder({
|
|
|
86
86
|
|
|
87
87
|
// Recording mode
|
|
88
88
|
mode: 'automatic', // 'automatic' (default) or 'manual'
|
|
89
|
+
|
|
90
|
+
// Debug
|
|
91
|
+
debugLogger: msg => console.log(msg), // lifecycle/transport messages (default: off, or console.log when CSR_DEBUG is set in sessionStorage)
|
|
89
92
|
});
|
|
90
93
|
```
|
|
91
94
|
|
|
@@ -112,6 +115,33 @@ const recorder = initSessionRecorder({
|
|
|
112
115
|
|
|
113
116
|
See the [`@spotify-confidence/csr-recorder` README](../csr-recorder/README.md#route-parameterization) for the full list of default patterns.
|
|
114
117
|
|
|
118
|
+
## Debug logging
|
|
119
|
+
|
|
120
|
+
The SDK can emit one-line lifecycle and transport messages to help you verify your setup and diagnose issues.
|
|
121
|
+
|
|
122
|
+
### Option 1: pass a logger
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
const recorder = initSessionRecorder({
|
|
126
|
+
clientSecret: '<your-client-secret>',
|
|
127
|
+
debugLogger: msg => console.log(msg),
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
All messages are prefixed with `[CSR]` — you can filter your browser console to quickly find them.
|
|
132
|
+
|
|
133
|
+
### Option 2: sessionStorage flag
|
|
134
|
+
|
|
135
|
+
If you can't (or don't want to) change code, open your browser DevTools console and run:
|
|
136
|
+
|
|
137
|
+
```javascript
|
|
138
|
+
sessionStorage.setItem('CSR_DEBUG', 'true');
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Then reload the page. The SDK will detect the flag and log to `console.log` automatically. Remove it with `sessionStorage.removeItem('CSR_DEBUG')` when you're done.
|
|
142
|
+
|
|
143
|
+
> **Tip:** We recommend enabling debug logging when first integrating the SDK. It lets you confirm that a session is established, events are flowing, and the backend is reachable — all before you open the Confidence dashboard.
|
|
144
|
+
|
|
115
145
|
## Manual mode
|
|
116
146
|
|
|
117
147
|
Use `manual` mode to control when recording starts — useful for gating on user consent or feature flags.
|
package/dist/index.cjs
CHANGED
|
@@ -11179,14 +11179,16 @@ function observeFlags(onFlagWrite) {
|
|
|
11179
11179
|
_target[prop] = value;
|
|
11180
11180
|
onFlagWrite({
|
|
11181
11181
|
flagKey: prop,
|
|
11182
|
-
variant: value.variant
|
|
11182
|
+
variant: value.variant,
|
|
11183
|
+
assignmentOrigin: value.assignmentOrigin ?? ""
|
|
11183
11184
|
});
|
|
11184
11185
|
}
|
|
11185
11186
|
return true;
|
|
11186
11187
|
} });
|
|
11187
11188
|
for (const [name, data] of Object.entries(existing)) if (data && typeof data.variant === "string") onFlagWrite({
|
|
11188
11189
|
flagKey: name,
|
|
11189
|
-
variant: data.variant
|
|
11190
|
+
variant: data.variant,
|
|
11191
|
+
assignmentOrigin: data.assignmentOrigin ?? ""
|
|
11190
11192
|
});
|
|
11191
11193
|
return () => {
|
|
11192
11194
|
confidence.flags = { ...target };
|
|
@@ -12934,11 +12936,15 @@ function collectUserAgentContext() {
|
|
|
12934
12936
|
};
|
|
12935
12937
|
}
|
|
12936
12938
|
//#endregion
|
|
12939
|
+
//#region ../csr-common/src/uploader/worker/worker-script.ts
|
|
12940
|
+
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";
|
|
12941
|
+
//#endregion
|
|
12937
12942
|
//#region ../csr-common/src/uploader/create-uploader.ts
|
|
12938
12943
|
const STORAGE_TAB_ID = "csr:tabId";
|
|
12939
12944
|
const STORAGE_SESSION = "csr:session";
|
|
12940
12945
|
const STORAGE_COUNTER = "csr:counter";
|
|
12941
12946
|
const DEFAULT_SESSION_TTL_MS = 1800 * 1e3;
|
|
12947
|
+
const WELCOME_TIMEOUT_MS = 1e4;
|
|
12942
12948
|
async function createUploader(opts) {
|
|
12943
12949
|
const log = opts.debugLogger;
|
|
12944
12950
|
const sessionTtlMs = opts.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
@@ -12947,7 +12953,9 @@ async function createUploader(opts) {
|
|
|
12947
12953
|
const counterHint = readCounter();
|
|
12948
12954
|
const mode = resolveMode(opts.workerMode ?? "auto");
|
|
12949
12955
|
log?.(`tab: createUploader mode=${mode} tabId=${tabId} sessionHint=${sessionHint?.id ?? "(none)"} counterHint=${counterHint}`);
|
|
12950
|
-
const
|
|
12956
|
+
const workerName = mode === "shared" ? await hashSecret(opts.clientSecret) : void 0;
|
|
12957
|
+
const { port, urlScheme } = await openWorkerPort(mode, opts.workerUrl, workerName, log);
|
|
12958
|
+
log?.(`tab: worker loaded via ${urlScheme}${urlScheme === "blob" ? " (fallback — cross-tab sharing unavailable)" : ""}`);
|
|
12951
12959
|
let phase = "awaiting-welcome";
|
|
12952
12960
|
let sessionId = null;
|
|
12953
12961
|
let sessionToken = null;
|
|
@@ -13007,7 +13015,17 @@ async function createUploader(opts) {
|
|
|
13007
13015
|
debugLogs: log !== void 0
|
|
13008
13016
|
});
|
|
13009
13017
|
log?.("tab: hello sent, awaiting welcome");
|
|
13010
|
-
const
|
|
13018
|
+
const timeoutMs = opts._welcomeTimeoutMs ?? WELCOME_TIMEOUT_MS;
|
|
13019
|
+
let welcomeTimer;
|
|
13020
|
+
const welcomeDeadline = new Promise((_, reject) => {
|
|
13021
|
+
port.onError((err) => reject(err));
|
|
13022
|
+
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);
|
|
13023
|
+
});
|
|
13024
|
+
welcomeDeadline.catch(() => {});
|
|
13025
|
+
const welcome = await Promise.race([welcomePromise.then((msg) => {
|
|
13026
|
+
clearTimeout(welcomeTimer);
|
|
13027
|
+
return msg;
|
|
13028
|
+
}), welcomeDeadline]);
|
|
13011
13029
|
log?.(welcome.type === "welcome" ? `tab: welcome (${"sessionId" in welcome.result ? `sessionId=${welcome.result.sessionId}` : "skipRecording"})` : `tab: dead reason=${welcome.reason}`);
|
|
13012
13030
|
if (welcome.type === "dead") throw new Error(`uploader: ${welcome.reason}`);
|
|
13013
13031
|
if ("skipRecording" in welcome.result) {
|
|
@@ -13077,37 +13095,101 @@ function resolveMode(mode) {
|
|
|
13077
13095
|
if (mode === "auto") return typeof SharedWorker !== "undefined" ? "shared" : "dedicated";
|
|
13078
13096
|
return mode;
|
|
13079
13097
|
}
|
|
13080
|
-
async function openWorkerPort(mode,
|
|
13081
|
-
const url = workerUrl ?? toDataUrl("//#region src/uploader/worker/web-socket-transport.ts\n/**\n* WebSocket-backed Transport. Internal retry policy: clean server-initiated close after the\n* first successful open → reconnect and resume; abrupt close (or any close before the first\n* open) → fire `onClose` and stop. Frames received while a (re)connect is in progress are\n* buffered and flushed on open.\n*\n* `ready()` resolves on the first successful open and rejects on close-before-open. Callers\n* should await it before treating the Transport as live, so a failure to open can be caught\n* (e.g. 4404 unknown session) and recovered from.\n*/\nvar WebSocketTransport = class {\n url;\n ws = null;\n onCloseCb = null;\n onStateChangeCb = null;\n intentionallyClosed = false;\n dead = false;\n /** Frames buffered while a (re)connect is in progress. */\n pending = [];\n readyPromise;\n constructor(url) {\n this.url = url;\n this.readyPromise = new Promise((resolve, reject) => {\n this.connect(false, resolve, reject);\n });\n this.readyPromise.catch(() => {});\n }\n ready() {\n return this.readyPromise;\n }\n send(frame) {\n if (this.dead || this.intentionallyClosed) return;\n if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));\n else this.pending.push(frame);\n }\n close(reason = \"transport-close\") {\n this.intentionallyClosed = true;\n this.ws?.close(1e3, reason);\n }\n onClose(cb) {\n this.onCloseCb = cb;\n }\n onStateChange(cb) {\n this.onStateChangeCb = cb;\n }\n connect(isReconnect, onReady, onReadyFail) {\n const ws = new WebSocket(this.url);\n this.ws = ws;\n let opened = false;\n ws.onopen = () => {\n opened = true;\n onReady?.();\n if (isReconnect) this.onStateChangeCb?.({ connected: true });\n while (this.pending.length > 0) {\n const f = this.pending.shift();\n ws.send(JSON.stringify(f));\n }\n };\n ws.onclose = (event) => {\n if (this.intentionallyClosed) return;\n if (!opened) {\n const reason = `${isReconnect ? \"reconnect\" : \"initial\"}-failed code=${event.code}`;\n if (onReadyFail) {\n onReadyFail(new Error(reason));\n this.dead = true;\n } else this.die(reason);\n return;\n }\n if (event.wasClean && (event.code === 1e3 || event.code === 1001)) {\n this.onStateChangeCb?.({ connected: false });\n this.connect(true);\n } else this.die(`close code=${event.code} wasClean=${event.wasClean}`);\n };\n }\n die(reason) {\n this.dead = true;\n this.onCloseCb?.({ reason });\n }\n};\n//#endregion\n//#region src/uploader/worker/csr-client.ts\n/**\n* Single Client implementation that talks to the recording backend's REST + WS protocol.\n* Both dev-server and prod implement the same protocol, so we don't need polymorphism here yet.\n*/\nvar CsrClient = class {\n apiUrl;\n clientSecret;\n context;\n websocketUrl;\n log;\n forceRecord;\n constructor(apiUrl, clientSecret, context, websocketUrl, log = () => {}, forceRecord) {\n this.apiUrl = apiUrl;\n this.clientSecret = clientSecret;\n this.context = context;\n this.websocketUrl = websocketUrl;\n this.log = log;\n this.forceRecord = forceRecord;\n }\n async initSession() {\n const url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;\n this.log(`fetch POST ${url}`);\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n clientSecret: this.clientSecret,\n ...this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {},\n ...this.forceRecord ? { forceRecord: true } : {}\n })\n });\n if (!res.ok) throw new Error(`init-session failed: HTTP ${res.status}`);\n const data = await res.json();\n if (data.skipRecording) return { skipRecording: true };\n if (!data.sessionId || !data.sessionToken) throw new Error(\"init-session response missing sessionId or sessionToken\");\n return {\n sessionId: data.sessionId,\n sessionToken: data.sessionToken\n };\n }\n async openTransport(sessionToken) {\n const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;\n const url = `${wsBase}${wsBase.includes(\"?\") ? \"&\" : \"?\"}session_token=${encodeURIComponent(sessionToken)}`;\n this.log(`WebSocket connect ${url}`);\n const transport = new WebSocketTransport(url);\n await transport.ready();\n return transport;\n }\n trimSlash(s) {\n return s.endsWith(\"/\") ? s.slice(0, -1) : s;\n }\n toWsScheme(base) {\n if (base.startsWith(\"https://\")) return `wss://${base.slice(8)}`;\n if (base.startsWith(\"http://\")) return `ws://${base.slice(7)}`;\n return base;\n }\n};\n//#endregion\n//#region src/uploader/worker/core.ts\nconst IDLE_GRACE_MS = 5e3;\nlet state = { phase: \"init\" };\nconst ports = [];\nlet idleTimer = null;\nfunction cancelIdleTimer() {\n if (idleTimer !== null) {\n clearTimeout(idleTimer);\n idleTimer = null;\n }\n}\nfunction log(msg) {\n for (const handle of ports) if (handle.debugLogs) handle.port.postMessage({\n type: \"log\",\n msg\n });\n}\n/**\n* The first hello \"locks in\" the session's apiUrl/clientSecret. Any later tab arriving\n* with different values is misconfigured — we reject it rather than silently using the\n* locked values. In SharedWorker mode the `name = hash(clientSecret)` scoping already\n* prevents secret-mismatch from sharing a worker, but this defends against the dedicated\n* path and against future bugs.\n*/\nlet lockedConfig = null;\nfunction registerPort(adapter) {\n cancelIdleTimer();\n const handle = {\n port: adapter,\n hello: null,\n debugLogs: false\n };\n ports.push(handle);\n adapter.onmessage((data) => {\n handleMessage(handle, data);\n });\n}\nfunction handleMessage(handle, message) {\n switch (message.type) {\n case \"hello\":\n handle.hello = message;\n handle.debugLogs = message.debugLogs ?? false;\n if (rejectIfIncompatible(handle)) return;\n if (state.phase !== \"dead\" && state.phase !== \"skipping\") detectDuplicateTab(handle);\n onHello(handle);\n return;\n case \"frame\":\n onFrame(message.frame);\n return;\n case \"bye\":\n onBye(handle);\n return;\n default: break;\n }\n}\n/**\n* Reject hellos whose `apiUrl`/`clientSecret` don't match the values established by the\n* first hello. Returns true if the port was rejected (caller should not continue\n* processing this hello).\n*/\nfunction rejectIfIncompatible(handle) {\n if (lockedConfig === null) return false;\n const incoming = handle.hello;\n if (incoming.apiUrl === lockedConfig.apiUrl && incoming.websocketUrl === lockedConfig.websocketUrl && incoming.clientSecret === lockedConfig.clientSecret) return false;\n handle.port.postMessage({\n type: \"dead\",\n reason: \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\"\n });\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n return true;\n}\n/**\n* If another already-connected port has the same `tabId`, this hello is from a duplicate\n* tab (browser \"Duplicate\" command clones sessionStorage). Mint a fresh `tabId` so the two\n* tabs don't collide on the same `(sessionId, tabId)` Recording. The new tabId is returned\n* to the tab in `welcome` so it can update its own state and sessionStorage.\n*/\nfunction detectDuplicateTab(handle) {\n const tabId = handle.hello.tabId;\n if (!ports.some((p) => p !== handle && p.hello?.tabId === tabId)) return;\n const fresh = crypto.randomUUID();\n handle.newTabId = fresh;\n handle.hello.tabId = fresh;\n}\nfunction onHello(handle) {\n switch (state.phase) {\n case \"init\":\n lockedConfig = {\n apiUrl: handle.hello.apiUrl,\n websocketUrl: handle.hello.websocketUrl,\n clientSecret: handle.hello.clientSecret\n };\n log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ?? \"(derive)\"} sessionIdHint=${handle.hello.sessionIdHint ?? \"(none)\"}`);\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n case \"initializing\": return;\n case \"active\":\n sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n return;\n case \"idle\": {\n const { client, sessionId, sessionToken } = state;\n state = { phase: \"initializing\" };\n resumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);\n return;\n }\n case \"skipping\":\n if (handle.hello.forceRecord) {\n log(\"forceRecord set; re-initializing from skipping state\");\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n }\n handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n return;\n case \"dead\":\n handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n return;\n default: break;\n }\n}\nasync function initializeSession(firstHello) {\n const client = new CsrClient(firstHello.apiUrl, firstHello.clientSecret, firstHello.context, firstHello.websocketUrl, log, firstHello.forceRecord);\n if (firstHello.sessionIdHint && firstHello.sessionTokenHint) {\n log(`adopting sessionIdHint=${firstHello.sessionIdHint}`);\n try {\n const transport = await client.openTransport(firstHello.sessionTokenHint);\n wireTransport(transport);\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: firstHello.sessionIdHint,\n sessionToken: firstHello.sessionTokenHint\n };\n log(\"hint adopted; transport open\");\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n return;\n } catch (err) {\n log(`hint rejected (${String(err)}); falling back to fresh init`);\n }\n }\n let result;\n try {\n result = await client.initSession();\n } catch (err) {\n log(`init-session threw: ${String(err)}`);\n transitionToDead(`init-session-failed: ${String(err)}`);\n return;\n }\n if (\"skipRecording\" in result) {\n log(\"init-session: skipRecording\");\n state = { phase: \"skipping\" };\n return;\n }\n log(`init-session ok sessionId=${result.sessionId}`);\n let transport;\n try {\n transport = await client.openTransport(result.sessionToken);\n } catch (err) {\n log(`openTransport threw: ${String(err)}`);\n transitionToDead(`open-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport open; session active\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: result.sessionId,\n sessionToken: result.sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nasync function resumeTransport(client, sessionId, sessionToken) {\n log(\"resuming transport from idle\");\n let transport;\n try {\n transport = await client.openTransport(sessionToken);\n } catch (err) {\n log(`resume-transport threw: ${String(err)}`);\n transitionToDead(`resume-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport resumed\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId,\n sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nfunction wireTransport(transport) {\n transport.onClose((info) => {\n if (state.phase !== \"active\") return;\n transitionToDead(info.reason);\n });\n transport.onStateChange((info) => {\n if (state.phase !== \"active\") return;\n for (const handle of ports) handle.port.postMessage({\n type: \"state\",\n connected: info.connected\n });\n });\n}\nfunction transitionToDead(reason) {\n state = {\n phase: \"dead\",\n reason\n };\n for (const handle of ports) handle.port.postMessage({\n type: \"dead\",\n reason\n });\n}\nfunction flushPendingWelcomes() {\n for (const handle of ports) {\n if (handle.hello === null) continue;\n if (state.phase === \"active\") sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n else if (state.phase === \"skipping\") handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n else if (state.phase === \"dead\") handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n }\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n const hint = handle.hello?.sessionIdHint;\n const adopted = hint !== void 0 && hint !== currentSessionId;\n const newTabId = handle.newTabId;\n handle.port.postMessage({\n type: \"welcome\",\n result: {\n sessionId: currentSessionId,\n sessionToken: currentSessionToken\n },\n adoptedFromSessionId: adopted ? hint : void 0,\n newTabId,\n resetCounter: adopted || newTabId !== void 0\n });\n}\nfunction onFrame(frame) {\n if (state.phase !== \"active\") return;\n state.transport.send(frame);\n}\nfunction onBye(handle) {\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n if (ports.length === 0 && state.phase === \"active\") {\n log(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);\n idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n }\n}\nfunction enterIdle() {\n idleTimer = null;\n if (state.phase !== \"active\" || ports.length > 0) return;\n log(\"idle timeout; closing transport\");\n state.transport.close(\"idle\");\n state = {\n phase: \"idle\",\n client: state.client,\n sessionId: state.sessionId,\n sessionToken: state.sessionToken\n };\n}\n//#endregion\n//#region src/uploader/worker/entry.ts\nconst SharedWorkerScopeCtor = globalThis.SharedWorkerGlobalScope;\nif (typeof SharedWorkerScopeCtor === \"function\" && self instanceof SharedWorkerScopeCtor) self.onconnect = (event) => {\n const port = event.ports[0];\n port.start();\n registerPort(adaptMessagePort(port));\n};\nelse registerPort(adaptDedicatedSelf());\nfunction adaptMessagePort(port) {\n return {\n postMessage: (message) => port.postMessage(message),\n onmessage: (cb) => {\n port.onmessage = (e) => cb(e.data);\n }\n };\n}\nfunction adaptDedicatedSelf() {\n const ws = self;\n return {\n postMessage: (message) => ws.postMessage(message),\n onmessage: (cb) => {\n ws.onmessage = (e) => cb(e.data);\n }\n };\n}\n//#endregion\n");
|
|
13082
|
-
|
|
13083
|
-
|
|
13084
|
-
|
|
13085
|
-
|
|
13086
|
-
|
|
13087
|
-
|
|
13088
|
-
|
|
13089
|
-
|
|
13098
|
+
async function openWorkerPort(mode, workerUrl, workerName, log) {
|
|
13099
|
+
if (workerUrl) return {
|
|
13100
|
+
port: createWorker(mode, workerUrl, workerName),
|
|
13101
|
+
urlScheme: "custom"
|
|
13102
|
+
};
|
|
13103
|
+
const dataUrl = toDataUrl(workerScript);
|
|
13104
|
+
try {
|
|
13105
|
+
const port = createWorker(mode, dataUrl, workerName);
|
|
13106
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
13107
|
+
const asyncErr = port.getBufferedError();
|
|
13108
|
+
if (asyncErr) throw asyncErr;
|
|
13090
13109
|
return {
|
|
13091
|
-
|
|
13092
|
-
|
|
13093
|
-
worker.port.onmessage = (e) => cb(e.data);
|
|
13094
|
-
}
|
|
13110
|
+
port,
|
|
13111
|
+
urlScheme: "data"
|
|
13095
13112
|
};
|
|
13113
|
+
} catch (_dataErr) {
|
|
13114
|
+
log?.("tab: data: worker blocked, falling back to blob: URL (dedicated mode)");
|
|
13115
|
+
const blobUrl = toBlobUrl(workerScript);
|
|
13116
|
+
try {
|
|
13117
|
+
return {
|
|
13118
|
+
port: createDedicatedWorker(blobUrl),
|
|
13119
|
+
urlScheme: "blob"
|
|
13120
|
+
};
|
|
13121
|
+
} catch (blobErr) {
|
|
13122
|
+
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.";
|
|
13123
|
+
log?.(`tab: worker-load-failed: ${hint}`);
|
|
13124
|
+
throw new Error(`worker-load-failed: ${hint}`, blobErr instanceof Error ? { cause: blobErr } : void 0);
|
|
13125
|
+
}
|
|
13096
13126
|
}
|
|
13127
|
+
}
|
|
13128
|
+
function createWorker(mode, url, workerName) {
|
|
13129
|
+
if (mode === "shared") return createSharedWorker(url, workerName);
|
|
13130
|
+
return createDedicatedWorker(url);
|
|
13131
|
+
}
|
|
13132
|
+
function createSharedWorker(url, name) {
|
|
13133
|
+
const options = {
|
|
13134
|
+
name,
|
|
13135
|
+
type: "module",
|
|
13136
|
+
extendedLifetime: true
|
|
13137
|
+
};
|
|
13138
|
+
const worker = new SharedWorker(url, options);
|
|
13139
|
+
let errorCb = null;
|
|
13140
|
+
let bufferedError = null;
|
|
13141
|
+
worker.onerror = (e) => {
|
|
13142
|
+
const err = workerLoadError(e, url);
|
|
13143
|
+
if (errorCb) errorCb(err);
|
|
13144
|
+
else bufferedError = err;
|
|
13145
|
+
};
|
|
13146
|
+
worker.port.start();
|
|
13147
|
+
return {
|
|
13148
|
+
postMessage: (m) => worker.port.postMessage(m),
|
|
13149
|
+
setHandler: (cb) => {
|
|
13150
|
+
worker.port.onmessage = (e) => cb(e.data);
|
|
13151
|
+
},
|
|
13152
|
+
onError: (cb) => {
|
|
13153
|
+
errorCb = cb;
|
|
13154
|
+
if (bufferedError) cb(bufferedError);
|
|
13155
|
+
},
|
|
13156
|
+
getBufferedError: () => bufferedError
|
|
13157
|
+
};
|
|
13158
|
+
}
|
|
13159
|
+
function createDedicatedWorker(url) {
|
|
13097
13160
|
const worker = new Worker(url, { type: "module" });
|
|
13161
|
+
let errorCb = null;
|
|
13162
|
+
let bufferedError = null;
|
|
13163
|
+
worker.onerror = (e) => {
|
|
13164
|
+
const err = workerLoadError(e, url);
|
|
13165
|
+
if (errorCb) errorCb(err);
|
|
13166
|
+
else bufferedError = err;
|
|
13167
|
+
};
|
|
13098
13168
|
return {
|
|
13099
13169
|
postMessage: (m) => worker.postMessage(m),
|
|
13100
13170
|
setHandler: (cb) => {
|
|
13101
13171
|
worker.onmessage = (e) => cb(e.data);
|
|
13102
|
-
}
|
|
13172
|
+
},
|
|
13173
|
+
onError: (cb) => {
|
|
13174
|
+
errorCb = cb;
|
|
13175
|
+
if (bufferedError) cb(bufferedError);
|
|
13176
|
+
},
|
|
13177
|
+
getBufferedError: () => bufferedError
|
|
13103
13178
|
};
|
|
13104
13179
|
}
|
|
13180
|
+
function workerLoadError(cause, url) {
|
|
13181
|
+
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.`}`;
|
|
13182
|
+
return new Error(msg, cause instanceof Error ? { cause } : void 0);
|
|
13183
|
+
}
|
|
13105
13184
|
function toDataUrl(script) {
|
|
13106
13185
|
const bytes = new TextEncoder().encode(script);
|
|
13107
13186
|
let binary = "";
|
|
13108
13187
|
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
13109
13188
|
return `data:application/javascript;base64,${btoa(binary)}`;
|
|
13110
13189
|
}
|
|
13190
|
+
function toBlobUrl(script) {
|
|
13191
|
+
return URL.createObjectURL(new Blob([script], { type: "application/javascript" }));
|
|
13192
|
+
}
|
|
13111
13193
|
async function hashSecret(secret) {
|
|
13112
13194
|
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
|
|
13113
13195
|
return Array.from(new Uint8Array(buf)).slice(0, 8).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -13153,7 +13235,7 @@ function writeCounter(counter) {
|
|
|
13153
13235
|
}
|
|
13154
13236
|
//#endregion
|
|
13155
13237
|
//#region src/version.ts
|
|
13156
|
-
const SDK_VERSION = "0.18.
|
|
13238
|
+
const SDK_VERSION = "0.18.4";
|
|
13157
13239
|
//#endregion
|
|
13158
13240
|
//#region src/index.ts
|
|
13159
13241
|
const DEFAULT_API_URL = "https://recording.confidence.dev";
|
|
@@ -13228,12 +13310,13 @@ function initSessionRecorder(options) {
|
|
|
13228
13310
|
}
|
|
13229
13311
|
};
|
|
13230
13312
|
stopRecorder = record(sendEvent, recordingConfig);
|
|
13231
|
-
stopObservingFlags = observeFlags(({ flagKey, variant }) => {
|
|
13313
|
+
stopObservingFlags = observeFlags(({ flagKey, variant, assignmentOrigin }) => {
|
|
13232
13314
|
const data = {
|
|
13233
13315
|
plugin: "csr:flagEvaluation",
|
|
13234
13316
|
payload: {
|
|
13235
13317
|
flagKey,
|
|
13236
|
-
variant
|
|
13318
|
+
variant,
|
|
13319
|
+
assignmentOrigin
|
|
13237
13320
|
}
|
|
13238
13321
|
};
|
|
13239
13322
|
sendEvent?.({
|
package/dist/index.js
CHANGED
|
@@ -11160,14 +11160,16 @@ function observeFlags(onFlagWrite) {
|
|
|
11160
11160
|
_target[prop] = value;
|
|
11161
11161
|
onFlagWrite({
|
|
11162
11162
|
flagKey: prop,
|
|
11163
|
-
variant: value.variant
|
|
11163
|
+
variant: value.variant,
|
|
11164
|
+
assignmentOrigin: value.assignmentOrigin ?? ""
|
|
11164
11165
|
});
|
|
11165
11166
|
}
|
|
11166
11167
|
return true;
|
|
11167
11168
|
} });
|
|
11168
11169
|
for (const [name, data] of Object.entries(existing)) if (data && typeof data.variant === "string") onFlagWrite({
|
|
11169
11170
|
flagKey: name,
|
|
11170
|
-
variant: data.variant
|
|
11171
|
+
variant: data.variant,
|
|
11172
|
+
assignmentOrigin: data.assignmentOrigin ?? ""
|
|
11171
11173
|
});
|
|
11172
11174
|
return () => {
|
|
11173
11175
|
confidence.flags = { ...target };
|
|
@@ -12915,11 +12917,15 @@ function collectUserAgentContext() {
|
|
|
12915
12917
|
};
|
|
12916
12918
|
}
|
|
12917
12919
|
//#endregion
|
|
12920
|
+
//#region ../csr-common/src/uploader/worker/worker-script.ts
|
|
12921
|
+
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";
|
|
12922
|
+
//#endregion
|
|
12918
12923
|
//#region ../csr-common/src/uploader/create-uploader.ts
|
|
12919
12924
|
const STORAGE_TAB_ID = "csr:tabId";
|
|
12920
12925
|
const STORAGE_SESSION = "csr:session";
|
|
12921
12926
|
const STORAGE_COUNTER = "csr:counter";
|
|
12922
12927
|
const DEFAULT_SESSION_TTL_MS = 1800 * 1e3;
|
|
12928
|
+
const WELCOME_TIMEOUT_MS = 1e4;
|
|
12923
12929
|
async function createUploader(opts) {
|
|
12924
12930
|
const log = opts.debugLogger;
|
|
12925
12931
|
const sessionTtlMs = opts.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
@@ -12928,7 +12934,9 @@ async function createUploader(opts) {
|
|
|
12928
12934
|
const counterHint = readCounter();
|
|
12929
12935
|
const mode = resolveMode(opts.workerMode ?? "auto");
|
|
12930
12936
|
log?.(`tab: createUploader mode=${mode} tabId=${tabId} sessionHint=${sessionHint?.id ?? "(none)"} counterHint=${counterHint}`);
|
|
12931
|
-
const
|
|
12937
|
+
const workerName = mode === "shared" ? await hashSecret(opts.clientSecret) : void 0;
|
|
12938
|
+
const { port, urlScheme } = await openWorkerPort(mode, opts.workerUrl, workerName, log);
|
|
12939
|
+
log?.(`tab: worker loaded via ${urlScheme}${urlScheme === "blob" ? " (fallback — cross-tab sharing unavailable)" : ""}`);
|
|
12932
12940
|
let phase = "awaiting-welcome";
|
|
12933
12941
|
let sessionId = null;
|
|
12934
12942
|
let sessionToken = null;
|
|
@@ -12988,7 +12996,17 @@ async function createUploader(opts) {
|
|
|
12988
12996
|
debugLogs: log !== void 0
|
|
12989
12997
|
});
|
|
12990
12998
|
log?.("tab: hello sent, awaiting welcome");
|
|
12991
|
-
const
|
|
12999
|
+
const timeoutMs = opts._welcomeTimeoutMs ?? WELCOME_TIMEOUT_MS;
|
|
13000
|
+
let welcomeTimer;
|
|
13001
|
+
const welcomeDeadline = new Promise((_, reject) => {
|
|
13002
|
+
port.onError((err) => reject(err));
|
|
13003
|
+
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);
|
|
13004
|
+
});
|
|
13005
|
+
welcomeDeadline.catch(() => {});
|
|
13006
|
+
const welcome = await Promise.race([welcomePromise.then((msg) => {
|
|
13007
|
+
clearTimeout(welcomeTimer);
|
|
13008
|
+
return msg;
|
|
13009
|
+
}), welcomeDeadline]);
|
|
12992
13010
|
log?.(welcome.type === "welcome" ? `tab: welcome (${"sessionId" in welcome.result ? `sessionId=${welcome.result.sessionId}` : "skipRecording"})` : `tab: dead reason=${welcome.reason}`);
|
|
12993
13011
|
if (welcome.type === "dead") throw new Error(`uploader: ${welcome.reason}`);
|
|
12994
13012
|
if ("skipRecording" in welcome.result) {
|
|
@@ -13058,37 +13076,101 @@ function resolveMode(mode) {
|
|
|
13058
13076
|
if (mode === "auto") return typeof SharedWorker !== "undefined" ? "shared" : "dedicated";
|
|
13059
13077
|
return mode;
|
|
13060
13078
|
}
|
|
13061
|
-
async function openWorkerPort(mode,
|
|
13062
|
-
const url = workerUrl ?? toDataUrl("//#region src/uploader/worker/web-socket-transport.ts\n/**\n* WebSocket-backed Transport. Internal retry policy: clean server-initiated close after the\n* first successful open → reconnect and resume; abrupt close (or any close before the first\n* open) → fire `onClose` and stop. Frames received while a (re)connect is in progress are\n* buffered and flushed on open.\n*\n* `ready()` resolves on the first successful open and rejects on close-before-open. Callers\n* should await it before treating the Transport as live, so a failure to open can be caught\n* (e.g. 4404 unknown session) and recovered from.\n*/\nvar WebSocketTransport = class {\n url;\n ws = null;\n onCloseCb = null;\n onStateChangeCb = null;\n intentionallyClosed = false;\n dead = false;\n /** Frames buffered while a (re)connect is in progress. */\n pending = [];\n readyPromise;\n constructor(url) {\n this.url = url;\n this.readyPromise = new Promise((resolve, reject) => {\n this.connect(false, resolve, reject);\n });\n this.readyPromise.catch(() => {});\n }\n ready() {\n return this.readyPromise;\n }\n send(frame) {\n if (this.dead || this.intentionallyClosed) return;\n if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));\n else this.pending.push(frame);\n }\n close(reason = \"transport-close\") {\n this.intentionallyClosed = true;\n this.ws?.close(1e3, reason);\n }\n onClose(cb) {\n this.onCloseCb = cb;\n }\n onStateChange(cb) {\n this.onStateChangeCb = cb;\n }\n connect(isReconnect, onReady, onReadyFail) {\n const ws = new WebSocket(this.url);\n this.ws = ws;\n let opened = false;\n ws.onopen = () => {\n opened = true;\n onReady?.();\n if (isReconnect) this.onStateChangeCb?.({ connected: true });\n while (this.pending.length > 0) {\n const f = this.pending.shift();\n ws.send(JSON.stringify(f));\n }\n };\n ws.onclose = (event) => {\n if (this.intentionallyClosed) return;\n if (!opened) {\n const reason = `${isReconnect ? \"reconnect\" : \"initial\"}-failed code=${event.code}`;\n if (onReadyFail) {\n onReadyFail(new Error(reason));\n this.dead = true;\n } else this.die(reason);\n return;\n }\n if (event.wasClean && (event.code === 1e3 || event.code === 1001)) {\n this.onStateChangeCb?.({ connected: false });\n this.connect(true);\n } else this.die(`close code=${event.code} wasClean=${event.wasClean}`);\n };\n }\n die(reason) {\n this.dead = true;\n this.onCloseCb?.({ reason });\n }\n};\n//#endregion\n//#region src/uploader/worker/csr-client.ts\n/**\n* Single Client implementation that talks to the recording backend's REST + WS protocol.\n* Both dev-server and prod implement the same protocol, so we don't need polymorphism here yet.\n*/\nvar CsrClient = class {\n apiUrl;\n clientSecret;\n context;\n websocketUrl;\n log;\n forceRecord;\n constructor(apiUrl, clientSecret, context, websocketUrl, log = () => {}, forceRecord) {\n this.apiUrl = apiUrl;\n this.clientSecret = clientSecret;\n this.context = context;\n this.websocketUrl = websocketUrl;\n this.log = log;\n this.forceRecord = forceRecord;\n }\n async initSession() {\n const url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;\n this.log(`fetch POST ${url}`);\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n clientSecret: this.clientSecret,\n ...this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {},\n ...this.forceRecord ? { forceRecord: true } : {}\n })\n });\n if (!res.ok) throw new Error(`init-session failed: HTTP ${res.status}`);\n const data = await res.json();\n if (data.skipRecording) return { skipRecording: true };\n if (!data.sessionId || !data.sessionToken) throw new Error(\"init-session response missing sessionId or sessionToken\");\n return {\n sessionId: data.sessionId,\n sessionToken: data.sessionToken\n };\n }\n async openTransport(sessionToken) {\n const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;\n const url = `${wsBase}${wsBase.includes(\"?\") ? \"&\" : \"?\"}session_token=${encodeURIComponent(sessionToken)}`;\n this.log(`WebSocket connect ${url}`);\n const transport = new WebSocketTransport(url);\n await transport.ready();\n return transport;\n }\n trimSlash(s) {\n return s.endsWith(\"/\") ? s.slice(0, -1) : s;\n }\n toWsScheme(base) {\n if (base.startsWith(\"https://\")) return `wss://${base.slice(8)}`;\n if (base.startsWith(\"http://\")) return `ws://${base.slice(7)}`;\n return base;\n }\n};\n//#endregion\n//#region src/uploader/worker/core.ts\nconst IDLE_GRACE_MS = 5e3;\nlet state = { phase: \"init\" };\nconst ports = [];\nlet idleTimer = null;\nfunction cancelIdleTimer() {\n if (idleTimer !== null) {\n clearTimeout(idleTimer);\n idleTimer = null;\n }\n}\nfunction log(msg) {\n for (const handle of ports) if (handle.debugLogs) handle.port.postMessage({\n type: \"log\",\n msg\n });\n}\n/**\n* The first hello \"locks in\" the session's apiUrl/clientSecret. Any later tab arriving\n* with different values is misconfigured — we reject it rather than silently using the\n* locked values. In SharedWorker mode the `name = hash(clientSecret)` scoping already\n* prevents secret-mismatch from sharing a worker, but this defends against the dedicated\n* path and against future bugs.\n*/\nlet lockedConfig = null;\nfunction registerPort(adapter) {\n cancelIdleTimer();\n const handle = {\n port: adapter,\n hello: null,\n debugLogs: false\n };\n ports.push(handle);\n adapter.onmessage((data) => {\n handleMessage(handle, data);\n });\n}\nfunction handleMessage(handle, message) {\n switch (message.type) {\n case \"hello\":\n handle.hello = message;\n handle.debugLogs = message.debugLogs ?? false;\n if (rejectIfIncompatible(handle)) return;\n if (state.phase !== \"dead\" && state.phase !== \"skipping\") detectDuplicateTab(handle);\n onHello(handle);\n return;\n case \"frame\":\n onFrame(message.frame);\n return;\n case \"bye\":\n onBye(handle);\n return;\n default: break;\n }\n}\n/**\n* Reject hellos whose `apiUrl`/`clientSecret` don't match the values established by the\n* first hello. Returns true if the port was rejected (caller should not continue\n* processing this hello).\n*/\nfunction rejectIfIncompatible(handle) {\n if (lockedConfig === null) return false;\n const incoming = handle.hello;\n if (incoming.apiUrl === lockedConfig.apiUrl && incoming.websocketUrl === lockedConfig.websocketUrl && incoming.clientSecret === lockedConfig.clientSecret) return false;\n handle.port.postMessage({\n type: \"dead\",\n reason: \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\"\n });\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n return true;\n}\n/**\n* If another already-connected port has the same `tabId`, this hello is from a duplicate\n* tab (browser \"Duplicate\" command clones sessionStorage). Mint a fresh `tabId` so the two\n* tabs don't collide on the same `(sessionId, tabId)` Recording. The new tabId is returned\n* to the tab in `welcome` so it can update its own state and sessionStorage.\n*/\nfunction detectDuplicateTab(handle) {\n const tabId = handle.hello.tabId;\n if (!ports.some((p) => p !== handle && p.hello?.tabId === tabId)) return;\n const fresh = crypto.randomUUID();\n handle.newTabId = fresh;\n handle.hello.tabId = fresh;\n}\nfunction onHello(handle) {\n switch (state.phase) {\n case \"init\":\n lockedConfig = {\n apiUrl: handle.hello.apiUrl,\n websocketUrl: handle.hello.websocketUrl,\n clientSecret: handle.hello.clientSecret\n };\n log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ?? \"(derive)\"} sessionIdHint=${handle.hello.sessionIdHint ?? \"(none)\"}`);\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n case \"initializing\": return;\n case \"active\":\n sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n return;\n case \"idle\": {\n const { client, sessionId, sessionToken } = state;\n state = { phase: \"initializing\" };\n resumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);\n return;\n }\n case \"skipping\":\n if (handle.hello.forceRecord) {\n log(\"forceRecord set; re-initializing from skipping state\");\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n }\n handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n return;\n case \"dead\":\n handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n return;\n default: break;\n }\n}\nasync function initializeSession(firstHello) {\n const client = new CsrClient(firstHello.apiUrl, firstHello.clientSecret, firstHello.context, firstHello.websocketUrl, log, firstHello.forceRecord);\n if (firstHello.sessionIdHint && firstHello.sessionTokenHint) {\n log(`adopting sessionIdHint=${firstHello.sessionIdHint}`);\n try {\n const transport = await client.openTransport(firstHello.sessionTokenHint);\n wireTransport(transport);\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: firstHello.sessionIdHint,\n sessionToken: firstHello.sessionTokenHint\n };\n log(\"hint adopted; transport open\");\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n return;\n } catch (err) {\n log(`hint rejected (${String(err)}); falling back to fresh init`);\n }\n }\n let result;\n try {\n result = await client.initSession();\n } catch (err) {\n log(`init-session threw: ${String(err)}`);\n transitionToDead(`init-session-failed: ${String(err)}`);\n return;\n }\n if (\"skipRecording\" in result) {\n log(\"init-session: skipRecording\");\n state = { phase: \"skipping\" };\n return;\n }\n log(`init-session ok sessionId=${result.sessionId}`);\n let transport;\n try {\n transport = await client.openTransport(result.sessionToken);\n } catch (err) {\n log(`openTransport threw: ${String(err)}`);\n transitionToDead(`open-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport open; session active\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: result.sessionId,\n sessionToken: result.sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nasync function resumeTransport(client, sessionId, sessionToken) {\n log(\"resuming transport from idle\");\n let transport;\n try {\n transport = await client.openTransport(sessionToken);\n } catch (err) {\n log(`resume-transport threw: ${String(err)}`);\n transitionToDead(`resume-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport resumed\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId,\n sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nfunction wireTransport(transport) {\n transport.onClose((info) => {\n if (state.phase !== \"active\") return;\n transitionToDead(info.reason);\n });\n transport.onStateChange((info) => {\n if (state.phase !== \"active\") return;\n for (const handle of ports) handle.port.postMessage({\n type: \"state\",\n connected: info.connected\n });\n });\n}\nfunction transitionToDead(reason) {\n state = {\n phase: \"dead\",\n reason\n };\n for (const handle of ports) handle.port.postMessage({\n type: \"dead\",\n reason\n });\n}\nfunction flushPendingWelcomes() {\n for (const handle of ports) {\n if (handle.hello === null) continue;\n if (state.phase === \"active\") sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n else if (state.phase === \"skipping\") handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n else if (state.phase === \"dead\") handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n }\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n const hint = handle.hello?.sessionIdHint;\n const adopted = hint !== void 0 && hint !== currentSessionId;\n const newTabId = handle.newTabId;\n handle.port.postMessage({\n type: \"welcome\",\n result: {\n sessionId: currentSessionId,\n sessionToken: currentSessionToken\n },\n adoptedFromSessionId: adopted ? hint : void 0,\n newTabId,\n resetCounter: adopted || newTabId !== void 0\n });\n}\nfunction onFrame(frame) {\n if (state.phase !== \"active\") return;\n state.transport.send(frame);\n}\nfunction onBye(handle) {\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n if (ports.length === 0 && state.phase === \"active\") {\n log(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);\n idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n }\n}\nfunction enterIdle() {\n idleTimer = null;\n if (state.phase !== \"active\" || ports.length > 0) return;\n log(\"idle timeout; closing transport\");\n state.transport.close(\"idle\");\n state = {\n phase: \"idle\",\n client: state.client,\n sessionId: state.sessionId,\n sessionToken: state.sessionToken\n };\n}\n//#endregion\n//#region src/uploader/worker/entry.ts\nconst SharedWorkerScopeCtor = globalThis.SharedWorkerGlobalScope;\nif (typeof SharedWorkerScopeCtor === \"function\" && self instanceof SharedWorkerScopeCtor) self.onconnect = (event) => {\n const port = event.ports[0];\n port.start();\n registerPort(adaptMessagePort(port));\n};\nelse registerPort(adaptDedicatedSelf());\nfunction adaptMessagePort(port) {\n return {\n postMessage: (message) => port.postMessage(message),\n onmessage: (cb) => {\n port.onmessage = (e) => cb(e.data);\n }\n };\n}\nfunction adaptDedicatedSelf() {\n const ws = self;\n return {\n postMessage: (message) => ws.postMessage(message),\n onmessage: (cb) => {\n ws.onmessage = (e) => cb(e.data);\n }\n };\n}\n//#endregion\n");
|
|
13063
|
-
|
|
13064
|
-
|
|
13065
|
-
|
|
13066
|
-
|
|
13067
|
-
|
|
13068
|
-
|
|
13069
|
-
|
|
13070
|
-
|
|
13079
|
+
async function openWorkerPort(mode, workerUrl, workerName, log) {
|
|
13080
|
+
if (workerUrl) return {
|
|
13081
|
+
port: createWorker(mode, workerUrl, workerName),
|
|
13082
|
+
urlScheme: "custom"
|
|
13083
|
+
};
|
|
13084
|
+
const dataUrl = toDataUrl(workerScript);
|
|
13085
|
+
try {
|
|
13086
|
+
const port = createWorker(mode, dataUrl, workerName);
|
|
13087
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
13088
|
+
const asyncErr = port.getBufferedError();
|
|
13089
|
+
if (asyncErr) throw asyncErr;
|
|
13071
13090
|
return {
|
|
13072
|
-
|
|
13073
|
-
|
|
13074
|
-
worker.port.onmessage = (e) => cb(e.data);
|
|
13075
|
-
}
|
|
13091
|
+
port,
|
|
13092
|
+
urlScheme: "data"
|
|
13076
13093
|
};
|
|
13094
|
+
} catch (_dataErr) {
|
|
13095
|
+
log?.("tab: data: worker blocked, falling back to blob: URL (dedicated mode)");
|
|
13096
|
+
const blobUrl = toBlobUrl(workerScript);
|
|
13097
|
+
try {
|
|
13098
|
+
return {
|
|
13099
|
+
port: createDedicatedWorker(blobUrl),
|
|
13100
|
+
urlScheme: "blob"
|
|
13101
|
+
};
|
|
13102
|
+
} catch (blobErr) {
|
|
13103
|
+
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.";
|
|
13104
|
+
log?.(`tab: worker-load-failed: ${hint}`);
|
|
13105
|
+
throw new Error(`worker-load-failed: ${hint}`, blobErr instanceof Error ? { cause: blobErr } : void 0);
|
|
13106
|
+
}
|
|
13077
13107
|
}
|
|
13108
|
+
}
|
|
13109
|
+
function createWorker(mode, url, workerName) {
|
|
13110
|
+
if (mode === "shared") return createSharedWorker(url, workerName);
|
|
13111
|
+
return createDedicatedWorker(url);
|
|
13112
|
+
}
|
|
13113
|
+
function createSharedWorker(url, name) {
|
|
13114
|
+
const options = {
|
|
13115
|
+
name,
|
|
13116
|
+
type: "module",
|
|
13117
|
+
extendedLifetime: true
|
|
13118
|
+
};
|
|
13119
|
+
const worker = new SharedWorker(url, options);
|
|
13120
|
+
let errorCb = null;
|
|
13121
|
+
let bufferedError = null;
|
|
13122
|
+
worker.onerror = (e) => {
|
|
13123
|
+
const err = workerLoadError(e, url);
|
|
13124
|
+
if (errorCb) errorCb(err);
|
|
13125
|
+
else bufferedError = err;
|
|
13126
|
+
};
|
|
13127
|
+
worker.port.start();
|
|
13128
|
+
return {
|
|
13129
|
+
postMessage: (m) => worker.port.postMessage(m),
|
|
13130
|
+
setHandler: (cb) => {
|
|
13131
|
+
worker.port.onmessage = (e) => cb(e.data);
|
|
13132
|
+
},
|
|
13133
|
+
onError: (cb) => {
|
|
13134
|
+
errorCb = cb;
|
|
13135
|
+
if (bufferedError) cb(bufferedError);
|
|
13136
|
+
},
|
|
13137
|
+
getBufferedError: () => bufferedError
|
|
13138
|
+
};
|
|
13139
|
+
}
|
|
13140
|
+
function createDedicatedWorker(url) {
|
|
13078
13141
|
const worker = new Worker(url, { type: "module" });
|
|
13142
|
+
let errorCb = null;
|
|
13143
|
+
let bufferedError = null;
|
|
13144
|
+
worker.onerror = (e) => {
|
|
13145
|
+
const err = workerLoadError(e, url);
|
|
13146
|
+
if (errorCb) errorCb(err);
|
|
13147
|
+
else bufferedError = err;
|
|
13148
|
+
};
|
|
13079
13149
|
return {
|
|
13080
13150
|
postMessage: (m) => worker.postMessage(m),
|
|
13081
13151
|
setHandler: (cb) => {
|
|
13082
13152
|
worker.onmessage = (e) => cb(e.data);
|
|
13083
|
-
}
|
|
13153
|
+
},
|
|
13154
|
+
onError: (cb) => {
|
|
13155
|
+
errorCb = cb;
|
|
13156
|
+
if (bufferedError) cb(bufferedError);
|
|
13157
|
+
},
|
|
13158
|
+
getBufferedError: () => bufferedError
|
|
13084
13159
|
};
|
|
13085
13160
|
}
|
|
13161
|
+
function workerLoadError(cause, url) {
|
|
13162
|
+
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.`}`;
|
|
13163
|
+
return new Error(msg, cause instanceof Error ? { cause } : void 0);
|
|
13164
|
+
}
|
|
13086
13165
|
function toDataUrl(script) {
|
|
13087
13166
|
const bytes = new TextEncoder().encode(script);
|
|
13088
13167
|
let binary = "";
|
|
13089
13168
|
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
13090
13169
|
return `data:application/javascript;base64,${btoa(binary)}`;
|
|
13091
13170
|
}
|
|
13171
|
+
function toBlobUrl(script) {
|
|
13172
|
+
return URL.createObjectURL(new Blob([script], { type: "application/javascript" }));
|
|
13173
|
+
}
|
|
13092
13174
|
async function hashSecret(secret) {
|
|
13093
13175
|
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
|
|
13094
13176
|
return Array.from(new Uint8Array(buf)).slice(0, 8).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -13134,7 +13216,7 @@ function writeCounter(counter) {
|
|
|
13134
13216
|
}
|
|
13135
13217
|
//#endregion
|
|
13136
13218
|
//#region src/version.ts
|
|
13137
|
-
const SDK_VERSION = "0.18.
|
|
13219
|
+
const SDK_VERSION = "0.18.4";
|
|
13138
13220
|
//#endregion
|
|
13139
13221
|
//#region src/index.ts
|
|
13140
13222
|
const DEFAULT_API_URL = "https://recording.confidence.dev";
|
|
@@ -13209,12 +13291,13 @@ function initSessionRecorder(options) {
|
|
|
13209
13291
|
}
|
|
13210
13292
|
};
|
|
13211
13293
|
stopRecorder = record(sendEvent, recordingConfig);
|
|
13212
|
-
stopObservingFlags = observeFlags(({ flagKey, variant }) => {
|
|
13294
|
+
stopObservingFlags = observeFlags(({ flagKey, variant, assignmentOrigin }) => {
|
|
13213
13295
|
const data = {
|
|
13214
13296
|
plugin: "csr:flagEvaluation",
|
|
13215
13297
|
payload: {
|
|
13216
13298
|
flagKey,
|
|
13217
|
-
variant
|
|
13299
|
+
variant,
|
|
13300
|
+
assignmentOrigin
|
|
13218
13301
|
}
|
|
13219
13302
|
};
|
|
13220
13303
|
sendEvent?.({
|
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.4",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/spotify/confidence-sdk-js.git",
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
}
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@spotify-confidence/csr-common": "0.18.
|
|
39
|
-
"@spotify-confidence/csr-recorder": "0.17.
|
|
38
|
+
"@spotify-confidence/csr-common": "0.18.3",
|
|
39
|
+
"@spotify-confidence/csr-recorder": "0.17.9"
|
|
40
40
|
},
|
|
41
41
|
"module": "./dist/index.js",
|
|
42
42
|
"exports": {
|
|
@@ -11,62 +11,71 @@ describe('observeFlags', () => {
|
|
|
11
11
|
const writes: FlagWrite[] = [];
|
|
12
12
|
observeFlags(w => writes.push(w));
|
|
13
13
|
|
|
14
|
-
(window as any).__confidence.flags['my-flag'] = { variant: 'treatment-a' };
|
|
14
|
+
(window as any).__confidence.flags['my-flag'] = { variant: 'treatment-a', assignmentOrigin: 'rule-1' };
|
|
15
15
|
|
|
16
|
-
expect(writes).toEqual([{ flagKey: 'my-flag', variant: 'treatment-a' }]);
|
|
16
|
+
expect(writes).toEqual([{ flagKey: 'my-flag', variant: 'treatment-a', assignmentOrigin: 'rule-1' }]);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
it('emits snapshot entries for pre-existing flags', () => {
|
|
20
20
|
(window as any).__confidence = {
|
|
21
21
|
flags: {
|
|
22
|
-
'flag-a': { variant: 'v1' },
|
|
23
|
-
'flag-b': { variant: 'v2' },
|
|
22
|
+
'flag-a': { variant: 'v1', assignmentOrigin: 'rule-a' },
|
|
23
|
+
'flag-b': { variant: 'v2', assignmentOrigin: 'rule-b' },
|
|
24
24
|
},
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
const writes: FlagWrite[] = [];
|
|
28
28
|
observeFlags(w => writes.push(w));
|
|
29
29
|
|
|
30
|
-
expect(writes).toContainEqual({ flagKey: 'flag-a', variant: 'v1' });
|
|
31
|
-
expect(writes).toContainEqual({ flagKey: 'flag-b', variant: 'v2' });
|
|
30
|
+
expect(writes).toContainEqual({ flagKey: 'flag-a', variant: 'v1', assignmentOrigin: 'rule-a' });
|
|
31
|
+
expect(writes).toContainEqual({ flagKey: 'flag-b', variant: 'v2', assignmentOrigin: 'rule-b' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('defaults assignmentOrigin to empty string when missing', () => {
|
|
35
|
+
const writes: FlagWrite[] = [];
|
|
36
|
+
observeFlags(w => writes.push(w));
|
|
37
|
+
|
|
38
|
+
(window as any).__confidence.flags['my-flag'] = { variant: 'treatment-a' };
|
|
39
|
+
|
|
40
|
+
expect(writes).toEqual([{ flagKey: 'my-flag', variant: 'treatment-a', assignmentOrigin: '' }]);
|
|
32
41
|
});
|
|
33
42
|
|
|
34
43
|
it('observes new writes after reading the snapshot', () => {
|
|
35
44
|
(window as any).__confidence = {
|
|
36
|
-
flags: { existing: { variant: 'old' } },
|
|
45
|
+
flags: { existing: { variant: 'old', assignmentOrigin: 'rule-old' } },
|
|
37
46
|
};
|
|
38
47
|
|
|
39
48
|
const writes: FlagWrite[] = [];
|
|
40
49
|
observeFlags(w => writes.push(w));
|
|
41
50
|
|
|
42
|
-
(window as any).__confidence.flags['new-flag'] = { variant: 'new' };
|
|
51
|
+
(window as any).__confidence.flags['new-flag'] = { variant: 'new', assignmentOrigin: 'rule-new' };
|
|
43
52
|
|
|
44
53
|
expect(writes).toHaveLength(2);
|
|
45
|
-
expect(writes[0]).toEqual({ flagKey: 'existing', variant: 'old' });
|
|
46
|
-
expect(writes[1]).toEqual({ flagKey: 'new-flag', variant: 'new' });
|
|
54
|
+
expect(writes[0]).toEqual({ flagKey: 'existing', variant: 'old', assignmentOrigin: 'rule-old' });
|
|
55
|
+
expect(writes[1]).toEqual({ flagKey: 'new-flag', variant: 'new', assignmentOrigin: 'rule-new' });
|
|
47
56
|
});
|
|
48
57
|
|
|
49
58
|
it('cleanup replaces proxy with plain copy', () => {
|
|
50
59
|
const writes: FlagWrite[] = [];
|
|
51
60
|
const cleanup = observeFlags(w => writes.push(w));
|
|
52
61
|
|
|
53
|
-
(window as any).__confidence.flags['flag-a'] = { variant: 'v1' };
|
|
62
|
+
(window as any).__confidence.flags['flag-a'] = { variant: 'v1', assignmentOrigin: 'rule-1' };
|
|
54
63
|
expect(writes).toHaveLength(1);
|
|
55
64
|
|
|
56
65
|
cleanup();
|
|
57
66
|
|
|
58
|
-
(window as any).__confidence.flags['flag-b'] = { variant: 'v2' };
|
|
67
|
+
(window as any).__confidence.flags['flag-b'] = { variant: 'v2', assignmentOrigin: 'rule-2' };
|
|
59
68
|
expect(writes).toHaveLength(1);
|
|
60
69
|
});
|
|
61
70
|
|
|
62
71
|
it('preserves data after cleanup', () => {
|
|
63
72
|
observeFlags(() => {});
|
|
64
|
-
(window as any).__confidence.flags['my-flag'] = { variant: 'treatment' };
|
|
73
|
+
(window as any).__confidence.flags['my-flag'] = { variant: 'treatment', assignmentOrigin: 'rule-1' };
|
|
65
74
|
|
|
66
75
|
const cleanup = observeFlags(() => {});
|
|
67
76
|
cleanup();
|
|
68
77
|
|
|
69
|
-
expect((window as any).__confidence.flags['my-flag']).toEqual({ variant: 'treatment' });
|
|
78
|
+
expect((window as any).__confidence.flags['my-flag']).toEqual({ variant: 'treatment', assignmentOrigin: 'rule-1' });
|
|
70
79
|
});
|
|
71
80
|
|
|
72
81
|
it('ignores writes with missing variant', () => {
|
package/src/flag-observer.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
export type FlagWrite = { flagKey: string; variant: string };
|
|
1
|
+
export type FlagWrite = { flagKey: string; variant: string; assignmentOrigin: string };
|
|
2
2
|
export type FlagWriteCallback = (write: FlagWrite) => void;
|
|
3
3
|
|
|
4
4
|
export function observeFlags(onFlagWrite: FlagWriteCallback): () => void {
|
|
5
5
|
if (typeof window === 'undefined') return () => {};
|
|
6
6
|
|
|
7
7
|
const confidence = ((window as any).__confidence ??= {});
|
|
8
|
-
const existing: Record<string, { variant: string }> = confidence.flags ?? {};
|
|
9
|
-
const target: Record<string, { variant: string }> = { ...existing };
|
|
8
|
+
const existing: Record<string, { variant: string; assignmentOrigin?: string }> = confidence.flags ?? {};
|
|
9
|
+
const target: Record<string, { variant: string; assignmentOrigin?: string }> = { ...existing };
|
|
10
10
|
|
|
11
11
|
const proxy = new Proxy(target, {
|
|
12
12
|
set(_target, prop, value) {
|
|
13
13
|
if (typeof prop === 'string' && value && typeof value.variant === 'string') {
|
|
14
14
|
_target[prop] = value;
|
|
15
|
-
onFlagWrite({ flagKey: prop, variant: value.variant });
|
|
15
|
+
onFlagWrite({ flagKey: prop, variant: value.variant, assignmentOrigin: value.assignmentOrigin ?? '' });
|
|
16
16
|
}
|
|
17
17
|
return true;
|
|
18
18
|
},
|
|
@@ -22,7 +22,11 @@ export function observeFlags(onFlagWrite: FlagWriteCallback): () => void {
|
|
|
22
22
|
|
|
23
23
|
for (const [name, data] of Object.entries(existing)) {
|
|
24
24
|
if (data && typeof (data as any).variant === 'string') {
|
|
25
|
-
onFlagWrite({
|
|
25
|
+
onFlagWrite({
|
|
26
|
+
flagKey: name,
|
|
27
|
+
variant: (data as any).variant,
|
|
28
|
+
assignmentOrigin: (data as any).assignmentOrigin ?? '',
|
|
29
|
+
});
|
|
26
30
|
}
|
|
27
31
|
}
|
|
28
32
|
|
package/src/index.test.ts
CHANGED
|
@@ -93,6 +93,16 @@ describe('initSessionRecorder', () => {
|
|
|
93
93
|
expect(recorder).toBeDefined();
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
it('surfaces createUploader errors via debugLogger', async () => {
|
|
97
|
+
createUploader.mockRejectedValueOnce(new Error('worker-load-failed: worker-src'));
|
|
98
|
+
|
|
99
|
+
const logger = vi.fn();
|
|
100
|
+
initSessionRecorder({ clientSecret: 'secret', debugLogger: logger });
|
|
101
|
+
await flushPromises();
|
|
102
|
+
|
|
103
|
+
expect(logger).toHaveBeenCalledWith(expect.stringContaining('worker-load-failed'));
|
|
104
|
+
});
|
|
105
|
+
|
|
96
106
|
it('manual mode does not init until start is called', async () => {
|
|
97
107
|
createUploader.mockResolvedValueOnce(mockUploader());
|
|
98
108
|
record.mockReturnValueOnce(() => {});
|
package/src/index.ts
CHANGED
|
@@ -163,10 +163,10 @@ export function initSessionRecorder(options: InitSessionRecorderOptions): Sessio
|
|
|
163
163
|
|
|
164
164
|
stopRecorder = record(sendEvent, recordingConfig);
|
|
165
165
|
|
|
166
|
-
stopObservingFlags = observeFlags(({ flagKey, variant }) => {
|
|
166
|
+
stopObservingFlags = observeFlags(({ flagKey, variant, assignmentOrigin }) => {
|
|
167
167
|
const data: FlagEvaluationPluginData = {
|
|
168
168
|
plugin: 'csr:flagEvaluation',
|
|
169
|
-
payload: { flagKey, variant },
|
|
169
|
+
payload: { flagKey, variant, assignmentOrigin },
|
|
170
170
|
};
|
|
171
171
|
sendEvent?.({
|
|
172
172
|
type: RecordingEventType.Plugin,
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '0.18.
|
|
1
|
+
export const SDK_VERSION = '0.18.4';
|