@stream-io/video-client 1.57.0 → 1.59.0

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 (130) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/e2ee-worker.browser.es.js +1342 -0
  3. package/dist/e2ee-worker.browser.es.js.map +1 -0
  4. package/dist/index.browser.es.js +1141 -117
  5. package/dist/index.browser.es.js.map +1 -1
  6. package/dist/index.cjs.js +1196 -115
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +5 -0
  9. package/dist/index.es.js +1174 -117
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/src/Call.d.ts +66 -9
  12. package/dist/src/StreamVideoClient.d.ts +9 -1
  13. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  14. package/dist/src/devices/CameraManager.d.ts +8 -1
  15. package/dist/src/devices/DeviceManager.d.ts +2 -1
  16. package/dist/src/devices/MicrophoneManager.d.ts +8 -1
  17. package/dist/src/devices/SpeakerManager.d.ts +2 -1
  18. package/dist/src/events/participant.d.ts +10 -3
  19. package/dist/src/gen/coordinator/index.d.ts +1386 -240
  20. package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
  21. package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
  22. package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
  23. package/dist/src/helpers/concurrency.d.ts +8 -0
  24. package/dist/src/helpers/ensureExhausted.d.ts +1 -1
  25. package/dist/src/helpers/loopback.d.ts +55 -0
  26. package/dist/src/permissions/PermissionsContext.d.ts +2 -2
  27. package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
  28. package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
  29. package/dist/src/rtc/Subscriber.d.ts +5 -0
  30. package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
  31. package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
  32. package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
  33. package/dist/src/rtc/e2ee/events.d.ts +163 -0
  34. package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
  35. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
  36. package/dist/src/rtc/index.d.ts +1 -0
  37. package/dist/src/rtc/mediaEngine.d.ts +23 -0
  38. package/dist/src/rtc/types.d.ts +2 -0
  39. package/dist/src/store/CallState.d.ts +21 -0
  40. package/dist/src/types.d.ts +15 -6
  41. package/index.ts +5 -0
  42. package/package.json +1 -1
  43. package/src/Call.ts +311 -155
  44. package/src/StreamVideoClient.ts +35 -8
  45. package/src/__tests__/Call.lifecycle.test.ts +33 -2
  46. package/src/__tests__/Call.publishing.test.ts +42 -1
  47. package/src/__tests__/Call.test.ts +15 -0
  48. package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
  49. package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
  50. package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
  51. package/src/__tests__/StreamVideoClient.test.ts +14 -12
  52. package/src/coordinator/connection/connection.ts +6 -3
  53. package/src/devices/AudioDeviceManager.ts +6 -0
  54. package/src/devices/CameraManager.ts +67 -8
  55. package/src/devices/DeviceManager.ts +24 -4
  56. package/src/devices/MicrophoneManager.ts +66 -8
  57. package/src/devices/ScreenShareManager.ts +3 -0
  58. package/src/devices/SpeakerManager.ts +42 -12
  59. package/src/devices/__tests__/CameraManager.test.ts +27 -1
  60. package/src/devices/__tests__/DeviceManager.test.ts +49 -1
  61. package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
  62. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -20
  63. package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
  64. package/src/devices/__tests__/mocks.ts +36 -3
  65. package/src/events/__tests__/participant.test.ts +134 -1
  66. package/src/events/call.ts +6 -3
  67. package/src/events/callEventHandlers.ts +9 -3
  68. package/src/events/participant.ts +42 -7
  69. package/src/gen/coordinator/index.ts +1402 -236
  70. package/src/gen/video/sfu/models/models.ts +15 -0
  71. package/src/helpers/MediaStreamRecorder.ts +104 -0
  72. package/src/helpers/TypedEventEmitter.ts +71 -0
  73. package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
  74. package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
  75. package/src/helpers/__tests__/concurrency.test.ts +74 -0
  76. package/src/helpers/__tests__/loopback.test.ts +367 -0
  77. package/src/helpers/concurrency.ts +24 -0
  78. package/src/helpers/ensureExhausted.ts +4 -2
  79. package/src/helpers/loopback.ts +200 -0
  80. package/src/permissions/PermissionsContext.ts +10 -5
  81. package/src/reporting/ClientEventReporter.ts +15 -28
  82. package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
  83. package/src/rtc/BasePeerConnection.ts +12 -1
  84. package/src/rtc/Publisher.ts +8 -0
  85. package/src/rtc/Subscriber.ts +13 -0
  86. package/src/rtc/__tests__/Publisher.test.ts +101 -0
  87. package/src/rtc/__tests__/Subscriber.test.ts +190 -0
  88. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
  89. package/src/rtc/e2ee/E2EEManager.ts +12 -0
  90. package/src/rtc/e2ee/EncryptionManager.ts +336 -0
  91. package/src/rtc/e2ee/SPEC.md +586 -0
  92. package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
  93. package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
  94. package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
  95. package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
  96. package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
  97. package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
  98. package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
  99. package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
  100. package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
  101. package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
  102. package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
  103. package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
  104. package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
  105. package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
  106. package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
  107. package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
  108. package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
  109. package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
  110. package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
  111. package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
  112. package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
  113. package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
  114. package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
  115. package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
  116. package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
  117. package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
  118. package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
  119. package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
  120. package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
  121. package/src/rtc/e2ee/e2ee-worker.ts +27 -0
  122. package/src/rtc/e2ee/events.ts +179 -0
  123. package/src/rtc/e2ee/transformSupport.ts +35 -0
  124. package/src/rtc/helpers/rtcConfiguration.ts +4 -2
  125. package/src/rtc/index.ts +1 -0
  126. package/src/rtc/mediaEngine.ts +44 -0
  127. package/src/rtc/types.ts +2 -0
  128. package/src/store/CallState.ts +33 -1
  129. package/src/store/__tests__/CallState.test.ts +82 -1
  130. package/src/types.ts +23 -5
