@stream-io/video-client 1.54.1-beta.0 → 1.55.1

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 (59) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/index.browser.es.js +9700 -8873
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +9707 -8880
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +9708 -8881
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +4 -4
  9. package/dist/src/StreamSfuClient.d.ts +11 -3
  10. package/dist/src/coordinator/connection/connection.d.ts +2 -1
  11. package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
  12. package/dist/src/rtc/BasePeerConnection.d.ts +2 -12
  13. package/dist/src/rtc/IceTrickleBuffer.d.ts +41 -3
  14. package/dist/src/rtc/Publisher.d.ts +1 -1
  15. package/dist/src/rtc/Subscriber.d.ts +2 -1
  16. package/dist/src/rtc/helpers/iceCandiates.d.ts +12 -0
  17. package/dist/src/rtc/types.d.ts +3 -0
  18. package/dist/src/stats/SfuStatsReporter.d.ts +32 -1
  19. package/dist/src/stats/rtc/StatsTracer.d.ts +38 -8
  20. package/dist/src/stats/rtc/Tracer.d.ts +9 -2
  21. package/dist/src/stats/rtc/types.d.ts +10 -4
  22. package/package.json +5 -3
  23. package/src/Call.ts +47 -44
  24. package/src/StreamSfuClient.ts +36 -21
  25. package/src/__tests__/StreamSfuClient.test.ts +159 -1
  26. package/src/__tests__/StreamVideoClient.api.test.ts +123 -97
  27. package/src/coordinator/connection/__tests__/connection.test.ts +69 -0
  28. package/src/coordinator/connection/connection.ts +28 -13
  29. package/src/gen/video/sfu/event/events.ts +0 -1
  30. package/src/gen/video/sfu/models/models.ts +0 -1
  31. package/src/gen/video/sfu/signal_rpc/signal.client.ts +0 -1
  32. package/src/gen/video/sfu/signal_rpc/signal.ts +0 -1
  33. package/src/helpers/MediaPlaybackWatchdog.ts +1 -0
  34. package/src/helpers/__tests__/browsers.test.ts +12 -12
  35. package/src/helpers/browsers.ts +5 -5
  36. package/src/helpers/client-details.ts +1 -1
  37. package/src/reporting/ClientEventReporter.ts +17 -12
  38. package/src/reporting/__tests__/ClientEventReporter.test.ts +52 -0
  39. package/src/rtc/BasePeerConnection.ts +15 -34
  40. package/src/rtc/IceTrickleBuffer.ts +105 -12
  41. package/src/rtc/Publisher.ts +26 -19
  42. package/src/rtc/Subscriber.ts +71 -37
  43. package/src/rtc/__tests__/Call.reconnect.test.ts +45 -45
  44. package/src/rtc/__tests__/IceTrickleBuffer.test.ts +127 -0
  45. package/src/rtc/__tests__/Publisher.test.ts +76 -31
  46. package/src/rtc/__tests__/Subscriber.test.ts +271 -20
  47. package/src/rtc/helpers/__tests__/iceCandiates.test.ts +88 -0
  48. package/src/rtc/helpers/degradationPreference.ts +1 -0
  49. package/src/rtc/helpers/iceCandiates.ts +35 -0
  50. package/src/rtc/helpers/sdp.ts +3 -2
  51. package/src/rtc/helpers/tracks.ts +2 -0
  52. package/src/rtc/types.ts +3 -0
  53. package/src/stats/SfuStatsReporter.ts +149 -49
  54. package/src/stats/__tests__/SfuStatsReporter.test.ts +235 -0
  55. package/src/stats/rtc/StatsTracer.ts +90 -32
  56. package/src/stats/rtc/Tracer.ts +23 -2
  57. package/src/stats/rtc/__tests__/StatsTracer.test.ts +213 -6
  58. package/src/stats/rtc/__tests__/Tracer.test.ts +34 -0
  59. package/src/stats/rtc/types.ts +11 -4
