@spotify-confidence/csr-common 0.18.9 → 0.18.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/confidence-worker.js +53 -14
  3. package/dist/index.cjs +3 -15
  4. package/dist/index.d.cts +4 -2
  5. package/dist/index.d.ts +4 -2
  6. package/dist/index.js +2 -15
  7. package/dist/{session-activity-HfuXKqGd.cjs → session-activity-B8swR5Wg.cjs} +29 -0
  8. package/dist/{session-activity-CJ1wae06.js → session-activity-tshS9Hua.js} +18 -1
  9. package/dist/{types-Bg0UKTVQ.d.cts → types-CdvFpcEN.d.cts} +8 -7
  10. package/dist/{types-Bg0UKTVQ.d.ts → types-CdvFpcEN.d.ts} +8 -7
  11. package/dist/uploader/index.cjs +8 -3
  12. package/dist/uploader/index.d.cts +5 -2
  13. package/dist/uploader/index.d.ts +5 -2
  14. package/dist/uploader/index.js +8 -4
  15. package/package.json +1 -1
  16. package/src/index.ts +1 -1
  17. package/src/test-utils/mock-ws-server.ts +18 -2
  18. package/src/uploader/client-context.test.ts +27 -0
  19. package/src/uploader/client-context.ts +3 -1
  20. package/src/uploader/create-uploader.test.ts +105 -0
  21. package/src/uploader/create-uploader.ts +8 -0
  22. package/src/uploader/index.ts +1 -0
  23. package/src/uploader/types.ts +7 -6
  24. package/src/uploader/worker/core.test.ts +146 -4
  25. package/src/uploader/worker/core.ts +6 -1
  26. package/src/uploader/worker/csr-client.test.ts +33 -18
  27. package/src/uploader/worker/csr-client.ts +13 -4
  28. package/src/uploader/worker/web-socket-transport.test.ts +71 -4
  29. package/src/uploader/worker/web-socket-transport.ts +30 -15
  30. package/src/uploader/worker/websocket-auth.test.ts +29 -0
  31. package/src/uploader/worker/websocket-auth.ts +15 -0
  32. package/src/uploader/worker/worker-script.test.ts +205 -0
  33. package/src/uploader/worker/worker-script.ts +1 -1
  34. package/src/uploader/worker-hash.ts +2 -0
  35. package/src/url.ts +5 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.18.11](https://github.com/spotify/confidence-sdk-js/compare/csr-common-v0.18.10...csr-common-v0.18.11) (2026-09-11)
4
+
5
+
6
+ ### ✨ New Features
7
+
8
+ * **csr:** authenticate recording WebSockets with subprotocols ([#455](https://github.com/spotify/confidence-sdk-js/issues/455)) ([250342f](https://github.com/spotify/confidence-sdk-js/commit/250342f015a70ba12e92ff9725d3c48180047786))
9
+ * **csr:** log version drift between SDK and worker ([#415](https://github.com/spotify/confidence-sdk-js/issues/415)) ([4ce14ee](https://github.com/spotify/confidence-sdk-js/commit/4ce14eee2704fef6e514b1b5a3b1976705ea22ba))
10
+
11
+ ## [0.18.10](https://github.com/spotify/confidence-sdk-js/compare/csr-common-v0.18.9...csr-common-v0.18.10) (2026-09-09)
12
+
13
+
14
+ ### 🐛 Bug Fixes
15
+
16
+ * strip query and hash from recording URLs ([#452](https://github.com/spotify/confidence-sdk-js/issues/452)) ([1ce37f3](https://github.com/spotify/confidence-sdk-js/commit/1ce37f3f43c05bfea4a90839a6de42314096bb40))
17
+
3
18
  ## [0.18.9](https://github.com/spotify/confidence-sdk-js/compare/csr-common-v0.18.8...csr-common-v0.18.9) (2026-09-07)
4
19
 
5
20
 
@@ -1,3 +1,4 @@
1
+ globalThis.__WORKER_HASH__ = '1774b236fa1eb46a';
1
2
  //#region src/uploader/worker/web-socket-transport.ts
2
3
  /**
3
4
  * WebSocket-backed Transport. Internal retry policy: clean server-initiated close after the
@@ -19,8 +20,10 @@ var WebSocketTransport = class {
19
20
  /** Frames buffered while a (re)connect is in progress. */
20
21
  pending = [];
21
22
  readyPromise;
22
- constructor(url) {
23
+ protocols;
24
+ constructor(url, protocols = []) {
23
25
  this.url = url;
26
+ this.protocols = [...protocols];
24
27
  this.readyPromise = new Promise((resolve, reject) => {
25
28
  this.connect(false, resolve, reject);
26
29
  });
@@ -45,10 +48,22 @@ var WebSocketTransport = class {
45
48
  this.onStateChangeCb = cb;
46
49
  }
47
50
  connect(isReconnect, onReady, onReadyFail) {
48
- const ws = new WebSocket(this.url);
51
+ let ws;
52
+ try {
53
+ ws = new WebSocket(this.url, [...this.protocols]);
54
+ } catch (_error) {
55
+ this.failConnection(isReconnect, onReadyFail);
56
+ return;
57
+ }
49
58
  this.ws = ws;
50
59
  let opened = false;
51
60
  ws.onopen = () => {
61
+ const expectedProtocol = this.protocols[0];
62
+ if (expectedProtocol !== void 0 && ws.protocol !== expectedProtocol) {
63
+ this.failConnection(isReconnect, onReadyFail);
64
+ ws.close(1e3, "protocol-mismatch");
65
+ return;
66
+ }
52
67
  opened = true;
53
68
  onReady?.();
54
69
  if (isReconnect) this.onStateChangeCb?.({ connected: true });
@@ -58,13 +73,9 @@ var WebSocketTransport = class {
58
73
  }
59
74
  };
60
75
  ws.onclose = (event) => {
61
- if (this.intentionallyClosed) return;
76
+ if (this.intentionallyClosed || this.dead) return;
62
77
  if (!opened) {
63
- const reason = `${isReconnect ? "reconnect" : "initial"}-failed code=${event.code}`;
64
- if (onReadyFail) {
65
- onReadyFail(new Error(reason));
66
- this.dead = true;
67
- } else this.die(reason);
78
+ this.failConnection(isReconnect, onReadyFail);
68
79
  return;
69
80
  }
70
81
  if (event.wasClean && (event.code === 1e3 || event.code === 1001)) {
@@ -73,12 +84,29 @@ var WebSocketTransport = class {
73
84
  } else this.die(`close code=${event.code} wasClean=${event.wasClean}`);
74
85
  };
75
86
  }
87
+ failConnection(isReconnect, onReadyFail) {
88
+ const reason = isReconnect ? "reconnect-failed" : "initial-failed";
89
+ if (onReadyFail) {
90
+ this.dead = true;
91
+ onReadyFail(new Error(reason));
92
+ } else this.die(reason);
93
+ }
76
94
  die(reason) {
77
95
  this.dead = true;
78
96
  this.onCloseCb?.({ reason });
79
97
  }
80
98
  };
81
99
  //#endregion
100
+ //#region src/uploader/worker/websocket-auth.ts
101
+ const RECORDING_PROTOCOL = "recording.v1";
102
+ const AUTH_PROTOCOL_PREFIX = "auth.";
103
+ const MAX_TOKEN_LENGTH = 4096;
104
+ function recordingProtocols(sessionToken) {
105
+ if (sessionToken.length > MAX_TOKEN_LENGTH) throw new Error("Session token is too long for WebSocket authentication");
106
+ if (!sessionToken || /[^A-Za-z0-9._-]/.test(sessionToken)) throw new Error("Invalid session token for WebSocket authentication");
107
+ return [RECORDING_PROTOCOL, `${AUTH_PROTOCOL_PREFIX}${sessionToken}`];
108
+ }
109
+ //#endregion
82
110
  //#region src/uploader/worker/csr-client.ts
83
111
  /**
84
112
  * Single Client implementation that talks to the recording backend's REST + WS protocol.
@@ -122,9 +150,16 @@ var CsrClient = class {
122
150
  }
123
151
  async openTransport(sessionToken) {
124
152
  const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;
125
- const url = `${wsBase}${wsBase.includes("?") ? "&" : "?"}session_token=${encodeURIComponent(sessionToken)}`;
126
- this.log(`WebSocket connect ${url.replace(/session_token=[^&]*/, "session_token=[REDACTED]")}`);
127
- const transport = new WebSocketTransport(url);
153
+ let parsedUrl;
154
+ try {
155
+ parsedUrl = new URL(wsBase);
156
+ } catch (_error) {
157
+ throw new Error("Invalid WebSocket URL");
158
+ }
159
+ if (parsedUrl.searchParams.has("session_token")) throw new Error("WebSocket URL must not include a session token");
160
+ const protocols = recordingProtocols(sessionToken);
161
+ this.log(`WebSocket connect ${wsBase}`);
162
+ const transport = new WebSocketTransport(wsBase, protocols);
128
163
  await transport.ready();
129
164
  return transport;
130
165
  }
@@ -139,6 +174,7 @@ var CsrClient = class {
139
174
  };
140
175
  //#endregion
141
176
  //#region src/uploader/worker/core.ts
177
+ const WORKER_HASH = globalThis.__WORKER_HASH__;
142
178
  const IDLE_GRACE_MS = 5e3;
143
179
  let state = { phase: "init" };
144
180
  const ports = [];
@@ -231,7 +267,7 @@ function onHello(handle) {
231
267
  websocketUrl: handle.hello.websocketUrl,
232
268
  clientSecret: handle.hello.clientSecret
233
269
  };
234
- log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ?? "(derive)"} sessionIdHint=${handle.hello.sessionIdHint ?? "(none)"}`);
270
+ log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ? "(configured)" : "(derive)"} sessionIdHint=${handle.hello.sessionIdHint ?? "(none)"}`);
235
271
  state = { phase: "initializing" };
236
272
  initializeSession(handle.hello).then(flushPendingWelcomes);
237
273
  return;
@@ -254,7 +290,8 @@ function onHello(handle) {
254
290
  }
255
291
  handle.port.postMessage({
256
292
  type: "welcome",
257
- result: { skipRecording: true }
293
+ result: { skipRecording: true },
294
+ workerHash: WORKER_HASH
258
295
  });
259
296
  return;
260
297
  case "dead":
@@ -370,7 +407,8 @@ function flushPendingWelcomes() {
370
407
  if (state.phase === "active") sendActiveWelcome(handle, state.sessionId, state.sessionToken);
371
408
  else if (state.phase === "skipping") handle.port.postMessage({
372
409
  type: "welcome",
373
- result: { skipRecording: true }
410
+ result: { skipRecording: true },
411
+ workerHash: WORKER_HASH
374
412
  });
375
413
  else if (state.phase === "dead") handle.port.postMessage({
376
414
  type: "dead",
@@ -388,6 +426,7 @@ function sendActiveWelcome(handle, currentSessionId, currentSessionToken) {
388
426
  sessionId: currentSessionId,
389
427
  sessionToken: currentSessionToken
390
428
  },
429
+ workerHash: WORKER_HASH,
391
430
  adoptedFromSessionId: adopted ? hint : void 0,
392
431
  newTabId,
393
432
  resetCounter: adopted || newTabId !== void 0
package/dist/index.cjs CHANGED
@@ -1,18 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_session_activity = require("./session-activity-HfuXKqGd.cjs");
3
- //#region src/url.ts
4
- /**
5
- * Extract only the pathname from a URL, stripping origin, query string, and
6
- * hash. Used across recorder and analyzer to avoid capturing PII in route data.
7
- */
8
- function stripUrl(url) {
9
- try {
10
- return new URL(url).pathname;
11
- } catch (_e) {
12
- return url;
13
- }
14
- }
15
- //#endregion
2
+ const require_session_activity = require("./session-activity-B8swR5Wg.cjs");
16
3
  //#region src/custom-event-limits.ts
17
4
  const MAX_KEY_LENGTH = 128;
18
5
  const MAX_TAG_VALUE_LENGTH = 256;
@@ -51,7 +38,8 @@ exports.SerializedNodeType = require_session_activity.SerializedNodeType;
51
38
  exports.isSessionActivityEvent = require_session_activity.isSessionActivityEvent;
52
39
  exports.isUserInteractionEvent = require_session_activity.isUserInteractionEvent;
53
40
  exports.isUserInteractionMetric = require_session_activity.isUserInteractionMetric;
54
- exports.stripUrl = stripUrl;
41
+ exports.stripUrl = require_session_activity.stripUrl;
42
+ exports.stripUrlQueryAndHash = require_session_activity.stripUrlQueryAndHash;
55
43
  exports.validateKey = validateKey;
56
44
  exports.validateMeasureValue = validateMeasureValue;
57
45
  exports.validateTagValue = validateTagValue;
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { i as ClientContext, n as Frame, o as UserAgentContext } from "./types-Bg0UKTVQ.cjs";
1
+ import { i as ClientContext, n as Frame, o as UserAgentContext } from "./types-CdvFpcEN.cjs";
2
2
 
3
3
  //#region src/events.d.ts
4
4
  /**
@@ -370,6 +370,8 @@ type RecordingEvent = {
370
370
  };
371
371
  //#endregion
372
372
  //#region src/url.d.ts
373
+ /** Remove query strings and fragments, including from relative or malformed URLs. */
374
+ declare function stripUrlQueryAndHash(url: string): string;
373
375
  /**
374
376
  * Extract only the pathname from a URL, stripping origin, query string, and
375
377
  * hash. Used across recorder and analyzer to avoid capturing PII in route data.
@@ -407,4 +409,4 @@ declare function validateKey(key: string): string | null;
407
409
  declare function validateTagValue(value: string | undefined): string | null;
408
410
  declare function validateMeasureValue(value: number | undefined): string | null;
409
411
  //#endregion
410
- export { type AwayGapCustomData, type ClickCustomData, type ClientContext, type ClipboardAction, type ClipboardPluginData, type ConsoleLogLevel, type ConsoleLogPluginData, type CustomEventData, type DeadClickCustomData, type DialogOpenedCustomData, type ElementDescriptor, type ErrorMessageCustomData, type FlagEvaluationPluginData, type FormFieldReEditCustomData, type Frame, type GraphQLRequestMetadata, type IdleGapCustomData, type IncrementalSnapshotData, IncrementalSource, type InputCustomData, MAX_DISTINCT_KEYS, MAX_KEY_LENGTH, MAX_TAG_VALUE_LENGTH, MAX_VALUES_PER_KEY, type MeasurePluginData, type MouseInteractionData, MouseInteractions, type NetworkRequestInitiator, type NetworkRequestPluginData, type OpaqueIncrementalData, type PluginEventData, type RageClickCustomData, RecordingCustomEventTag, type RecordingEvent, RecordingEventType, RecordingMetricKey, RecordingPluginName, type RouteChangeCustomData, type RouteChangePayload, type RouteChangePluginData, type RouteChangeTrigger, type ScrollBackCustomData, type SelectionData, type SelectionRange, SerializedNodeType, type TabRefocusCustomData, type TabUnfocusCustomData, type TabVisibilityPluginData, type TagPluginData, type UserAgentContext, isSessionActivityEvent, isUserInteractionEvent, isUserInteractionMetric, stripUrl, validateKey, validateMeasureValue, validateTagValue };
412
+ export { type AwayGapCustomData, type ClickCustomData, type ClientContext, type ClipboardAction, type ClipboardPluginData, type ConsoleLogLevel, type ConsoleLogPluginData, type CustomEventData, type DeadClickCustomData, type DialogOpenedCustomData, type ElementDescriptor, type ErrorMessageCustomData, type FlagEvaluationPluginData, type FormFieldReEditCustomData, type Frame, type GraphQLRequestMetadata, type IdleGapCustomData, type IncrementalSnapshotData, IncrementalSource, type InputCustomData, MAX_DISTINCT_KEYS, MAX_KEY_LENGTH, MAX_TAG_VALUE_LENGTH, MAX_VALUES_PER_KEY, type MeasurePluginData, type MouseInteractionData, MouseInteractions, type NetworkRequestInitiator, type NetworkRequestPluginData, type OpaqueIncrementalData, type PluginEventData, type RageClickCustomData, RecordingCustomEventTag, type RecordingEvent, RecordingEventType, RecordingMetricKey, RecordingPluginName, type RouteChangeCustomData, type RouteChangePayload, type RouteChangePluginData, type RouteChangeTrigger, type ScrollBackCustomData, type SelectionData, type SelectionRange, SerializedNodeType, type TabRefocusCustomData, type TabUnfocusCustomData, type TabVisibilityPluginData, type TagPluginData, type UserAgentContext, isSessionActivityEvent, isUserInteractionEvent, isUserInteractionMetric, stripUrl, stripUrlQueryAndHash, validateKey, validateMeasureValue, validateTagValue };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { i as ClientContext, n as Frame, o as UserAgentContext } from "./types-Bg0UKTVQ.js";
1
+ import { i as ClientContext, n as Frame, o as UserAgentContext } from "./types-CdvFpcEN.js";
2
2
 
3
3
  //#region src/events.d.ts
4
4
  /**
@@ -370,6 +370,8 @@ type RecordingEvent = {
370
370
  };
371
371
  //#endregion
372
372
  //#region src/url.d.ts
373
+ /** Remove query strings and fragments, including from relative or malformed URLs. */
374
+ declare function stripUrlQueryAndHash(url: string): string;
373
375
  /**
374
376
  * Extract only the pathname from a URL, stripping origin, query string, and
375
377
  * hash. Used across recorder and analyzer to avoid capturing PII in route data.
@@ -407,4 +409,4 @@ declare function validateKey(key: string): string | null;
407
409
  declare function validateTagValue(value: string | undefined): string | null;
408
410
  declare function validateMeasureValue(value: number | undefined): string | null;
409
411
  //#endregion
410
- export { type AwayGapCustomData, type ClickCustomData, type ClientContext, type ClipboardAction, type ClipboardPluginData, type ConsoleLogLevel, type ConsoleLogPluginData, type CustomEventData, type DeadClickCustomData, type DialogOpenedCustomData, type ElementDescriptor, type ErrorMessageCustomData, type FlagEvaluationPluginData, type FormFieldReEditCustomData, type Frame, type GraphQLRequestMetadata, type IdleGapCustomData, type IncrementalSnapshotData, IncrementalSource, type InputCustomData, MAX_DISTINCT_KEYS, MAX_KEY_LENGTH, MAX_TAG_VALUE_LENGTH, MAX_VALUES_PER_KEY, type MeasurePluginData, type MouseInteractionData, MouseInteractions, type NetworkRequestInitiator, type NetworkRequestPluginData, type OpaqueIncrementalData, type PluginEventData, type RageClickCustomData, RecordingCustomEventTag, type RecordingEvent, RecordingEventType, RecordingMetricKey, RecordingPluginName, type RouteChangeCustomData, type RouteChangePayload, type RouteChangePluginData, type RouteChangeTrigger, type ScrollBackCustomData, type SelectionData, type SelectionRange, SerializedNodeType, type TabRefocusCustomData, type TabUnfocusCustomData, type TabVisibilityPluginData, type TagPluginData, type UserAgentContext, isSessionActivityEvent, isUserInteractionEvent, isUserInteractionMetric, stripUrl, validateKey, validateMeasureValue, validateTagValue };
412
+ export { type AwayGapCustomData, type ClickCustomData, type ClientContext, type ClipboardAction, type ClipboardPluginData, type ConsoleLogLevel, type ConsoleLogPluginData, type CustomEventData, type DeadClickCustomData, type DialogOpenedCustomData, type ElementDescriptor, type ErrorMessageCustomData, type FlagEvaluationPluginData, type FormFieldReEditCustomData, type Frame, type GraphQLRequestMetadata, type IdleGapCustomData, type IncrementalSnapshotData, IncrementalSource, type InputCustomData, MAX_DISTINCT_KEYS, MAX_KEY_LENGTH, MAX_TAG_VALUE_LENGTH, MAX_VALUES_PER_KEY, type MeasurePluginData, type MouseInteractionData, MouseInteractions, type NetworkRequestInitiator, type NetworkRequestPluginData, type OpaqueIncrementalData, type PluginEventData, type RageClickCustomData, RecordingCustomEventTag, type RecordingEvent, RecordingEventType, RecordingMetricKey, RecordingPluginName, type RouteChangeCustomData, type RouteChangePayload, type RouteChangePluginData, type RouteChangeTrigger, type ScrollBackCustomData, type SelectionData, type SelectionRange, SerializedNodeType, type TabRefocusCustomData, type TabUnfocusCustomData, type TabVisibilityPluginData, type TagPluginData, type UserAgentContext, isSessionActivityEvent, isUserInteractionEvent, isUserInteractionMetric, stripUrl, stripUrlQueryAndHash, validateKey, validateMeasureValue, validateTagValue };
package/dist/index.js CHANGED
@@ -1,17 +1,4 @@
1
- import { a as IncrementalSource, c as RecordingEventType, i as RecordingMetricKey, l as RecordingPluginName, n as isUserInteractionEvent, o as MouseInteractions, r as isUserInteractionMetric, s as RecordingCustomEventTag, t as isSessionActivityEvent, u as SerializedNodeType } from "./session-activity-CJ1wae06.js";
2
- //#region src/url.ts
3
- /**
4
- * Extract only the pathname from a URL, stripping origin, query string, and
5
- * hash. Used across recorder and analyzer to avoid capturing PII in route data.
6
- */
7
- function stripUrl(url) {
8
- try {
9
- return new URL(url).pathname;
10
- } catch (_e) {
11
- return url;
12
- }
13
- }
14
- //#endregion
1
+ import { a as stripUrl, c as MouseInteractions, d as RecordingPluginName, f as SerializedNodeType, i as RecordingMetricKey, l as RecordingCustomEventTag, n as isUserInteractionEvent, o as stripUrlQueryAndHash, r as isUserInteractionMetric, s as IncrementalSource, t as isSessionActivityEvent, u as RecordingEventType } from "./session-activity-tshS9Hua.js";
15
2
  //#region src/custom-event-limits.ts
16
3
  const MAX_KEY_LENGTH = 128;
17
4
  const MAX_TAG_VALUE_LENGTH = 256;
@@ -36,4 +23,4 @@ function validateMeasureValue(value) {
36
23
  return null;
37
24
  }
38
25
  //#endregion
39
- export { IncrementalSource, MAX_DISTINCT_KEYS, MAX_KEY_LENGTH, MAX_TAG_VALUE_LENGTH, MAX_VALUES_PER_KEY, MouseInteractions, RecordingCustomEventTag, RecordingEventType, RecordingMetricKey, RecordingPluginName, SerializedNodeType, isSessionActivityEvent, isUserInteractionEvent, isUserInteractionMetric, stripUrl, validateKey, validateMeasureValue, validateTagValue };
26
+ export { IncrementalSource, MAX_DISTINCT_KEYS, MAX_KEY_LENGTH, MAX_TAG_VALUE_LENGTH, MAX_VALUES_PER_KEY, MouseInteractions, RecordingCustomEventTag, RecordingEventType, RecordingMetricKey, RecordingPluginName, SerializedNodeType, isSessionActivityEvent, isUserInteractionEvent, isUserInteractionMetric, stripUrl, stripUrlQueryAndHash, validateKey, validateMeasureValue, validateTagValue };
@@ -91,6 +91,23 @@ let MouseInteractions = /* @__PURE__ */ function(MouseInteractions) {
91
91
  return MouseInteractions;
92
92
  }({});
93
93
  //#endregion
94
+ //#region src/url.ts
95
+ /** Remove query strings and fragments, including from relative or malformed URLs. */
96
+ function stripUrlQueryAndHash(url) {
97
+ return url.split(/[?#]/, 1)[0];
98
+ }
99
+ /**
100
+ * Extract only the pathname from a URL, stripping origin, query string, and
101
+ * hash. Used across recorder and analyzer to avoid capturing PII in route data.
102
+ */
103
+ function stripUrl(url) {
104
+ try {
105
+ return new URL(url).pathname;
106
+ } catch (_e) {
107
+ return url;
108
+ }
109
+ }
110
+ //#endregion
94
111
  //#region src/metrics.ts
95
112
  const RecordingMetricKey = {
96
113
  Click: "clicks",
@@ -210,3 +227,15 @@ Object.defineProperty(exports, "isUserInteractionMetric", {
210
227
  return isUserInteractionMetric;
211
228
  }
212
229
  });
230
+ Object.defineProperty(exports, "stripUrl", {
231
+ enumerable: true,
232
+ get: function() {
233
+ return stripUrl;
234
+ }
235
+ });
236
+ Object.defineProperty(exports, "stripUrlQueryAndHash", {
237
+ enumerable: true,
238
+ get: function() {
239
+ return stripUrlQueryAndHash;
240
+ }
241
+ });
@@ -91,6 +91,23 @@ let MouseInteractions = /* @__PURE__ */ function(MouseInteractions) {
91
91
  return MouseInteractions;
92
92
  }({});
93
93
  //#endregion
94
+ //#region src/url.ts
95
+ /** Remove query strings and fragments, including from relative or malformed URLs. */
96
+ function stripUrlQueryAndHash(url) {
97
+ return url.split(/[?#]/, 1)[0];
98
+ }
99
+ /**
100
+ * Extract only the pathname from a URL, stripping origin, query string, and
101
+ * hash. Used across recorder and analyzer to avoid capturing PII in route data.
102
+ */
103
+ function stripUrl(url) {
104
+ try {
105
+ return new URL(url).pathname;
106
+ } catch (_e) {
107
+ return url;
108
+ }
109
+ }
110
+ //#endregion
94
111
  //#region src/metrics.ts
95
112
  const RecordingMetricKey = {
96
113
  Click: "clicks",
@@ -150,4 +167,4 @@ const isUserInteractionEvent = (event) => {
150
167
  const isUserInteractionMetric = (metricKey) => USER_INTERACTION_METRIC_KEYS.has(metricKey);
151
168
  const isSessionActivityEvent = (event) => isObject(event) && event.type === 4 || isUserInteractionEvent(event);
152
169
  //#endregion
153
- export { IncrementalSource as a, RecordingEventType as c, RecordingMetricKey as i, RecordingPluginName as l, isUserInteractionEvent as n, MouseInteractions as o, isUserInteractionMetric as r, RecordingCustomEventTag as s, isSessionActivityEvent as t, SerializedNodeType as u };
170
+ export { stripUrl as a, MouseInteractions as c, RecordingPluginName as d, SerializedNodeType as f, RecordingMetricKey as i, RecordingCustomEventTag as l, isUserInteractionEvent as n, stripUrlQueryAndHash as o, isUserInteractionMetric as r, IncrementalSource as s, isSessionActivityEvent as t, RecordingEventType as u };
@@ -22,7 +22,7 @@ type UserAgentContext = {
22
22
  screenWidth?: number;
23
23
  screenHeight?: number;
24
24
  devicePixelRatio?: number; /** Initial document URI — without query/hash to avoid leaking PII. */
25
- uri?: string;
25
+ uri?: string; /** Document referrer — without query/hash to avoid leaking PII. */
26
26
  referrer?: string;
27
27
  };
28
28
  interface ClientContext {
@@ -41,11 +41,12 @@ interface CreateUploaderOptions {
41
41
  apiUrl: string;
42
42
  /**
43
43
  * URL of the WebSocket ingest endpoint, including the path (e.g.
44
- * `wss://recording-ws.confidence.dev/sessions/stream`) but **without** any query
45
- * the worker appends `?session_token=…`. Optional: when omitted the worker derives
46
- * one from `apiUrl` by swapping `http(s)://` `ws(s)://` and appending
47
- * `/sessions/stream`. Set this when the init endpoint and the WS ingest live on
48
- * different hosts (e.g. prod).
44
+ * `wss://recording-ws.confidence.dev/sessions/stream`). The worker sends the session
45
+ * token through the WebSocket subprotocol header. A `session_token` query parameter is
46
+ * rejected. Other query parameters are retained. Optional: when omitted the worker
47
+ * derives one from `apiUrl` by swapping `http(s)://` `ws(s)://` and appending
48
+ * `/sessions/stream`. Set this when the init endpoint and the WS ingest live on different
49
+ * hosts (e.g. prod).
49
50
  */
50
51
  websocketUrl?: string;
51
52
  /** Per-tenant secret. Hashed to scope the SharedWorker so different secrets never share a session, and sent in the `initSession` request body. */
@@ -88,7 +89,7 @@ interface CreateUploaderOptions {
88
89
  }) => void;
89
90
  /**
90
91
  * Optional verbose tracer. Called on key tab- and worker-side events
91
- * (hello/welcome, init-session URL, ws connect URL, retries, transitions).
92
+ * (hello/welcome, init-session URL, credential-free ws connect URL, retries, transitions).
92
93
  * Worker messages are forwarded over the port and tagged so you can tell them apart.
93
94
  */
94
95
  debugLogger?: (msg: string) => void;
@@ -22,7 +22,7 @@ type UserAgentContext = {
22
22
  screenWidth?: number;
23
23
  screenHeight?: number;
24
24
  devicePixelRatio?: number; /** Initial document URI — without query/hash to avoid leaking PII. */
25
- uri?: string;
25
+ uri?: string; /** Document referrer — without query/hash to avoid leaking PII. */
26
26
  referrer?: string;
27
27
  };
28
28
  interface ClientContext {
@@ -41,11 +41,12 @@ interface CreateUploaderOptions {
41
41
  apiUrl: string;
42
42
  /**
43
43
  * URL of the WebSocket ingest endpoint, including the path (e.g.
44
- * `wss://recording-ws.confidence.dev/sessions/stream`) but **without** any query
45
- * the worker appends `?session_token=…`. Optional: when omitted the worker derives
46
- * one from `apiUrl` by swapping `http(s)://` `ws(s)://` and appending
47
- * `/sessions/stream`. Set this when the init endpoint and the WS ingest live on
48
- * different hosts (e.g. prod).
44
+ * `wss://recording-ws.confidence.dev/sessions/stream`). The worker sends the session
45
+ * token through the WebSocket subprotocol header. A `session_token` query parameter is
46
+ * rejected. Other query parameters are retained. Optional: when omitted the worker
47
+ * derives one from `apiUrl` by swapping `http(s)://` `ws(s)://` and appending
48
+ * `/sessions/stream`. Set this when the init endpoint and the WS ingest live on different
49
+ * hosts (e.g. prod).
49
50
  */
50
51
  websocketUrl?: string;
51
52
  /** Per-tenant secret. Hashed to scope the SharedWorker so different secrets never share a session, and sent in the `initSession` request body. */
@@ -88,7 +89,7 @@ interface CreateUploaderOptions {
88
89
  }) => void;
89
90
  /**
90
91
  * Optional verbose tracer. Called on key tab- and worker-side events
91
- * (hello/welcome, init-session URL, ws connect URL, retries, transitions).
92
+ * (hello/welcome, init-session URL, credential-free ws connect URL, retries, transitions).
92
93
  * Worker messages are forwarded over the port and tagged so you can tell them apart.
93
94
  */
94
95
  debugLogger?: (msg: string) => void;
@@ -21,7 +21,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  enumerable: true
22
22
  }) : target, mod));