@@ -39,7 +39,6 @@ import {
39
39
  getInstanceKey,
40
40
  } from './helpers/clientUtils';
41
41
  import { logToConsole, ScopedLogger, videoLoggerSystem } from './logger';
42
- import { isReactNative } from './helpers/platforms';
43
42
  import { withoutConcurrency } from './helpers/concurrency';
44
43
  import { enableTimerWorker } from './timers';
45
44
  import { ClientEventReporter } from './reporting';
@@ -211,6 +210,7 @@ export class StreamVideoClient {
211
210
  await call.reject('busy');
212
211
  } else {
213
212
  await call.updateFromRingingEvent(e as CallRingEvent);
213
+ await call.get();
214
214
  }
215
215
  } else {
216
216
  call.state.updateFromCallResponse(e.call);
@@ -248,6 +248,17 @@ export class StreamVideoClient {
248
248
  }
249
249
  };
250
250
 
251
+ /**
252
+ * Queries the API for calls matching the given filters.
253
+ * @param data the query data.
254
+ */
255
+ private doQueryCalls = (data: QueryCallsRequest) => {
256
+ return this.streamClient.post<QueryCallsResponse, QueryCallsRequest>(
257
+ '/calls',
258
+ data,
259
+ );
260
+ };
261
+
251
262
  /**
252
263
  * Rewatches the given calls with retry logic.
253
264
  * @param callsToReWatch array of call IDs to rewatch
@@ -261,11 +272,22 @@ export class StreamVideoClient {
261
272
  `Rewatching calls ${callsToReWatch.join(', ')} attempt ${attempt + 1}`,
262
273
  );
263
274
 
264
- await this.queryCalls({
275
+ const response = await this.doQueryCalls({
265
276
  watch: true,
266
277
  filter_conditions: { cid: { $in: callsToReWatch } },
267
278
  });
268
279
 
280
+ for (const c of response.calls) {
281
+ const call = this.writeableStateStore.findCall(
282
+ c.call.type,
283
+ c.call.id,
284
+ );
285
+
286
+ if (call) {
287
+ call.updateFromCallStateResponse(c);
288
+ }
289
+ }
290
+
269
291
  return;
270
292
  } catch (err) {
271
293
  if (err instanceof ErrorFromResponse && err.unrecoverable) {
@@ -452,12 +474,14 @@ export class StreamVideoClient {
452
474
  * Will query the API for calls matching the given filters.
453
475
  *
454
476
  * @param data the query data.
477
+ * @param opts additional options, for tweaking the API behavior.
455
478
  */
