@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.
- package/CHANGELOG.md +15 -0
- package/dist/confidence-worker.js +53 -14
- package/dist/index.cjs +3 -15
- package/dist/index.d.cts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -15
- package/dist/{session-activity-HfuXKqGd.cjs → session-activity-B8swR5Wg.cjs} +29 -0
- package/dist/{session-activity-CJ1wae06.js → session-activity-tshS9Hua.js} +18 -1
- package/dist/{types-Bg0UKTVQ.d.cts → types-CdvFpcEN.d.cts} +8 -7
- package/dist/{types-Bg0UKTVQ.d.ts → types-CdvFpcEN.d.ts} +8 -7
- package/dist/uploader/index.cjs +8 -3
- package/dist/uploader/index.d.cts +5 -2
- package/dist/uploader/index.d.ts +5 -2
- package/dist/uploader/index.js +8 -4
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/test-utils/mock-ws-server.ts +18 -2
- package/src/uploader/client-context.test.ts +27 -0
- package/src/uploader/client-context.ts +3 -1
- package/src/uploader/create-uploader.test.ts +105 -0
- package/src/uploader/create-uploader.ts +8 -0
- package/src/uploader/index.ts +1 -0
- package/src/uploader/types.ts +7 -6
- package/src/uploader/worker/core.test.ts +146 -4
- package/src/uploader/worker/core.ts +6 -1
- package/src/uploader/worker/csr-client.test.ts +33 -18
- package/src/uploader/worker/csr-client.ts +13 -4
- package/src/uploader/worker/web-socket-transport.test.ts +71 -4
- package/src/uploader/worker/web-socket-transport.ts +30 -15
- package/src/uploader/worker/websocket-auth.test.ts +29 -0
- package/src/uploader/worker/websocket-auth.ts +15 -0
- package/src/uploader/worker/worker-script.test.ts +205 -0
- package/src/uploader/worker/worker-script.ts +1 -1
- package/src/uploader/worker-hash.ts +2 -0
- 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
|
-
|
|
23
|
+
protocols;
|
|
24
|
+
constructor(url, protocols = []) {
|
|
23
25
|
this.url = url;
|
|
26
|
+
this.protocols = [...protocols];
|
|
24
27
|
this.readyPromise = new Promise((resolve, reject) => {
|
|
25
28
|
this.connect(false, resolve, reject);
|
|
26
29
|
});
|
|
@@ -45,10 +48,22 @@ var WebSocketTransport = class {
|
|
|
45
48
|
this.onStateChangeCb = cb;
|
|
46
49
|
}
|
|
47
50
|
connect(isReconnect, onReady, onReadyFail) {
|
|
48
|
-
|
|
51
|
+
let ws;
|
|
52
|
+
try {
|
|
53
|
+
ws = new WebSocket(this.url, [...this.protocols]);
|
|
54
|
+
} catch (_error) {
|
|
55
|
+
this.failConnection(isReconnect, onReadyFail);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
49
58
|
this.ws = ws;
|
|
50
59
|
let opened = false;
|
|
51
60
|
ws.onopen = () => {
|
|
61
|
+
const expectedProtocol = this.protocols[0];
|
|
62
|
+
if (expectedProtocol !== void 0 && ws.protocol !== expectedProtocol) {
|
|
63
|
+
this.failConnection(isReconnect, onReadyFail);
|
|
64
|
+
ws.close(1e3, "protocol-mismatch");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
52
67
|
opened = true;
|
|
53
68
|
onReady?.();
|
|
54
69
|
if (isReconnect) this.onStateChangeCb?.({ connected: true });
|
|
@@ -58,13 +73,9 @@ var WebSocketTransport = class {
|
|
|
58
73
|
}
|
|
59
74
|
};
|
|
60
75
|
ws.onclose = (event) => {
|
|
61
|
-
if (this.intentionallyClosed) return;
|
|
76
|
+
if (this.intentionallyClosed || this.dead) return;
|
|
62
77
|
if (!opened) {
|
|
63
|
-
|
|
64
|
-
if (onReadyFail) {
|
|
65
|
-
onReadyFail(new Error(reason));
|
|
66
|
-
this.dead = true;
|
|
67
|
-
} else this.die(reason);
|
|
78
|
+
this.failConnection(isReconnect, onReadyFail);
|
|
68
79
|
return;
|
|
69
80
|
}
|
|
70
81
|
if (event.wasClean && (event.code === 1e3 || event.code === 1001)) {
|
|
@@ -73,12 +84,29 @@ var WebSocketTransport = class {
|
|
|
73
84
|
} else this.die(`close code=${event.code} wasClean=${event.wasClean}`);
|
|
74
85
|
};
|
|
75
86
|
}
|
|
87
|
+
failConnection(isReconnect, onReadyFail) {
|
|
88
|
+
const reason = isReconnect ? "reconnect-failed" : "initial-failed";
|
|
89
|
+
if (onReadyFail) {
|
|
90
|
+
this.dead = true;
|
|
91
|
+
onReadyFail(new Error(reason));
|
|
92
|
+
} else this.die(reason);
|
|
93
|
+
}
|
|
76
94
|
die(reason) {
|
|
77
95
|
this.dead = true;
|
|
78
96
|
this.onCloseCb?.({ reason });
|
|
79
97
|
}
|
|
80
98
|
};
|
|
81
99
|
//#endregion
|
|
100
|
+
//#region src/uploader/worker/websocket-auth.ts
|
|
101
|
+
const RECORDING_PROTOCOL = "recording.v1";
|
|
102
|
+
const AUTH_PROTOCOL_PREFIX = "auth.";
|
|
103
|
+
const MAX_TOKEN_LENGTH = 4096;
|
|
104
|
+
function recordingProtocols(sessionToken) {
|
|
105
|
+
if (sessionToken.length > MAX_TOKEN_LENGTH) throw new Error("Session token is too long for WebSocket authentication");
|
|
106
|
+
if (!sessionToken || /[^A-Za-z0-9._-]/.test(sessionToken)) throw new Error("Invalid session token for WebSocket authentication");
|
|
107
|
+
return [RECORDING_PROTOCOL, `${AUTH_PROTOCOL_PREFIX}${sessionToken}`];
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
82
110
|
//#region src/uploader/worker/csr-client.ts
|
|
83
111
|
/**
|
|
84
112
|
* Single Client implementation that talks to the recording backend's REST + WS protocol.
|
|
@@ -122,9 +150,16 @@ var CsrClient = class {
|
|
|
122
150
|
}
|
|
123
151
|
async openTransport(sessionToken) {
|
|
124
152
|
const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
153
|
+
let parsedUrl;
|
|
154
|
+
try {
|
|
155
|
+
parsedUrl = new URL(wsBase);
|
|
156
|
+
} catch (_error) {
|
|
157
|
+
throw new Error("Invalid WebSocket URL");
|
|
158
|
+
}
|
|
159
|
+
if (parsedUrl.searchParams.has("session_token")) throw new Error("WebSocket URL must not include a session token");
|
|
160
|
+
const protocols = recordingProtocols(sessionToken);
|
|
161
|
+
this.log(`WebSocket connect ${wsBase}`);
|
|
162
|
+
const transport = new WebSocketTransport(wsBase, protocols);
|
|
128
163
|
await transport.ready();
|
|
129
164
|
return transport;
|
|
130
165
|
}
|
|
@@ -139,6 +174,7 @@ var CsrClient = class {
|
|
|
139
174
|
};
|
|
140
175
|
//#endregion
|
|
141
176
|
//#region src/uploader/worker/core.ts
|
|
177
|
+
const WORKER_HASH = globalThis.__WORKER_HASH__;
|
|
142
178
|
const IDLE_GRACE_MS = 5e3;
|
|
143
179
|
let state = { phase: "init" };
|
|
144
180
|
const ports = [];
|
|
@@ -231,7 +267,7 @@ function onHello(handle) {
|
|
|
231
267
|
websocketUrl: handle.hello.websocketUrl,
|
|
232
268
|
clientSecret: handle.hello.clientSecret
|
|
233
269
|
};
|
|
234
|
-
log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl
|
|
270
|
+
log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ? "(configured)" : "(derive)"} sessionIdHint=${handle.hello.sessionIdHint ?? "(none)"}`);
|
|
235
271
|
state = { phase: "initializing" };
|
|
236
272
|
initializeSession(handle.hello).then(flushPendingWelcomes);
|
|
237
273
|
return;
|
|
@@ -254,7 +290,8 @@ function onHello(handle) {
|
|
|
254
290
|
}
|
|
255
291
|
handle.port.postMessage({
|
|
256
292
|
type: "welcome",
|
|
257
|
-
result: { skipRecording: true }
|
|
293
|
+
result: { skipRecording: true },
|
|
294
|
+
workerHash: WORKER_HASH
|
|
258
295
|
});
|
|
259
296
|
return;
|
|
260
297
|
case "dead":
|
|
@@ -370,7 +407,8 @@ function flushPendingWelcomes() {
|
|
|
370
407
|
if (state.phase === "active") sendActiveWelcome(handle, state.sessionId, state.sessionToken);
|
|
371
408
|
else if (state.phase === "skipping") handle.port.postMessage({
|
|
372
409
|
type: "welcome",
|
|
373
|
-
result: { skipRecording: true }
|
|
410
|
+
result: { skipRecording: true },
|
|
411
|
+
workerHash: WORKER_HASH
|
|
374
412
|
});
|
|
375
413
|
else if (state.phase === "dead") handle.port.postMessage({
|
|
376
414
|
type: "dead",
|
|
@@ -388,6 +426,7 @@ function sendActiveWelcome(handle, currentSessionId, currentSessionToken) {
|
|
|
388
426
|
sessionId: currentSessionId,
|
|
389
427
|
sessionToken: currentSessionToken
|
|
390
428
|
},
|
|
429
|
+
workerHash: WORKER_HASH,
|
|
391
430
|
adoptedFromSessionId: adopted ? hint : void 0,
|
|
392
431
|
newTabId,
|
|
393
432
|
resetCounter: adopted || newTabId !== void 0
|
package/dist/index.cjs
CHANGED
|
@@ -1,18 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_session_activity = require("./session-activity-
|
|
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-
|
|
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-
|
|
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
|
|
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 {
|
|
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`)
|
|
45
|
-
* the
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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`)
|
|
45
|
-
* the
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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;
|
package/dist/uploader/index.cjs
CHANGED
|
@@ -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-
|
|
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-
|
|
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
|
-
|
|
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 };
|
package/dist/uploader/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as ContextValue, i as ClientContext, o as UserAgentContext, r as Uploader, s as collectUserAgentContext, t as CreateUploaderOptions } from "../types-
|
|
1
|
+
import { a as ContextValue, i as ClientContext, o as UserAgentContext, r as Uploader, s as collectUserAgentContext, t as CreateUploaderOptions } from "../types-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
|
-
|
|
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 };
|