livekit-client 2.22.0 → 2.22.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.
Files changed (106) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +364 -40
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +1847 -179
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js +1 -1
  8. package/dist/livekit-client.fm.worker.js.map +1 -1
  9. package/dist/livekit-client.fm.worker.mjs +187 -1
  10. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  11. package/dist/livekit-client.umd.js +1 -1
  12. package/dist/livekit-client.umd.js.map +1 -1
  13. package/dist/src/api/SignalClient.d.ts +25 -1
  14. package/dist/src/api/SignalClient.d.ts.map +1 -1
  15. package/dist/src/api/SignalClientStateMachine.d.ts +85 -0
  16. package/dist/src/api/SignalClientStateMachine.d.ts.map +1 -0
  17. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  18. package/dist/src/api/utils.d.ts.map +1 -1
  19. package/dist/src/e2ee/E2eeManager.d.ts +7 -0
  20. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  21. package/dist/src/e2ee/constants.d.ts +5 -0
  22. package/dist/src/e2ee/constants.d.ts.map +1 -1
  23. package/dist/src/e2ee/types.d.ts +9 -2
  24. package/dist/src/e2ee/types.d.ts.map +1 -1
  25. package/dist/src/e2ee/worker/FrameCryptor.d.ts +42 -1
  26. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  27. package/dist/src/index.d.ts +3 -2
  28. package/dist/src/index.d.ts.map +1 -1
  29. package/dist/src/logger.d.ts +2 -1
  30. package/dist/src/logger.d.ts.map +1 -1
  31. package/dist/src/options.d.ts +22 -0
  32. package/dist/src/options.d.ts.map +1 -1
  33. package/dist/src/room/PCTransport.d.ts +2 -1
  34. package/dist/src/room/PCTransport.d.ts.map +1 -1
  35. package/dist/src/room/PCTransportManager.d.ts +12 -0
  36. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  37. package/dist/src/room/RTCEngine.d.ts +7 -2
  38. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  39. package/dist/src/room/Room.d.ts +8 -1
  40. package/dist/src/room/Room.d.ts.map +1 -1
  41. package/dist/src/room/data-stream/incoming/StreamReader.d.ts +17 -17
  42. package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
  43. package/dist/src/room/events.d.ts +1 -1
  44. package/dist/src/room/statsSummary.d.ts +13 -0
  45. package/dist/src/room/statsSummary.d.ts.map +1 -0
  46. package/dist/src/room/token-source/utils.d.ts.map +1 -1
  47. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  48. package/dist/src/room/track/Track.d.ts +10 -0
  49. package/dist/src/room/track/Track.d.ts.map +1 -1
  50. package/dist/src/utils/machineInspector.d.ts +54 -0
  51. package/dist/src/utils/machineInspector.d.ts.map +1 -0
  52. package/dist/ts4.2/api/SignalClient.d.ts +25 -1
  53. package/dist/ts4.2/api/SignalClientStateMachine.d.ts +85 -0
  54. package/dist/ts4.2/e2ee/E2eeManager.d.ts +7 -0
  55. package/dist/ts4.2/e2ee/constants.d.ts +5 -0
  56. package/dist/ts4.2/e2ee/types.d.ts +9 -2
  57. package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +42 -1
  58. package/dist/ts4.2/index.d.ts +3 -2
  59. package/dist/ts4.2/logger.d.ts +2 -1
  60. package/dist/ts4.2/options.d.ts +22 -0
  61. package/dist/ts4.2/room/PCTransport.d.ts +2 -1
  62. package/dist/ts4.2/room/PCTransportManager.d.ts +12 -0
  63. package/dist/ts4.2/room/RTCEngine.d.ts +7 -2
  64. package/dist/ts4.2/room/Room.d.ts +8 -1
  65. package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +17 -17
  66. package/dist/ts4.2/room/events.d.ts +1 -1
  67. package/dist/ts4.2/room/statsSummary.d.ts +13 -0
  68. package/dist/ts4.2/room/track/Track.d.ts +10 -0
  69. package/dist/ts4.2/utils/machineInspector.d.ts +54 -0
  70. package/package.json +7 -1
  71. package/src/api/SignalClient.test.ts +320 -8
  72. package/src/api/SignalClient.ts +261 -82
  73. package/src/api/SignalClientStateMachine.test.ts +472 -0
  74. package/src/api/SignalClientStateMachine.ts +180 -0
  75. package/src/api/WebSocketStream.ts +19 -3
  76. package/src/api/utils.test.ts +20 -1
  77. package/src/api/utils.ts +5 -0
  78. package/src/e2ee/E2eeManager.ts +38 -14
  79. package/src/e2ee/constants.ts +6 -0
  80. package/src/e2ee/subscriberBlackScreen.test.ts +544 -0
  81. package/src/e2ee/types.ts +9 -2
  82. package/src/e2ee/worker/FrameCryptor.race.test.ts +9 -26
  83. package/src/e2ee/worker/FrameCryptor.test.ts +0 -1
  84. package/src/e2ee/worker/FrameCryptor.ts +185 -52
  85. package/src/e2ee/worker/e2ee.worker.ts +38 -6
  86. package/src/index.ts +5 -0
  87. package/src/logger.ts +1 -0
  88. package/src/options.ts +24 -0
  89. package/src/room/PCTransport.ts +2 -1
  90. package/src/room/PCTransportManager.test.ts +35 -0
  91. package/src/room/PCTransportManager.ts +12 -4
  92. package/src/room/RTCEngine.ts +16 -7
  93. package/src/room/Room.ts +70 -5
  94. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +171 -0
  95. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +17 -18
  96. package/src/room/data-stream/incoming/StreamReader.ts +20 -50
  97. package/src/room/events.ts +1 -1
  98. package/src/room/statsSummary.ts +187 -0
  99. package/src/room/token-source/test-tokens.ts +20 -0
  100. package/src/room/token-source/utils.test.ts +27 -0
  101. package/src/room/token-source/utils.ts +12 -5
  102. package/src/room/track/LocalAudioTrack.ts +9 -3
  103. package/src/room/track/Track.ts +27 -0
  104. package/src/room/utils.test.ts +24 -1
  105. package/src/room/utils.ts +1 -1
  106. package/src/utils/machineInspector.ts +90 -0