456
- queryCalls = async (data: QueryCallsRequest = {}) => {
457
- const response = await this.streamClient.post<
458
- QueryCallsResponse,
459
- QueryCallsRequest
460
- >('/calls', data);
479
+ queryCalls = async (
480
+ data: QueryCallsRequest = {},
481
+ opts: { withDisabledDevices?: boolean } = {},
482
+ ) => {
483
+ const { withDisabledDevices = true } = opts;
484
+ const response = await this.doQueryCalls(data);
461
485
  const calls = [];
462
486
  for (const c of response.calls) {
463
487
  const call = new Call({
@@ -471,7 +495,10 @@ export class StreamVideoClient {
471
495
  clientStore: this.writeableStateStore,
472
496
  });
473
497
  call.state.updateFromCallResponse(c.call);
474
- await call.applyDeviceConfig(c.call.settings, false, isReactNative());
498
+ await call.applyDeviceConfig(c.call.settings, {
499
+ publish: false,
500
+ withDisabledDevices,
501
+ });
475
502
  if (data.watch) {
476
503
  await call.setup();
477
504
  this.writeableStateStore.registerCall(call);
@@ -4,27 +4,38 @@
4
4
 
5
5
  import '../rtc/__tests__/mocks/webrtc.mocks';
6
6
 
7
- import { beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
8
  import { Call } from '../Call';
9
9
  import { StreamClient } from '../coordinator/connection/client';
10
10
  import { ClientEventReporter } from '../reporting';
11
11
  import { generateUUIDv4 } from '../coordinator/connection/utils';
12
12
  import { StreamVideoWriteableStateStore } from '../store';
13
+ import { promiseWithResolvers } from '../helpers/promise';
13
14
 
14
15
  describe('Call lifecycle wiring', () => {
15
16
  let call: Call;
16
17
 
17
18
  beforeEach(() => {
19
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
20
+ headers: { get: () => 'AMS1-P2' },
21
+ } as Response);
18
22
  const streamClient = new StreamClient('abc');
19
23
  call = new Call({
20
24
  type: 'test',
21
25
  id: generateUUIDv4(),
22
26
  streamClient,
23
- clientEventReporter: new ClientEventReporter({ streamClient }),
27
+ clientEventReporter: new ClientEventReporter({
28
+ streamClient,
29
+ enabled: false,
30
+ }),
24
31
  clientStore: new StreamVideoWriteableStateStore(),
25
32
  });
26
33
  });
27
34
 
35
+ afterEach(() => {
36
+ vi.restoreAllMocks();
37
+ });
38
+
28
39
  // Regression guard for the Call-owned helper teardown chain. Each of
29
40
  // these helpers holds a resource (timer, listener, AudioContext) that
30
41
  // leaks across calls if teardown is dropped during a refactor.
@@ -67,4 +78,24 @@ describe('Call lifecycle wiring', () => {
67
78
  expect(trackSubOrder).toBeLessThan(audioBindingsOrder);
68
79
  expect(audioBindingsOrder).toBeLessThan(dynascaleOrder);
69
80
  });
81
+
82
+ it('call.join() shares an in-flight join flow', async () => {
83
+ const joinTask = promiseWithResolvers<void>();
84
+ vi.spyOn(call, 'setup').mockResolvedValue(undefined);
85
+ const doJoin = vi
86
+ .spyOn(call as unknown as { doJoin: Call['join'] }, 'doJoin')
87
+ .mockReturnValue(joinTask.promise);
88
+
89
+ const firstJoin = call.join();
90
+ const secondJoin = call.join();
91
+
92
+ await Promise.resolve();
93
+ expect(doJoin).toHaveBeenCalledTimes(1);
94
+
95
+ joinTask.resolve();
96
+ await expect(Promise.all([firstJoin, secondJoin])).resolves.toEqual([
97
+ undefined,
98
+ undefined,
99
+ ]);
100
+ });
70
101
  });
@@ -2,7 +2,7 @@ import '../rtc/__tests__/mocks/webrtc.mocks';
2
2
 
3
3
  import { beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { Call } from '../Call';
5
- import { Publisher } from '../rtc';
5
+ import { Publisher, Subscriber } from '../rtc';
6
6
  import { StreamClient } from '../coordinator/connection/client';
7
7
  import { ClientEventReporter } from '../reporting';
8
8
  import { generateUUIDv4 } from '../coordinator/connection/utils';
@@ -183,6 +183,47 @@ describe('Publishing and Unpublishing tracks', () => {
183
183
  expect(participant!.audioStream).toEqual(mediaStream);
184
184
  });
185
185
 
186
+ it('keeps a stream the SFU already echoed back instead of the capture one', async () => {
187
+ // `allowOwnTracksLoopback`: the echo can land on the participant before
188
+ // publishing gets to write the capture stream to the same field.
189
+ const echoedStream = new MediaStream();
190
+ call.subscriber = {
191
+ isSelfSubscribedStream: (stream: MediaStream | undefined) =>
192
+ stream === echoedStream,
193
+ } as unknown as Subscriber;
194
+ call.state.updateParticipant(sessionId, { audioStream: echoedStream });
195
+
196
+ const track = new MediaStreamTrack();
197
+ const captureStream = new MediaStream();
198
+ vi.spyOn(captureStream, 'getAudioTracks').mockReturnValue([track]);
199
+
200
+ await call.publish(captureStream, TrackType.AUDIO);
201
+
202
+ const participant = call.state.findParticipantBySessionId(sessionId);
203
+ expect(participant!.publishedTracks).toEqual([TrackType.AUDIO]);
204
+ expect(participant!.audioStream).toBe(echoedStream);
205
+ });
206
+
207
+ it('keeps an echoed stream when the track is unpublished', async () => {
208
+ // a device switch unpublishes and republishes; the echoed track goes
209
+ // briefly silent but is never replaced, so the reference has to survive
210
+ const echoedStream = new MediaStream();
211
+ call.subscriber = {
212
+ isSelfSubscribedStream: (stream: MediaStream | undefined) =>
213
+ stream === echoedStream,
214
+ } as unknown as Subscriber;
215
+ call.state.updateParticipant(sessionId, {
216
+ audioStream: echoedStream,
217
+ publishedTracks: [TrackType.AUDIO],
218
+ });
219
+
220
+ await call.stopPublish(TrackType.AUDIO);
221
+
222
+ const participant = call.state.findParticipantBySessionId(sessionId);
223
+ expect(participant!.publishedTracks).toEqual([]);
224
+ expect(participant!.audioStream).toBe(echoedStream);
225
+ });
226
+
186
227
  it('publish screen share stream', async () => {
187
228
  const track = new MediaStreamTrack();
188
229
  const mediaStream = new MediaStream();
@@ -17,6 +17,7 @@ import { Dispatcher } from '../rtc';
17
17
  import { Call } from '../Call';
18
18
  import { StreamVideoParticipant } from '../types';
19
19
  import { TrackType } from '../gen/video/sfu/models/models';
20
+ import type { E2EEManager } from '../rtc/e2ee/E2EEManager';
20
21
 
21
22
  const apiKey = process.env.STREAM_API_KEY!;
22
23
  const secret = process.env.STREAM_SECRET!;
@@ -300,6 +301,20 @@ describe('muting logic', () => {
300
301
  });
301
302
  });
302
303
 
304
+ describe('setE2EEManager', () => {
305
+ it('throws when called after the peer connections exist', () => {
306
+ const call = client.call('default', generateUUIDv4());
307
+ const e2ee = fromPartial<E2EEManager>({});
308
+ // Before join there are no peer connections, so it is accepted.
309
+ expect(() => call.setE2EEManager(e2ee)).not.toThrow();
310
+ // Once a subscriber/publisher exists (i.e. after join) the PCs were already
311
+ // built without the manager; setting it now would leave the live session
312
+ // half-encrypted, so it must be rejected (finding 12).
313
+ call['subscriber'] = fromPartial({});
314
+ expect(() => call.setE2EEManager(e2ee)).toThrow(/before join/);
315
+ });
316
+ });
317
+
303
318
  describe('client event reporting', () => {
304
319
  afterEach(() => {
305
320
  vi.restoreAllMocks();
@@ -9,6 +9,8 @@ import {
9
9
  } from 'vitest';
10
10
  import { StreamVideoClient } from '../StreamVideoClient';
11
11
  import { Call } from '../Call';
12
+ import { CameraManager } from '../devices/CameraManager';
13
+ import { MicrophoneManager } from '../devices/MicrophoneManager';
12
14
  import { CallCreatedPayload } from './data';
13
15
  import { generateUUIDv4 } from '../coordinator/connection/utils';
14
16
  import type { StreamClient } from '../coordinator/connection/client';
@@ -73,6 +75,37 @@ describe('StreamVideoClient - coordinator API', () => {
73
75
  expect(call.cid).toBe(CallCreatedPayload.call.cid);
74
76
  });
75
77
 
78
+ it('query calls keeps devices disabled by default', async () => {
79
+ const response: QueryCallsResponse = {
80
+ duration: '1ms',
81
+ calls: [
82
+ {
83
+ call: CallCreatedPayload.call,
84
+ members: CallCreatedPayload.members,
85
+ own_capabilities: [],
86
+ },
87
+ ],
88
+ };
89
+ post.mockResolvedValue(response);
90
+ const applyCamera = vi
91
+ .spyOn(CameraManager.prototype, 'apply')
92
+ .mockResolvedValue();
93
+ const applyMicrophone = vi
94
+ .spyOn(MicrophoneManager.prototype, 'apply')
95
+ .mockResolvedValue();
96
+
97
+ await client.queryCalls();
98
+ await client.queryCalls({}, {});
99
+ await client.queryCalls({}, { withDisabledDevices: false });
100
+
101
+ expect(
102
+ applyCamera.mock.calls.map(([, , forceDisabled]) => forceDisabled),
103
+ ).toEqual([true, true, false]);
104
+ expect(
105
+ applyMicrophone.mock.calls.map(([, , forceDisabled]) => forceDisabled),
106
+ ).toEqual([true, true, false]);
107
+ });
108
+
76
109
  it('query calls - ongoing', async () => {
77
110
  post.mockResolvedValue({ duration: '1ms', calls: [] });
78
111
 
@@ -0,0 +1,213 @@
1
+ import '../rtc/__tests__/mocks/webrtc.mocks';
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { StreamVideoClient } from '../StreamVideoClient';
5
+ import { Call } from '../Call';
6
+ import { CallRingPayload } from './data';
7
+ import { settled, withoutConcurrency } from '../helpers/concurrency';
8
+ import { getCallInitConcurrencyTag } from '../helpers/clientUtils';
9
+ import { CallingState } from '../store';
10
+ import type { StreamVideoEvent } from '../coordinator/connection/types';
11
+ import type {
12
+ CallResponse,
13
+ ConnectedEvent,
14
+ GetCallResponse,
15
+ QueryCallsResponse,
16
+ } from '../gen/coordinator';
17
+
18
+ const apiKey = 'mock-api-key';
19
+ // the receiver of the CallRingPayload fixture
20
+ const userId = 'marcelo';
21
+
22
+ const queryCallsResponse = (call: CallResponse): QueryCallsResponse => ({
23
+ duration: '1ms',
24
+ calls: [{ call, members: CallRingPayload.members, own_capabilities: [] }],
25
+ });
26
+
27
+ const countListeners = (client: StreamVideoClient) =>
28
+ Object.values(client.streamClient.listeners).reduce(
29
+ (count, listeners) => count + (listeners?.length ?? 0),
30
+ 0,
31
+ );
32
+
33
+ describe('StreamVideoClient re-watching calls on reconnect', () => {
34
+ let client: StreamVideoClient;
35
+
36
+ beforeEach(async () => {
37
+ client = new StreamVideoClient(apiKey, {
38
+ // tests run in node, so we have to fake being in browser env
39
+ browser: true,
40
+ });
41
+ client.streamClient.connectUser = vi.fn().mockResolvedValue({
42
+ me: { id: userId },
43
+ } as ConnectedEvent);
44
+ await client.connectUser({ id: userId }, 'mock-token');
45
+ });
46
+
47
+ afterEach(() => {
48
+ for (const call of client.state.calls) {
49
+ call['cancelAutoDrop']();
50
+ }
51
+ vi.restoreAllMocks();
52
+ });
53
+
54
+ const setupRingingCall = async () => {
55
+ vi.spyOn(client.streamClient, 'get').mockResolvedValue({
56
+ duration: '1ms',
57
+ call: CallRingPayload.call,
58
+ members: CallRingPayload.members,
59
+ own_capabilities: [],
60
+ } as GetCallResponse);
61
+
62
+ client.streamClient.dispatchEvent(CallRingPayload as StreamVideoEvent);
63
+ await settled(getCallInitConcurrencyTag(CallRingPayload.call_cid));
64
+
65
+ const [call] = client.state.calls;
66
+ expect(call).toBeDefined();
67
+ expect(call.watching).toBe(true);
68
+ expect(call.state.callingState).toBe(CallingState.RINGING);
69
+ return call;
70
+ };
71
+
72
+ const reconnect = () => {
73
+ client.streamClient.dispatchEvent({
74
+ type: 'connection.changed',
75
+ online: true,
76
+ });
77
+ };
78
+
79
+ it('reuses the registered instance and refreshes its state', async () => {
80
+ const call = await setupRingingCall();
81
+ const listenerCountBeforeReconnect = countListeners(client);
82
+
83
+ const post = vi
84
+ .spyOn(client.streamClient, 'post')
85
+ .mockResolvedValue(
86
+ queryCallsResponse({ ...CallRingPayload.call, recording: true }),
87
+ );
88
+
89
+ reconnect();
90
+
91
+ await vi.waitFor(() => expect(post).toHaveBeenCalled());
92
+ expect(post).toHaveBeenCalledWith('/calls', {
93
+ watch: true,
94
+ filter_conditions: { cid: { $in: [call.cid] } },
95
+ });
96
+
97
+ // the refreshed state must reach the instance the integrator holds
98
+ await vi.waitFor(() => expect(call.state.recording).toBe(true));
99
+ expect(client.state.calls).toHaveLength(1);
100
+ expect(client.state.calls[0]).toBe(call);
101
+ expect(call.watching).toBe(true);
102
+
103
+ // re-watching must not leak event handlers (one orphaned Call
104
+ // used to register its handlers on every reconnect)
105
+ expect(countListeners(client)).toBe(listenerCountBeforeReconnect);
106
+ });
107
+
108
+ it('drops a ringing call accepted elsewhere while the socket was down', async () => {
109
+ const call = await setupRingingCall();
110
+ const leave = vi.spyOn(call, 'leave').mockResolvedValue(undefined);
111
+
112
+ const session = CallRingPayload.call.session!;
113
+ vi.spyOn(client.streamClient, 'post').mockResolvedValue(
114
+ queryCallsResponse({
115
+ ...CallRingPayload.call,
116
+ session: {
117
+ ...session,
118
+ accepted_by: { [userId]: '2025-08-14T14:49:00Z' },
119
+ },
120
+ }),
121
+ );
122
+
123
+ reconnect();
124
+
125
+ // accepted on another device -> this device should stop ringing
126
+ await vi.waitFor(() => expect(leave).toHaveBeenCalled());
127
+ });
128
+
129
+ it('queryCalls returns an independent instance for registered calls', async () => {
130
+ // e.g. being on a call while watching a dashboard of calls:
131
+ // leaving the joined instance must not silence the dashboard instance
132
+ const joinedCall = await setupRingingCall();
133
+ vi.spyOn(client.streamClient, 'post').mockResolvedValue(
134
+ queryCallsResponse(CallRingPayload.call),
135
+ );
136
+
137
+ const result = await client.queryCalls({ watch: true });
138
+ const [dashboardCall] = result.calls;
139
+ expect(dashboardCall).not.toBe(joinedCall);
140
+ expect(client.state.calls[0]).toBe(joinedCall);
141
+
142
+ await joinedCall.leave({ reject: false });
143
+
144
+ client.streamClient.dispatchEvent({
145
+ type: 'call.updated',
146
+ call_cid: joinedCall.cid,
147
+ created_at: '2025-08-14T14:50:00Z',
148
+ call: { ...CallRingPayload.call, recording: true },
149
+ } as StreamVideoEvent);
150
+
151
+ await vi.waitFor(() => expect(dashboardCall.state.recording).toBe(true));
152
+ });
153
+
154
+ it('creates and registers a new instance for unknown cids', async () => {
155
+ vi.spyOn(client.streamClient, 'post').mockResolvedValue(
156
+ queryCallsResponse(CallRingPayload.call),
157
+ );
158
+
159
+ const result = await client.queryCalls({ watch: true });
160
+
161
+ expect(result.calls).toHaveLength(1);
162
+ const [call] = result.calls;
163
+ expect(call).toBeInstanceOf(Call);
164
+ expect(call.watching).toBe(true);
165
+ expect(client.state.calls[0]).toBe(call);
166
+ });
167
+
168
+ it('does not resurrect a call that leaves during a re-watch', async () => {
169
+ const listenersBeforeRing = countListeners(client);
170
+ const call = await setupRingingCall();
171
+ const post = vi
172
+ .spyOn(client.streamClient, 'post')
173
+ .mockResolvedValue(queryCallsResponse(CallRingPayload.call));
174
+
175
+ // hold the call's join/leave queue so leave() is still in flight
176
+ // while the re-watch response is being processed
177
+ let releaseGate!: () => void;
178
+ const gate = new Promise<void>((resolve) => (releaseGate = resolve));
179
+ withoutConcurrency(call['joinLeaveConcurrencyTag'], () => gate);
180
+ const leavePromise = call.leave({ reject: false });
181
+
182
+ reconnect();
183
+ await vi.waitFor(() => expect(post).toHaveBeenCalled());
184
+ await Promise.resolve();
185
+
186
+ releaseGate();
187
+ await leavePromise;
188
+
189
+ // the left call must stay dead: not re-registered, not re-initialized,
190
+ // and all of its event handlers released
191
+ expect(client.state.calls).toHaveLength(0);
192
+ expect(call.state.callingState).toBe(CallingState.LEFT);
193
+ expect(countListeners(client)).toBe(listenersBeforeRing);
194
+ });
195
+
196
+ it('creates a fresh instance when the previous one has left', async () => {
197
+ const call = await setupRingingCall();
198
+ const post = vi
199
+ .spyOn(client.streamClient, 'post')
200
+ .mockResolvedValue(queryCallsResponse(CallRingPayload.call));
201
+
202
+ // leaving unregisters the call from the client store
203
+ await call.leave({ reject: false });
204
+ expect(client.state.calls).toHaveLength(0);
205
+
206
+ const result = await client.queryCalls({ watch: true });
207
+ expect(post).toHaveBeenCalled();
208
+ expect(result.calls).toHaveLength(1);
209
+ expect(result.calls[0]).not.toBe(call);
210
+ expect(client.state.calls[0]).toBe(result.calls[0]);
211
+ expect(result.calls[0].watching).toBe(true);
212
+ });
213
+ });
@@ -45,12 +45,21 @@ describe('StreamVideoClient Ringing', () => {
45
45
  ]);
46
46
  };
47
47
 
48
+ const leaveAllCalls = async () => {
49
+ await Promise.all(
50
+ [oliverClient, sachaClient, marceloClient].flatMap((client) =>
51
+ client.state.calls.map((call) => call.leave().catch(() => {})),
52
+ ),
53
+ );
54
+ };
55
+
48
56
  beforeEach(async () => {
49
57
  [oliverClient, sachaClient, marceloClient] =
50
58
  await createClients(testUserIds);
51
59
  });
52
60
 
53
61
  afterEach(async () => {
62
+ await leaveAllCalls();
54
63
  await disconnectClients();
55
64
  });
56
65
 
@@ -275,6 +284,7 @@ describe('StreamVideoClient Ringing', () => {
275
284
  };
276
285
 
277
286
  it('should reject the call when the caller is busy', async () => {
287
+ await disconnectClients();
278
288
  [oliverClient, sachaClient, marceloClient] = await createClients(
279
289
  testUserIds,
280
290
  {
@@ -301,11 +311,10 @@ describe('StreamVideoClient Ringing', () => {
301
311
  marceloCall.cid,
302
312
  );
303
313
  expect(oliverClient.state.calls.length).toBe(1);
304
-
305
- await disconnectClients();
306
314
  });
307
315
 
308
316
  it('should reject the call when the callee is busy', async () => {
317
+ await disconnectClients();
309
318
  [oliverClient, sachaClient, marceloClient] = await createClients(
310
319
  testUserIds,
311
320
  {
@@ -332,8 +341,6 @@ describe('StreamVideoClient Ringing', () => {
332
341
  marceloCall.cid,
333
342
  );
334
343
  expect(sachaClient.state.calls.length).toBe(1);
335
-
336
- await disconnectClients();
337
344
  });
338
345
 
339
346
  it('should allow multiple simultaneous calls to the same caller when rejectCallWhenBusy is false', async () => {
@@ -277,7 +277,7 @@ describe('StreamVideoClient.connectUser retries', () => {
277
277
  });
278
278
 
279
279
  describe('StreamVideoClient.watchCalls retries', () => {
280
- it('should retry queryCalls up to 3 times after reconnecting', async () => {
280
+ it('should retry the re-watch request up to 3 times after reconnecting', async () => {
281
281
  vi.useFakeTimers();
282
282
  const client = new StreamVideoClient(apiKey, {
283
283
  browser: true,
@@ -290,8 +290,8 @@ describe('StreamVideoClient.watchCalls retries', () => {
290
290
  const call = client.call('default', generateUUIDv4());
291
291
  await call.getOrCreate();
292
292
 
293
- const queryCallsSpy = vi
294
- .spyOn(client, 'queryCalls')
293
+ const doQueryCalls = vi
294
+ .spyOn(client as any, 'doQueryCalls')
295
295
  .mockRejectedValueOnce(new Error('fail 1'))
296
296
  .mockRejectedValueOnce(new Error('fail 2'))
297
297
  .mockResolvedValue({
@@ -306,7 +306,7 @@ describe('StreamVideoClient.watchCalls retries', () => {
306
306
 
307
307
  await vi.runAllTimersAsync();
308
308
 
309
- expect(queryCallsSpy).toHaveBeenCalledTimes(3);
309
+ expect(doQueryCalls).toHaveBeenCalledTimes(3);
310
310
 
311
311
  await client.disconnectUser();
312
312
 
@@ -327,8 +327,8 @@ describe('StreamVideoClient.watchCalls retries', () => {
327
327
  const call = client.call('default', generateUUIDv4());
328
328
  await call.getOrCreate();
329
329
 
330
- const queryCallsSpy = vi
331
- .spyOn(client, 'queryCalls')
330
+ const doQueryCalls = vi
331
+ .spyOn(client as any, 'doQueryCalls')
332
332
  .mockRejectedValueOnce(new Error('fail 1'))
333
333
  .mockRejectedValueOnce(new Error('fail 2'))
334
334
  .mockRejectedValueOnce(new Error('fail 3'))
@@ -344,7 +344,7 @@ describe('StreamVideoClient.watchCalls retries', () => {
344
344
 
345
345
  await vi.runAllTimersAsync();
346
346
 
347
- expect(queryCallsSpy).toHaveBeenCalledTimes(3);
347
+ expect(doQueryCalls).toHaveBeenCalledTimes(3);
348
348
 
349
349
  await client.disconnectUser();
350
350
  vi.useRealTimers();
@@ -363,10 +363,12 @@ describe('StreamVideoClient.watchCalls retries', () => {
363
363
  const call = client.call('default', generateUUIDv4());
364
364
  await call.getOrCreate();
365
365
 
366
- const queryCallsSpy = vi.spyOn(client, 'queryCalls').mockResolvedValue({
367
- calls: [],
368
- duration: '0ms',
369
- });
366
+ const doQueryCalls = vi
367
+ .spyOn(client as any, 'doQueryCalls')
368
+ .mockResolvedValue({
369
+ calls: [],
370
+ duration: '0ms',
371
+ });
370
372
 
371
373
  client.streamClient.dispatchEvent({
372
374
  type: 'connection.changed',
@@ -375,7 +377,7 @@ describe('StreamVideoClient.watchCalls retries', () => {
375
377
 
376
378
  await vi.runAllTimersAsync();
377
379
 
378
- expect(queryCallsSpy).toHaveBeenCalledTimes(1);
380
+ expect(doQueryCalls).toHaveBeenCalledTimes(1);
379
381
 
380
382
  await client.disconnectUser();
381
383
  vi.useRealTimers();
@@ -9,10 +9,11 @@ import {
9
9
  } from './utils';
10
10
  import type { StreamVideoEvent, UR, WSConnectionError } from './types';
11
11
  import type { LogLevel } from '@stream-io/logger';
12
- import type {
12
+ import {
13
13
  ConnectedEvent,
14
14
  ConnectionErrorEvent,
15
15
  WSAuthMessage,
16
+ WSAuthMessageProductsEnum,
16
17
  } from '../../gen/coordinator';
17
18
  import { makeSafePromise, type SafePromise } from '../../helpers/promise';
18
19
  import { getTimers } from '../../timers';
@@ -529,15 +530,17 @@ export class StableWSConnection {
529
530
  return;
530
531
  }
531
532
 
532
- const authMessage = JSON.stringify({
533
+ const wsAuthMessage: WSAuthMessage = {
533
534
  token,
535
+ products: [WSAuthMessageProductsEnum.VIDEO],
534
536
  user_details: {
535
537
  id: user.id,
536
538
  name: user.name,
537
539
  image: user.image,
538
540
  custom: user.custom,
539
541
  },
540
- } as WSAuthMessage);
542
+ };
543
+ const authMessage = JSON.stringify(wsAuthMessage);
541
544
 
542
545
  this._log(`onopen() - Sending auth message ${authMessage}`, {}, 'trace');
543
546
 
@@ -2,6 +2,7 @@ import { DeviceManager } from './DeviceManager';
2
2
  import { AudioDeviceManagerState } from './AudioDeviceManagerState';
3
3
  import { AudioBitrateProfile } from '../gen/video/sfu/models/models';
4
4
  import { TrackPublishOptions } from '../rtc';
5
+ import { isReactNative } from '../helpers/platforms';
5
6
 
6
7
  /**
7
8
  * Base class for High Fidelity enabled Device Managers.
@@ -17,6 +18,11 @@ export abstract class AudioDeviceManager<
17
18
  if (!this.call.state.settings?.audio.hifi_audio_enabled) {
18
19
  throw new Error('High Fidelity audio is not enabled for this call');
19
20
  }
21
+ if (isReactNative() && this.call.hasMediaEngine) {
22
+ throw new Error(
23
+ 'setAudioBitrateProfile must be called before joining the call.',
24
+ );
25
+ }
20
26
  this.doSetAudioBitrateProfile(profile);
21
27
  this.state.setAudioBitrateProfile(profile);
22
28
  if (this.enabled) {