@spotify-confidence/session-recording 0.17.4 → 0.18.0
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 +29 -0
- package/README.md +7 -6
- package/dist/index.cjs +2 -4
- package/dist/index.d.cts +0 -2
- package/dist/index.d.ts +0 -2
- package/dist/index.js +2 -4
- package/package.json +3 -3
- package/src/index.test.ts +0 -2
- package/src/index.ts +0 -3
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.18.0](https://github.com/spotify/confidence-sdk-js/compare/session-recording-v0.17.4...session-recording-v0.18.0) (2026-06-30)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* **csr:** remove targetingKey from session recording API ([#381](https://github.com/spotify/confidence-sdk-js/issues/381))
|
|
9
|
+
|
|
10
|
+
### 🐛 Bug Fixes
|
|
11
|
+
|
|
12
|
+
* **csr:** use workspace:* for inter-package deps ([#383](https://github.com/spotify/confidence-sdk-js/issues/383)) ([cb46378](https://github.com/spotify/confidence-sdk-js/commit/cb46378716d7c2402e400167dec2e31c1c6ffe4b))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### ✨ New Features
|
|
16
|
+
|
|
17
|
+
* **csr:** remove targetingKey from session recording API ([#381](https://github.com/spotify/confidence-sdk-js/issues/381)) ([4d2a930](https://github.com/spotify/confidence-sdk-js/commit/4d2a9301d700ad8d68cfcf28b9d620f24c55aee5))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### 📚 Documentation
|
|
21
|
+
|
|
22
|
+
* add session recording packages to README ([#380](https://github.com/spotify/confidence-sdk-js/issues/380)) ([10c3541](https://github.com/spotify/confidence-sdk-js/commit/10c354177627e3f6889be09943f9cb49e65ea8e7))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Dependencies
|
|
26
|
+
|
|
27
|
+
* The following workspace dependencies were updated
|
|
28
|
+
* dependencies
|
|
29
|
+
* @spotify-confidence/csr-common bumped to 0.18.0
|
|
30
|
+
* @spotify-confidence/csr-recorder bumped to 0.17.5
|
|
31
|
+
|
|
3
32
|
## [0.17.4](https://github.com/spotify/confidence-sdk-js/compare/session-recording-v0.17.3...session-recording-v0.17.4) (2026-06-24)
|
|
4
33
|
|
|
5
34
|
|
package/README.md
CHANGED
|
@@ -54,8 +54,8 @@ Context lets you attach custom dimensions to the recording session. These are se
|
|
|
54
54
|
```typescript
|
|
55
55
|
const recorder = initSessionRecorder({
|
|
56
56
|
clientSecret: '<your-client-secret>',
|
|
57
|
-
targetingKey: 'user-42',
|
|
58
57
|
context: {
|
|
58
|
+
visitor_id: 'user-42',
|
|
59
59
|
buildVersion: '2.3.1',
|
|
60
60
|
environment: 'production',
|
|
61
61
|
plan: 'premium',
|
|
@@ -64,8 +64,6 @@ const recorder = initSessionRecorder({
|
|
|
64
64
|
});
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
`targetingKey` is the end-user identifier (visitor ID, device ID, etc.). The backend uses it for sampling and targeting decisions — it determines whether this user should be recorded.
|
|
68
|
-
|
|
69
67
|
## Configuration
|
|
70
68
|
|
|
71
69
|
```typescript
|
|
@@ -73,9 +71,8 @@ const recorder = initSessionRecorder({
|
|
|
73
71
|
// Required
|
|
74
72
|
clientSecret: '<your-client-secret>',
|
|
75
73
|
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
context: { buildVersion: '2.3.1' },
|
|
74
|
+
// Context
|
|
75
|
+
context: { visitor_id: 'user-42', buildVersion: '2.3.1' },
|
|
79
76
|
|
|
80
77
|
// Privacy
|
|
81
78
|
maskSelectors: ['.pii'],
|
|
@@ -92,6 +89,10 @@ const recorder = initSessionRecorder({
|
|
|
92
89
|
});
|
|
93
90
|
```
|
|
94
91
|
|
|
92
|
+
## Using with the Confidence flags SDK
|
|
93
|
+
|
|
94
|
+
If you use both session recording and the Confidence SDK (or an OpenFeature provider) for feature flags, try your best to keep the contexts aligned. Aligning the contexts will make sense when using the Confidence app to set up targeting on recording rules and flag rules. Matching contexts will let you set up policies like "record sessions for users in the `beta` environment" or "only record premium users" without surprises.
|
|
95
|
+
|
|
95
96
|
## Route parameterization
|
|
96
97
|
|
|
97
98
|
Routes containing dynamic segments (such as IDs in the URL) are automatically normalized into patterns — for example, `/users/123/profile` becomes `/users/:id/profile`. This ensures that per-page metrics are grouped by route rather than by individual page visit, keeping dashboards meaningful and query performance fast.
|
package/dist/index.cjs
CHANGED
|
@@ -12998,7 +12998,6 @@ async function createUploader(opts) {
|
|
|
12998
12998
|
apiUrl: opts.apiUrl,
|
|
12999
12999
|
websocketUrl: opts.websocketUrl,
|
|
13000
13000
|
clientSecret: opts.clientSecret,
|
|
13001
|
-
targetingKey: opts.targetingKey,
|
|
13002
13001
|
context,
|
|
13003
13002
|
forceRecord: opts.forceRecord,
|
|
13004
13003
|
sessionIdHint: sessionHint?.id,
|
|
@@ -13078,7 +13077,7 @@ function resolveMode(mode) {
|
|
|
13078
13077
|
return mode;
|
|
13079
13078
|
}
|
|
13080
13079
|
async function openWorkerPort(mode, clientSecret, workerUrl) {
|
|
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 targetingKey;\n context;\n websocketUrl;\n log;\n forceRecord;\n constructor(apiUrl, clientSecret, targetingKey, context, websocketUrl, log = () => {}, forceRecord) {\n this.apiUrl = apiUrl;\n this.clientSecret = clientSecret;\n this.targetingKey = targetingKey;\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.targetingKey ? { targetingKey: this.targetingKey } : {},\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.targetingKey, 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");
|
|
13080
|
+
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
13081
|
if (mode === "shared") {
|
|
13083
13082
|
const options = {
|
|
13084
13083
|
name: await hashSecret(clientSecret),
|
|
@@ -13153,7 +13152,7 @@ function writeCounter(counter) {
|
|
|
13153
13152
|
}
|
|
13154
13153
|
//#endregion
|
|
13155
13154
|
//#region src/version.ts
|
|
13156
|
-
const SDK_VERSION = "0.
|
|
13155
|
+
const SDK_VERSION = "0.18.0";
|
|
13157
13156
|
//#endregion
|
|
13158
13157
|
//#region src/index.ts
|
|
13159
13158
|
const DEFAULT_API_URL = "https://recording.confidence.dev";
|
|
@@ -13194,7 +13193,6 @@ function initSessionRecorder(options) {
|
|
|
13194
13193
|
apiUrl: options.apiUrl ?? DEFAULT_API_URL,
|
|
13195
13194
|
websocketUrl: options.websocketUrl ?? DEFAULT_WEBSOCKET_URL,
|
|
13196
13195
|
clientSecret: options.clientSecret,
|
|
13197
|
-
targetingKey: options.targetingKey,
|
|
13198
13196
|
context: {
|
|
13199
13197
|
...options.context,
|
|
13200
13198
|
_csr_sdk_version: SDK_VERSION,
|
package/dist/index.d.cts
CHANGED
|
@@ -37,8 +37,6 @@ interface ClientContext {
|
|
|
37
37
|
interface InitSessionRecorderOptions {
|
|
38
38
|
/** Per-tenant secret. */
|
|
39
39
|
clientSecret: string;
|
|
40
|
-
/** End-user identifier (visitor / device id). */
|
|
41
|
-
targetingKey?: string;
|
|
42
40
|
/** CSS selectors whose text content should be masked. */
|
|
43
41
|
maskSelectors?: string[];
|
|
44
42
|
/** CSS selectors whose subtrees should be blocked (replaced with a placeholder, never serialized). */
|
package/dist/index.d.ts
CHANGED
|
@@ -37,8 +37,6 @@ interface ClientContext {
|
|
|
37
37
|
interface InitSessionRecorderOptions {
|
|
38
38
|
/** Per-tenant secret. */
|
|
39
39
|
clientSecret: string;
|
|
40
|
-
/** End-user identifier (visitor / device id). */
|
|
41
|
-
targetingKey?: string;
|
|
42
40
|
/** CSS selectors whose text content should be masked. */
|
|
43
41
|
maskSelectors?: string[];
|
|
44
42
|
/** CSS selectors whose subtrees should be blocked (replaced with a placeholder, never serialized). */
|
package/dist/index.js
CHANGED
|
@@ -12979,7 +12979,6 @@ async function createUploader(opts) {
|
|
|
12979
12979
|
apiUrl: opts.apiUrl,
|
|
12980
12980
|
websocketUrl: opts.websocketUrl,
|
|
12981
12981
|
clientSecret: opts.clientSecret,
|
|
12982
|
-
targetingKey: opts.targetingKey,
|
|
12983
12982
|
context,
|
|
12984
12983
|
forceRecord: opts.forceRecord,
|
|
12985
12984
|
sessionIdHint: sessionHint?.id,
|
|
@@ -13059,7 +13058,7 @@ function resolveMode(mode) {
|
|
|
13059
13058
|
return mode;
|
|
13060
13059
|
}
|
|
13061
13060
|
async function openWorkerPort(mode, clientSecret, workerUrl) {
|
|
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 targetingKey;\n context;\n websocketUrl;\n log;\n forceRecord;\n constructor(apiUrl, clientSecret, targetingKey, context, websocketUrl, log = () => {}, forceRecord) {\n this.apiUrl = apiUrl;\n this.clientSecret = clientSecret;\n this.targetingKey = targetingKey;\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.targetingKey ? { targetingKey: this.targetingKey } : {},\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.targetingKey, 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");
|
|
13061
|
+
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
13062
|
if (mode === "shared") {
|
|
13064
13063
|
const options = {
|
|
13065
13064
|
name: await hashSecret(clientSecret),
|
|
@@ -13134,7 +13133,7 @@ function writeCounter(counter) {
|
|
|
13134
13133
|
}
|
|
13135
13134
|
//#endregion
|
|
13136
13135
|
//#region src/version.ts
|
|
13137
|
-
const SDK_VERSION = "0.
|
|
13136
|
+
const SDK_VERSION = "0.18.0";
|
|
13138
13137
|
//#endregion
|
|
13139
13138
|
//#region src/index.ts
|
|
13140
13139
|
const DEFAULT_API_URL = "https://recording.confidence.dev";
|
|
@@ -13175,7 +13174,6 @@ function initSessionRecorder(options) {
|
|
|
13175
13174
|
apiUrl: options.apiUrl ?? DEFAULT_API_URL,
|
|
13176
13175
|
websocketUrl: options.websocketUrl ?? DEFAULT_WEBSOCKET_URL,
|
|
13177
13176
|
clientSecret: options.clientSecret,
|
|
13178
|
-
targetingKey: options.targetingKey,
|
|
13179
13177
|
context: {
|
|
13180
13178
|
...options.context,
|
|
13181
13179
|
_csr_sdk_version: SDK_VERSION,
|
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.
|
|
4
|
+
"version": "0.18.0",
|
|
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": "
|
|
39
|
-
"@spotify-confidence/csr-recorder": "
|
|
38
|
+
"@spotify-confidence/csr-common": "0.18.0",
|
|
39
|
+
"@spotify-confidence/csr-recorder": "0.17.5"
|
|
40
40
|
},
|
|
41
41
|
"module": "./dist/index.js",
|
|
42
42
|
"exports": {
|
package/src/index.test.ts
CHANGED
|
@@ -55,7 +55,6 @@ describe('initSessionRecorder', () => {
|
|
|
55
55
|
const ctx = { buildVersion: '2.3.1' };
|
|
56
56
|
initSessionRecorder({
|
|
57
57
|
clientSecret: 'secret',
|
|
58
|
-
targetingKey: 'user-42',
|
|
59
58
|
context: ctx,
|
|
60
59
|
maskSelectors: ['.private'],
|
|
61
60
|
blockSelectors: ['video', '.third-party'],
|
|
@@ -67,7 +66,6 @@ describe('initSessionRecorder', () => {
|
|
|
67
66
|
apiUrl: 'https://recording.confidence.dev',
|
|
68
67
|
websocketUrl: 'wss://recording-ws.confidence.dev/sessions/stream',
|
|
69
68
|
clientSecret: 'secret',
|
|
70
|
-
targetingKey: 'user-42',
|
|
71
69
|
context: ctx,
|
|
72
70
|
});
|
|
73
71
|
expect(record.mock.calls[0][1]).toEqual({
|
package/src/index.ts
CHANGED
|
@@ -18,8 +18,6 @@ const DEFAULT_WEBSOCKET_URL = 'wss://recording-ws.confidence.dev/sessions/stream
|
|
|
18
18
|
export interface InitSessionRecorderOptions {
|
|
19
19
|
/** Per-tenant secret. */
|
|
20
20
|
clientSecret: string;
|
|
21
|
-
/** End-user identifier (visitor / device id). */
|
|
22
|
-
targetingKey?: string;
|
|
23
21
|
/** CSS selectors whose text content should be masked. */
|
|
24
22
|
maskSelectors?: string[];
|
|
25
23
|
/** CSS selectors whose subtrees should be blocked (replaced with a placeholder, never serialized). */
|
|
@@ -119,7 +117,6 @@ export function initSessionRecorder(options: InitSessionRecorderOptions): Sessio
|
|
|
119
117
|
apiUrl: options.apiUrl ?? DEFAULT_API_URL,
|
|
120
118
|
websocketUrl: options.websocketUrl ?? DEFAULT_WEBSOCKET_URL,
|
|
121
119
|
clientSecret: options.clientSecret,
|
|
122
|
-
targetingKey: options.targetingKey,
|
|
123
120
|
context: {
|
|
124
121
|
...options.context,
|
|
125
122
|
_csr_sdk_version: SDK_VERSION,
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '0.
|
|
1
|
+
export const SDK_VERSION = '0.18.0';
|