@@ -0,0 +1,472 @@
1
+ // eslint-disable-next-line import-x/no-duplicates
2
+ import 'machina-test';
3
+ // eslint-disable-next-line import-x/no-duplicates
4
+ import { walkAll } from 'machina-test';
5
+ import { describe, expect, it } from 'vitest';
6
+ import {
7
+ type SignalLifecycleState,
8
+ type SignalMachine,
9
+ type SignalMachineContext,
10
+ type SignalMachineInput,
11
+ createSignalMachine,
12
+ signalLifecycleStates,
13
+ } from './SignalClientStateMachine';
14
+
15
+ function send(machine: SignalMachine, input: SignalMachineInput) {
16
+ machine.handle(input.type, input);
17
+ }
18
+
19
+ /** Drives a machine into `state` through legal inputs only. */
20
+ function machineIn(state: SignalLifecycleState): SignalMachine {
21
+ const machine = createSignalMachine(state);
22
+ expect(machine.currentState()).toBe(state);
23
+ return machine;
24
+ }
25
+
26
+ type TransitionTarget = SignalLifecycleState | 'ignored';
27
+
28
+ /**
29
+ * Every state/input pair and what it resolves to. Asserted exhaustively below, and used as the
30
+ * oracle for the random walks, so adding a state or an input cannot silently leave a hole.
31
+ */
32
+ const documentedTransitions: Record<string, Record<string, TransitionTarget>> = {
33
+ // Establishing a session is legal exactly where no transport and no attempt are in play: `new`,
34
+ // `offline`, `closed`. Elsewhere it is a caller error, and `SignalClient` refuses rather than
35
+ // opening a transport the lifecycle would not own — except from `disconnecting`, which it waits out.
36
+ new: {
37
+ connect: 'connecting',
38
+ close: 'disconnecting',
39
+ reconnect: 'ignored', // nothing to resume yet
40
+ connectComplete: 'ignored',
41
+ connectFailed: 'ignored',
42
+ reconnectComplete: 'ignored',
43
+ reconnectFailed: 'ignored',
44
+ transportFailed: 'ignored',
45
+ closeComplete: 'ignored',
46
+ },
47
+ connecting: {
48
+ connectComplete: 'connected',
49
+ connectFailed: 'closed',
50
+ close: 'disconnecting',
51
+ connect: 'ignored', // an attempt is in flight; starting another abandons it
52
+ reconnect: 'ignored',
53
+ reconnectComplete: 'ignored',
54
+ reconnectFailed: 'ignored',
55
+ transportFailed: 'ignored',
56
+ closeComplete: 'ignored',
57
+ },
58
+ connected: {
59
+ reconnect: 'reconnecting', // the peer connection was severed while signalling stayed up
60
+ transportFailed: 'offline',
61
+ close: 'disconnecting',
62
+ connect: 'ignored', // close this session before starting another
63
+ connectComplete: 'ignored',
64
+ connectFailed: 'ignored',
65
+ reconnectComplete: 'ignored',
66
+ reconnectFailed: 'ignored',
67
+ closeComplete: 'ignored',
68
+ },
69
+ offline: {
70
+ connect: 'connecting', // escalation: give up on the session and join a new one
71
+ reconnect: 'reconnecting', // the retry the engine drives after its backoff
72
+ close: 'disconnecting',
73
+ connectComplete: 'ignored',
74
+ connectFailed: 'ignored',
75
+ reconnectComplete: 'ignored',
76
+ reconnectFailed: 'ignored',
77
+ transportFailed: 'ignored',
78
+ closeComplete: 'ignored',
79
+ },
80
+ reconnecting: {
81
+ reconnectComplete: 'connected',
82
+ reconnectFailed: 'offline', // probe is recoverable; terminal goes to closed
83
+ close: 'disconnecting',
84
+ connect: 'ignored', // an attempt is in flight
85
+ reconnect: 'ignored',
86
+ connectComplete: 'ignored',
87
+ connectFailed: 'ignored',
88
+ transportFailed: 'ignored',
89
+ closeComplete: 'ignored',
90
+ },
91
+ // Establishing here would race the teardown for the transport, so callers wait for the close to
92
+ // settle and establish from `closed` instead.
93
+ disconnecting: {
94
+ closeComplete: 'closed',
95
+ connect: 'ignored',
96
+ reconnect: 'ignored',
97
+ connectComplete: 'ignored',
98
+ connectFailed: 'ignored',
99
+ reconnectComplete: 'ignored',
100
+ reconnectFailed: 'ignored',
101
+ transportFailed: 'ignored',
102
+ close: 'ignored',
103
+ },
104
+ closed: {
105
+ connect: 'connecting',
106
+ reconnect: 'reconnecting', // a closed transport does not end the session; the engine resumes
107
+ connectComplete: 'ignored',
108
+ connectFailed: 'ignored',
109
+ reconnectComplete: 'ignored',
110
+ reconnectFailed: 'ignored',
111
+ transportFailed: 'ignored',
112
+ close: 'ignored',
113
+ closeComplete: 'ignored',
114
+ },
115
+ };
116
+
117
+ /**
118
+ * The one cell whose target depends on the payload rather than the state alone. The walks vary
119
+ * `recoverable`, so both outcomes are legal there; the matrix test pins the payload and asserts the
120
+ * exact target. (`transportFailed` is payload-dependent too, but its stale-id outcome is no
121
+ * transition at all, so it never reaches a walk invariant.)
122
+ */
123
+ const payloadDependent: Record<string, Array<TransitionTarget>> = {
124
+ 'reconnecting.reconnectFailed': ['offline', 'closed'],
125
+ };
126
+
127
+ /**
128
+ * A machine factory paired with deterministic payload generators. Handlers that read their payload
129
+ * would throw on a bare `handle(input)`, and alternating rather than random values keep a seed
130
+ * reproducible.
131
+ *
132
+ * The generators read the machine the factory last produced, so attempt-scoped inputs can address
133
+ * the live attempt. `transportFailed` alternates between the live attempt and a stale one, so walks
134
+ * exercise the identity guard from both sides.
135
+ */
136
+ function walkSetup(create: () => SignalMachine = createSignalMachine) {
137
+ let machine: SignalMachine | undefined;
138
+ let recoverable = true;
139
+ let stale = false;
140
+ const liveAttemptId = () => machine?.context.attemptId ?? 0;
141
+ return {
142
+ factory: () => {
143
+ machine = create();
144
+ return machine;
145
+ },
146
+ inputs: {
147
+ connectComplete: () => ({ type: 'connectComplete', attemptId: liveAttemptId() }),
148
+ reconnectComplete: () => ({ type: 'reconnectComplete', attemptId: liveAttemptId() }),
149
+ connectFailed: () => ({ type: 'connectFailed', error: new Error('walk') }),
150
+ reconnectFailed: () => {
151
+ recoverable = !recoverable;
152
+ return { type: 'reconnectFailed', error: new Error('walk'), recoverable };
153
+ },
154
+ transportFailed: () => {
155
+ stale = !stale;
156
+ return {
157
+ type: 'transportFailed',
158
+ attemptId: stale ? liveAttemptId() - 1 : liveAttemptId(),
159
+ reason: 'walk',
160
+ };
161
+ },
162
+ close: () => ({ type: 'close', reason: 'walk' }),
163
+ },
164
+ };
165
+ }
166
+
167
+ describe('signal lifecycle machine', () => {
168
+ it('starts in new', () => {
169
+ expect(createSignalMachine().currentState()).toBe('new');
170
+ });
171
+
172
+ it('gives every client its own context', () => {
173
+ const a = machineIn('connected');
174
+ const b = machineIn('connected');
175
+
176
+ send(a, { type: 'reconnect' });
177
+
178
+ expect(a.context.attemptId).toBe(1);
179
+ expect(b.context.attemptId).toBe(0);
180
+ });
181
+
182
+ // Topology: assertions about the wiring, independent of which handlers happen to fire.
183
+ describe('graph', () => {
184
+ it('has no unreachable states', () => {
185
+ expect(createSignalMachine()).toHaveNoUnreachableStates();
186
+ });
187
+
188
+ it('can recover to connected from anywhere, so no state is a dead end', () => {
189
+ for (const from of signalLifecycleStates) {
190
+ expect(createSignalMachine()).toAlwaysReach('connected', { from });
191
+ }
192
+ });
193
+
194
+ it('never returns to new once a session has been started', () => {
195
+ for (const from of signalLifecycleStates.filter((state) => state !== 'new')) {
196
+ expect(createSignalMachine()).toNeverReach('new', { from });
197
+ }
198
+ });
199
+ });
200
+
201
+ describe('initial connect', () => {
202
+ it('treats failure as terminal, since there is no session to fall back on', () => {
203
+ const machine = machineIn('connecting');
204
+ send(machine, { type: 'connectFailed', error: new Error('nope') });
205
+ expect(machine.currentState()).toBe('closed');
206
+ expect(machine.context.lastError).toBeInstanceOf(Error);
207
+ });
208
+ });
209
+
210
+ describe('transport loss', () => {
211
+ it('ignores a transport that has already been replaced', () => {
212
+ const machine = machineIn('connected');
213
+ const staleAttemptId = machine.context.attemptId;
214
+
215
+ // a newer attempt takes over, then the old socket reports its close
216
+ send(machine, { type: 'reconnect' });
217
+ send(machine, { type: 'transportFailed', attemptId: staleAttemptId, reason: 'late close' });
218
+
219
+ expect(machine.currentState()).toBe('reconnecting');
220
+ });
221
+
222
+ it('ignores a completion from an attempt that has been superseded', () => {
223
+ // the abort-then-retry sequence: an attempt is abandoned, a new one starts, and the first
224
+ // one's transport reports success afterwards
225
+ const machine = machineIn('connecting');
226
+ const abandonedAttemptId = machine.context.attemptId;
227
+ send(machine, { type: 'connectFailed', error: new Error('aborted') });
228
+ send(machine, { type: 'connect' });
229
+
230
+ send(machine, { type: 'connectComplete', attemptId: abandonedAttemptId });
231
+
232
+ // still establishing: the abandoned attempt cannot declare the session live
233
+ expect(machine.currentState()).toBe('connecting');
234
+ send(machine, { type: 'connectComplete', attemptId: machine.context.attemptId });
235
+ expect(machine.currentState()).toBe('connected');
236
+ });
237
+
238
+ it('ignores a resume completion from an attempt that has been superseded', () => {
239
+ // a resume starts while the transport of the previous attempt is still reporting in
240
+ const machine = machineIn('offline');
241
+ const supersededAttemptId = machine.context.attemptId;
242
+ send(machine, { type: 'reconnect' });
243
+
244
+ send(machine, { type: 'reconnectComplete', attemptId: supersededAttemptId });
245
+
246
+ expect(machine.currentState()).toBe('reconnecting');
247
+ });
248
+
249
+ it('is ignored while establishing, where the attempt itself reports the outcome', () => {
250
+ const machine = machineIn('connecting');
251
+ send(machine, {
252
+ type: 'transportFailed',
253
+ attemptId: machine.context.attemptId,
254
+ reason: 'ws closed',
255
+ });
256
+ expect(machine.currentState()).toBe('connecting');
257
+ });
258
+ });
259
+
260
+ describe('resume', () => {
261
+ it('can be retried after a recoverable failure', () => {
262
+ const machine = machineIn('reconnecting');
263
+ send(machine, { type: 'reconnectFailed', recoverable: true });
264
+ expect(machine.currentState()).toBe('offline');
265
+
266
+ // the retry the engine drives after its backoff
267
+ send(machine, { type: 'reconnect' });
268
+ expect(machine.currentState()).toBe('reconnecting');
269
+ send(machine, { type: 'reconnectComplete', attemptId: machine.context.attemptId });
270
+ expect(machine.currentState()).toBe('connected');
271
+ });
272
+
273
+ it('closes on a terminal failure', () => {
274
+ const machine = machineIn('reconnecting');
275
+ send(machine, { type: 'reconnectFailed', recoverable: false });
276
+ expect(machine.currentState()).toBe('closed');
277
+ });
278
+
279
+ it('is legal after a close, because a closed transport does not end the session', () => {
280
+ // the engine's recovery path after an unexpected close: close() takes the client to `closed`,
281
+ // then onClose has the engine resume the session it still holds
282
+ const machine = machineIn('connected');
283
+ send(machine, { type: 'close', reason: 'transport lost' });
284
+ send(machine, { type: 'closeComplete' });
285
+ expect(machine.currentState()).toBe('closed');
286
+
287
+ send(machine, { type: 'reconnect' });
288
+ expect(machine.currentState()).toBe('reconnecting');
289
+ send(machine, { type: 'reconnectComplete', attemptId: machine.context.attemptId });
290
+ expect(machine.currentState()).toBe('connected');
291
+ });
292
+
293
+ it('can escalate to a full reconnect from offline', () => {
294
+ const machine = machineIn('offline');
295
+ send(machine, { type: 'connect' });
296
+ expect(machine.currentState()).toBe('connecting');
297
+ });
298
+
299
+ it('resumes from connected, for when only the peer connection was severed', () => {
300
+ const machine = machineIn('connected');
301
+ send(machine, { type: 'reconnect' });
302
+ expect(machine.currentState()).toBe('reconnecting');
303
+ });
304
+
305
+ it('bumps the attempt id for every attempt, so transports stay distinguishable', () => {
306
+ const machine = machineIn('offline');
307
+ const first = machine.context.attemptId;
308
+
309
+ send(machine, { type: 'reconnect' });
310
+ expect(machine.context.attemptId).toBe(first + 1);
311
+
312
+ send(machine, { type: 'reconnectFailed', recoverable: true });
313
+ send(machine, { type: 'connect' });
314
+ expect(machine.context.attemptId).toBe(first + 2);
315
+ });
316
+ });
317
+
318
+ describe('close', () => {
319
+ it('is legal from every state that owns a transport or an attempt', () => {
320
+ for (const state of ['new', 'connecting', 'connected', 'offline', 'reconnecting'] as const) {
321
+ const machine = machineIn(state);
322
+ send(machine, { type: 'close', reason: 'bye' });
323
+ expect(machine.currentState()).toBe('disconnecting');
324
+ expect(machine.context.closeReason).toBe('bye');
325
+ }
326
+ });
327
+
328
+ it('is a no-op once closing or closed', () => {
329
+ for (const state of ['disconnecting', 'closed'] as const) {
330
+ const machine = machineIn(state);
331
+ send(machine, { type: 'close', reason: 'again' });
332
+ expect(machine.currentState()).toBe(state);
333
+ }
334
+ });
335
+
336
+ it('reports unhandled inputs so they can be logged rather than thrown', () => {
337
+ const machine = machineIn('closed');
338
+ const unhandled: Array<string> = [];
339
+ machine.on('nohandler', ({ inputName }) => unhandled.push(inputName));
340
+ send(machine, { type: 'closeComplete' });
341
+ expect(unhandled).toEqual(['closeComplete']);
342
+ });
343
+ });
344
+
345
+ describe('transition matrix', () => {
346
+ const probes: Record<SignalMachineInput['type'], SignalMachineInput> = {
347
+ connect: { type: 'connect' },
348
+ connectComplete: { type: 'connectComplete', attemptId: 0 },
349
+ connectFailed: { type: 'connectFailed' },
350
+ reconnect: { type: 'reconnect' },
351
+ reconnectComplete: { type: 'reconnectComplete', attemptId: 0 },
352
+ reconnectFailed: { type: 'reconnectFailed', recoverable: true },
353
+ transportFailed: { type: 'transportFailed', attemptId: 0, reason: 'probe' },
354
+ close: { type: 'close', reason: 'probe' },
355
+ closeComplete: { type: 'closeComplete' },
356
+ };
357
+
358
+ it('agrees with canHandle, which is what tooling highlights as available', () => {
359
+ // The inspector example lights up an input when `canHandle` is true. That highlight is only
360
+ // trustworthy if it matches the table, so pin the two together: an input is handled exactly
361
+ // where the table gives it a target. (Holds because no current handler declines under the
362
+ // matrix probes — one that did would belong in `payloadDependent`.)
363
+ for (const state of signalLifecycleStates) {
364
+ const machine = machineIn(state);
365
+ const handled = Object.keys(probes).filter((input) => machine.canHandle(input));
366
+ const documented = Object.entries(documentedTransitions[state])
367
+ .filter(([, target]) => target !== 'ignored')
368
+ .map(([input]) => input);
369
+ expect(handled.sort()).toEqual(documented.sort());
370
+ }
371
+ });
372
+
373
+ it('matches the documented table', () => {
374
+ const matrix: Record<string, Record<string, TransitionTarget>> = {};
375
+ for (const state of signalLifecycleStates) {
376
+ matrix[state] = {};
377
+ for (const [type, probe] of Object.entries(probes)) {
378
+ const machine = machineIn(state);
379
+ // attempt-scoped probes address the live attempt, so only the state gates the transition
380
+ const input =
381
+ 'attemptId' in probe ? { ...probe, attemptId: machine.context.attemptId } : probe;
382
+ const before = machine.currentState();
383
+ send(machine, input);
384
+ const after = machine.currentState();
385
+ matrix[state][type] = after === before ? 'ignored' : after;
386
+ }
387
+ }
388
+
389
+ expect(matrix).toEqual(documentedTransitions);
390
+ });
391
+ });
392
+
393
+ // Random input sequences, checked against the properties the lifecycle is supposed to hold.
394
+ // Seeded, so a failure replays exactly; the seed is reported in the failure either way.
395
+ describe('random walks', () => {
396
+ it('only ever takes transitions the table documents', () => {
397
+ const { factory, inputs } = walkSetup();
398
+ walkAll(factory, {
399
+ seed: 20260818,
400
+ walks: 200,
401
+ maxSteps: 25,
402
+ inputs,
403
+ invariant({ state, previousState, input }) {
404
+ const actual: TransitionTarget =
405
+ state === previousState ? 'ignored' : (state as TransitionTarget);
406
+ const allowed = payloadDependent[`${previousState}.${input}`] ?? [
407
+ documentedTransitions[previousState]?.[input],
408
+ ];
409
+ if (!allowed.includes(actual)) {
410
+ throw new Error(
411
+ `${previousState} --${input}--> ${state}, but the table documents ${allowed.join(' or ')}`,
412
+ );
413
+ }
414
+ },
415
+ });
416
+ });
417
+
418
+ it('never starts a recovery attempt that was not requested', () => {
419
+ const { factory, inputs } = walkSetup(() => machineIn('connected'));
420
+ walkAll(factory, {
421
+ seed: 20260818,
422
+ walks: 200,
423
+ maxSteps: 25,
424
+ // the two inputs that ask for recovery; everything else is an outcome or a close
425
+ exclude: ['connect', 'reconnect'],
426
+ inputs,
427
+ invariant({ state, previousState, input }) {
428
+ if (state === 'connecting' || state === 'reconnecting') {
429
+ throw new Error(`entered ${state} from ${previousState} on ${input}, unrequested`);
430
+ }
431
+ },
432
+ });
433
+ });
434
+
435
+ it('keeps the attempt id monotonic, so a replaced transport stays distinguishable', () => {
436
+ // keyed on the context object: each walk gets a fresh machine, and the invariant only runs
437
+ // on transitions, so `step` cannot be used to tell one walk from the next
438
+ const highest = new WeakMap<SignalMachineContext, number>();
439
+ const { factory, inputs } = walkSetup();
440
+ walkAll(factory, {
441
+ seed: 20260818,
442
+ walks: 200,
443
+ maxSteps: 25,
444
+ inputs,
445
+ invariant({ ctx }) {
446
+ const context = ctx as SignalMachineContext;
447
+ const previous = highest.get(context) ?? 0;
448
+ if (context.attemptId < previous) {
449
+ throw new Error(`attempt id went backwards: ${previous} -> ${context.attemptId}`);
450
+ }
451
+ highest.set(context, context.attemptId);
452
+ },
453
+ });
454
+ });
455
+
456
+ it('always knows why it is closing', () => {
457
+ const { factory, inputs } = walkSetup();
458
+ walkAll(factory, {
459
+ seed: 20260818,
460
+ walks: 200,
461
+ maxSteps: 25,
462
+ inputs,
463
+ invariant({ state, ctx }) {
464
+ const { closeReason } = ctx as SignalMachineContext;
465
+ if (state === 'disconnecting' && closeReason === undefined) {
466
+ throw new Error('reached disconnecting without a reason');
467
+ }
468
+ },
469
+ });
470
+ });
471
+ });
472
+ });
@@ -0,0 +1,180 @@
1
+ import { type HandlerArgs, type HandlerFn, createFsm } from 'machina';
2
+
3
+ /**
4
+ * Runtime lifecycle states of the signal connection.
5
+ *
6
+ * `offline` is the resting state after the transport was lost without an explicit close: no
7
+ * attempt is in flight, but a resume is still legal. It is what makes retrying a failed resume
8
+ * expressible — `closed` is only reached by an explicit close or by a terminal attempt failure.
9
+ */
10
+ export type SignalLifecycleState =
11
+ 'new' | 'connecting' | 'connected' | 'offline' | 'reconnecting' | 'disconnecting' | 'closed';
12
+
13
+ export interface SignalMachineContext {
14
+ /** Monotonic id of the current (re)connection attempt and of the transport it owns */
15
+ attemptId: number;
16
+ /** Error or reason that ended the last attempt, kept for diagnostics. */
17
+ lastError?: unknown;
18
+ /** Reason passed to the last close request. */
19
+ closeReason?: string;
20
+ }
21
+
22
+ export type SignalMachineInput =
23
+ /**
24
+ * Start an initial session, or restart from scratch (full reconnect).
25
+ *
26
+ * Establishing is legal exactly where no transport and no attempt are in play: `new`, `offline`
27
+ * and `closed`. `closed` is included because it means "no transport", not "session over" — a
28
+ * deliberate close and an unexpected loss leave the session equally resumable, and the engine
29
+ * recovers from both. Establishing over a live session, or over an attempt already in flight, is
30
+ * a caller error; `disconnecting` is waited out rather than refused.
31
+ */
32
+ | { type: 'connect' }
33
+ /**
34
+ * Resume the existing session: legal wherever establishing is, minus `new` (nothing to resume
35
+ * yet), plus `connected` — the peer connection can be severed while signalling stays up.
36
+ */
37
+ | { type: 'reconnect' }
38
+ /**
39
+ * An attempt established its transport. Carries the attempt it belongs to: an attempt that a
40
+ * newer one has already superseded must not declare the session live.
41
+ */
42
+ | { type: 'connectComplete'; attemptId: number }
43
+ | { type: 'connectFailed'; error?: unknown }
44
+ | { type: 'reconnectComplete'; attemptId: number }
45
+ /**
46
+ * A resume attempt ended. `recoverable` distinguishes "another resume may follow" (→ `offline`)
47
+ * from a terminal outcome such as a server leave or an expired token (→ `closed`).
48
+ */
49
+ | { type: 'reconnectFailed'; error?: unknown; recoverable: boolean }
50
+ /** The transport identified by `attemptId` was lost (unexpected ws close, ping timeout). */
51
+ | { type: 'transportFailed'; attemptId: number; reason: string }
52
+ | { type: 'close'; reason: string }
53
+ | { type: 'closeComplete' };
54
+
55
+ type Args = HandlerArgs<SignalMachineContext, SignalLifecycleState>;
56
+
57
+ type Handler = HandlerFn<SignalMachineContext, SignalLifecycleState>;
58
+
59
+ /**
60
+ * Declares a handler for one input, restoring the payload typing that machina's `...unknown[]`
61
+ * handler arguments give up.
62
+ */
63
+ function on<T extends SignalMachineInput['type']>(
64
+ handler: (
65
+ args: Args,
66
+ event: Extract<SignalMachineInput, { type: T }>,
67
+ ) => SignalLifecycleState | void,
68
+ ): Handler {
69
+ return handler as Handler;
70
+ }
71
+
72
+ /**
73
+ * Whether a transport-originated input belongs to the attempt that currently owns the session.
74
+ * Inputs from a superseded attempt are dropped: its transport is already being replaced, so it can
75
+ * neither declare the session live nor take it down.
76
+ */
77
+ function isCurrentAttempt(ctx: SignalMachineContext, event: { attemptId: number }) {
78
+ return event.attemptId === ctx.attemptId;
79
+ }
80
+
81
+ const attemptEstablished = on<'connectComplete' | 'reconnectComplete'>(({ ctx }, event) => {
82
+ if (!isCurrentAttempt(ctx, event)) {
83
+ return;
84
+ }
85
+ return 'connected';
86
+ });
87
+
88
+ const startConnect = on<'connect'>(({ ctx }) => {
89
+ ctx.attemptId += 1;
90
+ ctx.lastError = undefined;
91
+ return 'connecting';
92
+ });
93
+
94
+ const startReconnect = on<'reconnect'>(({ ctx }) => {
95
+ ctx.attemptId += 1;
96
+ ctx.lastError = undefined;
97
+ return 'reconnecting';
98
+ });
99
+
100
+ const requestClose = on<'close'>(({ ctx }, event) => {
101
+ ctx.closeReason = event.reason;
102
+ return 'disconnecting';
103
+ });
104
+
105
+ const signalStates = {
106
+ new: {
107
+ connect: startConnect,
108
+ close: requestClose,
109
+ },
110
+ connecting: {
111
+ connectComplete: attemptEstablished,
112
+ // An initial connect has no session to fall back on, so failure is terminal.
113
+ connectFailed: on<'connectFailed'>(({ ctx }, event) => {
114
+ ctx.lastError = event.error;
115
+ return 'closed';
116
+ }),
117
+ close: requestClose,
118
+ },
119
+ connected: {
120
+ reconnect: startReconnect,
121
+ transportFailed: on<'transportFailed'>(({ ctx }, event) => {
122
+ if (!isCurrentAttempt(ctx, event)) {
123
+ // a transport that has already been replaced, reporting its close late
124
+ return;
125
+ }
126
+ ctx.lastError = event.reason;
127
+ return 'offline';
128
+ }),
129
+ close: requestClose,
130
+ },
131
+ offline: {
132
+ connect: startConnect,
133
+ reconnect: startReconnect,
134
+ close: requestClose,
135
+ },
136
+ reconnecting: {
137
+ reconnectComplete: attemptEstablished,
138
+ reconnectFailed: on<'reconnectFailed'>(({ ctx }, event) => {
139
+ ctx.lastError = event.error;
140
+ return event.recoverable ? 'offline' : 'closed';
141
+ }),
142
+ close: requestClose,
143
+ },
144
+ // Owns the transport until the close handshake settles. Every path into this state is followed
145
+ // by a `closeComplete`, so it cannot become a trap.
146
+ disconnecting: {
147
+ closeComplete: 'closed',
148
+ },
149
+ // No transport, but the session may still be resumable: the engine resumes after an unexpected
150
+ // close just as it does from `offline`.
151
+ closed: {
152
+ connect: startConnect,
153
+ reconnect: startReconnect,
154
+ },
155
+ } as const;
156
+
157
+ /**
158
+ * Lifecycle model of the signal connection.
159
+ *
160
+ * The machine deliberately does not own connection attempts: `SignalClient` performs the
161
+ * asynchronous work and reports the outcome. It also does not decide *whether* to reconnect —
162
+ * that policy (backoff, resume vs. full reconnect, region failover, giving up) belongs to
163
+ * `RTCEngine`, so transport loss lands in `offline` rather than starting a reconnect on its own.
164
+ *
165
+ * Each client gets its own instance: the context is mutable and per-connection.
166
+ */
167
+ export function createSignalMachine(initialState: SignalLifecycleState = 'new') {
168
+ const context: SignalMachineContext = { attemptId: 0 };
169
+ return createFsm({
170
+ id: 'signal',
171
+ initialState: initialState,
172
+ context,
173
+ states: signalStates,
174
+ });
175
+ }
176
+
177
+ export type SignalMachine = ReturnType<typeof createSignalMachine>;
178
+
179
+ /** All lifecycle states, derived from the machine itself so the two cannot drift. */
180
+ export const signalLifecycleStates = Object.keys(signalStates) as Array<SignalLifecycleState>;
@@ -67,7 +67,23 @@ export class WebSocketStream<T extends ArrayBuffer | string = ArrayBuffer | stri
67
67
  readable: new ReadableStream<T>({
68
68
  start(controller) {
69
69
  ws.onmessage = ({ data }) => controller.enqueue(data);
70
- ws.onerror = (e) => controller.error(e);
70
+ ws.onerror = (e) =>
71
+ controller.error(
72
+ ConnectionError.websocket(
73
+ e instanceof Error
74
+ ? `${e.name}: ${e.message}`
75
+ : `Encountered unknown websocket error: ${String(e)}`,
76
+ ),
77
+ );
78
+ ws.onclose = (ev) => {
79
+ if (ev.wasClean) {
80
+ controller.close();
81
+ } else {
82
+ controller.error(
83
+ ConnectionError.websocket(`WS closed unexpectedly with code ${ev.code}`),
84
+ );
85
+ }
86
+ };
71
87
  },
72
88
  cancel: closeWithInfo,
73
89
  }),
@@ -114,10 +130,10 @@ export class WebSocketStream<T extends ArrayBuffer | string = ArrayBuffer | stri
114
130
  resolve(reason);
115
131
  }
116
132
  };
117
- ws.onclose = ({ code, reason }) => {
133
+ ws.addEventListener('close', ({ code, reason }) => {
118
134
  resolve({ closeCode: code, reason });
119
135
  ws.removeEventListener('error', rejectHandler);
120
- };
136
+ });
121
137
 
122
138
  ws.addEventListener('error', rejectHandler);
123
139
  });