@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
|
@@ -69,48 +69,63 @@ describe('CsrClient.initSession', () => {
|
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
describe('CsrClient.openTransport', () => {
|
|
72
|
-
it('derives
|
|
73
|
-
installMockWsServer('ws://api.example/sessions/stream
|
|
72
|
+
it('derives the unchanged ws:// endpoint and sends the token as a protocol', async () => {
|
|
73
|
+
const ws = installMockWsServer('ws://api.example/sessions/stream');
|
|
74
74
|
|
|
75
75
|
const client = new CsrClient('http://api.example', 'secret', undefined);
|
|
76
|
-
await expect(client.openTransport('tok')).resolves.toBeDefined();
|
|
76
|
+
await expect(client.openTransport('tok-1')).resolves.toBeDefined();
|
|
77
|
+
|
|
78
|
+
expect(ws.connections[0].url).toBe('ws://api.example/sessions/stream');
|
|
79
|
+
expect(ws.protocolOffers).toEqual([['recording.v1', 'auth.tok-1']]);
|
|
77
80
|
});
|
|
78
81
|
|
|
79
82
|
it('derives a wss:// URL from an https:// apiUrl', async () => {
|
|
80
|
-
installMockWsServer('wss://api.example/sessions/stream
|
|
83
|
+
installMockWsServer('wss://api.example/sessions/stream');
|
|
81
84
|
|
|
82
85
|
const client = new CsrClient('https://api.example', 'secret', undefined);
|
|
83
86
|
await expect(client.openTransport('tok')).resolves.toBeDefined();
|
|
84
87
|
});
|
|
85
88
|
|
|
86
|
-
it('
|
|
87
|
-
installMockWsServer('wss://recording-ws.confidence.dev/sessions/stream?
|
|
89
|
+
it('preserves unrelated query parameters in a configured websocketUrl', async () => {
|
|
90
|
+
const ws = installMockWsServer('wss://recording-ws.confidence.dev/sessions/stream?region=eu');
|
|
88
91
|
|
|
89
92
|
const client = new CsrClient(
|
|
90
93
|
'https://recording.confidence.dev',
|
|
91
94
|
'secret',
|
|
92
95
|
undefined,
|
|
93
|
-
'wss://recording-ws.confidence.dev/sessions/stream',
|
|
96
|
+
'wss://recording-ws.confidence.dev/sessions/stream?region=eu',
|
|
94
97
|
);
|
|
95
98
|
await expect(client.openTransport('tok')).resolves.toBeDefined();
|
|
99
|
+
|
|
100
|
+
expect(ws.connections[0].url).toBe('wss://recording-ws.confidence.dev/sessions/stream?region=eu');
|
|
96
101
|
});
|
|
97
102
|
|
|
98
|
-
it('
|
|
99
|
-
installMockWsServer('wss://api/sessions/stream
|
|
103
|
+
it('keeps the credential out of the URL and debug log', async () => {
|
|
104
|
+
const ws = installMockWsServer('wss://api/sessions/stream');
|
|
100
105
|
|
|
101
106
|
const logs: string[] = [];
|
|
102
107
|
const client = new CsrClient('https://api', 'secret', undefined, undefined, msg => logs.push(msg));
|
|
103
|
-
await client.openTransport('
|
|
108
|
+
await client.openTransport('sensitive-token');
|
|
104
109
|
|
|
105
110
|
expect(logs).toHaveLength(1);
|
|
106
|
-
expect(logs[0]).
|
|
107
|
-
|
|
111
|
+
expect(logs[0]).toBe('WebSocket connect wss://api/sessions/stream');
|
|
112
|
+
for (const value of [ws.connections[0].url, ...logs]) {
|
|
113
|
+
expect(value).not.toContain('sensitive-token');
|
|
114
|
+
}
|
|
108
115
|
});
|
|
109
116
|
|
|
110
|
-
it('
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
117
|
+
it.each(['wss://api/sessions/stream?session_token=legacy', 'wss://api/sessions/stream?region=eu&session_token='])(
|
|
118
|
+
'rejects a configured legacy credential before logging or connecting: %s',
|
|
119
|
+
async websocketUrl => {
|
|
120
|
+
const ws = installMockWsServer(websocketUrl);
|
|
121
|
+
const logs: string[] = [];
|
|
122
|
+
const client = new CsrClient('https://api', 'secret', undefined, websocketUrl, msg => logs.push(msg));
|
|
123
|
+
|
|
124
|
+
await expect(client.openTransport('sensitive-token')).rejects.toThrowError(
|
|
125
|
+
'WebSocket URL must not include a session token',
|
|
126
|
+
);
|
|
127
|
+
expect(logs).toEqual([]);
|
|
128
|
+
expect(ws.connections).toEqual([]);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
116
131
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ClientContext } from '../client-context';
|
|
2
2
|
import type { Client, Transport } from '../types';
|
|
3
3
|
import { WebSocketTransport } from './web-socket-transport';
|
|
4
|
+
import { recordingProtocols } from './websocket-auth';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Single Client implementation that talks to the recording backend's REST + WS protocol.
|
|
@@ -45,10 +46,18 @@ export class CsrClient implements Client {
|
|
|
45
46
|
|
|
46
47
|
async openTransport(sessionToken: string): Promise<Transport> {
|
|
47
48
|
const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
let parsedUrl: URL;
|
|
50
|
+
try {
|
|
51
|
+
parsedUrl = new URL(wsBase);
|
|
52
|
+
} catch (_error) {
|
|
53
|
+
throw new Error('Invalid WebSocket URL');
|
|
54
|
+
}
|
|
55
|
+
if (parsedUrl.searchParams.has('session_token')) {
|
|
56
|
+
throw new Error('WebSocket URL must not include a session token');
|
|
57
|
+
}
|
|
58
|
+
const protocols = recordingProtocols(sessionToken);
|
|
59
|
+
this.log(`WebSocket connect ${wsBase}`);
|
|
60
|
+
const transport = new WebSocketTransport(wsBase, protocols);
|
|
52
61
|
await transport.ready();
|
|
53
62
|
return transport;
|
|
54
63
|
}
|
|
@@ -1,12 +1,24 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
1
|
+
import { describe, expect, it, onTestFinished, vi } from 'vitest';
|
|
2
2
|
import { installMockWsServer } from '../../test-utils';
|
|
3
3
|
import { WebSocketTransport } from './web-socket-transport';
|
|
4
4
|
|
|
5
|
-
const URL = 'ws://localhost:1234/sessions/stream
|
|
5
|
+
const URL = 'ws://localhost:1234/sessions/stream';
|
|
6
|
+
const PROTOCOLS = ['recording.v1', 'auth.sensitive-token'];
|
|
6
7
|
|
|
7
8
|
describe('WebSocketTransport', () => {
|
|
8
9
|
const setup = () => installMockWsServer(URL);
|
|
9
10
|
|
|
11
|
+
it('copies and supplies the protocols on the first connection', async () => {
|
|
12
|
+
const ws = setup();
|
|
13
|
+
const protocols = [...PROTOCOLS];
|
|
14
|
+
const t = new WebSocketTransport(URL, protocols);
|
|
15
|
+
protocols[0] = 'changed-after-construction';
|
|
16
|
+
|
|
17
|
+
await expect(t.ready()).resolves.toBeUndefined();
|
|
18
|
+
|
|
19
|
+
expect(ws.protocolOffers).toEqual([PROTOCOLS]);
|
|
20
|
+
});
|
|
21
|
+
|
|
10
22
|
it('resolves ready() once the server accepts the connection', async () => {
|
|
11
23
|
setup();
|
|
12
24
|
const t = new WebSocketTransport(URL);
|
|
@@ -48,8 +60,8 @@ describe('WebSocketTransport', () => {
|
|
|
48
60
|
});
|
|
49
61
|
|
|
50
62
|
it('reconnects on a graceful drain (code 1000) and emits state changes', async () => {
|
|
51
|
-
const { waitForConnection } = setup();
|
|
52
|
-
const t = new WebSocketTransport(URL);
|
|
63
|
+
const { protocolOffers, waitForConnection } = setup();
|
|
64
|
+
const t = new WebSocketTransport(URL, PROTOCOLS);
|
|
53
65
|
const states: boolean[] = [];
|
|
54
66
|
t.onStateChange(({ connected }) => states.push(connected));
|
|
55
67
|
await t.ready();
|
|
@@ -61,6 +73,61 @@ describe('WebSocketTransport', () => {
|
|
|
61
73
|
// First open → no state event (welcome implies connected).
|
|
62
74
|
// Drain → state(false). Reconnect open → state(true).
|
|
63
75
|
await vi.waitFor(() => expect(states).toEqual([false, true]));
|
|
76
|
+
expect(protocolOffers).toEqual([PROTOCOLS, PROTOCOLS]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it.each([
|
|
80
|
+
['no protocol', ''],
|
|
81
|
+
['the authentication protocol', PROTOCOLS[1]],
|
|
82
|
+
])('rejects readiness and keeps buffered frames when the server selects %s', async (_case, selectedProtocol) => {
|
|
83
|
+
const { messages } = installMockWsServer(URL, { selectProtocol: () => selectedProtocol });
|
|
84
|
+
const t = new WebSocketTransport(URL, PROTOCOLS);
|
|
85
|
+
t.send({ tabId: 'tab-1', eventCounter: 0, data: 'must-not-send' });
|
|
86
|
+
|
|
87
|
+
const error = await t.ready().catch((caught: unknown) => caught);
|
|
88
|
+
|
|
89
|
+
expect(String(error)).toContain('initial-failed');
|
|
90
|
+
expect(String(error)).not.toContain(PROTOCOLS[1]);
|
|
91
|
+
expect(messages).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('uses the protocols on reconnect and never flushes frames after wrong selection', async () => {
|
|
95
|
+
const ws = installMockWsServer(URL, {
|
|
96
|
+
selectProtocol: (_protocols, connectionIndex) => (connectionIndex === 0 ? PROTOCOLS[0] : PROTOCOLS[1]),
|
|
97
|
+
});
|
|
98
|
+
const t = new WebSocketTransport(URL, PROTOCOLS);
|
|
99
|
+
const closeReasons: string[] = [];
|
|
100
|
+
t.onClose(({ reason }) => closeReasons.push(reason));
|
|
101
|
+
t.onStateChange(({ connected }) => {
|
|
102
|
+
if (!connected) {
|
|
103
|
+
t.send({ tabId: 'tab-1', eventCounter: 0, data: 'must-not-send' });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
await t.ready();
|
|
107
|
+
|
|
108
|
+
const first = await ws.waitForConnection();
|
|
109
|
+
first.close({ code: 1000, reason: 'drain', wasClean: true });
|
|
110
|
+
|
|
111
|
+
await vi.waitFor(() => expect(closeReasons).toEqual(['reconnect-failed']));
|
|
112
|
+
expect(ws.protocolOffers).toEqual([PROTOCOLS, PROTOCOLS]);
|
|
113
|
+
expect(ws.messages).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('hides protocol values from WebSocket constructor failures', async () => {
|
|
117
|
+
const protocolFromBrowserError = PROTOCOLS[1];
|
|
118
|
+
class ThrowingWebSocket {
|
|
119
|
+
constructor() {
|
|
120
|
+
throw new Error(`synthetic constructor failure for ${protocolFromBrowserError}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
onTestFinished(() => vi.unstubAllGlobals());
|
|
124
|
+
vi.stubGlobal('WebSocket', ThrowingWebSocket);
|
|
125
|
+
|
|
126
|
+
const t = new WebSocketTransport(URL, PROTOCOLS);
|
|
127
|
+
const error = await t.ready().catch((caught: unknown) => caught);
|
|
128
|
+
|
|
129
|
+
expect(String(error)).toContain('initial-failed');
|
|
130
|
+
expect(String(error)).not.toContain(protocolFromBrowserError);
|
|
64
131
|
});
|
|
65
132
|
|
|
66
133
|
it('fires onClose with reason on abrupt close after open', async () => {
|
|
@@ -19,8 +19,10 @@ export class WebSocketTransport implements Transport {
|
|
|
19
19
|
/** Frames buffered while a (re)connect is in progress. */
|
|
20
20
|
private pending: Frame[] = [];
|
|
21
21
|
private readyPromise: Promise<void>;
|
|
22
|
+
private readonly protocols: string[];
|
|
22
23
|
|
|
23
|
-
constructor(private readonly url: string) {
|
|
24
|
+
constructor(private readonly url: string, protocols: string[] = []) {
|
|
25
|
+
this.protocols = [...protocols];
|
|
24
26
|
this.readyPromise = new Promise<void>((resolve, reject) => {
|
|
25
27
|
this.connect(false, resolve, reject);
|
|
26
28
|
});
|
|
@@ -56,11 +58,23 @@ export class WebSocketTransport implements Transport {
|
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
private connect(isReconnect: boolean, onReady?: () => void, onReadyFail?: (err: Error) => void): void {
|
|
59
|
-
|
|
61
|
+
let ws: WebSocket;
|
|
62
|
+
try {
|
|
63
|
+
ws = new WebSocket(this.url, [...this.protocols]);
|
|
64
|
+
} catch (_error) {
|
|
65
|
+
this.failConnection(isReconnect, onReadyFail);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
60
68
|
this.ws = ws;
|
|
61
69
|
let opened = false;
|
|
62
70
|
|
|
63
71
|
ws.onopen = () => {
|
|
72
|
+
const expectedProtocol = this.protocols[0];
|
|
73
|
+
if (expectedProtocol !== undefined && ws.protocol !== expectedProtocol) {
|
|
74
|
+
this.failConnection(isReconnect, onReadyFail);
|
|
75
|
+
ws.close(1000, 'protocol-mismatch');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
64
78
|
opened = true;
|
|
65
79
|
onReady?.();
|
|
66
80
|
// Emit state on every successful open EXCEPT the very first one (welcome already
|
|
@@ -75,20 +89,11 @@ export class WebSocketTransport implements Transport {
|
|
|
75
89
|
};
|
|
76
90
|
|
|
77
91
|
ws.onclose = event => {
|
|
78
|
-
if (this.intentionallyClosed) return;
|
|
92
|
+
if (this.intentionallyClosed || this.dead) return;
|
|
79
93
|
if (!opened) {
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (onReadyFail) {
|
|
84
|
-
// First attempt — surface the failure to whoever is awaiting `ready()` so they
|
|
85
|
-
// can decide whether to recover (e.g. fall back to a fresh initSession).
|
|
86
|
-
onReadyFail(new Error(reason));
|
|
87
|
-
this.dead = true;
|
|
88
|
-
} else {
|
|
89
|
-
// Reconnect failed; the consumer is past `ready()` and only learns about it via onClose.
|
|
90
|
-
this.die(reason);
|
|
91
|
-
}
|
|
94
|
+
// Browsers do not expose the HTTP status for a failed WebSocket handshake. Keep
|
|
95
|
+
// this failure generic and do not infer an authentication result from close 1006.
|
|
96
|
+
this.failConnection(isReconnect, onReadyFail);
|
|
92
97
|
return;
|
|
93
98
|
}
|
|
94
99
|
// The WS opened and is now closing. Distinguish graceful drain (retry) from app
|
|
@@ -106,6 +111,16 @@ export class WebSocketTransport implements Transport {
|
|
|
106
111
|
};
|
|
107
112
|
}
|
|
108
113
|
|
|
114
|
+
private failConnection(isReconnect: boolean, onReadyFail?: (err: Error) => void): void {
|
|
115
|
+
const reason = isReconnect ? 'reconnect-failed' : 'initial-failed';
|
|
116
|
+
if (onReadyFail) {
|
|
117
|
+
this.dead = true;
|
|
118
|
+
onReadyFail(new Error(reason));
|
|
119
|
+
} else {
|
|
120
|
+
this.die(reason);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
109
124
|
private die(reason: string): void {
|
|
110
125
|
this.dead = true;
|
|
111
126
|
this.onCloseCb?.({ reason });
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { recordingProtocols } from './websocket-auth';
|
|
3
|
+
|
|
4
|
+
describe('recordingProtocols', () => {
|
|
5
|
+
it('offers the issued token unchanged, including dots, hyphens, and underscores', () => {
|
|
6
|
+
expect(recordingProtocols('cGF5bG9hZA.c2lnbmF0dXJl_-')).toEqual(['recording.v1', 'auth.cGF5bG9hZA.c2lnbmF0dXJl_-']);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('allows a token at the 4096-character limit', () => {
|
|
10
|
+
expect(recordingProtocols('a'.repeat(4096))[1]).toHaveLength('auth.'.length + 4096);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('rejects a token longer than 4096 characters without exposing it', () => {
|
|
14
|
+
const token = `sensitive-${'a'.repeat(4097)}`;
|
|
15
|
+
expect(() => recordingProtocols(token)).toThrowError('Session token is too long for WebSocket authentication');
|
|
16
|
+
try {
|
|
17
|
+
recordingProtocols(token);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
expect(String(error)).not.toContain(token);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it.each(['', 'sensitive/token', 'sensitive=token', 'sensitive token', 'tøken-☃', 'sensitive\n'])(
|
|
24
|
+
'rejects unsupported token characters without exposing the token',
|
|
25
|
+
token => {
|
|
26
|
+
expect(() => recordingProtocols(token)).toThrowError('Invalid session token for WebSocket authentication');
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const RECORDING_PROTOCOL = 'recording.v1';
|
|
2
|
+
|
|
3
|
+
const AUTH_PROTOCOL_PREFIX = 'auth.';
|
|
4
|
+
const MAX_TOKEN_LENGTH = 4096;
|
|
5
|
+
|
|
6
|
+
export function recordingProtocols(sessionToken: string): string[] {
|
|
7
|
+
if (sessionToken.length > MAX_TOKEN_LENGTH) {
|
|
8
|
+
throw new Error('Session token is too long for WebSocket authentication');
|
|
9
|
+
}
|
|
10
|
+
// Issued tokens are unpadded Base64URL parts joined by a dot; dev tokens are UUIDs.
|
|
11
|
+
if (!sessionToken || /[^A-Za-z0-9._-]/.test(sessionToken)) {
|
|
12
|
+
throw new Error('Invalid session token for WebSocket authentication');
|
|
13
|
+
}
|
|
14
|
+
return [RECORDING_PROTOCOL, `${AUTH_PROTOCOL_PREFIX}${sessionToken}`];
|
|
15
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { runInNewContext } from 'node:vm';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { workerScript } from './worker-script';
|
|
4
|
+
|
|
5
|
+
const API_URL = 'https://api.example';
|
|
6
|
+
const WS_URL = 'wss://api.example/sessions/stream?region=eu';
|
|
7
|
+
const TOKEN = 'worker-marker-token';
|
|
8
|
+
const PROTOCOLS = ['recording.v1', `auth.${TOKEN}`];
|
|
9
|
+
|
|
10
|
+
interface WorkerMessage {
|
|
11
|
+
type: string;
|
|
12
|
+
msg?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface WebSocketCall {
|
|
16
|
+
url: string;
|
|
17
|
+
protocols: string[];
|
|
18
|
+
socket: ControlledWebSocket;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class ControlledWebSocket {
|
|
22
|
+
static readonly CONNECTING = 0;
|
|
23
|
+
static readonly OPEN = 1;
|
|
24
|
+
static readonly CLOSING = 2;
|
|
25
|
+
static readonly CLOSED = 3;
|
|
26
|
+
|
|
27
|
+
readonly url: string;
|
|
28
|
+
readonly protocol: string;
|
|
29
|
+
readyState = ControlledWebSocket.CONNECTING;
|
|
30
|
+
onopen: (() => void) | null = null;
|
|
31
|
+
onclose: ((event: { code: number; wasClean: boolean }) => void) | null = null;
|
|
32
|
+
sent: string[] = [];
|
|
33
|
+
|
|
34
|
+
constructor(calls: WebSocketCall[], url: string | URL, protocols: string | string[] = []) {
|
|
35
|
+
this.url = String(url);
|
|
36
|
+
const offered = typeof protocols === 'string' ? [protocols] : [...protocols];
|
|
37
|
+
this.protocol = offered[0] ?? '';
|
|
38
|
+
calls.push({ url: this.url, protocols: offered, socket: this });
|
|
39
|
+
queueMicrotask(() => {
|
|
40
|
+
this.readyState = ControlledWebSocket.OPEN;
|
|
41
|
+
this.onopen?.();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
send(message: string): void {
|
|
46
|
+
this.sent.push(message);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
close(code = 1000): void {
|
|
50
|
+
this.readyState = ControlledWebSocket.CLOSED;
|
|
51
|
+
this.onclose?.({ code, wasClean: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
serverClose(code = 1000, wasClean = true): void {
|
|
55
|
+
this.readyState = ControlledWebSocket.CLOSED;
|
|
56
|
+
this.onclose?.({ code, wasClean });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createHarness(
|
|
61
|
+
mode: 'dedicated' | 'shared',
|
|
62
|
+
initResult: { sessionId: string; sessionToken: string } = {
|
|
63
|
+
sessionId: 'worker-session',
|
|
64
|
+
sessionToken: TOKEN,
|
|
65
|
+
},
|
|
66
|
+
) {
|
|
67
|
+
const calls: WebSocketCall[] = [];
|
|
68
|
+
const received: WorkerMessage[] = [];
|
|
69
|
+
const fetchCalls: string[] = [];
|
|
70
|
+
let tabToWorker: ((event: { data: unknown }) => void) | null = null;
|
|
71
|
+
let started = false;
|
|
72
|
+
|
|
73
|
+
class TestWebSocket extends ControlledWebSocket {
|
|
74
|
+
static readonly CONNECTING = ControlledWebSocket.CONNECTING;
|
|
75
|
+
static readonly OPEN = ControlledWebSocket.OPEN;
|
|
76
|
+
static readonly CLOSING = ControlledWebSocket.CLOSING;
|
|
77
|
+
static readonly CLOSED = ControlledWebSocket.CLOSED;
|
|
78
|
+
|
|
79
|
+
constructor(url: string | URL, protocols?: string | string[]) {
|
|
80
|
+
super(calls, url, protocols);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
class TestSharedWorkerGlobalScope {}
|
|
85
|
+
|
|
86
|
+
const port = {
|
|
87
|
+
start: () => {
|
|
88
|
+
started = true;
|
|
89
|
+
},
|
|
90
|
+
postMessage: (message: WorkerMessage) => received.push(message),
|
|
91
|
+
get onmessage() {
|
|
92
|
+
return tabToWorker;
|
|
93
|
+
},
|
|
94
|
+
set onmessage(callback: ((event: { data: unknown }) => void) | null) {
|
|
95
|
+
tabToWorker = callback;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const dedicatedSelf = {
|
|
100
|
+
postMessage: (message: WorkerMessage) => received.push(message),
|
|
101
|
+
get onmessage() {
|
|
102
|
+
return tabToWorker;
|
|
103
|
+
},
|
|
104
|
+
set onmessage(callback: ((event: { data: unknown }) => void) | null) {
|
|
105
|
+
tabToWorker = callback;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
const self = mode === 'shared' ? new TestSharedWorkerGlobalScope() : dedicatedSelf;
|
|
109
|
+
|
|
110
|
+
runInNewContext(workerScript, {
|
|
111
|
+
URL,
|
|
112
|
+
WebSocket: TestWebSocket,
|
|
113
|
+
clearTimeout,
|
|
114
|
+
crypto,
|
|
115
|
+
fetch: async (url: string) => {
|
|
116
|
+
fetchCalls.push(url);
|
|
117
|
+
return {
|
|
118
|
+
ok: true,
|
|
119
|
+
status: 200,
|
|
120
|
+
json: async () => initResult,
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
queueMicrotask,
|
|
124
|
+
self,
|
|
125
|
+
setTimeout,
|
|
126
|
+
SharedWorkerGlobalScope: mode === 'shared' ? TestSharedWorkerGlobalScope : undefined,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (mode === 'shared') {
|
|
130
|
+
const shared = self as TestSharedWorkerGlobalScope & {
|
|
131
|
+
onconnect: (event: { ports: [typeof port] }) => void;
|
|
132
|
+
};
|
|
133
|
+
shared.onconnect({ ports: [port] });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
calls,
|
|
138
|
+
received,
|
|
139
|
+
fetchCalls,
|
|
140
|
+
send: (data: unknown) => {
|
|
141
|
+
if (!tabToWorker) throw new Error('worker message handler is not installed');
|
|
142
|
+
tabToWorker({ data });
|
|
143
|
+
},
|
|
144
|
+
started: () => started,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
describe('generated workerScript', () => {
|
|
149
|
+
it.each(['dedicated', 'shared'] as const)(
|
|
150
|
+
'uses header authentication without credential leaks for the %s worker and its reconnect',
|
|
151
|
+
async mode => {
|
|
152
|
+
const harness = createHarness(mode);
|
|
153
|
+
if (mode === 'shared') expect(harness.started()).toBe(true);
|
|
154
|
+
|
|
155
|
+
harness.send({
|
|
156
|
+
type: 'hello',
|
|
157
|
+
apiUrl: API_URL,
|
|
158
|
+
websocketUrl: WS_URL,
|
|
159
|
+
clientSecret: 'client-secret',
|
|
160
|
+
tabId: 'tab-1',
|
|
161
|
+
debugLogs: true,
|
|
162
|
+
});
|
|
163
|
+
await vi.waitFor(() => expect(harness.received.some(message => message.type === 'welcome')).toBe(true));
|
|
164
|
+
|
|
165
|
+
expect(harness.fetchCalls).toEqual([`${API_URL}/v1/sessions:initSession`]);
|
|
166
|
+
expect(harness.calls[0].url).toBe(WS_URL);
|
|
167
|
+
expect(harness.calls[0].protocols).toEqual(PROTOCOLS);
|
|
168
|
+
|
|
169
|
+
harness.calls[0].socket.serverClose();
|
|
170
|
+
await vi.waitFor(() => expect(harness.calls).toHaveLength(2));
|
|
171
|
+
expect(harness.calls[1].url).toBe(WS_URL);
|
|
172
|
+
expect(harness.calls[1].protocols).toEqual(PROTOCOLS);
|
|
173
|
+
|
|
174
|
+
const logs = harness.received.filter(message => message.type === 'log').map(message => message.msg ?? '');
|
|
175
|
+
expect(logs).toContain('init-session ok sessionId=worker-session');
|
|
176
|
+
for (const value of [...harness.calls.map(call => call.url), ...logs]) {
|
|
177
|
+
expect(value).not.toContain(TOKEN);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
it.each(['dedicated', 'shared'] as const)(
|
|
183
|
+
'rejects a legacy credential without logging it in the %s worker',
|
|
184
|
+
async mode => {
|
|
185
|
+
const harness = createHarness(mode);
|
|
186
|
+
|
|
187
|
+
harness.send({
|
|
188
|
+
type: 'hello',
|
|
189
|
+
apiUrl: API_URL,
|
|
190
|
+
websocketUrl: `${WS_URL}&session_token=${TOKEN}`,
|
|
191
|
+
clientSecret: 'client-secret',
|
|
192
|
+
tabId: 'tab-1',
|
|
193
|
+
debugLogs: true,
|
|
194
|
+
});
|
|
195
|
+
await vi.waitFor(() => expect(harness.received.some(message => message.type === 'dead')).toBe(true));
|
|
196
|
+
|
|
197
|
+
expect(harness.calls).toEqual([]);
|
|
198
|
+
const logs = harness.received.filter(message => message.type === 'log').map(message => message.msg ?? '');
|
|
199
|
+
expect(logs).toContain('init-session ok sessionId=worker-session');
|
|
200
|
+
for (const log of logs) {
|
|
201
|
+
expect(log).not.toContain(TOKEN);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
});
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Generated by scripts/build-worker.mjs at build time. Do not edit.
|
|
2
2
|
// Run `yarn workspace @spotify-confidence/csr-common build:worker` to regenerate.
|
|
3
|
-
export const workerScript: string = "//#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\turl;\n\tws = null;\n\tonCloseCb = null;\n\tonStateChangeCb = null;\n\tintentionallyClosed = false;\n\tdead = false;\n\t/** Frames buffered while a (re)connect is in progress. */\n\tpending = [];\n\treadyPromise;\n\tconstructor(url) {\n\t\tthis.url = url;\n\t\tthis.readyPromise = new Promise((resolve, reject) => {\n\t\t\tthis.connect(false, resolve, reject);\n\t\t});\n\t\tthis.readyPromise.catch(() => {});\n\t}\n\tready() {\n\t\treturn this.readyPromise;\n\t}\n\tsend(frame) {\n\t\tif (this.dead || this.intentionallyClosed) return;\n\t\tif (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));\n\t\telse this.pending.push(frame);\n\t}\n\tclose(reason = \"transport-close\") {\n\t\tthis.intentionallyClosed = true;\n\t\tthis.ws?.close(1e3, reason);\n\t}\n\tonClose(cb) {\n\t\tthis.onCloseCb = cb;\n\t}\n\tonStateChange(cb) {\n\t\tthis.onStateChangeCb = cb;\n\t}\n\tconnect(isReconnect, onReady, onReadyFail) {\n\t\tconst ws = new WebSocket(this.url);\n\t\tthis.ws = ws;\n\t\tlet opened = false;\n\t\tws.onopen = () => {\n\t\t\topened = true;\n\t\t\tonReady?.();\n\t\t\tif (isReconnect) this.onStateChangeCb?.({ connected: true });\n\t\t\twhile (this.pending.length > 0) {\n\t\t\t\tconst f = this.pending.shift();\n\t\t\t\tws.send(JSON.stringify(f));\n\t\t\t}\n\t\t};\n\t\tws.onclose = (event) => {\n\t\t\tif (this.intentionallyClosed) return;\n\t\t\tif (!opened) {\n\t\t\t\tconst reason = `${isReconnect ? \"reconnect\" : \"initial\"}-failed code=${event.code}`;\n\t\t\t\tif (onReadyFail) {\n\t\t\t\t\tonReadyFail(new Error(reason));\n\t\t\t\t\tthis.dead = true;\n\t\t\t\t} else this.die(reason);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (event.wasClean && (event.code === 1e3 || event.code === 1001)) {\n\t\t\t\tthis.onStateChangeCb?.({ connected: false });\n\t\t\t\tthis.connect(true);\n\t\t\t} else this.die(`close code=${event.code} wasClean=${event.wasClean}`);\n\t\t};\n\t}\n\tdie(reason) {\n\t\tthis.dead = true;\n\t\tthis.onCloseCb?.({ reason });\n\t}\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\tapiUrl;\n\tclientSecret;\n\tcontext;\n\twebsocketUrl;\n\tlog;\n\tforceRecord;\n\tconstructor(apiUrl, clientSecret, context, websocketUrl, log = () => {}, forceRecord) {\n\t\tthis.apiUrl = apiUrl;\n\t\tthis.clientSecret = clientSecret;\n\t\tthis.context = context;\n\t\tthis.websocketUrl = websocketUrl;\n\t\tthis.log = log;\n\t\tthis.forceRecord = forceRecord;\n\t}\n\tasync initSession() {\n\t\tconst url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;\n\t\tthis.log(`fetch POST ${url}`);\n\t\tconst res = await fetch(url, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\tbody: JSON.stringify({\n\t\t\t\tclientSecret: this.clientSecret,\n\t\t\t\t...this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {},\n\t\t\t\t...this.forceRecord ? { forceRecord: true } : {}\n\t\t\t})\n\t\t});\n\t\tif (!res.ok) throw new Error(`init-session failed: HTTP ${res.status}`);\n\t\tconst data = await res.json();\n\t\tif (data.skipRecording) return { skipRecording: true };\n\t\tif (!data.sessionId || !data.sessionToken) throw new Error(\"init-session response missing sessionId or sessionToken\");\n\t\treturn {\n\t\t\tsessionId: data.sessionId,\n\t\t\tsessionToken: data.sessionToken\n\t\t};\n\t}\n\tasync openTransport(sessionToken) {\n\t\tconst wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;\n\t\tconst url = `${wsBase}${wsBase.includes(\"?\") ? \"&\" : \"?\"}session_token=${encodeURIComponent(sessionToken)}`;\n\t\tthis.log(`WebSocket connect ${url.replace(/session_token=[^&]*/, \"session_token=[REDACTED]\")}`);\n\t\tconst transport = new WebSocketTransport(url);\n\t\tawait transport.ready();\n\t\treturn transport;\n\t}\n\ttrimSlash(s) {\n\t\treturn s.endsWith(\"/\") ? s.slice(0, -1) : s;\n\t}\n\ttoWsScheme(base) {\n\t\tif (base.startsWith(\"https://\")) return `wss://${base.slice(8)}`;\n\t\tif (base.startsWith(\"http://\")) return `ws://${base.slice(7)}`;\n\t\treturn base;\n\t}\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\tif (idleTimer !== null) {\n\t\tclearTimeout(idleTimer);\n\t\tidleTimer = null;\n\t}\n}\nfunction log(msg) {\n\tfor (const handle of ports) if (handle.debugLogs) handle.port.postMessage({\n\t\ttype: \"log\",\n\t\tmsg\n\t});\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\tcancelIdleTimer();\n\tconst handle = {\n\t\tport: adapter,\n\t\thello: null,\n\t\tdebugLogs: false\n\t};\n\tports.push(handle);\n\tadapter.onmessage((data) => {\n\t\thandleMessage(handle, data);\n\t});\n}\nfunction handleMessage(handle, message) {\n\tswitch (message.type) {\n\t\tcase \"hello\":\n\t\t\thandle.hello = message;\n\t\t\thandle.debugLogs = message.debugLogs ?? false;\n\t\t\tif (rejectIfIncompatible(handle)) return;\n\t\t\tif (state.phase !== \"dead\" && state.phase !== \"skipping\") detectDuplicateTab(handle);\n\t\t\tonHello(handle);\n\t\t\treturn;\n\t\tcase \"frame\":\n\t\t\tonFrame(message.frame);\n\t\t\treturn;\n\t\tcase \"bye\":\n\t\t\tonBye(handle);\n\t\t\treturn;\n\t\tdefault: break;\n\t}\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\tif (lockedConfig === null) return false;\n\tconst incoming = handle.hello;\n\tif (incoming.apiUrl === lockedConfig.apiUrl && incoming.websocketUrl === lockedConfig.websocketUrl && incoming.clientSecret === lockedConfig.clientSecret) return false;\n\thandle.port.postMessage({\n\t\ttype: \"dead\",\n\t\treason: \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\"\n\t});\n\tconst idx = ports.indexOf(handle);\n\tif (idx >= 0) ports.splice(idx, 1);\n\treturn 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\tconst tabId = handle.hello.tabId;\n\tif (!ports.some((p) => p !== handle && p.hello?.tabId === tabId)) return;\n\tconst fresh = crypto.randomUUID();\n\thandle.newTabId = fresh;\n\thandle.hello.tabId = fresh;\n}\nfunction onHello(handle) {\n\tswitch (state.phase) {\n\t\tcase \"init\":\n\t\t\tlockedConfig = {\n\t\t\t\tapiUrl: handle.hello.apiUrl,\n\t\t\t\twebsocketUrl: handle.hello.websocketUrl,\n\t\t\t\tclientSecret: handle.hello.clientSecret\n\t\t\t};\n\t\t\tlog(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ?? \"(derive)\"} sessionIdHint=${handle.hello.sessionIdHint ?? \"(none)\"}`);\n\t\t\tstate = { phase: \"initializing\" };\n\t\t\tinitializeSession(handle.hello).then(flushPendingWelcomes);\n\t\t\treturn;\n\t\tcase \"initializing\": return;\n\t\tcase \"active\":\n\t\t\tsendActiveWelcome(handle, state.sessionId, state.sessionToken);\n\t\t\treturn;\n\t\tcase \"idle\": {\n\t\t\tconst { client, sessionId, sessionToken } = state;\n\t\t\tstate = { phase: \"initializing\" };\n\t\t\tresumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);\n\t\t\treturn;\n\t\t}\n\t\tcase \"skipping\":\n\t\t\tif (handle.hello.forceRecord) {\n\t\t\t\tlog(\"forceRecord set; re-initializing from skipping state\");\n\t\t\t\tstate = { phase: \"initializing\" };\n\t\t\t\tinitializeSession(handle.hello).then(flushPendingWelcomes);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\thandle.port.postMessage({\n\t\t\t\ttype: \"welcome\",\n\t\t\t\tresult: { skipRecording: true }\n\t\t\t});\n\t\t\treturn;\n\t\tcase \"dead\":\n\t\t\thandle.port.postMessage({\n\t\t\t\ttype: \"dead\",\n\t\t\t\treason: state.reason\n\t\t\t});\n\t\t\treturn;\n\t\tdefault: break;\n\t}\n}\nasync function initializeSession(firstHello) {\n\tconst client = new CsrClient(firstHello.apiUrl, firstHello.clientSecret, firstHello.context, firstHello.websocketUrl, log, firstHello.forceRecord);\n\tif (firstHello.sessionIdHint && firstHello.sessionTokenHint) {\n\t\tlog(`adopting sessionIdHint=${firstHello.sessionIdHint}`);\n\t\ttry {\n\t\t\tconst transport = await client.openTransport(firstHello.sessionTokenHint);\n\t\t\twireTransport(transport);\n\t\t\tstate = {\n\t\t\t\tphase: \"active\",\n\t\t\t\tclient,\n\t\t\t\ttransport,\n\t\t\t\tsessionId: firstHello.sessionIdHint,\n\t\t\t\tsessionToken: firstHello.sessionTokenHint\n\t\t\t};\n\t\t\tlog(\"hint adopted; transport open\");\n\t\t\tif (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n\t\t\treturn;\n\t\t} catch (err) {\n\t\t\tlog(`hint rejected (${String(err)}); falling back to fresh init`);\n\t\t}\n\t}\n\tlet result;\n\ttry {\n\t\tresult = await client.initSession();\n\t} catch (err) {\n\t\tlog(`init-session threw: ${String(err)}`);\n\t\ttransitionToDead(`init-session-failed: ${String(err)}`);\n\t\treturn;\n\t}\n\tif (\"skipRecording\" in result) {\n\t\tlog(\"init-session: skipRecording\");\n\t\tstate = { phase: \"skipping\" };\n\t\treturn;\n\t}\n\tlog(`init-session ok sessionId=${result.sessionId}`);\n\tlet transport;\n\ttry {\n\t\ttransport = await client.openTransport(result.sessionToken);\n\t} catch (err) {\n\t\tlog(`openTransport threw: ${String(err)}`);\n\t\ttransitionToDead(`open-transport-failed: ${String(err)}`);\n\t\treturn;\n\t}\n\twireTransport(transport);\n\tlog(\"transport open; session active\");\n\tstate = {\n\t\tphase: \"active\",\n\t\tclient,\n\t\ttransport,\n\t\tsessionId: result.sessionId,\n\t\tsessionToken: result.sessionToken\n\t};\n\tif (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nasync function resumeTransport(client, sessionId, sessionToken) {\n\tlog(\"resuming transport from idle\");\n\tlet transport;\n\ttry {\n\t\ttransport = await client.openTransport(sessionToken);\n\t} catch (err) {\n\t\tlog(`resume-transport threw: ${String(err)}`);\n\t\ttransitionToDead(`resume-transport-failed: ${String(err)}`);\n\t\treturn;\n\t}\n\twireTransport(transport);\n\tlog(\"transport resumed\");\n\tstate = {\n\t\tphase: \"active\",\n\t\tclient,\n\t\ttransport,\n\t\tsessionId,\n\t\tsessionToken\n\t};\n\tif (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nfunction wireTransport(transport) {\n\ttransport.onClose((info) => {\n\t\tif (state.phase !== \"active\") return;\n\t\ttransitionToDead(info.reason);\n\t});\n\ttransport.onStateChange((info) => {\n\t\tif (state.phase !== \"active\") return;\n\t\tfor (const handle of ports) handle.port.postMessage({\n\t\t\ttype: \"state\",\n\t\t\tconnected: info.connected\n\t\t});\n\t});\n}\nfunction transitionToDead(reason) {\n\tstate = {\n\t\tphase: \"dead\",\n\t\treason\n\t};\n\tfor (const handle of ports) handle.port.postMessage({\n\t\ttype: \"dead\",\n\t\treason\n\t});\n}\nfunction flushPendingWelcomes() {\n\tfor (const handle of ports) {\n\t\tif (handle.hello === null) continue;\n\t\tif (state.phase === \"active\") sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n\t\telse if (state.phase === \"skipping\") handle.port.postMessage({\n\t\t\ttype: \"welcome\",\n\t\t\tresult: { skipRecording: true }\n\t\t});\n\t\telse if (state.phase === \"dead\") handle.port.postMessage({\n\t\t\ttype: \"dead\",\n\t\t\treason: state.reason\n\t\t});\n\t}\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n\tconst hint = handle.hello?.sessionIdHint;\n\tconst adopted = hint !== void 0 && hint !== currentSessionId;\n\tconst newTabId = handle.newTabId;\n\thandle.port.postMessage({\n\t\ttype: \"welcome\",\n\t\tresult: {\n\t\t\tsessionId: currentSessionId,\n\t\t\tsessionToken: currentSessionToken\n\t\t},\n\t\tadoptedFromSessionId: adopted ? hint : void 0,\n\t\tnewTabId,\n\t\tresetCounter: adopted || newTabId !== void 0\n\t});\n}\nfunction onFrame(frame) {\n\tif (state.phase !== \"active\") return;\n\tstate.transport.send(frame);\n}\nfunction onBye(handle) {\n\tconst idx = ports.indexOf(handle);\n\tif (idx >= 0) ports.splice(idx, 1);\n\tif (ports.length === 0 && state.phase === \"active\") {\n\t\tlog(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);\n\t\tidleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n\t}\n}\nfunction enterIdle() {\n\tidleTimer = null;\n\tif (state.phase !== \"active\" || ports.length > 0) return;\n\tlog(\"idle timeout; closing transport\");\n\tstate.transport.close(\"idle\");\n\tstate = {\n\t\tphase: \"idle\",\n\t\tclient: state.client,\n\t\tsessionId: state.sessionId,\n\t\tsessionToken: state.sessionToken\n\t};\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\tconst port = event.ports[0];\n\tport.start();\n\tregisterPort(adaptMessagePort(port));\n};\nelse registerPort(adaptDedicatedSelf());\nfunction adaptMessagePort(port) {\n\treturn {\n\t\tpostMessage: (message) => port.postMessage(message),\n\t\tonmessage: (cb) => {\n\t\t\tport.onmessage = (e) => cb(e.data);\n\t\t}\n\t};\n}\nfunction adaptDedicatedSelf() {\n\tconst ws = self;\n\treturn {\n\t\tpostMessage: (message) => ws.postMessage(message),\n\t\tonmessage: (cb) => {\n\t\t\tws.onmessage = (e) => cb(e.data);\n\t\t}\n\t};\n}\n//#endregion\n";
|
|
3
|
+
export const workerScript: string = "//#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\turl;\n\tws = null;\n\tonCloseCb = null;\n\tonStateChangeCb = null;\n\tintentionallyClosed = false;\n\tdead = false;\n\t/** Frames buffered while a (re)connect is in progress. */\n\tpending = [];\n\treadyPromise;\n\tprotocols;\n\tconstructor(url, protocols = []) {\n\t\tthis.url = url;\n\t\tthis.protocols = [...protocols];\n\t\tthis.readyPromise = new Promise((resolve, reject) => {\n\t\t\tthis.connect(false, resolve, reject);\n\t\t});\n\t\tthis.readyPromise.catch(() => {});\n\t}\n\tready() {\n\t\treturn this.readyPromise;\n\t}\n\tsend(frame) {\n\t\tif (this.dead || this.intentionallyClosed) return;\n\t\tif (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));\n\t\telse this.pending.push(frame);\n\t}\n\tclose(reason = \"transport-close\") {\n\t\tthis.intentionallyClosed = true;\n\t\tthis.ws?.close(1e3, reason);\n\t}\n\tonClose(cb) {\n\t\tthis.onCloseCb = cb;\n\t}\n\tonStateChange(cb) {\n\t\tthis.onStateChangeCb = cb;\n\t}\n\tconnect(isReconnect, onReady, onReadyFail) {\n\t\tlet ws;\n\t\ttry {\n\t\t\tws = new WebSocket(this.url, [...this.protocols]);\n\t\t} catch (_error) {\n\t\t\tthis.failConnection(isReconnect, onReadyFail);\n\t\t\treturn;\n\t\t}\n\t\tthis.ws = ws;\n\t\tlet opened = false;\n\t\tws.onopen = () => {\n\t\t\tconst expectedProtocol = this.protocols[0];\n\t\t\tif (expectedProtocol !== void 0 && ws.protocol !== expectedProtocol) {\n\t\t\t\tthis.failConnection(isReconnect, onReadyFail);\n\t\t\t\tws.close(1e3, \"protocol-mismatch\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\topened = true;\n\t\t\tonReady?.();\n\t\t\tif (isReconnect) this.onStateChangeCb?.({ connected: true });\n\t\t\twhile (this.pending.length > 0) {\n\t\t\t\tconst f = this.pending.shift();\n\t\t\t\tws.send(JSON.stringify(f));\n\t\t\t}\n\t\t};\n\t\tws.onclose = (event) => {\n\t\t\tif (this.intentionallyClosed || this.dead) return;\n\t\t\tif (!opened) {\n\t\t\t\tthis.failConnection(isReconnect, onReadyFail);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (event.wasClean && (event.code === 1e3 || event.code === 1001)) {\n\t\t\t\tthis.onStateChangeCb?.({ connected: false });\n\t\t\t\tthis.connect(true);\n\t\t\t} else this.die(`close code=${event.code} wasClean=${event.wasClean}`);\n\t\t};\n\t}\n\tfailConnection(isReconnect, onReadyFail) {\n\t\tconst reason = isReconnect ? \"reconnect-failed\" : \"initial-failed\";\n\t\tif (onReadyFail) {\n\t\t\tthis.dead = true;\n\t\t\tonReadyFail(new Error(reason));\n\t\t} else this.die(reason);\n\t}\n\tdie(reason) {\n\t\tthis.dead = true;\n\t\tthis.onCloseCb?.({ reason });\n\t}\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\tif (sessionToken.length > MAX_TOKEN_LENGTH) throw new Error(\"Session token is too long for WebSocket authentication\");\n\tif (!sessionToken || /[^A-Za-z0-9._-]/.test(sessionToken)) throw new Error(\"Invalid session token for WebSocket authentication\");\n\treturn [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\tapiUrl;\n\tclientSecret;\n\tcontext;\n\twebsocketUrl;\n\tlog;\n\tforceRecord;\n\tconstructor(apiUrl, clientSecret, context, websocketUrl, log = () => {}, forceRecord) {\n\t\tthis.apiUrl = apiUrl;\n\t\tthis.clientSecret = clientSecret;\n\t\tthis.context = context;\n\t\tthis.websocketUrl = websocketUrl;\n\t\tthis.log = log;\n\t\tthis.forceRecord = forceRecord;\n\t}\n\tasync initSession() {\n\t\tconst url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;\n\t\tthis.log(`fetch POST ${url}`);\n\t\tconst res = await fetch(url, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\tbody: JSON.stringify({\n\t\t\t\tclientSecret: this.clientSecret,\n\t\t\t\t...this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {},\n\t\t\t\t...this.forceRecord ? { forceRecord: true } : {}\n\t\t\t})\n\t\t});\n\t\tif (!res.ok) throw new Error(`init-session failed: HTTP ${res.status}`);\n\t\tconst data = await res.json();\n\t\tif (data.skipRecording) return { skipRecording: true };\n\t\tif (!data.sessionId || !data.sessionToken) throw new Error(\"init-session response missing sessionId or sessionToken\");\n\t\treturn {\n\t\t\tsessionId: data.sessionId,\n\t\t\tsessionToken: data.sessionToken\n\t\t};\n\t}\n\tasync openTransport(sessionToken) {\n\t\tconst wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;\n\t\tlet parsedUrl;\n\t\ttry {\n\t\t\tparsedUrl = new URL(wsBase);\n\t\t} catch (_error) {\n\t\t\tthrow new Error(\"Invalid WebSocket URL\");\n\t\t}\n\t\tif (parsedUrl.searchParams.has(\"session_token\")) throw new Error(\"WebSocket URL must not include a session token\");\n\t\tconst protocols = recordingProtocols(sessionToken);\n\t\tthis.log(`WebSocket connect ${wsBase}`);\n\t\tconst transport = new WebSocketTransport(wsBase, protocols);\n\t\tawait transport.ready();\n\t\treturn transport;\n\t}\n\ttrimSlash(s) {\n\t\treturn s.endsWith(\"/\") ? s.slice(0, -1) : s;\n\t}\n\ttoWsScheme(base) {\n\t\tif (base.startsWith(\"https://\")) return `wss://${base.slice(8)}`;\n\t\tif (base.startsWith(\"http://\")) return `ws://${base.slice(7)}`;\n\t\treturn base;\n\t}\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\tif (idleTimer !== null) {\n\t\tclearTimeout(idleTimer);\n\t\tidleTimer = null;\n\t}\n}\nfunction log(msg) {\n\tfor (const handle of ports) if (handle.debugLogs) handle.port.postMessage({\n\t\ttype: \"log\",\n\t\tmsg\n\t});\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\tcancelIdleTimer();\n\tconst handle = {\n\t\tport: adapter,\n\t\thello: null,\n\t\tdebugLogs: false\n\t};\n\tports.push(handle);\n\tadapter.onmessage((data) => {\n\t\thandleMessage(handle, data);\n\t});\n}\nfunction handleMessage(handle, message) {\n\tswitch (message.type) {\n\t\tcase \"hello\":\n\t\t\thandle.hello = message;\n\t\t\thandle.debugLogs = message.debugLogs ?? false;\n\t\t\tif (rejectIfIncompatible(handle)) return;\n\t\t\tif (state.phase !== \"dead\" && state.phase !== \"skipping\") detectDuplicateTab(handle);\n\t\t\tonHello(handle);\n\t\t\treturn;\n\t\tcase \"frame\":\n\t\t\tonFrame(message.frame);\n\t\t\treturn;\n\t\tcase \"bye\":\n\t\t\tonBye(handle);\n\t\t\treturn;\n\t\tdefault: break;\n\t}\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\tif (lockedConfig === null) return false;\n\tconst incoming = handle.hello;\n\tif (incoming.apiUrl === lockedConfig.apiUrl && incoming.websocketUrl === lockedConfig.websocketUrl && incoming.clientSecret === lockedConfig.clientSecret) return false;\n\thandle.port.postMessage({\n\t\ttype: \"dead\",\n\t\treason: \"incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session\"\n\t});\n\tconst idx = ports.indexOf(handle);\n\tif (idx >= 0) ports.splice(idx, 1);\n\treturn 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\tconst tabId = handle.hello.tabId;\n\tif (!ports.some((p) => p !== handle && p.hello?.tabId === tabId)) return;\n\tconst fresh = crypto.randomUUID();\n\thandle.newTabId = fresh;\n\thandle.hello.tabId = fresh;\n}\nfunction onHello(handle) {\n\tswitch (state.phase) {\n\t\tcase \"init\":\n\t\t\tlockedConfig = {\n\t\t\t\tapiUrl: handle.hello.apiUrl,\n\t\t\t\twebsocketUrl: handle.hello.websocketUrl,\n\t\t\t\tclientSecret: handle.hello.clientSecret\n\t\t\t};\n\t\t\tlog(`hello received apiUrl=${handle.hello.apiUrl} websocketUrl=${handle.hello.websocketUrl ? \"(configured)\" : \"(derive)\"} sessionIdHint=${handle.hello.sessionIdHint ?? \"(none)\"}`);\n\t\t\tstate = { phase: \"initializing\" };\n\t\t\tinitializeSession(handle.hello).then(flushPendingWelcomes);\n\t\t\treturn;\n\t\tcase \"initializing\": return;\n\t\tcase \"active\":\n\t\t\tsendActiveWelcome(handle, state.sessionId, state.sessionToken);\n\t\t\treturn;\n\t\tcase \"idle\": {\n\t\t\tconst { client, sessionId, sessionToken } = state;\n\t\t\tstate = { phase: \"initializing\" };\n\t\t\tresumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);\n\t\t\treturn;\n\t\t}\n\t\tcase \"skipping\":\n\t\t\tif (handle.hello.forceRecord) {\n\t\t\t\tlog(\"forceRecord set; re-initializing from skipping state\");\n\t\t\t\tstate = { phase: \"initializing\" };\n\t\t\t\tinitializeSession(handle.hello).then(flushPendingWelcomes);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\thandle.port.postMessage({\n\t\t\t\ttype: \"welcome\",\n\t\t\t\tresult: { skipRecording: true },\n\t\t\t\tworkerHash: WORKER_HASH\n\t\t\t});\n\t\t\treturn;\n\t\tcase \"dead\":\n\t\t\thandle.port.postMessage({\n\t\t\t\ttype: \"dead\",\n\t\t\t\treason: state.reason\n\t\t\t});\n\t\t\treturn;\n\t\tdefault: break;\n\t}\n}\nasync function initializeSession(firstHello) {\n\tconst client = new CsrClient(firstHello.apiUrl, firstHello.clientSecret, firstHello.context, firstHello.websocketUrl, log, firstHello.forceRecord);\n\tif (firstHello.sessionIdHint && firstHello.sessionTokenHint) {\n\t\tlog(`adopting sessionIdHint=${firstHello.sessionIdHint}`);\n\t\ttry {\n\t\t\tconst transport = await client.openTransport(firstHello.sessionTokenHint);\n\t\t\twireTransport(transport);\n\t\t\tstate = {\n\t\t\t\tphase: \"active\",\n\t\t\t\tclient,\n\t\t\t\ttransport,\n\t\t\t\tsessionId: firstHello.sessionIdHint,\n\t\t\t\tsessionToken: firstHello.sessionTokenHint\n\t\t\t};\n\t\t\tlog(\"hint adopted; transport open\");\n\t\t\tif (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n\t\t\treturn;\n\t\t} catch (err) {\n\t\t\tlog(`hint rejected (${String(err)}); falling back to fresh init`);\n\t\t}\n\t}\n\tlet result;\n\ttry {\n\t\tresult = await client.initSession();\n\t} catch (err) {\n\t\tlog(`init-session threw: ${String(err)}`);\n\t\ttransitionToDead(`init-session-failed: ${String(err)}`);\n\t\treturn;\n\t}\n\tif (\"skipRecording\" in result) {\n\t\tlog(\"init-session: skipRecording\");\n\t\tstate = { phase: \"skipping\" };\n\t\treturn;\n\t}\n\tlog(`init-session ok sessionId=${result.sessionId}`);\n\tlet transport;\n\ttry {\n\t\ttransport = await client.openTransport(result.sessionToken);\n\t} catch (err) {\n\t\tlog(`openTransport threw: ${String(err)}`);\n\t\ttransitionToDead(`open-transport-failed: ${String(err)}`);\n\t\treturn;\n\t}\n\twireTransport(transport);\n\tlog(\"transport open; session active\");\n\tstate = {\n\t\tphase: \"active\",\n\t\tclient,\n\t\ttransport,\n\t\tsessionId: result.sessionId,\n\t\tsessionToken: result.sessionToken\n\t};\n\tif (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nasync function resumeTransport(client, sessionId, sessionToken) {\n\tlog(\"resuming transport from idle\");\n\tlet transport;\n\ttry {\n\t\ttransport = await client.openTransport(sessionToken);\n\t} catch (err) {\n\t\tlog(`resume-transport threw: ${String(err)}`);\n\t\ttransitionToDead(`resume-transport-failed: ${String(err)}`);\n\t\treturn;\n\t}\n\twireTransport(transport);\n\tlog(\"transport resumed\");\n\tstate = {\n\t\tphase: \"active\",\n\t\tclient,\n\t\ttransport,\n\t\tsessionId,\n\t\tsessionToken\n\t};\n\tif (ports.length === 0) idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n}\nfunction wireTransport(transport) {\n\ttransport.onClose((info) => {\n\t\tif (state.phase !== \"active\") return;\n\t\ttransitionToDead(info.reason);\n\t});\n\ttransport.onStateChange((info) => {\n\t\tif (state.phase !== \"active\") return;\n\t\tfor (const handle of ports) handle.port.postMessage({\n\t\t\ttype: \"state\",\n\t\t\tconnected: info.connected\n\t\t});\n\t});\n}\nfunction transitionToDead(reason) {\n\tstate = {\n\t\tphase: \"dead\",\n\t\treason\n\t};\n\tfor (const handle of ports) handle.port.postMessage({\n\t\ttype: \"dead\",\n\t\treason\n\t});\n}\nfunction flushPendingWelcomes() {\n\tfor (const handle of ports) {\n\t\tif (handle.hello === null) continue;\n\t\tif (state.phase === \"active\") sendActiveWelcome(handle, state.sessionId, state.sessionToken);\n\t\telse if (state.phase === \"skipping\") handle.port.postMessage({\n\t\t\ttype: \"welcome\",\n\t\t\tresult: { skipRecording: true },\n\t\t\tworkerHash: WORKER_HASH\n\t\t});\n\t\telse if (state.phase === \"dead\") handle.port.postMessage({\n\t\t\ttype: \"dead\",\n\t\t\treason: state.reason\n\t\t});\n\t}\n}\nfunction sendActiveWelcome(handle, currentSessionId, currentSessionToken) {\n\tconst hint = handle.hello?.sessionIdHint;\n\tconst adopted = hint !== void 0 && hint !== currentSessionId;\n\tconst newTabId = handle.newTabId;\n\thandle.port.postMessage({\n\t\ttype: \"welcome\",\n\t\tresult: {\n\t\t\tsessionId: currentSessionId,\n\t\t\tsessionToken: currentSessionToken\n\t\t},\n\t\tworkerHash: WORKER_HASH,\n\t\tadoptedFromSessionId: adopted ? hint : void 0,\n\t\tnewTabId,\n\t\tresetCounter: adopted || newTabId !== void 0\n\t});\n}\nfunction onFrame(frame) {\n\tif (state.phase !== \"active\") return;\n\tstate.transport.send(frame);\n}\nfunction onBye(handle) {\n\tconst idx = ports.indexOf(handle);\n\tif (idx >= 0) ports.splice(idx, 1);\n\tif (ports.length === 0 && state.phase === \"active\") {\n\t\tlog(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);\n\t\tidleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);\n\t}\n}\nfunction enterIdle() {\n\tidleTimer = null;\n\tif (state.phase !== \"active\" || ports.length > 0) return;\n\tlog(\"idle timeout; closing transport\");\n\tstate.transport.close(\"idle\");\n\tstate = {\n\t\tphase: \"idle\",\n\t\tclient: state.client,\n\t\tsessionId: state.sessionId,\n\t\tsessionToken: state.sessionToken\n\t};\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\tconst port = event.ports[0];\n\tport.start();\n\tregisterPort(adaptMessagePort(port));\n};\nelse registerPort(adaptDedicatedSelf());\nfunction adaptMessagePort(port) {\n\treturn {\n\t\tpostMessage: (message) => port.postMessage(message),\n\t\tonmessage: (cb) => {\n\t\t\tport.onmessage = (e) => cb(e.data);\n\t\t}\n\t};\n}\nfunction adaptDedicatedSelf() {\n\tconst ws = self;\n\treturn {\n\t\tpostMessage: (message) => ws.postMessage(message),\n\t\tonmessage: (cb) => {\n\t\t\tws.onmessage = (e) => cb(e.data);\n\t\t}\n\t};\n}\n//#endregion\n";
|
package/src/url.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** Remove query strings and fragments, including from relative or malformed URLs. */
|
|
2
|
+
export function stripUrlQueryAndHash(url: string): string {
|
|
3
|
+
return url.split(/[?#]/, 1)[0];
|
|
4
|
+
}
|
|
5
|
+
|
|
1
6
|
/**
|
|
2
7
|
* Extract only the pathname from a URL, stripping origin, query string, and
|
|
3
8
|
* hash. Used across recorder and analyzer to avoid capturing PII in route data.
|