23
23
  //#endregion
24
- const require_session_activity = require("../session-activity-HfuXKqGd.cjs");
24
+ const require_session_activity = require("../session-activity-B8swR5Wg.cjs");
25
25
  let bowser = require("bowser");
26
26
  bowser = __toESM(bowser, 1);
27
27
  //#region src/uploader/client-context.ts
@@ -46,12 +46,15 @@ function collectUserAgentContext() {
46
46
  screenHeight: window.screen.height,
47
47
  devicePixelRatio: window.devicePixelRatio,
48
48
  uri: `${window.location.origin}${window.location.pathname}`,
49
- referrer: document.referrer
49
+ referrer: require_session_activity.stripUrlQueryAndHash(document.referrer)
50
50
  };
51
51
  }
52
52
  //#endregion
53
53
  //#region src/uploader/worker/worker-script.ts
54
- const workerScript = "//#region src/uploader/worker/web-socket-transport.ts\n/**\n* WebSocket-backed Transport. Internal retry policy: clean server-initiated close after the\n* first successful open → reconnect and resume; abrupt close (or any close before the first\n* open) → fire `onClose` and stop. Frames received while a (re)connect is in progress are\n* buffered and flushed on open.\n*\n* `ready()` resolves on the first successful open and rejects on close-before-open. Callers\n* should await it before treating the Transport as live, so a failure to open can be caught\n* (e.g. 4404 unknown session) and recovered from.\n*/\nvar WebSocketTransport = class {\n url;\n ws = null;\n onCloseCb = null;\n onStateChangeCb = null;\n intentionallyClosed = false;\n dead = false;\n /** Frames buffered while a (re)connect is in progress. */\n pending = [];\n readyPromise;\n constructor(url) {\n this.url = url;\n this.readyPromise = new Promise((resolve, reject) => {\n this.connect(false, resolve, reject);\n });\n this.readyPromise.catch(() => {});\n }\n ready() {\n return this.readyPromise;\n }\n send(frame) {\n if (this.dead || this.intentionallyClosed) return;\n if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));\n else this.pending.push(frame);\n }\n close(reason = \"transport-close\") {\n this.intentionallyClosed = true;\n this.ws?.close(1e3, reason);\n }\n onClose(cb) {\n this.onCloseCb = cb;\n }\n onStateChange(cb) {\n this.onStateChangeCb = cb;\n }\n connect(isReconnect, onReady, onReadyFail) {\n const ws = new WebSocket(this.url);\n this.ws = ws;\n let opened = false;\n ws.onopen = () => {\n opened = true;\n onReady?.();\n if (isReconnect) this.onStateChangeCb?.({ connected: true });\n while (this.pending.length > 0) {\n const f = this.pending.shift();\n ws.send(JSON.stringify(f));\n }\n };\n ws.onclose = (event) => {\n if (this.intentionallyClosed) return;\n if (!opened) {\n const reason = `${isReconnect ? \"reconnect\" : \"initial\"}-failed code=${event.code}`;\n if (onReadyFail) {\n onReadyFail(new Error(reason));\n this.dead = true;\n } else this.die(reason);\n return;\n }\n if (event.wasClean && (event.code === 1e3 || event.code === 1001)) {\n this.onStateChangeCb?.({ connected: false });\n this.connect(true);\n } else this.die(`close code=${event.code} wasClean=${event.wasClean}`);\n };\n }\n die(reason) {\n this.dead = true;\n this.onCloseCb?.({ reason });\n }\n};\n//#endregion\n//#region src/uploader/worker/csr-client.ts\n/**\n* Single Client implementation that talks to the recording backend's REST + WS protocol.\n* Both dev-server and prod implement the same protocol, so we don't need polymorphism here yet.\n*/\nvar CsrClient = class {\n apiUrl;\n clientSecret;\n context;\n websocketUrl;\n log;\n forceRecord;\n constructor(apiUrl, clientSecret, context, websocketUrl, log = () => {}, forceRecord) {\n this.apiUrl = apiUrl;\n this.clientSecret = clientSecret;\n this.context = context;\n this.websocketUrl = websocketUrl;\n this.log = log;\n this.forceRecord = forceRecord;\n }\n async initSession() {\n const url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;\n this.log(`fetch POST ${url}`);\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n clientSecret: this.clientSecret,\n ...this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {},\n ...this.forceRecord ? { forceRecord: true } : {}\n })\n });\n if (!res.ok) throw new Error(`init-session failed: HTTP ${res.status}`);\n const data = await res.json();\n if (data.skipRecording) return { skipRecording: true };\n if (!data.sessionId || !data.sessionToken) throw new Error(\"init-session response missing sessionId or sessionToken\");\n return {\n sessionId: data.sessionId,\n sessionToken: data.sessionToken\n };\n }\n async openTransport(sessionToken) {\n const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;\n const url = `${wsBase}${wsBase.includes(\"?\") ? \"&\" : \"?\"}session_token=${encodeURIComponent(sessionToken)}`;\n this.log(`WebSocket connect ${url.replace(/session_token=[^&]*/, \"session_token=[REDACTED]\")}`);\n const transport = new WebSocketTransport(url);\n await transport.ready();\n return transport;\n }\n trimSlash(s) {\n return s.endsWith(\"/\") ? s.slice(0, -1) : s;\n }\n toWsScheme(base) {\n if (base.startsWith(\"https://\")) return `wss://${base.slice(8)}`;\n if (base.startsWith(\"http://\")) return `ws://${base.slice(7)}`;\n return base;\n }\n};\n//#endregion\n//#region src/uploader/worker/core.ts\nconst IDLE_GRACE_MS = 5e3;\nlet state = { phase: \"init\" };\nconst ports = [];\nlet idleTimer = null;\nfunction cancelIdleTimer() {\n if (idleTimer !== null) {\n clearTimeout(idleTimer);\n idleTimer = null;\n }\n}\nfunction log(msg) {\n for (const handle of ports) if (handle.debugLogs) handle.port.postMessage({\n type: \"log\",\n msg\n });\n}\n/**\n* The first hello \"locks in\" the session's apiUrl/clientSecret. Any later tab arriving\n* with different values is misconfigured — we reject it rather than silently using the\n* locked values. In SharedWorker mode the `name = hash(clientSecret)` scoping already\n* prevents secret-mismatch from sharing a worker, but this defends against the dedicated\n* path and against future bugs.\n*/\nlet lockedConfig = null;\nfunction registerPort(adapter) {\n cancelIdleTimer();\n const handle = {\n port: adapter,\n hello: null,\n debugLogs: false\n };\n ports.push(handle);\n adapter.onmessage((data) => {\n handleMessage(handle, data);\n });\n}\nfunction handleMessage(handle, message) {\n switch (message.type) {\n case \"hello\":\n handle.hello = message;\n handle.debugLogs = message.debugLogs ?? false;\n if (rejectIfIncompatible(handle)) return;\n if (state.phase !== \"dead\" && state.phase !== \"skipping\") detectDuplicateTab(handle);\n onHello(handle);\n return;\n case \"frame\":\n onFrame(message.frame);\n return;\n case \"bye\":\n onBye(handle);\n return;\n default: break;\n }\n}\n/**\n* Reject hellos whose `apiUrl`/`clientSecret` don't match the values established by the\n* first hello. Returns true if the port was rejected (caller should not continue\n* processing this hello).\n*/\nfunction rejectIfIncompatible(handle) {\n if (lockedConfig === null) return false;\n const incoming = handle.hello;\n if (incoming.apiUrl === lockedConfig.apiUrl && incoming.websocketUrl === lockedConfig.websocketUrl && incoming.clientSecret === lockedConfig.clientSecret) return false;\n handle.port.postMessage({\n type: \"dead\",\n reason: \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\"\n });\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n return true;\n}\n/**\n* If another already-connected port has the same `tabId`, this hello is from a duplicate\n* tab (browser \"Duplicate\" command clones sessionStorage). Mint a fresh `tabId` so the two\n* tabs don't collide on the same `(sessionId, tabId)` Recording. The new tabId is returned\n* to the tab in `welcome` so it can update its own state and sessionStorage.\n*/\nfunction detectDuplicateTab(handle) {\n const tabId = handle.hello.tabId;\n if (!ports.some((p) => p !== handle && p.hello?.tabId === tabId)) return;\n const fresh = crypto.randomUUID();\n handle.newTabId = fresh;\n handle.hello.tabId = fresh;\n}\nfunction onHello(handle) {\n switch (state.phase) {\n case \"init\":\n lockedConfig = {\n apiUrl: handle.hello.apiUrl,\n websocketUrl: handle.hello.websocketUrl,\n clientSecret: handle.hello.clientSecret\n };\n log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ?? \"(derive)\"} sessionIdHint=${handle.hello.sessionIdHint ?? \"(none)\"}`);\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n case \"initializing\": return;\n case \"active\":\n sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n return;\n case \"idle\": {\n const { client, sessionId, sessionToken } = state;\n state = { phase: \"initializing\" };\n resumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);\n return;\n }\n case \"skipping\":\n if (handle.hello.forceRecord) {\n log(\"forceRecord set; re-initializing from skipping state\");\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n }\n handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n return;\n case \"dead\":\n handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n return;\n default: break;\n }\n}\nasync function initializeSession(firstHello) {\n const client = new CsrClient(firstHello.apiUrl, firstHello.clientSecret, firstHello.context, firstHello.websocketUrl, log, firstHello.forceRecord);\n if (firstHello.sessionIdHint && firstHello.sessionTokenHint) {\n log(`adopting sessionIdHint=${firstHello.sessionIdHint}`);\n try {\n const transport = await client.openTransport(firstHello.sessionTokenHint);\n wireTransport(transport);\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: firstHello.sessionIdHint,\n sessionToken: firstHello.sessionTokenHint\n };\n log(\"hint adopted; transport open\");\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n return;\n } catch (err) {\n log(`hint rejected (${String(err)}); falling back to fresh init`);\n }\n }\n let result;\n try {\n result = await client.initSession();\n } catch (err) {\n log(`init-session threw: ${String(err)}`);\n transitionToDead(`init-session-failed: ${String(err)}`);\n return;\n }\n if (\"skipRecording\" in result) {\n log(\"init-session: skipRecording\");\n state = { phase: \"skipping\" };\n return;\n }\n log(`init-session ok sessionId=${result.sessionId}`);\n let transport;\n try {\n transport = await client.openTransport(result.sessionToken);\n } catch (err) {\n log(`openTransport threw: ${String(err)}`);\n transitionToDead(`open-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport open; session active\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: result.sessionId,\n sessionToken: result.sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nasync function resumeTransport(client, sessionId, sessionToken) {\n log(\"resuming transport from idle\");\n let transport;\n try {\n transport = await client.openTransport(sessionToken);\n } catch (err) {\n log(`resume-transport threw: ${String(err)}`);\n transitionToDead(`resume-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport resumed\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId,\n sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nfunction wireTransport(transport) {\n transport.onClose((info) => {\n if (state.phase !== \"active\") return;\n transitionToDead(info.reason);\n });\n transport.onStateChange((info) => {\n if (state.phase !== \"active\") return;\n for (const handle of ports) handle.port.postMessage({\n type: \"state\",\n connected: info.connected\n });\n });\n}\nfunction transitionToDead(reason) {\n state = {\n phase: \"dead\",\n reason\n };\n for (const handle of ports) handle.port.postMessage({\n type: \"dead\",\n reason\n });\n}\nfunction flushPendingWelcomes() {\n for (const handle of ports) {\n if (handle.hello === null) continue;\n if (state.phase === \"active\") sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n else if (state.phase === \"skipping\") handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n else if (state.phase === \"dead\") handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n }\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n const hint = handle.hello?.sessionIdHint;\n const adopted = hint !== void 0 && hint !== currentSessionId;\n const newTabId = handle.newTabId;\n handle.port.postMessage({\n type: \"welcome\",\n result: {\n sessionId: currentSessionId,\n sessionToken: currentSessionToken\n },\n adoptedFromSessionId: adopted ? hint : void 0,\n newTabId,\n resetCounter: adopted || newTabId !== void 0\n });\n}\nfunction onFrame(frame) {\n if (state.phase !== \"active\") return;\n state.transport.send(frame);\n}\nfunction onBye(handle) {\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n if (ports.length === 0 && state.phase === \"active\") {\n log(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);\n idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n }\n}\nfunction enterIdle() {\n idleTimer = null;\n if (state.phase !== \"active\" || ports.length > 0) return;\n log(\"idle timeout; closing transport\");\n state.transport.close(\"idle\");\n state = {\n phase: \"idle\",\n client: state.client,\n sessionId: state.sessionId,\n sessionToken: state.sessionToken\n };\n}\n//#endregion\n//#region src/uploader/worker/entry.ts\nconst SharedWorkerScopeCtor = globalThis.SharedWorkerGlobalScope;\nif (typeof SharedWorkerScopeCtor === \"function\" && self instanceof SharedWorkerScopeCtor) self.onconnect = (event) => {\n const port = event.ports[0];\n port.start();\n registerPort(adaptMessagePort(port));\n};\nelse registerPort(adaptDedicatedSelf());\nfunction adaptMessagePort(port) {\n return {\n postMessage: (message) => port.postMessage(message),\n onmessage: (cb) => {\n port.onmessage = (e) => cb(e.data);\n }\n };\n}\nfunction adaptDedicatedSelf() {\n const ws = self;\n return {\n postMessage: (message) => ws.postMessage(message),\n onmessage: (cb) => {\n ws.onmessage = (e) => cb(e.data);\n }\n };\n}\n//#endregion\n";
54
+ const workerScript = "//#region src/uploader/worker/web-socket-transport.ts\n/**\n* WebSocket-backed Transport. Internal retry policy: clean server-initiated close after the\n* first successful open → reconnect and resume; abrupt close (or any close before the first\n* open) → fire `onClose` and stop. Frames received while a (re)connect is in progress are\n* buffered and flushed on open.\n*\n* `ready()` resolves on the first successful open and rejects on close-before-open. Callers\n* should await it before treating the Transport as live, so a failure to open can be caught\n* (e.g. 4404 unknown session) and recovered from.\n*/\nvar WebSocketTransport = class {\n url;\n ws = null;\n onCloseCb = null;\n onStateChangeCb = null;\n intentionallyClosed = false;\n dead = false;\n /** Frames buffered while a (re)connect is in progress. */\n pending = [];\n readyPromise;\n protocols;\n constructor(url, protocols = []) {\n this.url = url;\n this.protocols = [...protocols];\n this.readyPromise = new Promise((resolve, reject) => {\n this.connect(false, resolve, reject);\n });\n this.readyPromise.catch(() => {});\n }\n ready() {\n return this.readyPromise;\n }\n send(frame) {\n if (this.dead || this.intentionallyClosed) return;\n if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));\n else this.pending.push(frame);\n }\n close(reason = \"transport-close\") {\n this.intentionallyClosed = true;\n this.ws?.close(1e3, reason);\n }\n onClose(cb) {\n this.onCloseCb = cb;\n }\n onStateChange(cb) {\n this.onStateChangeCb = cb;\n }\n connect(isReconnect, onReady, onReadyFail) {\n let ws;\n try {\n ws = new WebSocket(this.url, [...this.protocols]);\n } catch (_error) {\n this.failConnection(isReconnect, onReadyFail);\n return;\n }\n this.ws = ws;\n let opened = false;\n ws.onopen = () => {\n const expectedProtocol = this.protocols[0];\n if (expectedProtocol !== void 0 && ws.protocol !== expectedProtocol) {\n this.failConnection(isReconnect, onReadyFail);\n ws.close(1e3, \"protocol-mismatch\");\n return;\n }\n opened = true;\n onReady?.();\n if (isReconnect) this.onStateChangeCb?.({ connected: true });\n while (this.pending.length > 0) {\n const f = this.pending.shift();\n ws.send(JSON.stringify(f));\n }\n };\n ws.onclose = (event) => {\n if (this.intentionallyClosed || this.dead) return;\n if (!opened) {\n this.failConnection(isReconnect, onReadyFail);\n return;\n }\n if (event.wasClean && (event.code === 1e3 || event.code === 1001)) {\n this.onStateChangeCb?.({ connected: false });\n this.connect(true);\n } else this.die(`close code=${event.code} wasClean=${event.wasClean}`);\n };\n }\n failConnection(isReconnect, onReadyFail) {\n const reason = isReconnect ? \"reconnect-failed\" : \"initial-failed\";\n if (onReadyFail) {\n this.dead = true;\n onReadyFail(new Error(reason));\n } else this.die(reason);\n }\n die(reason) {\n this.dead = true;\n this.onCloseCb?.({ reason });\n }\n};\n//#endregion\n//#region src/uploader/worker/websocket-auth.ts\nconst RECORDING_PROTOCOL = \"recording.v1\";\nconst AUTH_PROTOCOL_PREFIX = \"auth.\";\nconst MAX_TOKEN_LENGTH = 4096;\nfunction recordingProtocols(sessionToken) {\n if (sessionToken.length > MAX_TOKEN_LENGTH) throw new Error(\"Session token is too long for WebSocket authentication\");\n if (!sessionToken || /[^A-Za-z0-9._-]/.test(sessionToken)) throw new Error(\"Invalid session token for WebSocket authentication\");\n return [RECORDING_PROTOCOL, `${AUTH_PROTOCOL_PREFIX}${sessionToken}`];\n}\n//#endregion\n//#region src/uploader/worker/csr-client.ts\n/**\n* Single Client implementation that talks to the recording backend's REST + WS protocol.\n* Both dev-server and prod implement the same protocol, so we don't need polymorphism here yet.\n*/\nvar CsrClient = class {\n apiUrl;\n clientSecret;\n context;\n websocketUrl;\n log;\n forceRecord;\n constructor(apiUrl, clientSecret, context, websocketUrl, log = () => {}, forceRecord) {\n this.apiUrl = apiUrl;\n this.clientSecret = clientSecret;\n this.context = context;\n this.websocketUrl = websocketUrl;\n this.log = log;\n this.forceRecord = forceRecord;\n }\n async initSession() {\n const url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;\n this.log(`fetch POST ${url}`);\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n clientSecret: this.clientSecret,\n ...this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {},\n ...this.forceRecord ? { forceRecord: true } : {}\n })\n });\n if (!res.ok) throw new Error(`init-session failed: HTTP ${res.status}`);\n const data = await res.json();\n if (data.skipRecording) return { skipRecording: true };\n if (!data.sessionId || !data.sessionToken) throw new Error(\"init-session response missing sessionId or sessionToken\");\n return {\n sessionId: data.sessionId,\n sessionToken: data.sessionToken\n };\n }\n async openTransport(sessionToken) {\n const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;\n let parsedUrl;\n try {\n parsedUrl = new URL(wsBase);\n } catch (_error) {\n throw new Error(\"Invalid WebSocket URL\");\n }\n if (parsedUrl.searchParams.has(\"session_token\")) throw new Error(\"WebSocket URL must not include a session token\");\n const protocols = recordingProtocols(sessionToken);\n this.log(`WebSocket connect ${wsBase}`);\n const transport = new WebSocketTransport(wsBase, protocols);\n await transport.ready();\n return transport;\n }\n trimSlash(s) {\n return s.endsWith(\"/\") ? s.slice(0, -1) : s;\n }\n toWsScheme(base) {\n if (base.startsWith(\"https://\")) return `wss://${base.slice(8)}`;\n if (base.startsWith(\"http://\")) return `ws://${base.slice(7)}`;\n return base;\n }\n};\n//#endregion\n//#region src/uploader/worker/core.ts\nconst WORKER_HASH = globalThis.__WORKER_HASH__;\nconst IDLE_GRACE_MS = 5e3;\nlet state = { phase: \"init\" };\nconst ports = [];\nlet idleTimer = null;\nfunction cancelIdleTimer() {\n if (idleTimer !== null) {\n clearTimeout(idleTimer);\n idleTimer = null;\n }\n}\nfunction log(msg) {\n for (const handle of ports) if (handle.debugLogs) handle.port.postMessage({\n type: \"log\",\n msg\n });\n}\n/**\n* The first hello \"locks in\" the session's apiUrl/clientSecret. Any later tab arriving\n* with different values is misconfigured — we reject it rather than silently using the\n* locked values. In SharedWorker mode the `name = hash(clientSecret)` scoping already\n* prevents secret-mismatch from sharing a worker, but this defends against the dedicated\n* path and against future bugs.\n*/\nlet lockedConfig = null;\nfunction registerPort(adapter) {\n cancelIdleTimer();\n const handle = {\n port: adapter,\n hello: null,\n debugLogs: false\n };\n ports.push(handle);\n adapter.onmessage((data) => {\n handleMessage(handle, data);\n });\n}\nfunction handleMessage(handle, message) {\n switch (message.type) {\n case \"hello\":\n handle.hello = message;\n handle.debugLogs = message.debugLogs ?? false;\n if (rejectIfIncompatible(handle)) return;\n if (state.phase !== \"dead\" && state.phase !== \"skipping\") detectDuplicateTab(handle);\n onHello(handle);\n return;\n case \"frame\":\n onFrame(message.frame);\n return;\n case \"bye\":\n onBye(handle);\n return;\n default: break;\n }\n}\n/**\n* Reject hellos whose `apiUrl`/`clientSecret` don't match the values established by the\n* first hello. Returns true if the port was rejected (caller should not continue\n* processing this hello).\n*/\nfunction rejectIfIncompatible(handle) {\n if (lockedConfig === null) return false;\n const incoming = handle.hello;\n if (incoming.apiUrl === lockedConfig.apiUrl && incoming.websocketUrl === lockedConfig.websocketUrl && incoming.clientSecret === lockedConfig.clientSecret) return false;\n handle.port.postMessage({\n type: \"dead\",\n reason: \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\"\n });\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n return true;\n}\n/**\n* If another already-connected port has the same `tabId`, this hello is from a duplicate\n* tab (browser \"Duplicate\" command clones sessionStorage). Mint a fresh `tabId` so the two\n* tabs don't collide on the same `(sessionId, tabId)` Recording. The new tabId is returned\n* to the tab in `welcome` so it can update its own state and sessionStorage.\n*/\nfunction detectDuplicateTab(handle) {\n const tabId = handle.hello.tabId;\n if (!ports.some((p) => p !== handle && p.hello?.tabId === tabId)) return;\n const fresh = crypto.randomUUID();\n handle.newTabId = fresh;\n handle.hello.tabId = fresh;\n}\nfunction onHello(handle) {\n switch (state.phase) {\n case \"init\":\n lockedConfig = {\n apiUrl: handle.hello.apiUrl,\n websocketUrl: handle.hello.websocketUrl,\n clientSecret: handle.hello.clientSecret\n };\n log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ? \"(configured)\" : \"(derive)\"} sessionIdHint=${handle.hello.sessionIdHint ?? \"(none)\"}`);\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n case \"initializing\": return;\n case \"active\":\n sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n return;\n case \"idle\": {\n const { client, sessionId, sessionToken } = state;\n state = { phase: \"initializing\" };\n resumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);\n return;\n }\n case \"skipping\":\n if (handle.hello.forceRecord) {\n log(\"forceRecord set; re-initializing from skipping state\");\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n }\n handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true },\n workerHash: WORKER_HASH\n });\n return;\n case \"dead\":\n handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n return;\n default: break;\n }\n}\nasync function initializeSession(firstHello) {\n const client = new CsrClient(firstHello.apiUrl, firstHello.clientSecret, firstHello.context, firstHello.websocketUrl, log, firstHello.forceRecord);\n if (firstHello.sessionIdHint && firstHello.sessionTokenHint) {\n log(`adopting sessionIdHint=${firstHello.sessionIdHint}`);\n try {\n const transport = await client.openTransport(firstHello.sessionTokenHint);\n wireTransport(transport);\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: firstHello.sessionIdHint,\n sessionToken: firstHello.sessionTokenHint\n };\n log(\"hint adopted; transport open\");\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n return;\n } catch (err) {\n log(`hint rejected (${String(err)}); falling back to fresh init`);\n }\n }\n let result;\n try {\n result = await client.initSession();\n } catch (err) {\n log(`init-session threw: ${String(err)}`);\n transitionToDead(`init-session-failed: ${String(err)}`);\n return;\n }\n if (\"skipRecording\" in result) {\n log(\"init-session: skipRecording\");\n state = { phase: \"skipping\" };\n return;\n }\n log(`init-session ok sessionId=${result.sessionId}`);\n let transport;\n try {\n transport = await client.openTransport(result.sessionToken);\n } catch (err) {\n log(`openTransport threw: ${String(err)}`);\n transitionToDead(`open-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport open; session active\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: result.sessionId,\n sessionToken: result.sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nasync function resumeTransport(client, sessionId, sessionToken) {\n log(\"resuming transport from idle\");\n let transport;\n try {\n transport = await client.openTransport(sessionToken);\n } catch (err) {\n log(`resume-transport threw: ${String(err)}`);\n transitionToDead(`resume-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport resumed\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId,\n sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nfunction wireTransport(transport) {\n transport.onClose((info) => {\n if (state.phase !== \"active\") return;\n transitionToDead(info.reason);\n });\n transport.onStateChange((info) => {\n if (state.phase !== \"active\") return;\n for (const handle of ports) handle.port.postMessage({\n type: \"state\",\n connected: info.connected\n });\n });\n}\nfunction transitionToDead(reason) {\n state = {\n phase: \"dead\",\n reason\n };\n for (const handle of ports) handle.port.postMessage({\n type: \"dead\",\n reason\n });\n}\nfunction flushPendingWelcomes() {\n for (const handle of ports) {\n if (handle.hello === null) continue;\n if (state.phase === \"active\") sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n else if (state.phase === \"skipping\") handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true },\n workerHash: WORKER_HASH\n });\n else if (state.phase === \"dead\") handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n }\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n const hint = handle.hello?.sessionIdHint;\n const adopted = hint !== void 0 && hint !== currentSessionId;\n const newTabId = handle.newTabId;\n handle.port.postMessage({\n type: \"welcome\",\n result: {\n sessionId: currentSessionId,\n sessionToken: currentSessionToken\n },\n workerHash: WORKER_HASH,\n adoptedFromSessionId: adopted ? hint : void 0,\n newTabId,\n resetCounter: adopted || newTabId !== void 0\n });\n}\nfunction onFrame(frame) {\n if (state.phase !== \"active\") return;\n state.transport.send(frame);\n}\nfunction onBye(handle) {\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n if (ports.length === 0 && state.phase === \"active\") {\n log(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);\n idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n }\n}\nfunction enterIdle() {\n idleTimer = null;\n if (state.phase !== \"active\" || ports.length > 0) return;\n log(\"idle timeout; closing transport\");\n state.transport.close(\"idle\");\n state = {\n phase: \"idle\",\n client: state.client,\n sessionId: state.sessionId,\n sessionToken: state.sessionToken\n };\n}\n//#endregion\n//#region src/uploader/worker/entry.ts\nconst SharedWorkerScopeCtor = globalThis.SharedWorkerGlobalScope;\nif (typeof SharedWorkerScopeCtor === \"function\" && self instanceof SharedWorkerScopeCtor) self.onconnect = (event) => {\n const port = event.ports[0];\n port.start();\n registerPort(adaptMessagePort(port));\n};\nelse registerPort(adaptDedicatedSelf());\nfunction adaptMessagePort(port) {\n return {\n postMessage: (message) => port.postMessage(message),\n onmessage: (cb) => {\n port.onmessage = (e) => cb(e.data);\n }\n };\n}\nfunction adaptDedicatedSelf() {\n const ws = self;\n return {\n postMessage: (message) => ws.postMessage(message),\n onmessage: (cb) => {\n ws.onmessage = (e) => cb(e.data);\n }\n };\n}\n//#endregion\n";
55
+ //#endregion
56
+ //#region src/uploader/worker-hash.ts
57
+ const WORKER_HASH = "1774b236fa1eb46a";
55
58
  //#endregion