@@ -164,13 +164,24 @@ export class StreamSfuClient {
164
164
  */
165
165
  isClosingClean = false;
166
166
 
167
+ /**
168
+ * One-shot latch guarding `onSignalClose`. The signal connection can be
169
+ * detected as dead by more than one source (the health watchdog and the
170
+ * WebSocket `close` event, which on a wedged socket can arrive seconds
171
+ * apart). This ensures revival is triggered at most once per client.
172
+ */
173
+ private signalClosed = false;
174
+
167
175
  private readonly rpc: SignalServerClient;
168
176
  private keepAliveInterval?: number;
169
- private connectionCheckTimeout?: NodeJS.Timeout;
177
+ private connectionCheckInterval?: number;
170
178
  private migrateAwayTimeout?: NodeJS.Timeout;
171
179
  private readonly pingIntervalInMs = 5 * 1000;
172
- private readonly unhealthyTimeoutInMs = 15 * 1000;
173
- private lastMessageTimestamp?: Date;
180
+ private readonly unhealthyTimeoutInMs = this.pingIntervalInMs * 2 + 2 * 1000;
181
+ private readonly connectionCheckIntervalInMs = Math.round(
182
+ this.unhealthyTimeoutInMs / 3,
183
+ );
184
+ private lastMessageTimestamp?: number;
174
185
  private readonly tracer?: Tracer;
175
186
  private readonly unsubscribeIceTrickle: () => void;
176
187
  private readonly unsubscribeNetworkChanged: () => void;
@@ -209,7 +220,7 @@ export class StreamSfuClient {
209
220
  /**
210
221
  * The error code used when the SFU connection is unhealthy.
211
222
  * Usually, this means that no message has been received from the SFU for
212
- * a certain amount of time (`connectionCheckTimeout`).
223
+ * a certain amount of time (`unhealthyTimeoutInMs`).
213
224
  */
214
225
  static ERROR_CONNECTION_UNHEALTHY = 4001;
215
226
  /**
@@ -311,7 +322,7 @@ export class StreamSfuClient {
311
322
  endpoint: `${this.credentials.server.ws_endpoint}?${new URLSearchParams(params).toString()}`,
312
323
  tracer: this.tracer,
313
324
  onMessage: (message) => {
314
- this.lastMessageTimestamp = new Date();
325
+ this.lastMessageTimestamp = Date.now();
315
326
  this.scheduleConnectionCheck();
316
327
  const eventKind = message.eventPayload.oneofKind;
317
328
  if (eventsToTrace[eventKind]) {
@@ -336,7 +347,7 @@ export class StreamSfuClient {
336
347
  this.signalWs.addEventListener('open', onOpen);
337
348
 
338
349
  this.signalWs.addEventListener('close', (e) => {
339
- this.handleWebSocketClose(e);
350
+ this.notifySignalClose(`${e.code} ${e.reason ?? ''}`);
340
351
  // Normally, this shouldn't have any effect, because WS should never emit 'close'
341
352
  // before emitting 'open'. However, stranger things have happened, and we don't
342
353
  // want to leave signalReady in a pending state.
@@ -371,11 +382,13 @@ export class StreamSfuClient {
371
382
  return this.joinResponseTask.promise;
372
383
  }
373
384
 
374
- private handleWebSocketClose = (e: CloseEvent) => {
375
- this.signalWs.removeEventListener('close', this.handleWebSocketClose);
376
- getTimers().clearInterval(this.keepAliveInterval);
377
- clearTimeout(this.connectionCheckTimeout);
378
- this.onSignalClose?.(`${e.code} ${e.reason}`);
385
+ private notifySignalClose = (reason: string) => {
386
+ if (this.signalClosed) return;
387
+ this.signalClosed = true;
388
+ const timers = getTimers();
389
+ timers.clearInterval(this.keepAliveInterval);
390
+ timers.clearInterval(this.connectionCheckInterval);
391
+ this.onSignalClose?.(reason.trim());
379
392
  };
380
393
 
381
394
  close = (code: number = StreamSfuClient.NORMAL_CLOSURE, reason?: string) => {
@@ -392,7 +405,9 @@ export class StreamSfuClient {
392
405
  ) {
393
406
  this.logger.debug(`Closing SFU WS connection: ${code} - ${reason}`);
394
407
  ws.close(code, `js-client: ${reason}`);
395
- ws.removeEventListener('close', this.handleWebSocketClose);
408
+ }
409
+ if (!this.isClosingClean) {
410
+ this.notifySignalClose(`${code} ${reason ?? ''}`);
396
411
  }
397
412
  this.dispose(reason);
398
413
  };
@@ -401,8 +416,9 @@ export class StreamSfuClient {
401
416
  this.logger.debug('Disposing SFU client');
402
417
  this.unsubscribeIceTrickle();
403
418
  this.unsubscribeNetworkChanged();
404
- clearInterval(this.keepAliveInterval);
405
- clearTimeout(this.connectionCheckTimeout);
419
+ const timers = getTimers();
420
+ timers.clearInterval(this.keepAliveInterval);
421
+ timers.clearInterval(this.connectionCheckInterval);
406
422
  clearTimeout(this.migrateAwayTimeout);
407
423
  this.abortController.abort();
408
424
  this.migrationTask?.resolve();
@@ -697,7 +713,7 @@ export class StreamSfuClient {
697
713
  return;
698
714
  }
699
715
  this.logger.debug(`Sending message to: ${this.edgeName}`, msgJson);
700
- this.signalWs.send(SfuRequest.toBinary(message));
716
+ this.signalWs.send(SfuRequest.toBinary(message) as Uint8Array<ArrayBuffer>);
701
717
  };
702
718
 
703
719
  private keepAlive = () => {
@@ -711,12 +727,11 @@ export class StreamSfuClient {
711
727
  };
712
728
 
713
729
  private scheduleConnectionCheck = () => {
714
- clearTimeout(this.connectionCheckTimeout);
715
- this.connectionCheckTimeout = setTimeout(() => {
730
+ const timers = getTimers();
731
+ timers.clearInterval(this.connectionCheckInterval);
732
+ this.connectionCheckInterval = timers.setInterval(() => {
716
733
  if (this.lastMessageTimestamp) {
717
- const timeSinceLastMessage =
718
- new Date().getTime() - this.lastMessageTimestamp.getTime();
719
-
734
+ const timeSinceLastMessage = Date.now() - this.lastMessageTimestamp;
720
735
  if (timeSinceLastMessage > this.unhealthyTimeoutInMs) {
721
736
  this.close(
722
737
  StreamSfuClient.ERROR_CONNECTION_UNHEALTHY,
@@ -724,6 +739,6 @@ export class StreamSfuClient {
724
739
  );
725
740
  }
726
741
  }
727
- }, this.unhealthyTimeoutInMs);
742
+ }, this.connectionCheckIntervalInMs);
728
743
  };
729
744
  }
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { StreamSfuClient } from '../StreamSfuClient';
3
3
  import { Dispatcher } from '../rtc';
4
4
  import { StreamClient } from '../coordinator/connection/client';
5
+ import { getTimers } from '../timers';
5
6
 
6
7
  /**
7
8
  * Minimal `WebSocket` stub used to drive `StreamSfuClient.close()` while the
@@ -38,9 +39,13 @@ class CapturingWebSocket {
38
39
  this.closeArgs = { code, reason };
39
40
  this.readyState = CapturingWebSocket.CLOSED;
40
41
  }
42
+ /** Test helper: synchronously fire a registered event (e.g. `close`). */
43
+ emit(event: string, payload: unknown) {
44
+ this.listeners.get(event)?.forEach((listener) => listener(payload));
45
+ }
41
46
  }
42
47
 
43
- const buildSfuClient = () => {
48
+ const buildSfuClient = (onSignalClose?: (reason: string) => void) => {
44
49
  const dispatcher = new Dispatcher();
45
50
  const streamClient = new StreamClient('test-key');
46
51
  return new StreamSfuClient({
@@ -59,9 +64,109 @@ const buildSfuClient = () => {
59
64
  },
60
65
  tag: 'test',
61
66
  enableTracing: false,
67
+ onSignalClose,
62
68
  });
63
69
  };
64
70
 
71
+ describe('StreamSfuClient unhealthy watchdog timer source', () => {
72
+ beforeEach(() => {
73
+ CapturingWebSocket.instances = [];
74
+ vi.stubGlobal('WebSocket', CapturingWebSocket);
75
+ });
76
+
77
+ afterEach(() => {
78
+ vi.unstubAllGlobals();
79
+ vi.restoreAllMocks();
80
+ });
81
+
82
+ it('arms the unhealthy watchdog on the worker timer, not the main-thread setInterval', () => {
83
+ const sfuClient = buildSfuClient();
84
+ const workerSetInterval = vi
85
+ .spyOn(getTimers(), 'setInterval')
86
+ .mockReturnValue(1 as unknown as number);
87
+ const mainSetInterval = vi.spyOn(globalThis, 'setInterval');
88
+
89
+ (
90
+ sfuClient as unknown as { scheduleConnectionCheck: () => void }
91
+ ).scheduleConnectionCheck();
92
+
93
+ expect(workerSetInterval).toHaveBeenCalledTimes(1);
94
+ expect(mainSetInterval).not.toHaveBeenCalled();
95
+
96
+ sfuClient.close(1000, 'test cleanup');
97
+ });
98
+ });
99
+
100
+ describe('StreamSfuClient unhealthy watchdog resilience', () => {
101
+ beforeEach(() => {
102
+ CapturingWebSocket.instances = [];
103
+ vi.stubGlobal('WebSocket', CapturingWebSocket);
104
+ });
105
+
106
+ afterEach(() => {
107
+ vi.useRealTimers();
108
+ vi.unstubAllGlobals();
109
+ vi.restoreAllMocks();
110
+ });
111
+
112
+ it('re-arms the unhealthy watchdog after a check passes (not single-shot)', () => {
113
+ vi.useFakeTimers();
114
+ const sfuClient = buildSfuClient();
115
+ const closeSpy = vi.spyOn(sfuClient, 'close').mockImplementation(() => {});
116
+ const c = sfuClient as unknown as {
117
+ lastMessageTimestamp?: number;
118
+ unhealthyTimeoutInMs: number;
119
+ scheduleConnectionCheck: () => void;
120
+ };
121
+ const window = c.unhealthyTimeoutInMs;
122
+
123
+ c.lastMessageTimestamp = Date.now();
124
+ c.scheduleConnectionCheck();
125
+
126
+ // At exactly the threshold the connection is still healthy (strict `>`),
127
+ // so no poll within the first window closes it. A single-shot watchdog
128
+ // armed for the threshold would now be dead.
129
+ vi.advanceTimersByTime(window);
130
+ expect(closeSpy).not.toHaveBeenCalled();
131
+
132
+ // No further messages arrive; the self-rescheduling watchdog keeps polling
133
+ // and detects the connection as unhealthy on a later tick.
134
+ vi.advanceTimersByTime(window);
135
+ expect(closeSpy).toHaveBeenCalledWith(
136
+ StreamSfuClient.ERROR_CONNECTION_UNHEALTHY,
137
+ expect.stringContaining('unhealthy'),
138
+ );
139
+ });
140
+
141
+ it('detects an unhealthy connection shortly after the threshold, not a full window later', () => {
142
+ vi.useFakeTimers();
143
+ const sfuClient = buildSfuClient();
144
+ const closeSpy = vi.spyOn(sfuClient, 'close').mockImplementation(() => {});
145
+ const c = sfuClient as unknown as {
146
+ lastMessageTimestamp?: number;
147
+ unhealthyTimeoutInMs: number;
148
+ scheduleConnectionCheck: () => void;
149
+ };
150
+ const window = c.unhealthyTimeoutInMs;
151
+
152
+ c.lastMessageTimestamp = Date.now();
153
+ c.scheduleConnectionCheck();
154
+
155
+ // healthy up to (and exactly at) the threshold
156
+ vi.advanceTimersByTime(window);
157
+ expect(closeSpy).not.toHaveBeenCalled();
158
+
159
+ // the watchdog polls finer than the window, so silence is caught well
160
+ // before a second full window elapses (the old period == window design
161
+ // could take up to 2x the window to notice).
162
+ vi.advanceTimersByTime(window / 2);
163
+ expect(closeSpy).toHaveBeenCalledWith(
164
+ StreamSfuClient.ERROR_CONNECTION_UNHEALTHY,
165
+ expect.stringContaining('unhealthy'),
166
+ );
167
+ });
168
+ });
169
+
65
170
  describe('StreamSfuClient.close()', () => {
66
171
  beforeEach(() => {
67
172
  CapturingWebSocket.instances = [];
@@ -164,6 +269,59 @@ describe('StreamSfuClient.close()', () => {
164
269
  });
165
270
  });
166
271
 
272
+ describe('StreamSfuClient signal-close revival', () => {
273
+ beforeEach(() => {
274
+ CapturingWebSocket.instances = [];
275
+ vi.stubGlobal('WebSocket', CapturingWebSocket);
276
+ });
277
+
278
+ afterEach(() => {
279
+ vi.unstubAllGlobals();
280
+ vi.clearAllMocks();
281
+ });
282
+
283
+ it('drives revival immediately on an unhealthy close, without waiting for the onclose event', () => {
284
+ const onSignalClose = vi.fn();
285
+ const sfuClient = buildSfuClient(onSignalClose);
286
+
287
+ // A wedged socket may fire `onclose` only after the OS TCP timeout. The
288
+ // health watchdog closes with ERROR_CONNECTION_UNHEALTHY; revival must
289
+ // start now, not when (or if) the transport `close` event arrives.
290
+ sfuClient.close(
291
+ StreamSfuClient.ERROR_CONNECTION_UNHEALTHY,
292
+ 'SFU connection unhealthy',
293
+ );
294
+
295
+ expect(onSignalClose).toHaveBeenCalledTimes(1);
296
+ });
297
+
298
+ it('notifies revival only once when the late onclose event follows an unhealthy close', () => {
299
+ const onSignalClose = vi.fn();
300
+ const sfuClient = buildSfuClient(onSignalClose);
301
+ const ws = CapturingWebSocket.instances.at(-1)!;
302
+
303
+ // watchdog closes the dead socket (revival triggered proactively)...
304
+ sfuClient.close(
305
+ StreamSfuClient.ERROR_CONNECTION_UNHEALTHY,
306
+ 'SFU connection unhealthy',
307
+ );
308
+ // ...then the OS finally surfaces the wedged socket's `close` event.
309
+ ws.emit('close', { code: 1006, reason: '' });
310
+
311
+ expect(onSignalClose).toHaveBeenCalledTimes(1);
312
+ });
313
+
314
+ it('notifies revival when only the onclose event fires (server-initiated close)', () => {
315
+ const onSignalClose = vi.fn();
316
+ buildSfuClient(onSignalClose);
317
+ const ws = CapturingWebSocket.instances.at(-1)!;
318
+
319
+ ws.emit('close', { code: 1006, reason: '' });
320
+
321
+ expect(onSignalClose).toHaveBeenCalledTimes(1);
322
+ });
323
+ });
324
+
167
325
  describe('StreamSfuClient.leaveAndClose()', () => {
168
326
  beforeEach(() => {
169
327
  CapturingWebSocket.instances = [];
@@ -1,94 +1,122 @@
1
- import { afterAll, beforeAll, describe, expect, it } from 'vitest';
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ it,
7
+ type Mock,
8
+ vi,
9
+ } from 'vitest';
2
10
  import { StreamVideoClient } from '../StreamVideoClient';
3
- import 'dotenv/config';
11
+ import { Call } from '../Call';
12
+ import { CallCreatedPayload } from './data';
4
13
  import { generateUUIDv4 } from '../coordinator/connection/utils';
5
- import { StreamClient } from '@stream-io/node-sdk';
6
- import { CreateDeviceRequest } from '../gen/coordinator';
14
+ import type { StreamClient } from '../coordinator/connection/client';
15
+ import type {
16
+ CreateDeviceRequest,
17
+ GetEdgesResponse,
18
+ ListDevicesResponse,
19
+ QueryCallsResponse,
20
+ QueryCallStatsResponse,
21
+ } from '../gen/coordinator';
7
22
 
8
- const apiKey = process.env.STREAM_API_KEY!;
9
- const secret = process.env.STREAM_SECRET!;
10
-
11
- const serverClient = new StreamClient(apiKey, secret);
23
+ const apiKey = 'mock-api-key';
12
24
 
13
25
  describe('StreamVideoClient - coordinator API', () => {
14
26
  let client: StreamVideoClient;
27
+ // the client only talks to the backend through streamClient.post/get/delete,
28
+ // so we spy on those and assert against them instead of a live backend.
29
+ let post: Mock<StreamClient['post']>;
30
+ let get: Mock<StreamClient['get']>;
31
+ let del: Mock<StreamClient['delete']>;
32
+
33
+ beforeEach(() => {
34
+ client = new StreamVideoClient(apiKey, { browser: true });
35
+ post = vi.spyOn(client.streamClient, 'post');
36
+ get = vi.spyOn(client.streamClient, 'get');
37
+ del = vi.spyOn(client.streamClient, 'delete');
38
+ });
15
39
 
16
- beforeAll(() => {
17
- const user = { id: 'sara' };
18
- client = new StreamVideoClient(apiKey, {
19
- // tests run in node, so we have to fake being in browser env
20
- browser: true,
21
- timeout: 15000,
22
- });
23
- client.connectUser(
24
- user,
25
- serverClient.generateUserToken({ user_id: user.id }),
26
- );
40
+ afterEach(() => {
41
+ vi.restoreAllMocks();
27
42
  });
28
43
 
29
- it('query calls', { retry: 3, timeout: 20000 }, async () => {
30
- let response = await client.queryCalls();
44
+ it('query calls', async () => {
45
+ const response: QueryCallsResponse = {
46
+ duration: '1ms',
47
+ next: 'next-page-token',
48
+ calls: [
49
+ {
50
+ call: CallCreatedPayload.call,
51
+ members: CallCreatedPayload.members,
52
+ own_capabilities: [],
53
+ },
54
+ ],
55
+ };
56
+ post.mockResolvedValue(response);
31
57
 
32
- let calls = response.calls;
33
- expect(calls.length).toBeGreaterThanOrEqual(1);
58
+ await client.queryCalls();
59
+ expect(post).toHaveBeenCalledWith('/calls', {});
34
60
 
35
61
  const queryCallsReq = {
36
62
  sort: [{ field: 'starts_at', direction: -1 }],
37
63
  limit: 2,
38
64
  };
39
- response = await client.queryCalls(queryCallsReq);
40
-
41
- calls = response.calls;
42
- expect(calls.length).toBe(2);
43
-
44
- response = await client.queryCalls({
45
- ...queryCallsReq,
46
- next: response.next,
47
- });
48
-
49
- expect(response.calls.length).toBeLessThanOrEqual(2);
50
-
51
- response = await client.queryCalls({
52
- filter_conditions: { backstage: { $eq: false } },
53
- });
54
-
55
- expect(response.calls.length).toBeGreaterThanOrEqual(1);
65
+ const result = await client.queryCalls(queryCallsReq);
66
+ expect(post).toHaveBeenCalledWith('/calls', queryCallsReq);
67
+
68
+ // each response entry is wrapped into a Call instance
69
+ expect(result.next).toBe('next-page-token');
70
+ expect(result.calls).toHaveLength(1);
71
+ const [call] = result.calls;
72
+ expect(call).toBeInstanceOf(Call);
73
+ expect(call.cid).toBe(CallCreatedPayload.call.cid);
56
74
  });
57
75
 
58
76
  it('query calls - ongoing', async () => {
59
- const response = await client.queryCalls({
60
- filter_conditions: { ongoing: { $eq: true } },
61
- });
77
+ post.mockResolvedValue({ duration: '1ms', calls: [] });
78
+
79
+ const queryCallsReq = { filter_conditions: { ongoing: { $eq: true } } };
80
+ await client.queryCalls(queryCallsReq);
62
81
 
63
- // Dummy test
64
- expect(response.calls).toBeDefined();
82
+ expect(post).toHaveBeenCalledWith('/calls', queryCallsReq);
65
83
  });
66
84
 
67
85
  it('query calls - upcoming', async () => {
86
+ post.mockResolvedValue({ duration: '1ms', calls: [] });
87
+
68
88
  const mins30 = 1000 * 60 * 60 * 30;
69
89
  const inNext30mins = new Date(Date.now() + mins30);
70
- const response = await client.queryCalls({
71
- filter_conditions: {
72
- starts_at: { $gt: inNext30mins.toISOString() },
73
- },
74
- });
90
+ const queryCallsReq = {
91
+ filter_conditions: { starts_at: { $gt: inNext30mins.toISOString() } },
92
+ };
93
+ await client.queryCalls(queryCallsReq);
75
94
 
76
- // Dummy test
77
- expect(response.calls).toBeDefined();
95
+ expect(post).toHaveBeenCalledWith('/calls', queryCallsReq);
78
96
  });
79
97
 
80
98
  it('query call stats', async () => {
81
- const response = await client.queryCallStats({
99
+ const response: QueryCallStatsResponse = { duration: '1ms', reports: [] };
100
+ post.mockResolvedValue(response);
101
+
102
+ const result = await client.queryCallStats({
82
103
  filter_conditions: { call_cid: 'default:test' },
83
104
  });
84
105
 
85
- expect(response.reports).toBeDefined();
106
+ expect(post).toHaveBeenCalledWith('/call/stats', {
107
+ filter_conditions: { call_cid: 'default:test' },
108
+ });
109
+ expect(result).toBe(response);
86
110
  });
87
111
 
88
112
  it('edges', async () => {
89
- const response = await client.edges();
113
+ const response: GetEdgesResponse = { duration: '1ms', edges: [] };
114
+ get.mockResolvedValue(response);
115
+
116
+ const result = await client.edges();
90
117
 
91
- expect(response.edges).toBeDefined();
118
+ expect(get).toHaveBeenCalledWith('/edges');
119
+ expect(result).toBe(response);
92
120
  });
93
121
 
94
122
  describe('devices', () => {
@@ -99,57 +127,55 @@ describe('StreamVideoClient - coordinator API', () => {
99
127
  };
100
128
 
101
129
  it('add device', async () => {
102
- expect(
103
- async () =>
104
- await client.addDevice(
105
- device.id,
106
- device.push_provider,
107
- device.push_provider_name,
108
- ),
109
- ).not.toThrowError();
130
+ post.mockResolvedValue(undefined);
131
+
132
+ await client.addDevice(
133
+ device.id,
134
+ device.push_provider,
135
+ device.push_provider_name,
136
+ );
137
+
138
+ expect(post).toHaveBeenCalledWith('/devices', {
139
+ id: device.id,
140
+ push_provider: device.push_provider,
141
+ voip_token: undefined,
142
+ push_provider_name: device.push_provider_name,
143
+ });
110
144
  });
111
145
 
112
146
  it('add voip device', async () => {
113
- expect(
114
- async () =>
115
- await client.addVoipDevice(
116
- device.id + 'voip',
117
- device.push_provider,
118
- device.push_provider_name!,
119
- ),
120
- ).not.toThrowError();
147
+ post.mockResolvedValue(undefined);
148
+
149
+ await client.addVoipDevice(
150
+ device.id + 'voip',
151
+ device.push_provider,
152
+ device.push_provider_name!,
153
+ );
154
+
155
+ expect(post).toHaveBeenCalledWith('/devices', {
156
+ id: device.id + 'voip',
157
+ push_provider: device.push_provider,
158
+ voip_token: true,
159
+ push_provider_name: device.push_provider_name,
160
+ });
121
161
  });
122
162
 
123
- it('get devices', { retry: 3, timeout: 15000 }, async () => {
124
- // Wait a little bit, because if we query devices too soon backend will return 404
125
- await new Promise<void>((resolve) => {
126
- setTimeout(resolve, 5000);
127
- });
163
+ it('get devices', async () => {
164
+ const response: ListDevicesResponse = { duration: '1ms', devices: [] };
165
+ get.mockResolvedValue(response);
128
166
 
129
- const response = await client.getDevices();
167
+ const result = await client.getDevices();
130
168
 
131
- expect(response.devices.find((d) => d.id === device.id)).toBeDefined();
132
- expect(
133
- response.devices.find((d) => d.id === device.id + 'voip'),
134
- ).toBeDefined();
169
+ expect(get).toHaveBeenCalledWith('/devices', {});
170
+ expect(result).toBe(response);
135
171
  });
136
172
 
137
- it('remove device', { retry: 3, timeout: 15000 }, async () => {
138
- // Wait a little bit, because if we query devices too soon backend will return 404
139
- await new Promise<void>((resolve) => {
140
- setTimeout(resolve, 5000);
141
- });
173
+ it('remove device', async () => {
174
+ del.mockResolvedValue(undefined);
142
175
 
143
- expect(
144
- async () => await client.removeDevice(device.id),
145
- ).not.toThrowError();
146
- expect(
147
- async () => await client.removeDevice(device.id + 'void'),
148
- ).not.toThrowError();
149
- });
150
- });
176
+ await client.removeDevice(device.id);
151
177
 
152
- afterAll(() => {
153
- client.disconnectUser();
178
+ expect(del).toHaveBeenCalledWith('/devices', { id: device.id });
179
+ });
154
180
  });
155
181
  });
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { StreamClient } from '../client';
3
3
  import { StableWSConnection } from '../connection';
4
4
  import type { WSConnectionError } from '../types';
5
+ import { getTimers } from '../../../timers';
5
6
 
6
7
  class StuckWebSocket {
7
8
  static CONNECTING = 0;
@@ -108,6 +109,27 @@ const buildClient = () => {
108
109
  return client;
109
110
  };
110
111
 
112
+ describe('StableWSConnection connection-check timer source', () => {
113
+ afterEach(() => {
114
+ vi.restoreAllMocks();
115
+ vi.useRealTimers();
116
+ });
117
+
118
+ it('arms the connection-check watchdog on the worker timer, not the main-thread setTimeout', () => {
119
+ const client = buildClient();
120
+ const wsConnection = new StableWSConnection(client);
121
+ const workerSetTimeout = vi
122
+ .spyOn(getTimers(), 'setTimeout')
123
+ .mockReturnValue(1 as unknown as number);
124
+ const mainSetTimeout = vi.spyOn(globalThis, 'setTimeout');
125
+
126
+ wsConnection.scheduleConnectionCheck();
127
+
128
+ expect(workerSetTimeout).toHaveBeenCalledTimes(1);
129
+ expect(mainSetTimeout).not.toHaveBeenCalled();
130
+ });
131
+ });
132
+
111
133
  describe('StableWSConnection - silent handshake hang', () => {
112
134
  beforeEach(() => {
113
135
  StuckWebSocket.instances = [];
@@ -193,6 +215,53 @@ describe('StableWSConnection - silent handshake hang', () => {
193
215
  expect(outcome.kind).toBe('rejected');
194
216
  });
195
217
 
218
+ it('preserves an initial WS close reason when reconnect cannot get healthy', async () => {
219
+ const client = new StreamClient('test-key', {
220
+ browser: false,
221
+ defaultWsTimeout: 5000,
222
+ WebSocketImpl: ManualWebSocket as unknown as typeof WebSocket,
223
+ timeout: 1000,
224
+ });
225
+ vi.spyOn(client.tokenManager, 'tokenReady').mockResolvedValue('fake-token');
226
+ vi.spyOn(client.tokenManager, 'loadToken').mockResolvedValue('fake-token');
227
+ vi.spyOn(client.tokenManager, 'getToken').mockReturnValue('fake-token');
228
+
229
+ client._setUser({ id: 'test-user' });
230
+ client.userID = 'test-user';
231
+ client.clientID = 'test-user--abcdef';
232
+ client._setupConnectionIdPromise();
233
+
234
+ const wsConnection = new StableWSConnection(client);
235
+ client.wsConnection = wsConnection;
236
+
237
+ const connectAttemptOutcome = wsConnection.connect(5000).then(
238
+ () => ({ kind: 'resolved' as const }),
239
+ (error: Error) => ({ kind: 'rejected' as const, error }),
240
+ );
241
+
242
+ await vi.advanceTimersByTimeAsync(0);
243
+ const ws = ManualWebSocket.instances.at(-1)!;
244
+ expect(ws).toBeDefined();
245
+
246
+ ws.onclose?.({
247
+ code: 1006,
248
+ reason: 'specific ws close reason',
249
+ wasClean: false,
250
+ target: ws,
251
+ });
252
+
253
+ await vi.advanceTimersByTimeAsync(20000);
254
+ const outcome = await connectAttemptOutcome;
255
+
256
+ expect(outcome.kind).toBe('rejected');
257
+ if (outcome.kind === 'rejected') {
258
+ expect(outcome.error.message).toContain('specific ws close reason');
259
+ expect(outcome.error.message).not.toContain(
260
+ 'initial WS connection could not be established',
261
+ );
262
+ }
263
+ });
264
+
196
265
  it('does not schedule a reconnect (and leaves connectionIdPromise rejected) on a permanent, non-WS failure', async () => {
197
266
  const client = new StreamClient('test-key', {
198
267
  browser: false,