@spotify-confidence/csr-common 0.0.0 → 0.17.2

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/dist/index.js ADDED
@@ -0,0 +1,104 @@
1
+ //#region src/events.ts
2
+ /**
3
+ * Mirrors rrweb's serialized-node types but decoupled — we own the enum.
4
+ */
5
+ let SerializedNodeType = /* @__PURE__ */ function(SerializedNodeType) {
6
+ SerializedNodeType[SerializedNodeType["Document"] = 0] = "Document";
7
+ SerializedNodeType[SerializedNodeType["DocumentType"] = 1] = "DocumentType";
8
+ SerializedNodeType[SerializedNodeType["Element"] = 2] = "Element";
9
+ SerializedNodeType[SerializedNodeType["Text"] = 3] = "Text";
10
+ SerializedNodeType[SerializedNodeType["CDATA"] = 4] = "CDATA";
11
+ SerializedNodeType[SerializedNodeType["Comment"] = 5] = "Comment";
12
+ return SerializedNodeType;
13
+ }({});
14
+ /**
15
+ * Mirrors rrweb event types but decoupled — we own the enum.
16
+ */
17
+ let RecordingEventType = /* @__PURE__ */ function(RecordingEventType) {
18
+ RecordingEventType[RecordingEventType["DomContentLoaded"] = 0] = "DomContentLoaded";
19
+ RecordingEventType[RecordingEventType["Load"] = 1] = "Load";
20
+ RecordingEventType[RecordingEventType["FullSnapshot"] = 2] = "FullSnapshot";
21
+ RecordingEventType[RecordingEventType["IncrementalSnapshot"] = 3] = "IncrementalSnapshot";
22
+ RecordingEventType[RecordingEventType["Meta"] = 4] = "Meta";
23
+ RecordingEventType[RecordingEventType["Custom"] = 5] = "Custom";
24
+ RecordingEventType[RecordingEventType["Plugin"] = 6] = "Plugin";
25
+ return RecordingEventType;
26
+ }({});
27
+ /**
28
+ * Incremental snapshot sub-types.
29
+ */
30
+ let IncrementalSource = /* @__PURE__ */ function(IncrementalSource) {
31
+ IncrementalSource[IncrementalSource["Mutation"] = 0] = "Mutation";
32
+ IncrementalSource[IncrementalSource["MouseMove"] = 1] = "MouseMove";
33
+ IncrementalSource[IncrementalSource["MouseInteraction"] = 2] = "MouseInteraction";
34
+ IncrementalSource[IncrementalSource["Scroll"] = 3] = "Scroll";
35
+ IncrementalSource[IncrementalSource["ViewportResize"] = 4] = "ViewportResize";
36
+ IncrementalSource[IncrementalSource["Input"] = 5] = "Input";
37
+ IncrementalSource[IncrementalSource["TouchMove"] = 6] = "TouchMove";
38
+ IncrementalSource[IncrementalSource["MediaInteraction"] = 7] = "MediaInteraction";
39
+ IncrementalSource[IncrementalSource["StyleSheetRule"] = 8] = "StyleSheetRule";
40
+ IncrementalSource[IncrementalSource["CanvasMutation"] = 9] = "CanvasMutation";
41
+ IncrementalSource[IncrementalSource["Font"] = 10] = "Font";
42
+ IncrementalSource[IncrementalSource["Log"] = 11] = "Log";
43
+ IncrementalSource[IncrementalSource["Drag"] = 12] = "Drag";
44
+ IncrementalSource[IncrementalSource["StyleDeclaration"] = 13] = "StyleDeclaration";
45
+ IncrementalSource[IncrementalSource["Selection"] = 14] = "Selection";
46
+ IncrementalSource[IncrementalSource["AdoptedStyleSheet"] = 15] = "AdoptedStyleSheet";
47
+ return IncrementalSource;
48
+ }({});
49
+ /**
50
+ * From rrweb MouseInteractions.
51
+ */
52
+ let MouseInteractions = /* @__PURE__ */ function(MouseInteractions) {
53
+ MouseInteractions[MouseInteractions["MouseUp"] = 0] = "MouseUp";
54
+ MouseInteractions[MouseInteractions["MouseDown"] = 1] = "MouseDown";
55
+ MouseInteractions[MouseInteractions["Click"] = 2] = "Click";
56
+ MouseInteractions[MouseInteractions["ContextMenu"] = 3] = "ContextMenu";
57
+ MouseInteractions[MouseInteractions["DblClick"] = 4] = "DblClick";
58
+ MouseInteractions[MouseInteractions["Focus"] = 5] = "Focus";
59
+ MouseInteractions[MouseInteractions["Blur"] = 6] = "Blur";
60
+ MouseInteractions[MouseInteractions["TouchStart"] = 7] = "TouchStart";
61
+ MouseInteractions[MouseInteractions["TouchMove_Departed"] = 8] = "TouchMove_Departed";
62
+ MouseInteractions[MouseInteractions["TouchEnd"] = 9] = "TouchEnd";
63
+ MouseInteractions[MouseInteractions["TouchCancel"] = 10] = "TouchCancel";
64
+ return MouseInteractions;
65
+ }({});
66
+ //#endregion
67
+ //#region src/url.ts
68
+ /**
69
+ * Extract only the pathname from a URL, stripping origin, query string, and
70
+ * hash. Used across recorder and analyzer to avoid capturing PII in route data.
71
+ */
72
+ function stripUrl(url) {
73
+ try {
74
+ return new URL(url).pathname;
75
+ } catch (_e) {
76
+ return url;
77
+ }
78
+ }
79
+ //#endregion
80
+ //#region src/custom-event-limits.ts
81
+ const MAX_KEY_LENGTH = 128;
82
+ const MAX_TAG_VALUE_LENGTH = 256;
83
+ const MAX_DISTINCT_KEYS = 100;
84
+ const MAX_VALUES_PER_KEY = 1e3;
85
+ const VALID_KEY_PATTERN = /^[a-zA-Z0-9_.-]+$/;
86
+ function validateKey(key) {
87
+ if (typeof key !== "string" || key.length === 0) return "key is empty";
88
+ if (key.length > 128) return `key exceeds 128 characters`;
89
+ if (!VALID_KEY_PATTERN.test(key)) return "key contains invalid characters (allowed: a-z A-Z 0-9 _ . -)";
90
+ return null;
91
+ }
92
+ function validateTagValue(value) {
93
+ if (value === void 0) return null;
94
+ if (typeof value !== "string") return "tag value is not a string";
95
+ if (value.length > 256) return `tag value exceeds 256 characters`;
96
+ return null;
97
+ }
98
+ function validateMeasureValue(value) {
99
+ if (value === void 0) return null;
100
+ if (typeof value !== "number" || !Number.isFinite(value)) return "measure value must be a finite number";
101
+ return null;
102
+ }
103
+ //#endregion
104
+ export { IncrementalSource, MAX_DISTINCT_KEYS, MAX_KEY_LENGTH, MAX_TAG_VALUE_LENGTH, MAX_VALUES_PER_KEY, MouseInteractions, RecordingEventType, SerializedNodeType, stripUrl, validateKey, validateMeasureValue, validateTagValue };
@@ -0,0 +1,111 @@
1
+ //#region src/uploader/client-context.d.ts
2
+ /**
3
+ * JSON-shaped values accepted in a Context — matches `google.protobuf.Struct`.
4
+ */
5
+ type ContextValue = string | number | boolean | null | ContextValue[] | {
6
+ [key: string]: ContextValue;
7
+ };
8
+ /**
9
+ * Browser-environment metadata captured at session init. Sent verbatim in the
10
+ * `context` field of the InitSession request.
11
+ */
12
+ type UserAgentContext = {
13
+ userAgent?: string; /** Coarse OS family — `windows`, `macos`, `ios`, `android`, `linux`, or `unknown`. */
14
+ os?: string; /** Coarse browser family — `chrome`, `firefox`, `safari`, `edge`, or `unknown`. */
15
+ browser?: string; /** Browser major version. */
16
+ browserVersion?: string;
17
+ mobile?: boolean; /** BCP-47 language tag (e.g. `en-US`). */
18
+ languageCode?: string; /** IANA time zone (e.g. `Europe/Stockholm`). */
19
+ timeZone?: string; /** Viewport in CSS pixels. */
20
+ viewportWidth?: number;
21
+ viewportHeight?: number; /** Physical screen in CSS pixels. */
22
+ screenWidth?: number;
23
+ screenHeight?: number;
24
+ devicePixelRatio?: number; /** Initial document URI — without query/hash to avoid leaking PII. */
25
+ uri?: string;
26
+ referrer?: string;
27
+ };
28
+ interface ClientContext {
29
+ userAgent?: UserAgentContext;
30
+ [key: string]: ContextValue | undefined;
31
+ }
32
+ declare function collectUserAgentContext(): UserAgentContext | undefined;
33
+ //#endregion
34
+ //#region src/uploader/types.d.ts
35
+ interface Uploader {
36
+ (event: unknown): void;
37
+ close(): void;
38
+ }
39
+ interface CreateUploaderOptions {
40
+ /** Base URL of the recording backend (serves `/v1/sessions:initSession` and, by default, the WS ingest endpoint). */
41
+ apiUrl: string;
42
+ /**
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).
49
+ */
50
+ websocketUrl?: string;
51
+ /** Per-tenant secret. Hashed to scope the SharedWorker so different secrets never share a session, and sent in the `initSession` request body. */
52
+ clientSecret: string;
53
+ /** End-user identifier (visitor / device ID). Forwarded in the `initSession` body for sampling and eligibility. */
54
+ targetingKey?: string;
55
+ /**
56
+ * Session context sent in the InitSession request. The SDK auto-populates
57
+ * `userAgent` with browser/OS/screen metadata. Pass any extra keys as
58
+ * custom dimensions.
59
+ */
60
+ context?: ClientContext;
61
+ /** Force a worker mode for testing. Default `"auto"`. */
62
+ workerMode?: "shared" | "dedicated" | "auto";
63
+ /**
64
+ * Optional override for the worker script URL. By default the bundled worker is loaded
65
+ * from a `data:` URL — self-contained, no infrastructure setup, works across tabs
66
+ * (`SharedWorker` is keyed by `(scriptURL, name)` and identical content yields identical
67
+ * data: URLs). Provide this only if your `worker-src` CSP forbids `data:`; you can serve
68
+ * the bundled worker yourself by re-exporting `workerScript` from
69
+ * `csr-common/uploader` and pointing `workerUrl` at the route.
70
+ */
71
+ workerUrl?: string;
72
+ /** Force recording regardless of backend sampling and targeting rules. Included in the `initSession` request body. */
73
+ forceRecord?: boolean;
74
+ /** Tab-side hint expiry; cached `sessionId`s older than this are discarded on init. */
75
+ sessionTtlMs?: number;
76
+ onStateChange?: (state: {
77
+ sessionId: string | null;
78
+ tabId: string | null;
79
+ connected: boolean;
80
+ /**
81
+ * Token issued by `initSession`. Null until the session is established.
82
+ * Most consumers should ignore this; it's exposed for tooling that needs
83
+ * to call session-lifecycle endpoints directly (e.g. `closeSession`).
84
+ */
85
+ sessionToken: string | null;
86
+ }) => void;
87
+ /** Called once when recording is permanently dead. SDK should dismantle the recorder. */
88
+ onTerminate?: (info: {
89
+ reason: string;
90
+ }) => void;
91
+ /**
92
+ * Optional verbose tracer. Called on key tab- and worker-side events
93
+ * (hello/welcome, init-session URL, ws connect URL, retries, transitions).
94
+ * Worker messages are forwarded over the port and tagged so you can tell them apart.
95
+ */
96
+ debugLogger?: (msg: string) => void;
97
+ }
98
+ /** Internal: wire-level frame. Session-id is implicit (Transport is session-bound at open). */
99
+ interface Frame {
100
+ tabId: string;
101
+ /** Monotonic, 0-based per Recording. Resets on adoption. */
102
+ eventCounter: number;
103
+ /** Opaque payload from the recorder. */
104
+ data: unknown;
105
+ /** Set only on the first frame emitted after the tab was adopted into a different session. */
106
+ adoptedFromSessionId?: string;
107
+ /** Epoch millis of the adoption event. */
108
+ adoptedAt?: number;
109
+ }
110
+ //#endregion
111
+ export { ContextValue as a, ClientContext as i, Frame as n, UserAgentContext as o, Uploader as r, collectUserAgentContext as s, CreateUploaderOptions as t };
@@ -0,0 +1,111 @@
1
+ //#region src/uploader/client-context.d.ts
2
+ /**
3
+ * JSON-shaped values accepted in a Context — matches `google.protobuf.Struct`.
4
+ */
5
+ type ContextValue = string | number | boolean | null | ContextValue[] | {
6
+ [key: string]: ContextValue;
7
+ };
8
+ /**
9
+ * Browser-environment metadata captured at session init. Sent verbatim in the
10
+ * `context` field of the InitSession request.
11
+ */
12
+ type UserAgentContext = {
13
+ userAgent?: string; /** Coarse OS family — `windows`, `macos`, `ios`, `android`, `linux`, or `unknown`. */
14
+ os?: string; /** Coarse browser family — `chrome`, `firefox`, `safari`, `edge`, or `unknown`. */
15
+ browser?: string; /** Browser major version. */
16
+ browserVersion?: string;
17
+ mobile?: boolean; /** BCP-47 language tag (e.g. `en-US`). */
18
+ languageCode?: string; /** IANA time zone (e.g. `Europe/Stockholm`). */
19
+ timeZone?: string; /** Viewport in CSS pixels. */
20
+ viewportWidth?: number;
21
+ viewportHeight?: number; /** Physical screen in CSS pixels. */
22
+ screenWidth?: number;
23
+ screenHeight?: number;
24
+ devicePixelRatio?: number; /** Initial document URI — without query/hash to avoid leaking PII. */
25
+ uri?: string;
26
+ referrer?: string;
27
+ };
28
+ interface ClientContext {
29
+ userAgent?: UserAgentContext;
30
+ [key: string]: ContextValue | undefined;
31
+ }
32
+ declare function collectUserAgentContext(): UserAgentContext | undefined;
33
+ //#endregion
34
+ //#region src/uploader/types.d.ts
35
+ interface Uploader {
36
+ (event: unknown): void;
37
+ close(): void;
38
+ }
39
+ interface CreateUploaderOptions {
40
+ /** Base URL of the recording backend (serves `/v1/sessions:initSession` and, by default, the WS ingest endpoint). */
41
+ apiUrl: string;
42
+ /**
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).
49
+ */
50
+ websocketUrl?: string;
51
+ /** Per-tenant secret. Hashed to scope the SharedWorker so different secrets never share a session, and sent in the `initSession` request body. */
52
+ clientSecret: string;
53
+ /** End-user identifier (visitor / device ID). Forwarded in the `initSession` body for sampling and eligibility. */
54
+ targetingKey?: string;
55
+ /**
56
+ * Session context sent in the InitSession request. The SDK auto-populates
57
+ * `userAgent` with browser/OS/screen metadata. Pass any extra keys as
58
+ * custom dimensions.
59
+ */
60
+ context?: ClientContext;
61
+ /** Force a worker mode for testing. Default `"auto"`. */
62
+ workerMode?: "shared" | "dedicated" | "auto";
63
+ /**
64
+ * Optional override for the worker script URL. By default the bundled worker is loaded
65
+ * from a `data:` URL — self-contained, no infrastructure setup, works across tabs
66
+ * (`SharedWorker` is keyed by `(scriptURL, name)` and identical content yields identical
67
+ * data: URLs). Provide this only if your `worker-src` CSP forbids `data:`; you can serve
68
+ * the bundled worker yourself by re-exporting `workerScript` from
69
+ * `csr-common/uploader` and pointing `workerUrl` at the route.
70
+ */
71
+ workerUrl?: string;
72
+ /** Force recording regardless of backend sampling and targeting rules. Included in the `initSession` request body. */
73
+ forceRecord?: boolean;
74
+ /** Tab-side hint expiry; cached `sessionId`s older than this are discarded on init. */
75
+ sessionTtlMs?: number;
76
+ onStateChange?: (state: {
77
+ sessionId: string | null;
78
+ tabId: string | null;
79
+ connected: boolean;
80
+ /**
81
+ * Token issued by `initSession`. Null until the session is established.
82
+ * Most consumers should ignore this; it's exposed for tooling that needs
83
+ * to call session-lifecycle endpoints directly (e.g. `closeSession`).
84
+ */
85
+ sessionToken: string | null;
86
+ }) => void;
87
+ /** Called once when recording is permanently dead. SDK should dismantle the recorder. */
88
+ onTerminate?: (info: {
89
+ reason: string;
90
+ }) => void;
91
+ /**
92
+ * Optional verbose tracer. Called on key tab- and worker-side events
93
+ * (hello/welcome, init-session URL, ws connect URL, retries, transitions).
94
+ * Worker messages are forwarded over the port and tagged so you can tell them apart.
95
+ */
96
+ debugLogger?: (msg: string) => void;
97
+ }
98
+ /** Internal: wire-level frame. Session-id is implicit (Transport is session-bound at open). */
99
+ interface Frame {
100
+ tabId: string;
101
+ /** Monotonic, 0-based per Recording. Resets on adoption. */
102
+ eventCounter: number;
103
+ /** Opaque payload from the recorder. */
104
+ data: unknown;
105
+ /** Set only on the first frame emitted after the tab was adopted into a different session. */
106
+ adoptedFromSessionId?: string;
107
+ /** Epoch millis of the adoption event. */
108
+ adoptedAt?: number;
109
+ }
110
+ //#endregion
111
+ export { ContextValue as a, ClientContext as i, Frame as n, UserAgentContext as o, Uploader as r, collectUserAgentContext as s, CreateUploaderOptions as t };
@@ -0,0 +1,276 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region \0rolldown/runtime.js
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
+ key = keys[i];
12
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
13
+ get: ((k) => from[k]).bind(null, key),
14
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
+ });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
20
+ value: mod,
21
+ enumerable: true
22
+ }) : target, mod));
23
+ //#endregion
24
+ let bowser = require("bowser");
25
+ bowser = __toESM(bowser, 1);
26
+ //#region src/uploader/client-context.ts
27
+ function collectUserAgentContext() {
28
+ if (typeof window === "undefined" || typeof navigator === "undefined") return void 0;
29
+ const parsed = bowser.default.parse(navigator.userAgent);
30
+ let timeZone;
31
+ try {
32
+ timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
33
+ } catch (_e) {}
34
+ return {
35
+ userAgent: navigator.userAgent,
36
+ os: parsed.os.name?.toLowerCase().replace(/\s+/g, ""),
37
+ browser: parsed.browser.name?.toLowerCase().replace(/\s+/g, ""),
38
+ browserVersion: parsed.browser.version?.split(".")[0],
39
+ mobile: parsed.platform.type ? parsed.platform.type === "mobile" || parsed.platform.type === "tablet" : void 0,
40
+ languageCode: navigator.language,
41
+ timeZone,
42
+ viewportWidth: window.innerWidth,
43
+ viewportHeight: window.innerHeight,
44
+ screenWidth: window.screen.width,
45
+ screenHeight: window.screen.height,
46
+ devicePixelRatio: window.devicePixelRatio,
47
+ uri: `${window.location.origin}${window.location.pathname}`,
48
+ referrer: document.referrer
49
+ };
50
+ }
51
+ //#endregion
52
+ //#region src/uploader/worker/worker-script.ts
53
+ 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 targetingKey;\n context;\n websocketUrl;\n log;\n forceRecord;\n constructor(apiUrl, clientSecret, targetingKey, context, websocketUrl, log = () => {}, forceRecord) {\n this.apiUrl = apiUrl;\n this.clientSecret = clientSecret;\n this.targetingKey = targetingKey;\n this.context = context;\n this.websocketUrl = websocketUrl;\n this.log = log;\n this.forceRecord = forceRecord;\n }\n async initSession() {\n const url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;\n this.log(`fetch POST ${url}`);\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n clientSecret: this.clientSecret,\n ...this.targetingKey ? { targetingKey: this.targetingKey } : {},\n ...this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {},\n ...this.forceRecord ? { forceRecord: true } : {}\n })\n });\n if (!res.ok) throw new Error(`init-session failed: HTTP ${res.status}`);\n const data = await res.json();\n if (data.skipRecording) return { skipRecording: true };\n if (!data.sessionId || !data.sessionToken) throw new Error(\"init-session response missing sessionId or sessionToken\");\n return {\n sessionId: data.sessionId,\n sessionToken: data.sessionToken\n };\n }\n async openTransport(sessionToken) {\n const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;\n const url = `${wsBase}${wsBase.includes(\"?\") ? \"&\" : \"?\"}session_token=${encodeURIComponent(sessionToken)}`;\n this.log(`WebSocket connect ${url}`);\n const transport = new WebSocketTransport(url);\n await transport.ready();\n return transport;\n }\n trimSlash(s) {\n return s.endsWith(\"/\") ? s.slice(0, -1) : s;\n }\n toWsScheme(base) {\n if (base.startsWith(\"https://\")) return `wss://${base.slice(8)}`;\n if (base.startsWith(\"http://\")) return `ws://${base.slice(7)}`;\n return base;\n }\n};\n//#endregion\n//#region src/uploader/worker/core.ts\nconst IDLE_GRACE_MS = 5e3;\nlet state = { phase: \"init\" };\nconst ports = [];\nlet idleTimer = null;\nfunction cancelIdleTimer() {\n if (idleTimer !== null) {\n clearTimeout(idleTimer);\n idleTimer = null;\n }\n}\nfunction log(msg) {\n for (const handle of ports) if (handle.debugLogs) handle.port.postMessage({\n type: \"log\",\n msg\n });\n}\n/**\n* The first hello \"locks in\" the session's apiUrl/clientSecret. Any later tab arriving\n* with different values is misconfigured — we reject it rather than silently using the\n* locked values. In SharedWorker mode the `name = hash(clientSecret)` scoping already\n* prevents secret-mismatch from sharing a worker, but this defends against the dedicated\n* path and against future bugs.\n*/\nlet lockedConfig = null;\nfunction registerPort(adapter) {\n cancelIdleTimer();\n const handle = {\n port: adapter,\n hello: null,\n debugLogs: false\n };\n ports.push(handle);\n adapter.onmessage((data) => {\n handleMessage(handle, data);\n });\n}\nfunction handleMessage(handle, message) {\n switch (message.type) {\n case \"hello\":\n handle.hello = message;\n handle.debugLogs = message.debugLogs ?? false;\n if (rejectIfIncompatible(handle)) return;\n if (state.phase !== \"dead\" && state.phase !== \"skipping\") detectDuplicateTab(handle);\n onHello(handle);\n return;\n case \"frame\":\n onFrame(message.frame);\n return;\n case \"bye\":\n onBye(handle);\n return;\n default: break;\n }\n}\n/**\n* Reject hellos whose `apiUrl`/`clientSecret` don't match the values established by the\n* first hello. Returns true if the port was rejected (caller should not continue\n* processing this hello).\n*/\nfunction rejectIfIncompatible(handle) {\n if (lockedConfig === null) return false;\n const incoming = handle.hello;\n if (incoming.apiUrl === lockedConfig.apiUrl && incoming.websocketUrl === lockedConfig.websocketUrl && incoming.clientSecret === lockedConfig.clientSecret) return false;\n handle.port.postMessage({\n type: \"dead\",\n reason: \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\"\n });\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n return true;\n}\n/**\n* If another already-connected port has the same `tabId`, this hello is from a duplicate\n* tab (browser \"Duplicate\" command clones sessionStorage). Mint a fresh `tabId` so the two\n* tabs don't collide on the same `(sessionId, tabId)` Recording. The new tabId is returned\n* to the tab in `welcome` so it can update its own state and sessionStorage.\n*/\nfunction detectDuplicateTab(handle) {\n const tabId = handle.hello.tabId;\n if (!ports.some((p) => p !== handle && p.hello?.tabId === tabId)) return;\n const fresh = crypto.randomUUID();\n handle.newTabId = fresh;\n handle.hello.tabId = fresh;\n}\nfunction onHello(handle) {\n switch (state.phase) {\n case \"init\":\n lockedConfig = {\n apiUrl: handle.hello.apiUrl,\n websocketUrl: handle.hello.websocketUrl,\n clientSecret: handle.hello.clientSecret\n };\n log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ?? \"(derive)\"} sessionIdHint=${handle.hello.sessionIdHint ?? \"(none)\"}`);\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n case \"initializing\": return;\n case \"active\":\n sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n return;\n case \"idle\": {\n const { client, sessionId, sessionToken } = state;\n state = { phase: \"initializing\" };\n resumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);\n return;\n }\n case \"skipping\":\n if (handle.hello.forceRecord) {\n log(\"forceRecord set; re-initializing from skipping state\");\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n }\n handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n return;\n case \"dead\":\n handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n return;\n default: break;\n }\n}\nasync function initializeSession(firstHello) {\n const client = new CsrClient(firstHello.apiUrl, firstHello.clientSecret, firstHello.targetingKey, firstHello.context, firstHello.websocketUrl, log, firstHello.forceRecord);\n if (firstHello.sessionIdHint && firstHello.sessionTokenHint) {\n log(`adopting sessionIdHint=${firstHello.sessionIdHint}`);\n try {\n const transport = await client.openTransport(firstHello.sessionTokenHint);\n wireTransport(transport);\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: firstHello.sessionIdHint,\n sessionToken: firstHello.sessionTokenHint\n };\n log(\"hint adopted; transport open\");\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n return;\n } catch (err) {\n log(`hint rejected (${String(err)}); falling back to fresh init`);\n }\n }\n let result;\n try {\n result = await client.initSession();\n } catch (err) {\n log(`init-session threw: ${String(err)}`);\n transitionToDead(`init-session-failed: ${String(err)}`);\n return;\n }\n if (\"skipRecording\" in result) {\n log(\"init-session: skipRecording\");\n state = { phase: \"skipping\" };\n return;\n }\n log(`init-session ok sessionId=${result.sessionId}`);\n let transport;\n try {\n transport = await client.openTransport(result.sessionToken);\n } catch (err) {\n log(`openTransport threw: ${String(err)}`);\n transitionToDead(`open-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport open; session active\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: result.sessionId,\n sessionToken: result.sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nasync function resumeTransport(client, sessionId, sessionToken) {\n log(\"resuming transport from idle\");\n let transport;\n try {\n transport = await client.openTransport(sessionToken);\n } catch (err) {\n log(`resume-transport threw: ${String(err)}`);\n transitionToDead(`resume-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport resumed\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId,\n sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nfunction wireTransport(transport) {\n transport.onClose((info) => {\n if (state.phase !== \"active\") return;\n transitionToDead(info.reason);\n });\n transport.onStateChange((info) => {\n if (state.phase !== \"active\") return;\n for (const handle of ports) handle.port.postMessage({\n type: \"state\",\n connected: info.connected\n });\n });\n}\nfunction transitionToDead(reason) {\n state = {\n phase: \"dead\",\n reason\n };\n for (const handle of ports) handle.port.postMessage({\n type: \"dead\",\n reason\n });\n}\nfunction flushPendingWelcomes() {\n for (const handle of ports) {\n if (handle.hello === null) continue;\n if (state.phase === \"active\") sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n else if (state.phase === \"skipping\") handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n else if (state.phase === \"dead\") handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n }\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n const hint = handle.hello?.sessionIdHint;\n const adopted = hint !== void 0 && hint !== currentSessionId;\n const newTabId = handle.newTabId;\n handle.port.postMessage({\n type: \"welcome\",\n result: {\n sessionId: currentSessionId,\n sessionToken: currentSessionToken\n },\n adoptedFromSessionId: adopted ? hint : void 0,\n newTabId,\n resetCounter: adopted || newTabId !== void 0\n });\n}\nfunction onFrame(frame) {\n if (state.phase !== \"active\") return;\n state.transport.send(frame);\n}\nfunction onBye(handle) {\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n if (ports.length === 0 && state.phase === \"active\") {\n log(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);\n idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n }\n}\nfunction enterIdle() {\n idleTimer = null;\n if (state.phase !== \"active\" || ports.length > 0) return;\n log(\"idle timeout; closing transport\");\n state.transport.close(\"idle\");\n state = {\n phase: \"idle\",\n client: state.client,\n sessionId: state.sessionId,\n sessionToken: state.sessionToken\n };\n}\n//#endregion\n//#region src/uploader/worker/entry.ts\nconst SharedWorkerScopeCtor = globalThis.SharedWorkerGlobalScope;\nif (typeof SharedWorkerScopeCtor === \"function\" && self instanceof SharedWorkerScopeCtor) self.onconnect = (event) => {\n const port = event.ports[0];\n port.start();\n registerPort(adaptMessagePort(port));\n};\nelse registerPort(adaptDedicatedSelf());\nfunction adaptMessagePort(port) {\n return {\n postMessage: (message) => port.postMessage(message),\n onmessage: (cb) => {\n port.onmessage = (e) => cb(e.data);\n }\n };\n}\nfunction adaptDedicatedSelf() {\n const ws = self;\n return {\n postMessage: (message) => ws.postMessage(message),\n onmessage: (cb) => {\n ws.onmessage = (e) => cb(e.data);\n }\n };\n}\n//#endregion\n";
54
+ //#endregion
55
+ //#region src/uploader/create-uploader.ts
56
+ const STORAGE_TAB_ID = "csr:tabId";
57
+ const STORAGE_SESSION = "csr:session";
58
+ const STORAGE_COUNTER = "csr:counter";
59
+ const DEFAULT_SESSION_TTL_MS = 1800 * 1e3;
60
+ async function createUploader(opts) {
61
+ const log = opts.debugLogger;
62
+ const sessionTtlMs = opts.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
63
+ const tabId = readOrMintTabId();
64
+ const sessionHint = readSessionHint(sessionTtlMs);
65
+ const counterHint = readCounter();
66
+ const mode = resolveMode(opts.workerMode ?? "auto");
67
+ log?.(`tab: createUploader mode=${mode} tabId=${tabId} sessionHint=${sessionHint?.id ?? "(none)"} counterHint=${counterHint}`);
68
+ const port = await openWorkerPort(mode, opts.clientSecret, opts.workerUrl);
69
+ let phase = "awaiting-welcome";
70
+ let sessionId = null;
71
+ let sessionToken = null;
72
+ let effectiveTabId = tabId;
73
+ let resolveWelcome;
74
+ const welcomePromise = new Promise((res) => {
75
+ resolveWelcome = res;
76
+ });
77
+ port.setHandler((data) => {
78
+ const msg = data;
79
+ if (msg.type === "log") {
80
+ log?.(`worker: ${msg.msg}`);
81
+ return;
82
+ }
83
+ if (phase === "awaiting-welcome") {
84
+ if (msg.type === "welcome" || msg.type === "dead") {
85
+ phase = msg.type === "welcome" ? "active" : "dead";
86
+ resolveWelcome(msg);
87
+ }
88
+ return;
89
+ }
90
+ if (msg.type === "state" && sessionId !== null) {
91
+ opts.onStateChange?.({
92
+ sessionId,
93
+ tabId: effectiveTabId,
94
+ connected: msg.connected,
95
+ sessionToken
96
+ });
97
+ return;
98
+ }
99
+ if (msg.type === "dead") {
100
+ phase = "dead";
101
+ if (sessionId !== null) opts.onStateChange?.({
102
+ sessionId,
103
+ tabId: effectiveTabId,
104
+ connected: false,
105
+ sessionToken
106
+ });
107
+ opts.onTerminate?.({ reason: msg.reason });
108
+ }
109
+ });
110
+ const autoUA = collectUserAgentContext();
111
+ const context = {
112
+ ...autoUA ? { userAgent: autoUA } : {},
113
+ ...opts.context ?? {}
114
+ };
115
+ port.postMessage({
116
+ type: "hello",
117
+ apiUrl: opts.apiUrl,
118
+ websocketUrl: opts.websocketUrl,
119
+ clientSecret: opts.clientSecret,
120
+ targetingKey: opts.targetingKey,
121
+ context,
122
+ forceRecord: opts.forceRecord,
123
+ sessionIdHint: sessionHint?.id,
124
+ sessionTokenHint: sessionHint?.token,
125
+ tabId,
126
+ debugLogs: log !== void 0
127
+ });
128
+ log?.("tab: hello sent, awaiting welcome");
129
+ const welcome = await welcomePromise;
130
+ log?.(welcome.type === "welcome" ? `tab: welcome (${"sessionId" in welcome.result ? `sessionId=${welcome.result.sessionId}` : "skipRecording"})` : `tab: dead reason=${welcome.reason}`);
131
+ if (welcome.type === "dead") throw new Error(`uploader: ${welcome.reason}`);
132
+ if ("skipRecording" in welcome.result) {
133
+ if (opts.forceRecord) log?.("tab: forceRecord was set but backend still skipped — backend may not support forceRecord yet");
134
+ return null;
135
+ }
136
+ sessionId = welcome.result.sessionId;
137
+ sessionToken = welcome.result.sessionToken;
138
+ writeSession(welcome.result.sessionId, welcome.result.sessionToken);
139
+ if (welcome.newTabId !== void 0) {
140
+ effectiveTabId = welcome.newTabId;
141
+ sessionStorage.setItem(STORAGE_TAB_ID, effectiveTabId);
142
+ }
143
+ let counter = welcome.resetCounter ? 0 : counterHint;
144
+ let nextAdoptionMeta = welcome.adoptedFromSessionId !== void 0 ? {
145
+ adoptedFromSessionId: welcome.adoptedFromSessionId,
146
+ adoptedAt: Date.now()
147
+ } : void 0;
148
+ opts.onStateChange?.({
149
+ sessionId,
150
+ tabId: effectiveTabId,
151
+ connected: true,
152
+ sessionToken
153
+ });
154
+ const flush = () => {
155
+ writeCounter(counter);
156
+ };
157
+ window.addEventListener("pagehide", () => {
158
+ flush();
159
+ try {
160
+ port.postMessage({
161
+ type: "bye",
162
+ reason: "pagehide"
163
+ });
164
+ } catch (_e) {}
165
+ });
166
+ document.addEventListener("visibilitychange", () => {
167
+ if (document.visibilityState === "hidden") flush();
168
+ });
169
+ const uploader = ((event) => {
170
+ if (phase === "dead") throw new Error("uploader: terminated");
171
+ const frame = {
172
+ tabId: effectiveTabId,
173
+ eventCounter: counter,
174
+ data: event,
175
+ ...nextAdoptionMeta ?? {}
176
+ };
177
+ counter += 1;
178
+ nextAdoptionMeta = void 0;
179
+ port.postMessage({
180
+ type: "frame",
181
+ frame
182
+ });
183
+ });
184
+ uploader.close = () => {
185
+ flush();
186
+ try {
187
+ port.postMessage({
188
+ type: "bye",
189
+ reason: "stop"
190
+ });
191
+ } catch (_e) {}
192
+ };
193
+ return uploader;
194
+ }
195
+ function resolveMode(mode) {
196
+ if (mode === "auto") return typeof SharedWorker !== "undefined" ? "shared" : "dedicated";
197
+ return mode;
198
+ }
199
+ async function openWorkerPort(mode, clientSecret, workerUrl) {
200
+ const url = workerUrl ?? toDataUrl("//#region src/uploader/worker/web-socket-transport.ts\n/**\n* WebSocket-backed Transport. Internal retry policy: clean server-initiated close after the\n* first successful open → reconnect and resume; abrupt close (or any close before the first\n* open) → fire `onClose` and stop. Frames received while a (re)connect is in progress are\n* buffered and flushed on open.\n*\n* `ready()` resolves on the first successful open and rejects on close-before-open. Callers\n* should await it before treating the Transport as live, so a failure to open can be caught\n* (e.g. 4404 unknown session) and recovered from.\n*/\nvar WebSocketTransport = class {\n url;\n ws = null;\n onCloseCb = null;\n onStateChangeCb = null;\n intentionallyClosed = false;\n dead = false;\n /** Frames buffered while a (re)connect is in progress. */\n pending = [];\n readyPromise;\n constructor(url) {\n this.url = url;\n this.readyPromise = new Promise((resolve, reject) => {\n this.connect(false, resolve, reject);\n });\n this.readyPromise.catch(() => {});\n }\n ready() {\n return this.readyPromise;\n }\n send(frame) {\n if (this.dead || this.intentionallyClosed) return;\n if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));\n else this.pending.push(frame);\n }\n close(reason = \"transport-close\") {\n this.intentionallyClosed = true;\n this.ws?.close(1e3, reason);\n }\n onClose(cb) {\n this.onCloseCb = cb;\n }\n onStateChange(cb) {\n this.onStateChangeCb = cb;\n }\n connect(isReconnect, onReady, onReadyFail) {\n const ws = new WebSocket(this.url);\n this.ws = ws;\n let opened = false;\n ws.onopen = () => {\n opened = true;\n onReady?.();\n if (isReconnect) this.onStateChangeCb?.({ connected: true });\n while (this.pending.length > 0) {\n const f = this.pending.shift();\n ws.send(JSON.stringify(f));\n }\n };\n ws.onclose = (event) => {\n if (this.intentionallyClosed) return;\n if (!opened) {\n const reason = `${isReconnect ? \"reconnect\" : \"initial\"}-failed code=${event.code}`;\n if (onReadyFail) {\n onReadyFail(new Error(reason));\n this.dead = true;\n } else this.die(reason);\n return;\n }\n if (event.wasClean && (event.code === 1e3 || event.code === 1001)) {\n this.onStateChangeCb?.({ connected: false });\n this.connect(true);\n } else this.die(`close code=${event.code} wasClean=${event.wasClean}`);\n };\n }\n die(reason) {\n this.dead = true;\n this.onCloseCb?.({ reason });\n }\n};\n//#endregion\n//#region src/uploader/worker/csr-client.ts\n/**\n* Single Client implementation that talks to the recording backend's REST + WS protocol.\n* Both dev-server and prod implement the same protocol, so we don't need polymorphism here yet.\n*/\nvar CsrClient = class {\n apiUrl;\n clientSecret;\n targetingKey;\n context;\n websocketUrl;\n log;\n forceRecord;\n constructor(apiUrl, clientSecret, targetingKey, context, websocketUrl, log = () => {}, forceRecord) {\n this.apiUrl = apiUrl;\n this.clientSecret = clientSecret;\n this.targetingKey = targetingKey;\n this.context = context;\n this.websocketUrl = websocketUrl;\n this.log = log;\n this.forceRecord = forceRecord;\n }\n async initSession() {\n const url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;\n this.log(`fetch POST ${url}`);\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n clientSecret: this.clientSecret,\n ...this.targetingKey ? { targetingKey: this.targetingKey } : {},\n ...this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {},\n ...this.forceRecord ? { forceRecord: true } : {}\n })\n });\n if (!res.ok) throw new Error(`init-session failed: HTTP ${res.status}`);\n const data = await res.json();\n if (data.skipRecording) return { skipRecording: true };\n if (!data.sessionId || !data.sessionToken) throw new Error(\"init-session response missing sessionId or sessionToken\");\n return {\n sessionId: data.sessionId,\n sessionToken: data.sessionToken\n };\n }\n async openTransport(sessionToken) {\n const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;\n const url = `${wsBase}${wsBase.includes(\"?\") ? \"&\" : \"?\"}session_token=${encodeURIComponent(sessionToken)}`;\n this.log(`WebSocket connect ${url}`);\n const transport = new WebSocketTransport(url);\n await transport.ready();\n return transport;\n }\n trimSlash(s) {\n return s.endsWith(\"/\") ? s.slice(0, -1) : s;\n }\n toWsScheme(base) {\n if (base.startsWith(\"https://\")) return `wss://${base.slice(8)}`;\n if (base.startsWith(\"http://\")) return `ws://${base.slice(7)}`;\n return base;\n }\n};\n//#endregion\n//#region src/uploader/worker/core.ts\nconst IDLE_GRACE_MS = 5e3;\nlet state = { phase: \"init\" };\nconst ports = [];\nlet idleTimer = null;\nfunction cancelIdleTimer() {\n if (idleTimer !== null) {\n clearTimeout(idleTimer);\n idleTimer = null;\n }\n}\nfunction log(msg) {\n for (const handle of ports) if (handle.debugLogs) handle.port.postMessage({\n type: \"log\",\n msg\n });\n}\n/**\n* The first hello \"locks in\" the session's apiUrl/clientSecret. Any later tab arriving\n* with different values is misconfigured — we reject it rather than silently using the\n* locked values. In SharedWorker mode the `name = hash(clientSecret)` scoping already\n* prevents secret-mismatch from sharing a worker, but this defends against the dedicated\n* path and against future bugs.\n*/\nlet lockedConfig = null;\nfunction registerPort(adapter) {\n cancelIdleTimer();\n const handle = {\n port: adapter,\n hello: null,\n debugLogs: false\n };\n ports.push(handle);\n adapter.onmessage((data) => {\n handleMessage(handle, data);\n });\n}\nfunction handleMessage(handle, message) {\n switch (message.type) {\n case \"hello\":\n handle.hello = message;\n handle.debugLogs = message.debugLogs ?? false;\n if (rejectIfIncompatible(handle)) return;\n if (state.phase !== \"dead\" && state.phase !== \"skipping\") detectDuplicateTab(handle);\n onHello(handle);\n return;\n case \"frame\":\n onFrame(message.frame);\n return;\n case \"bye\":\n onBye(handle);\n return;\n default: break;\n }\n}\n/**\n* Reject hellos whose `apiUrl`/`clientSecret` don't match the values established by the\n* first hello. Returns true if the port was rejected (caller should not continue\n* processing this hello).\n*/\nfunction rejectIfIncompatible(handle) {\n if (lockedConfig === null) return false;\n const incoming = handle.hello;\n if (incoming.apiUrl === lockedConfig.apiUrl && incoming.websocketUrl === lockedConfig.websocketUrl && incoming.clientSecret === lockedConfig.clientSecret) return false;\n handle.port.postMessage({\n type: \"dead\",\n reason: \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\"\n });\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n return true;\n}\n/**\n* If another already-connected port has the same `tabId`, this hello is from a duplicate\n* tab (browser \"Duplicate\" command clones sessionStorage). Mint a fresh `tabId` so the two\n* tabs don't collide on the same `(sessionId, tabId)` Recording. The new tabId is returned\n* to the tab in `welcome` so it can update its own state and sessionStorage.\n*/\nfunction detectDuplicateTab(handle) {\n const tabId = handle.hello.tabId;\n if (!ports.some((p) => p !== handle && p.hello?.tabId === tabId)) return;\n const fresh = crypto.randomUUID();\n handle.newTabId = fresh;\n handle.hello.tabId = fresh;\n}\nfunction onHello(handle) {\n switch (state.phase) {\n case \"init\":\n lockedConfig = {\n apiUrl: handle.hello.apiUrl,\n websocketUrl: handle.hello.websocketUrl,\n clientSecret: handle.hello.clientSecret\n };\n log(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ?? \"(derive)\"} sessionIdHint=${handle.hello.sessionIdHint ?? \"(none)\"}`);\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n case \"initializing\": return;\n case \"active\":\n sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n return;\n case \"idle\": {\n const { client, sessionId, sessionToken } = state;\n state = { phase: \"initializing\" };\n resumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);\n return;\n }\n case \"skipping\":\n if (handle.hello.forceRecord) {\n log(\"forceRecord set; re-initializing from skipping state\");\n state = { phase: \"initializing\" };\n initializeSession(handle.hello).then(flushPendingWelcomes);\n return;\n }\n handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n return;\n case \"dead\":\n handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n return;\n default: break;\n }\n}\nasync function initializeSession(firstHello) {\n const client = new CsrClient(firstHello.apiUrl, firstHello.clientSecret, firstHello.targetingKey, firstHello.context, firstHello.websocketUrl, log, firstHello.forceRecord);\n if (firstHello.sessionIdHint && firstHello.sessionTokenHint) {\n log(`adopting sessionIdHint=${firstHello.sessionIdHint}`);\n try {\n const transport = await client.openTransport(firstHello.sessionTokenHint);\n wireTransport(transport);\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: firstHello.sessionIdHint,\n sessionToken: firstHello.sessionTokenHint\n };\n log(\"hint adopted; transport open\");\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n return;\n } catch (err) {\n log(`hint rejected (${String(err)}); falling back to fresh init`);\n }\n }\n let result;\n try {\n result = await client.initSession();\n } catch (err) {\n log(`init-session threw: ${String(err)}`);\n transitionToDead(`init-session-failed: ${String(err)}`);\n return;\n }\n if (\"skipRecording\" in result) {\n log(\"init-session: skipRecording\");\n state = { phase: \"skipping\" };\n return;\n }\n log(`init-session ok sessionId=${result.sessionId}`);\n let transport;\n try {\n transport = await client.openTransport(result.sessionToken);\n } catch (err) {\n log(`openTransport threw: ${String(err)}`);\n transitionToDead(`open-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport open; session active\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId: result.sessionId,\n sessionToken: result.sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nasync function resumeTransport(client, sessionId, sessionToken) {\n log(\"resuming transport from idle\");\n let transport;\n try {\n transport = await client.openTransport(sessionToken);\n } catch (err) {\n log(`resume-transport threw: ${String(err)}`);\n transitionToDead(`resume-transport-failed: ${String(err)}`);\n return;\n }\n wireTransport(transport);\n log(\"transport resumed\");\n state = {\n phase: \"active\",\n client,\n transport,\n sessionId,\n sessionToken\n };\n if (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nfunction wireTransport(transport) {\n transport.onClose((info) => {\n if (state.phase !== \"active\") return;\n transitionToDead(info.reason);\n });\n transport.onStateChange((info) => {\n if (state.phase !== \"active\") return;\n for (const handle of ports) handle.port.postMessage({\n type: \"state\",\n connected: info.connected\n });\n });\n}\nfunction transitionToDead(reason) {\n state = {\n phase: \"dead\",\n reason\n };\n for (const handle of ports) handle.port.postMessage({\n type: \"dead\",\n reason\n });\n}\nfunction flushPendingWelcomes() {\n for (const handle of ports) {\n if (handle.hello === null) continue;\n if (state.phase === \"active\") sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n else if (state.phase === \"skipping\") handle.port.postMessage({\n type: \"welcome\",\n result: { skipRecording: true }\n });\n else if (state.phase === \"dead\") handle.port.postMessage({\n type: \"dead\",\n reason: state.reason\n });\n }\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n const hint = handle.hello?.sessionIdHint;\n const adopted = hint !== void 0 && hint !== currentSessionId;\n const newTabId = handle.newTabId;\n handle.port.postMessage({\n type: \"welcome\",\n result: {\n sessionId: currentSessionId,\n sessionToken: currentSessionToken\n },\n adoptedFromSessionId: adopted ? hint : void 0,\n newTabId,\n resetCounter: adopted || newTabId !== void 0\n });\n}\nfunction onFrame(frame) {\n if (state.phase !== \"active\") return;\n state.transport.send(frame);\n}\nfunction onBye(handle) {\n const idx = ports.indexOf(handle);\n if (idx >= 0) ports.splice(idx, 1);\n if (ports.length === 0 && state.phase === \"active\") {\n log(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);\n idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n }\n}\nfunction enterIdle() {\n idleTimer = null;\n if (state.phase !== \"active\" || ports.length > 0) return;\n log(\"idle timeout; closing transport\");\n state.transport.close(\"idle\");\n state = {\n phase: \"idle\",\n client: state.client,\n sessionId: state.sessionId,\n sessionToken: state.sessionToken\n };\n}\n//#endregion\n//#region src/uploader/worker/entry.ts\nconst SharedWorkerScopeCtor = globalThis.SharedWorkerGlobalScope;\nif (typeof SharedWorkerScopeCtor === \"function\" && self instanceof SharedWorkerScopeCtor) self.onconnect = (event) => {\n const port = event.ports[0];\n port.start();\n registerPort(adaptMessagePort(port));\n};\nelse registerPort(adaptDedicatedSelf());\nfunction adaptMessagePort(port) {\n return {\n postMessage: (message) => port.postMessage(message),\n onmessage: (cb) => {\n port.onmessage = (e) => cb(e.data);\n }\n };\n}\nfunction adaptDedicatedSelf() {\n const ws = self;\n return {\n postMessage: (message) => ws.postMessage(message),\n onmessage: (cb) => {\n ws.onmessage = (e) => cb(e.data);\n }\n };\n}\n//#endregion\n");
201
+ if (mode === "shared") {
202
+ const options = {
203
+ name: await hashSecret(clientSecret),
204
+ type: "module",
205
+ extendedLifetime: true
206
+ };
207
+ const worker = new SharedWorker(url, options);
208
+ worker.port.start();
209
+ return {
210
+ postMessage: (m) => worker.port.postMessage(m),
211
+ setHandler: (cb) => {
212
+ worker.port.onmessage = (e) => cb(e.data);
213
+ }
214
+ };
215
+ }
216
+ const worker = new Worker(url, { type: "module" });
217
+ return {
218
+ postMessage: (m) => worker.postMessage(m),
219
+ setHandler: (cb) => {
220
+ worker.onmessage = (e) => cb(e.data);
221
+ }
222
+ };
223
+ }
224
+ function toDataUrl(script) {
225
+ const bytes = new TextEncoder().encode(script);
226
+ let binary = "";
227
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
228
+ return `data:application/javascript;base64,${btoa(binary)}`;
229
+ }
230
+ async function hashSecret(secret) {
231
+ const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
232
+ return Array.from(new Uint8Array(buf)).slice(0, 8).map((b) => b.toString(16).padStart(2, "0")).join("");
233
+ }
234
+ function readOrMintTabId() {
235
+ let id = sessionStorage.getItem(STORAGE_TAB_ID);
236
+ if (!id) {
237
+ id = crypto.randomUUID();
238
+ sessionStorage.setItem(STORAGE_TAB_ID, id);
239
+ }
240
+ return id;
241
+ }
242
+ function readSessionHint(ttlMs) {
243
+ const raw = sessionStorage.getItem(STORAGE_SESSION);
244
+ if (!raw) return null;
245
+ try {
246
+ const parsed = JSON.parse(raw);
247
+ if (Date.now() - parsed.ts > ttlMs) return null;
248
+ if (!parsed.token) return null;
249
+ return {
250
+ id: parsed.id,
251
+ token: parsed.token
252
+ };
253
+ } catch (_e) {
254
+ return null;
255
+ }
256
+ }
257
+ function writeSession(sessionId, sessionToken) {
258
+ sessionStorage.setItem(STORAGE_SESSION, JSON.stringify({
259
+ id: sessionId,
260
+ token: sessionToken,
261
+ ts: Date.now()
262
+ }));
263
+ }
264
+ function readCounter() {
265
+ const raw = sessionStorage.getItem(STORAGE_COUNTER);
266
+ if (!raw) return 0;
267
+ const n = Number(raw);
268
+ return Number.isFinite(n) ? n : 0;
269
+ }
270
+ function writeCounter(counter) {
271
+ sessionStorage.setItem(STORAGE_COUNTER, String(counter));
272
+ }
273
+ //#endregion
274
+ exports.collectUserAgentContext = collectUserAgentContext;
275
+ exports.createUploader = createUploader;
276
+ exports.workerScript = workerScript;