56
59
  //#region src/uploader/create-uploader.ts
57
60
  const STORAGE_TAB_ID = "csr:tabId";
@@ -141,6 +144,7 @@ async function createUploader(opts) {
141
144
  return msg;
142
145
  }), welcomeDeadline]);
143
146
  log?.(welcome.type === "welcome" ? `tab: welcome (${"sessionId" in welcome.result ? `sessionId=${welcome.result.sessionId}` : "skipRecording"})` : `tab: dead reason=${welcome.reason}`);
147
+ if (welcome.type === "welcome" && urlScheme === "custom" && welcome.workerHash !== "1774b236fa1eb46a") log?.("tab: WORKER MISMATCH — the self-hosted confidence-worker.js does not match the installed SDK. Copy the updated file from node_modules/@spotify-confidence/session-recording/dist/confidence-worker.js");
144
148
  if (welcome.type === "dead") throw new Error(`uploader: ${welcome.reason}`);
145
149
  if ("skipRecording" in welcome.result) {
146
150
  if (opts.forceRecord) log?.("tab: forceRecord was set but backend still skipped — backend may not support forceRecord yet");
@@ -349,6 +353,7 @@ function writeCounter(counter) {
349
353
  sessionStorage.setItem(STORAGE_COUNTER, String(counter));
350
354
  }
351
355
  //#endregion
356
+ exports.WORKER_HASH = WORKER_HASH;
352
357
  exports.collectUserAgentContext = collectUserAgentContext;
353
358
  exports.createUploader = createUploader;
354
359
  exports.workerScript = workerScript;
@@ -1,4 +1,4 @@
1
- import { a as ContextValue, i as ClientContext, o as UserAgentContext, r as Uploader, s as collectUserAgentContext, t as CreateUploaderOptions } from "../types-Bg0UKTVQ.cjs";
1
+ import { a as ContextValue, i as ClientContext, o as UserAgentContext, r as Uploader, s as collectUserAgentContext, t as CreateUploaderOptions } from "../types-CdvFpcEN.cjs";
2
2
 
3
3
  //#region src/uploader/create-uploader.d.ts
4
4
  declare function createUploader(opts: CreateUploaderOptions): Promise<Uploader | null>;
@@ -6,4 +6,7 @@ declare function createUploader(opts: CreateUploaderOptions): Promise<Uploader |
6
6
  //#region src/uploader/worker/worker-script.d.ts
7
7
  declare const workerScript: string;
8
8
  //#endregion
9
- export { type ClientContext, type ContextValue, type CreateUploaderOptions, type Uploader, type UserAgentContext, collectUserAgentContext, createUploader, workerScript };
9
+ //#region src/uploader/worker-hash.d.ts
10
+ declare const WORKER_HASH = "1774b236fa1eb46a";
11
+ //#endregion
12
+ export { type ClientContext, type ContextValue, type CreateUploaderOptions, type Uploader, type UserAgentContext, WORKER_HASH, collectUserAgentContext, createUploader, workerScript };
@@ -1,4 +1,4 @@
1
- import { a as ContextValue, i as ClientContext, o as UserAgentContext, r as Uploader, s as collectUserAgentContext, t as CreateUploaderOptions } from "../types-Bg0UKTVQ.js";
1
+ import { a as ContextValue, i as ClientContext, o as UserAgentContext, r as Uploader, s as collectUserAgentContext, t as CreateUploaderOptions } from "../types-CdvFpcEN.js";
2
2
 
3
3
  //#region src/uploader/create-uploader.d.ts
4
4
  declare function createUploader(opts: CreateUploaderOptions): Promise<Uploader | null>;
@@ -6,4 +6,7 @@ declare function createUploader(opts: CreateUploaderOptions): Promise<Uploader |
6
6
  //#region src/uploader/worker/worker-script.d.ts
7
7
  declare const workerScript: string;
8
8
  //#endregion
9
- export { type ClientContext, type ContextValue, type CreateUploaderOptions, type Uploader, type UserAgentContext, collectUserAgentContext, createUploader, workerScript };
9
+ //#region src/uploader/worker-hash.d.ts
10
+ declare const WORKER_HASH = "1774b236fa1eb46a";
11
+ //#endregion
12
+ export { type ClientContext, type ContextValue, type CreateUploaderOptions, type Uploader, type UserAgentContext, WORKER_HASH, collectUserAgentContext, createUploader, workerScript };