@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/CHANGELOG.md +20 -0
- package/README.md +16 -0
- package/dist/index.cjs +116 -0
- package/dist/index.d.cts +239 -0
- package/dist/index.d.ts +239 -0
- package/dist/index.js +104 -0
- package/dist/types-BFkUa8kB.d.cts +111 -0
- package/dist/types-BFkUa8kB.d.ts +111 -0
- package/dist/uploader/index.cjs +276 -0
- package/dist/uploader/index.d.cts +9 -0
- package/dist/uploader/index.d.ts +9 -0
- package/dist/uploader/index.js +250 -0
- package/package.json +58 -1
- package/src/custom-event-limits.test.ts +79 -0
- package/src/custom-event-limits.ts +25 -0
- package/src/events.ts +252 -0
- package/src/index.ts +44 -0
- package/src/test-utils/index.ts +3 -0
- package/src/test-utils/mock-fetch.ts +34 -0
- package/src/test-utils/mock-port.ts +51 -0
- package/src/test-utils/mock-ws-server.ts +78 -0
- package/src/uploader/client-context.test.ts +149 -0
- package/src/uploader/client-context.ts +70 -0
- package/src/uploader/create-uploader.ts +309 -0
- package/src/uploader/index.ts +15 -0
- package/src/uploader/types.ts +101 -0
- package/src/uploader/worker/core.test.ts +247 -0
- package/src/uploader/worker/core.ts +386 -0
- package/src/uploader/worker/csr-client.test.ts +122 -0
- package/src/uploader/worker/csr-client.ts +67 -0
- package/src/uploader/worker/entry.ts +40 -0
- package/src/uploader/worker/web-socket-transport.test.ts +103 -0
- package/src/uploader/worker/web-socket-transport.ts +113 -0
- package/src/uploader/worker/worker-script.ts +3 -0
- package/src/url.ts +11 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import type { ClientContext } from '../client-context';
|
|
2
|
+
import type { Client, Frame, Transport } from '../types';
|
|
3
|
+
import { CsrClient } from './csr-client';
|
|
4
|
+
|
|
5
|
+
/** Adapter so this module is unaware of whether it's running in a SharedWorker or a dedicated Worker. */
|
|
6
|
+
export interface PortAdapter {
|
|
7
|
+
postMessage(message: unknown): void;
|
|
8
|
+
onmessage(cb: (data: unknown) => void): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface HelloMessage {
|
|
12
|
+
type: 'hello';
|
|
13
|
+
apiUrl: string;
|
|
14
|
+
websocketUrl?: string;
|
|
15
|
+
clientSecret: string;
|
|
16
|
+
targetingKey?: string;
|
|
17
|
+
context?: ClientContext;
|
|
18
|
+
forceRecord?: boolean;
|
|
19
|
+
sessionIdHint?: string;
|
|
20
|
+
sessionTokenHint?: string;
|
|
21
|
+
tabId: string;
|
|
22
|
+
debugLogs?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface FrameMessage {
|
|
26
|
+
type: 'frame';
|
|
27
|
+
frame: Frame;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ByeMessage {
|
|
31
|
+
type: 'bye';
|
|
32
|
+
reason: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type IncomingMessage = HelloMessage | FrameMessage | ByeMessage;
|
|
36
|
+
|
|
37
|
+
interface PortHandle {
|
|
38
|
+
port: PortAdapter;
|
|
39
|
+
hello: HelloMessage | null;
|
|
40
|
+
/** Whether this port opted into debug log forwarding. Per-port so a quiet tab doesn't pay the message cost when another tab opts in. */
|
|
41
|
+
debugLogs: boolean;
|
|
42
|
+
/** Set when the worker minted a fresh tabId for this port (tab duplication). Sent to the tab in welcome. */
|
|
43
|
+
newTabId?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const IDLE_GRACE_MS = 5_000;
|
|
47
|
+
|
|
48
|
+
type State =
|
|
49
|
+
| { phase: 'init' }
|
|
50
|
+
| { phase: 'initializing' }
|
|
51
|
+
| {
|
|
52
|
+
phase: 'active';
|
|
53
|
+
client: Client;
|
|
54
|
+
transport: Transport;
|
|
55
|
+
sessionId: string;
|
|
56
|
+
sessionToken: string;
|
|
57
|
+
}
|
|
58
|
+
| {
|
|
59
|
+
phase: 'idle';
|
|
60
|
+
client: Client;
|
|
61
|
+
sessionId: string;
|
|
62
|
+
sessionToken: string;
|
|
63
|
+
}
|
|
64
|
+
| { phase: 'skipping' }
|
|
65
|
+
| { phase: 'dead'; reason: string };
|
|
66
|
+
|
|
67
|
+
let state: State = { phase: 'init' };
|
|
68
|
+
const ports: PortHandle[] = [];
|
|
69
|
+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
70
|
+
|
|
71
|
+
function cancelIdleTimer(): void {
|
|
72
|
+
if (idleTimer !== null) {
|
|
73
|
+
clearTimeout(idleTimer);
|
|
74
|
+
idleTimer = null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function log(msg: string): void {
|
|
79
|
+
for (const handle of ports) {
|
|
80
|
+
if (handle.debugLogs) handle.port.postMessage({ type: 'log', msg });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The first hello "locks in" the session's apiUrl/clientSecret. Any later tab arriving
|
|
85
|
+
* with different values is misconfigured — we reject it rather than silently using the
|
|
86
|
+
* locked values. In SharedWorker mode the `name = hash(clientSecret)` scoping already
|
|
87
|
+
* prevents secret-mismatch from sharing a worker, but this defends against the dedicated
|
|
88
|
+
* path and against future bugs.
|
|
89
|
+
*/
|
|
90
|
+
let lockedConfig: {
|
|
91
|
+
apiUrl: string;
|
|
92
|
+
websocketUrl: string | undefined;
|
|
93
|
+
clientSecret: string;
|
|
94
|
+
} | null = null;
|
|
95
|
+
|
|
96
|
+
export function registerPort(adapter: PortAdapter): void {
|
|
97
|
+
cancelIdleTimer();
|
|
98
|
+
const handle: PortHandle = { port: adapter, hello: null, debugLogs: false };
|
|
99
|
+
ports.push(handle);
|
|
100
|
+
adapter.onmessage((data: unknown) => {
|
|
101
|
+
handleMessage(handle, data as IncomingMessage);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function handleMessage(handle: PortHandle, message: IncomingMessage): void {
|
|
106
|
+
switch (message.type) {
|
|
107
|
+
case 'hello':
|
|
108
|
+
handle.hello = message;
|
|
109
|
+
handle.debugLogs = message.debugLogs ?? false;
|
|
110
|
+
if (rejectIfIncompatible(handle)) return;
|
|
111
|
+
// Skip tabId-mint for hellos that won't end up recording anyway.
|
|
112
|
+
if (state.phase !== 'dead' && state.phase !== 'skipping') {
|
|
113
|
+
detectDuplicateTab(handle);
|
|
114
|
+
}
|
|
115
|
+
onHello(handle);
|
|
116
|
+
return;
|
|
117
|
+
case 'frame':
|
|
118
|
+
onFrame(message.frame);
|
|
119
|
+
return;
|
|
120
|
+
case 'bye':
|
|
121
|
+
onBye(handle);
|
|
122
|
+
return;
|
|
123
|
+
default:
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Reject hellos whose `apiUrl`/`clientSecret` don't match the values established by the
|
|
130
|
+
* first hello. Returns true if the port was rejected (caller should not continue
|
|
131
|
+
* processing this hello).
|
|
132
|
+
*/
|
|
133
|
+
function rejectIfIncompatible(handle: PortHandle): boolean {
|
|
134
|
+
if (lockedConfig === null) return false;
|
|
135
|
+
const incoming = handle.hello!;
|
|
136
|
+
if (
|
|
137
|
+
incoming.apiUrl === lockedConfig.apiUrl &&
|
|
138
|
+
incoming.websocketUrl === lockedConfig.websocketUrl &&
|
|
139
|
+
incoming.clientSecret === lockedConfig.clientSecret
|
|
140
|
+
) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
handle.port.postMessage({
|
|
144
|
+
type: 'dead',
|
|
145
|
+
reason: 'incompatible-options: apiUrl/websocketUrl/clientSecret differ from the worker session',
|
|
146
|
+
});
|
|
147
|
+
const idx = ports.indexOf(handle);
|
|
148
|
+
if (idx >= 0) ports.splice(idx, 1);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* If another already-connected port has the same `tabId`, this hello is from a duplicate
|
|
154
|
+
* tab (browser "Duplicate" command clones sessionStorage). Mint a fresh `tabId` so the two
|
|
155
|
+
* tabs don't collide on the same `(sessionId, tabId)` Recording. The new tabId is returned
|
|
156
|
+
* to the tab in `welcome` so it can update its own state and sessionStorage.
|
|
157
|
+
*/
|
|
158
|
+
function detectDuplicateTab(handle: PortHandle): void {
|
|
159
|
+
const tabId = handle.hello!.tabId;
|
|
160
|
+
const isDuplicate = ports.some(p => p !== handle && p.hello?.tabId === tabId);
|
|
161
|
+
if (!isDuplicate) return;
|
|
162
|
+
const fresh = crypto.randomUUID();
|
|
163
|
+
handle.newTabId = fresh;
|
|
164
|
+
handle.hello!.tabId = fresh; // future duplicate checks see this updated id
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function onHello(handle: PortHandle): void {
|
|
168
|
+
switch (state.phase) {
|
|
169
|
+
case 'init': {
|
|
170
|
+
lockedConfig = {
|
|
171
|
+
apiUrl: handle.hello!.apiUrl,
|
|
172
|
+
websocketUrl: handle.hello!.websocketUrl,
|
|
173
|
+
clientSecret: handle.hello!.clientSecret,
|
|
174
|
+
};
|
|
175
|
+
log(
|
|
176
|
+
`hello received apiUrl=${handle.hello!.apiUrl} websocketUrl=${
|
|
177
|
+
handle.hello!.websocketUrl ?? '(derive)'
|
|
178
|
+
} sessionIdHint=${handle.hello!.sessionIdHint ?? '(none)'}`,
|
|
179
|
+
);
|
|
180
|
+
state = { phase: 'initializing' };
|
|
181
|
+
void initializeSession(handle.hello!).then(flushPendingWelcomes);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
case 'initializing':
|
|
185
|
+
// Welcome will be sent once initialization resolves.
|
|
186
|
+
return;
|
|
187
|
+
case 'active':
|
|
188
|
+
sendActiveWelcome(handle, state.sessionId, state.sessionToken);
|
|
189
|
+
return;
|
|
190
|
+
case 'idle': {
|
|
191
|
+
const { client, sessionId, sessionToken } = state;
|
|
192
|
+
state = { phase: 'initializing' };
|
|
193
|
+
void resumeTransport(client, sessionId, sessionToken).then(flushPendingWelcomes);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
case 'skipping':
|
|
197
|
+
if (handle.hello!.forceRecord) {
|
|
198
|
+
log('forceRecord set; re-initializing from skipping state');
|
|
199
|
+
state = { phase: 'initializing' };
|
|
200
|
+
void initializeSession(handle.hello!).then(flushPendingWelcomes);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
handle.port.postMessage({
|
|
204
|
+
type: 'welcome',
|
|
205
|
+
result: { skipRecording: true },
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
case 'dead':
|
|
209
|
+
handle.port.postMessage({ type: 'dead', reason: state.reason });
|
|
210
|
+
return;
|
|
211
|
+
default:
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function initializeSession(firstHello: HelloMessage): Promise<void> {
|
|
217
|
+
const client = new CsrClient(
|
|
218
|
+
firstHello.apiUrl,
|
|
219
|
+
firstHello.clientSecret,
|
|
220
|
+
firstHello.targetingKey,
|
|
221
|
+
firstHello.context,
|
|
222
|
+
firstHello.websocketUrl,
|
|
223
|
+
log,
|
|
224
|
+
firstHello.forceRecord,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// Try to adopt the hint first. Need both sessionId (for tab-side state) and
|
|
228
|
+
// sessionToken (to authenticate the WS upgrade).
|
|
229
|
+
if (firstHello.sessionIdHint && firstHello.sessionTokenHint) {
|
|
230
|
+
log(`adopting sessionIdHint=${firstHello.sessionIdHint}`);
|
|
231
|
+
try {
|
|
232
|
+
const transport = await client.openTransport(firstHello.sessionTokenHint);
|
|
233
|
+
wireTransport(transport);
|
|
234
|
+
state = {
|
|
235
|
+
phase: 'active',
|
|
236
|
+
client,
|
|
237
|
+
transport,
|
|
238
|
+
sessionId: firstHello.sessionIdHint,
|
|
239
|
+
sessionToken: firstHello.sessionTokenHint,
|
|
240
|
+
};
|
|
241
|
+
log('hint adopted; transport open');
|
|
242
|
+
if (ports.length === 0) {
|
|
243
|
+
idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
// Stale or rejected — fall through to fresh init.
|
|
248
|
+
log(`hint rejected (${String(err)}); falling back to fresh init`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let result: { sessionId: string; sessionToken: string } | { skipRecording: true };
|
|
253
|
+
try {
|
|
254
|
+
result = await client.initSession();
|
|
255
|
+
} catch (err) {
|
|
256
|
+
log(`init-session threw: ${String(err)}`);
|
|
257
|
+
transitionToDead(`init-session-failed: ${String(err)}`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if ('skipRecording' in result) {
|
|
262
|
+
log('init-session: skipRecording');
|
|
263
|
+
state = { phase: 'skipping' };
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
log(`init-session ok sessionId=${result.sessionId}`);
|
|
268
|
+
let transport: Transport;
|
|
269
|
+
try {
|
|
270
|
+
transport = await client.openTransport(result.sessionToken);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
log(`openTransport threw: ${String(err)}`);
|
|
273
|
+
transitionToDead(`open-transport-failed: ${String(err)}`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
wireTransport(transport);
|
|
277
|
+
log('transport open; session active');
|
|
278
|
+
state = {
|
|
279
|
+
phase: 'active',
|
|
280
|
+
client,
|
|
281
|
+
transport,
|
|
282
|
+
sessionId: result.sessionId,
|
|
283
|
+
sessionToken: result.sessionToken,
|
|
284
|
+
};
|
|
285
|
+
if (ports.length === 0) {
|
|
286
|
+
idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function resumeTransport(client: Client, sessionId: string, sessionToken: string): Promise<void> {
|
|
291
|
+
log('resuming transport from idle');
|
|
292
|
+
let transport: Transport;
|
|
293
|
+
try {
|
|
294
|
+
transport = await client.openTransport(sessionToken);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
log(`resume-transport threw: ${String(err)}`);
|
|
297
|
+
transitionToDead(`resume-transport-failed: ${String(err)}`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
wireTransport(transport);
|
|
301
|
+
log('transport resumed');
|
|
302
|
+
state = { phase: 'active', client, transport, sessionId, sessionToken };
|
|
303
|
+
if (ports.length === 0) {
|
|
304
|
+
idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function wireTransport(transport: Transport): void {
|
|
309
|
+
transport.onClose(info => {
|
|
310
|
+
if (state.phase !== 'active') return;
|
|
311
|
+
transitionToDead(info.reason);
|
|
312
|
+
});
|
|
313
|
+
transport.onStateChange(info => {
|
|
314
|
+
if (state.phase !== 'active') return;
|
|
315
|
+
for (const handle of ports) {
|
|
316
|
+
handle.port.postMessage({ type: 'state', connected: info.connected });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function transitionToDead(reason: string): void {
|
|
322
|
+
state = { phase: 'dead', reason };
|
|
323
|
+
for (const handle of ports) {
|
|
324
|
+
handle.port.postMessage({ type: 'dead', reason });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function flushPendingWelcomes(): void {
|
|
329
|
+
for (const handle of ports) {
|
|
330
|
+
if (handle.hello === null) continue;
|
|
331
|
+
if (state.phase === 'active') {
|
|
332
|
+
sendActiveWelcome(handle, state.sessionId, state.sessionToken);
|
|
333
|
+
} else if (state.phase === 'skipping') {
|
|
334
|
+
handle.port.postMessage({
|
|
335
|
+
type: 'welcome',
|
|
336
|
+
result: { skipRecording: true },
|
|
337
|
+
});
|
|
338
|
+
} else if (state.phase === 'dead') {
|
|
339
|
+
handle.port.postMessage({ type: 'dead', reason: state.reason });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function sendActiveWelcome(handle: PortHandle, currentSessionId: string, currentSessionToken: string): void {
|
|
345
|
+
const hint = handle.hello?.sessionIdHint;
|
|
346
|
+
const adopted = hint !== undefined && hint !== currentSessionId;
|
|
347
|
+
const newTabId = handle.newTabId;
|
|
348
|
+
handle.port.postMessage({
|
|
349
|
+
type: 'welcome',
|
|
350
|
+
result: { sessionId: currentSessionId, sessionToken: currentSessionToken },
|
|
351
|
+
adoptedFromSessionId: adopted ? hint : undefined,
|
|
352
|
+
newTabId,
|
|
353
|
+
resetCounter: adopted || newTabId !== undefined,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function onFrame(frame: Frame): void {
|
|
358
|
+
// In normal flow the tab can't send frames before receiving `welcome` (only sent in
|
|
359
|
+
// 'active') and stops after `dead` (its uploader throws). The race we're guarding is a
|
|
360
|
+
// frame already in flight at the instant we transition out of 'active' — e.g.
|
|
361
|
+
// `transport.onClose` fires while the tab has just posted a frame to the port. Drop it.
|
|
362
|
+
if (state.phase !== 'active') return;
|
|
363
|
+
state.transport.send(frame);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function onBye(handle: PortHandle): void {
|
|
367
|
+
const idx = ports.indexOf(handle);
|
|
368
|
+
if (idx >= 0) ports.splice(idx, 1);
|
|
369
|
+
if (ports.length === 0 && state.phase === 'active') {
|
|
370
|
+
log(`last tab disconnected; closing transport in ${IDLE_GRACE_MS}ms`);
|
|
371
|
+
idleTimer = setTimeout(enterIdle, IDLE_GRACE_MS);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function enterIdle(): void {
|
|
376
|
+
idleTimer = null;
|
|
377
|
+
if (state.phase !== 'active' || ports.length > 0) return;
|
|
378
|
+
log('idle timeout; closing transport');
|
|
379
|
+
state.transport.close('idle');
|
|
380
|
+
state = {
|
|
381
|
+
phase: 'idle',
|
|
382
|
+
client: state.client,
|
|
383
|
+
sessionId: state.sessionId,
|
|
384
|
+
sessionToken: state.sessionToken,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { installMockFetch, installMockWsServer, jsonResponse } from '../../test-utils';
|
|
3
|
+
import { CsrClient } from './csr-client';
|
|
4
|
+
|
|
5
|
+
describe('CsrClient.initSession', () => {
|
|
6
|
+
it('POSTs to /v1/sessions:initSession with clientSecret', async () => {
|
|
7
|
+
const { calls } = installMockFetch(() => jsonResponse({ sessionId: 'sess-1', sessionToken: 'tok-1' }));
|
|
8
|
+
|
|
9
|
+
const client = new CsrClient('https://recording.confidence.dev', 'secret', undefined, undefined);
|
|
10
|
+
const result = await client.initSession();
|
|
11
|
+
|
|
12
|
+
expect(result).toEqual({ sessionId: 'sess-1', sessionToken: 'tok-1' });
|
|
13
|
+
expect(calls).toHaveLength(1);
|
|
14
|
+
expect(calls[0].url).toBe('https://recording.confidence.dev/v1/sessions:initSession');
|
|
15
|
+
expect(JSON.parse(calls[0].init!.body as string)).toEqual({
|
|
16
|
+
clientSecret: 'secret',
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('forwards targetingKey when set, omits it when undefined', async () => {
|
|
21
|
+
const { calls } = installMockFetch(() => jsonResponse({ sessionId: 's', sessionToken: 't' }));
|
|
22
|
+
|
|
23
|
+
const withKey = new CsrClient('https://api', 'secret', 'user-42', undefined);
|
|
24
|
+
await withKey.initSession();
|
|
25
|
+
expect(JSON.parse(calls[0].init!.body as string)).toEqual({
|
|
26
|
+
clientSecret: 'secret',
|
|
27
|
+
targetingKey: 'user-42',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const withoutKey = new CsrClient('https://api', 'secret', undefined, undefined);
|
|
31
|
+
await withoutKey.initSession();
|
|
32
|
+
expect(JSON.parse(calls[1].init!.body as string)).toEqual({
|
|
33
|
+
clientSecret: 'secret',
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('trims a trailing slash from apiUrl when building the init URL', async () => {
|
|
38
|
+
const { calls } = installMockFetch(() => jsonResponse({ sessionId: 's', sessionToken: 't' }));
|
|
39
|
+
|
|
40
|
+
const client = new CsrClient('https://api/', 'secret', undefined, undefined);
|
|
41
|
+
await client.initSession();
|
|
42
|
+
|
|
43
|
+
expect(calls[0].url).toBe('https://api/v1/sessions:initSession');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('forwards context when set, omits it when empty/undefined', async () => {
|
|
47
|
+
const { calls } = installMockFetch(() => jsonResponse({ sessionId: 's', sessionToken: 't' }));
|
|
48
|
+
|
|
49
|
+
const ctx = {
|
|
50
|
+
userAgent: { os: 'macos', browser: 'chrome', viewportWidth: 1440 },
|
|
51
|
+
};
|
|
52
|
+
const withCtx = new CsrClient('https://api', 'secret', undefined, ctx);
|
|
53
|
+
await withCtx.initSession();
|
|
54
|
+
expect(JSON.parse(calls[0].init!.body as string)).toEqual({
|
|
55
|
+
clientSecret: 'secret',
|
|
56
|
+
context: ctx,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const withEmpty = new CsrClient('https://api', 'secret', undefined, {});
|
|
60
|
+
await withEmpty.initSession();
|
|
61
|
+
expect(JSON.parse(calls[1].init!.body as string)).toEqual({
|
|
62
|
+
clientSecret: 'secret',
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns skipRecording when the backend asks to skip', async () => {
|
|
67
|
+
installMockFetch(() => jsonResponse({ skipRecording: true }));
|
|
68
|
+
|
|
69
|
+
const client = new CsrClient('https://api', 'secret', undefined, undefined);
|
|
70
|
+
expect(await client.initSession()).toEqual({ skipRecording: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('throws on non-2xx response', async () => {
|
|
74
|
+
installMockFetch(() => new Response(null, { status: 500 }));
|
|
75
|
+
|
|
76
|
+
const client = new CsrClient('https://api', 'secret', undefined, undefined);
|
|
77
|
+
await expect(client.initSession()).rejects.toThrow(/HTTP 500/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('throws when the response is missing sessionId/sessionToken', async () => {
|
|
81
|
+
installMockFetch(() => jsonResponse({ sessionId: 'x' }));
|
|
82
|
+
|
|
83
|
+
const client = new CsrClient('https://api', 'secret', undefined, undefined);
|
|
84
|
+
await expect(client.initSession()).rejects.toThrow(/missing sessionId or sessionToken/);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('CsrClient.openTransport', () => {
|
|
89
|
+
it('derives a ws:// URL from an http:// apiUrl when websocketUrl is unset', async () => {
|
|
90
|
+
installMockWsServer('ws://api.example/sessions/stream?session_token=tok');
|
|
91
|
+
|
|
92
|
+
const client = new CsrClient('http://api.example', 'secret', undefined, undefined);
|
|
93
|
+
await expect(client.openTransport('tok')).resolves.toBeDefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('derives a wss:// URL from an https:// apiUrl', async () => {
|
|
97
|
+
installMockWsServer('wss://api.example/sessions/stream?session_token=tok');
|
|
98
|
+
|
|
99
|
+
const client = new CsrClient('https://api.example', 'secret', undefined, undefined);
|
|
100
|
+
await expect(client.openTransport('tok')).resolves.toBeDefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('uses websocketUrl verbatim when provided (split-host prod layout)', async () => {
|
|
104
|
+
installMockWsServer('wss://recording-ws.confidence.dev/sessions/stream?session_token=tok');
|
|
105
|
+
|
|
106
|
+
const client = new CsrClient(
|
|
107
|
+
'https://recording.confidence.dev',
|
|
108
|
+
'secret',
|
|
109
|
+
undefined,
|
|
110
|
+
undefined,
|
|
111
|
+
'wss://recording-ws.confidence.dev/sessions/stream',
|
|
112
|
+
);
|
|
113
|
+
await expect(client.openTransport('tok')).resolves.toBeDefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('URL-encodes the session token', async () => {
|
|
117
|
+
installMockWsServer('wss://api/sessions/stream?session_token=tok%2Fwith%3Dspecials');
|
|
118
|
+
|
|
119
|
+
const client = new CsrClient('https://api', 'secret', undefined, undefined);
|
|
120
|
+
await expect(client.openTransport('tok/with=specials')).resolves.toBeDefined();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ClientContext } from '../client-context';
|
|
2
|
+
import type { Client, Transport } from '../types';
|
|
3
|
+
import { WebSocketTransport } from './web-socket-transport';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Single Client implementation that talks to the recording backend's REST + WS protocol.
|
|
7
|
+
* Both dev-server and prod implement the same protocol, so we don't need polymorphism here yet.
|
|
8
|
+
*/
|
|
9
|
+
export class CsrClient implements Client {
|
|
10
|
+
constructor(
|
|
11
|
+
private readonly apiUrl: string,
|
|
12
|
+
private readonly clientSecret: string,
|
|
13
|
+
private readonly targetingKey: string | undefined,
|
|
14
|
+
private readonly context: ClientContext | undefined,
|
|
15
|
+
private readonly websocketUrl?: string,
|
|
16
|
+
private readonly log: (msg: string) => void = () => {},
|
|
17
|
+
private readonly forceRecord?: boolean,
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
async initSession(): Promise<{ sessionId: string; sessionToken: string } | { skipRecording: true }> {
|
|
21
|
+
const url = `${this.trimSlash(this.apiUrl)}/v1/sessions:initSession`;
|
|
22
|
+
this.log(`fetch POST ${url}`);
|
|
23
|
+
const res = await fetch(url, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'Content-Type': 'application/json' },
|
|
26
|
+
body: JSON.stringify({
|
|
27
|
+
clientSecret: this.clientSecret,
|
|
28
|
+
...(this.targetingKey ? { targetingKey: this.targetingKey } : {}),
|
|
29
|
+
...(this.context && Object.keys(this.context).length > 0 ? { context: this.context } : {}),
|
|
30
|
+
...(this.forceRecord ? { forceRecord: true } : {}),
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
throw new Error(`init-session failed: HTTP ${res.status}`);
|
|
35
|
+
}
|
|
36
|
+
const data = (await res.json()) as {
|
|
37
|
+
sessionId?: string;
|
|
38
|
+
sessionToken?: string;
|
|
39
|
+
skipRecording?: boolean;
|
|
40
|
+
};
|
|
41
|
+
if (data.skipRecording) return { skipRecording: true };
|
|
42
|
+
if (!data.sessionId || !data.sessionToken) {
|
|
43
|
+
throw new Error('init-session response missing sessionId or sessionToken');
|
|
44
|
+
}
|
|
45
|
+
return { sessionId: data.sessionId, sessionToken: data.sessionToken };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async openTransport(sessionToken: string): Promise<Transport> {
|
|
49
|
+
const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;
|
|
50
|
+
const sep = wsBase.includes('?') ? '&' : '?';
|
|
51
|
+
const url = `${wsBase}${sep}session_token=${encodeURIComponent(sessionToken)}`;
|
|
52
|
+
this.log(`WebSocket connect ${url}`);
|
|
53
|
+
const transport = new WebSocketTransport(url);
|
|
54
|
+
await transport.ready();
|
|
55
|
+
return transport;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private trimSlash(s: string): string {
|
|
59
|
+
return s.endsWith('/') ? s.slice(0, -1) : s;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private toWsScheme(base: string): string {
|
|
63
|
+
if (base.startsWith('https://')) return `wss://${base.slice('https://'.length)}`;
|
|
64
|
+
if (base.startsWith('http://')) return `ws://${base.slice('http://'.length)}`;
|
|
65
|
+
return base;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { registerPort, type PortAdapter } from './core';
|
|
2
|
+
|
|
3
|
+
// Branch on which worker context we're running in. SharedWorkerGlobalScope only exists
|
|
4
|
+
// at runtime inside a SharedWorker; we feature-detect it via `globalThis`.
|
|
5
|
+
const SharedWorkerScopeCtor = (
|
|
6
|
+
globalThis as unknown as {
|
|
7
|
+
SharedWorkerGlobalScope?: new () => unknown;
|
|
8
|
+
}
|
|
9
|
+
).SharedWorkerGlobalScope;
|
|
10
|
+
|
|
11
|
+
const isShared = typeof SharedWorkerScopeCtor === 'function' && self instanceof SharedWorkerScopeCtor;
|
|
12
|
+
|
|
13
|
+
if (isShared) {
|
|
14
|
+
(self as unknown as SharedWorkerGlobalScope).onconnect = (event: MessageEvent) => {
|
|
15
|
+
const port = event.ports[0];
|
|
16
|
+
port.start();
|
|
17
|
+
registerPort(adaptMessagePort(port));
|
|
18
|
+
};
|
|
19
|
+
} else {
|
|
20
|
+
registerPort(adaptDedicatedSelf());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function adaptMessagePort(port: MessagePort): PortAdapter {
|
|
24
|
+
return {
|
|
25
|
+
postMessage: message => port.postMessage(message),
|
|
26
|
+
onmessage: cb => {
|
|
27
|
+
port.onmessage = (e: MessageEvent) => cb(e.data);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function adaptDedicatedSelf(): PortAdapter {
|
|
33
|
+
const ws = self as unknown as DedicatedWorkerGlobalScope;
|
|
34
|
+
return {
|
|
35
|
+
postMessage: message => ws.postMessage(message),
|
|
36
|
+
onmessage: cb => {
|
|
37
|
+
ws.onmessage = (e: MessageEvent) => cb(e.data);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|