@stream-io/video-client 1.58.0 → 2.0.0-beta.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 (57) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.browser.es.js +367 -76
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +378 -74
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.es.js +367 -76
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +15 -4
  10. package/dist/src/StreamVideoClient.d.ts +9 -1
  11. package/dist/src/coordinator/connection/utils.d.ts +10 -0
  12. package/dist/src/devices/CameraManager.d.ts +2 -1
  13. package/dist/src/devices/DeviceManager.d.ts +1 -1
  14. package/dist/src/devices/MicrophoneManager.d.ts +2 -1
  15. package/dist/src/gen/coordinator/index.d.ts +26 -0
  16. package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
  17. package/dist/src/helpers/concurrency.d.ts +8 -0
  18. package/dist/src/helpers/loopback.d.ts +55 -0
  19. package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
  20. package/dist/src/rtc/Subscriber.d.ts +5 -0
  21. package/dist/src/sorting/participants.d.ts +13 -0
  22. package/index.ts +2 -0
  23. package/package.json +1 -1
  24. package/src/Call.ts +156 -134
  25. package/src/StreamVideoClient.ts +35 -8
  26. package/src/__tests__/Call.lifecycle.test.ts +33 -2
  27. package/src/__tests__/Call.publishing.test.ts +42 -1
  28. package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
  29. package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
  30. package/src/__tests__/StreamVideoClient.ringing.test.ts +43 -24
  31. package/src/__tests__/StreamVideoClient.test.ts +14 -12
  32. package/src/coordinator/connection/__tests__/eventListenerIsolation.test.ts +115 -0
  33. package/src/coordinator/connection/client.ts +3 -2
  34. package/src/coordinator/connection/utils.ts +28 -0
  35. package/src/devices/CameraManager.ts +16 -4
  36. package/src/devices/DeviceManager.ts +7 -4
  37. package/src/devices/MicrophoneManager.ts +11 -3
  38. package/src/devices/__tests__/CameraManager.test.ts +27 -1
  39. package/src/devices/__tests__/DeviceManager.test.ts +49 -1
  40. package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
  41. package/src/events/call.ts +6 -3
  42. package/src/gen/coordinator/index.ts +26 -0
  43. package/src/helpers/MediaStreamRecorder.ts +104 -0
  44. package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
  45. package/src/helpers/__tests__/concurrency.test.ts +74 -0
  46. package/src/helpers/__tests__/loopback.test.ts +367 -0
  47. package/src/helpers/concurrency.ts +24 -0
  48. package/src/helpers/loopback.ts +200 -0
  49. package/src/reporting/ClientEventReporter.ts +15 -28
  50. package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
  51. package/src/rtc/Subscriber.ts +8 -0
  52. package/src/rtc/__tests__/Subscriber.test.ts +101 -0
  53. package/src/sorting/__tests__/presets.test.ts +188 -3
  54. package/src/sorting/__tests__/sorting.test.ts +22 -1
  55. package/src/sorting/participants.ts +20 -0
  56. package/src/sorting/presets.ts +4 -0
  57. package/src/store/__tests__/CallState.test.ts +2 -2
@@ -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
+ });
@@ -17,7 +17,14 @@ const secret = process.env.STREAM_SECRET!;
17
17
 
18
18
  describe('StreamVideoClient Ringing', () => {
19
19
  const serverClient = new StreamClient(apiKey, secret);
20
- const testUserIds = ['oliver', 'sacha', 'marcelo'];
20
+
21
+ // user ids are generated per test: CI runs share a single Stream app, so
22
+ // fixed ids let a concurrently running job ring these users and satisfy the
23
+ // assertions in this file with an unrelated call.
24
+ let oliverId: string;
25
+ let sachaId: string;
26
+ let marceloId: string;
27
+ let testUserIds: string[];
21
28
 
22
29
  let oliverClient: StreamVideoClient;
23
30
  let sachaClient: StreamVideoClient;
@@ -45,12 +52,26 @@ describe('StreamVideoClient Ringing', () => {
45
52
  ]);
46
53
  };
47
54
 
55
+ const leaveAllCalls = async () => {
56
+ await Promise.all(
57
+ [oliverClient, sachaClient, marceloClient].flatMap((client) =>
58
+ client.state.calls.map((call) => call.leave().catch(() => {})),
59
+ ),
60
+ );
61
+ };
62
+
48
63
  beforeEach(async () => {
64
+ const suffix = crypto.randomUUID();
65
+ oliverId = `oliver-${suffix}`;
66
+ sachaId = `sacha-${suffix}`;
67
+ marceloId = `marcelo-${suffix}`;
68
+ testUserIds = [oliverId, sachaId, marceloId];
49
69
  [oliverClient, sachaClient, marceloClient] =
50
70
  await createClients(testUserIds);
51
71
  });
52
72
 
53
73
  afterEach(async () => {
74
+ await leaveAllCalls();
54
75
  await disconnectClients();
55
76
  });
56
77
 
@@ -66,11 +87,11 @@ describe('StreamVideoClient Ringing', () => {
66
87
  await call.create({
67
88
  ring: true,
68
89
  data: {
69
- created_by_id: 'oliver',
90
+ created_by_id: oliverId,
70
91
  members: [
71
- { user_id: 'oliver' },
72
- { user_id: 'sacha' },
73
- { user_id: 'marcelo' },
92
+ { user_id: oliverId },
93
+ { user_id: sachaId },
94
+ { user_id: marceloId },
74
95
  ],
75
96
  },
76
97
  });
@@ -103,9 +124,9 @@ describe('StreamVideoClient Ringing', () => {
103
124
  ring: true,
104
125
  data: {
105
126
  members: [
106
- { user_id: 'oliver' },
107
- { user_id: 'sacha' },
108
- { user_id: 'marcelo' },
127
+ { user_id: oliverId },
128
+ { user_id: sachaId },
129
+ { user_id: marceloId },
109
130
  ],
110
131
  },
111
132
  });
@@ -135,9 +156,9 @@ describe('StreamVideoClient Ringing', () => {
135
156
  ring: false, // don't ring all members by default
136
157
  data: {
137
158
  members: [
138
- { user_id: 'oliver' },
139
- { user_id: 'sacha' },
140
- { user_id: 'marcelo' },
159
+ { user_id: oliverId },
160
+ { user_id: sachaId },
161
+ { user_id: marceloId },
141
162
  ],
142
163
  },
143
164
  });
@@ -153,7 +174,7 @@ describe('StreamVideoClient Ringing', () => {
153
174
  // oliver is calling sacha. only sacha should get a ring event
154
175
  const sachaIndividualRing = expectEvent(sachaClient, 'call.ring');
155
176
  const marceloIndividualRing = expectEvent(marceloClient, 'call.ring');
156
- await oliverCall.ring({ members_ids: ['sacha'] });
177
+ await oliverCall.ring({ members_ids: [sachaId] });
157
178
  await expect(sachaIndividualRing).resolves.toHaveProperty(
158
179
  'call.cid',
159
180
  oliverCall.cid,
@@ -166,7 +187,7 @@ describe('StreamVideoClient Ringing', () => {
166
187
  // sacha is calling marcelo. only marcelo should get a ring event
167
188
  const oliverIndividualRing = expectEvent(oliverClient, 'call.ring');
168
189
  const marceloIndividualRing2 = expectEvent(marceloClient, 'call.ring');
169
- await sachaCall.ring({ members_ids: ['marcelo'] });
190
+ await sachaCall.ring({ members_ids: [marceloId] });
170
191
  await expect(marceloIndividualRing2).resolves.toHaveProperty(
171
192
  'call.cid',
172
193
  sachaCall.cid,
@@ -232,8 +253,8 @@ describe('StreamVideoClient Ringing', () => {
232
253
  const serverCall = serverClient.video.call('default', callId);
233
254
  await serverCall.create({
234
255
  data: {
235
- created_by_id: 'oliver',
236
- members: [{ user_id: 'oliver' }, { user_id: 'sacha' }],
256
+ created_by_id: oliverId,
257
+ members: [{ user_id: oliverId }, { user_id: sachaId }],
237
258
  },
238
259
  });
239
260
 
@@ -257,7 +278,7 @@ describe('StreamVideoClient Ringing', () => {
257
278
  await oliverCall.create({
258
279
  ring: true,
259
280
  data: {
260
- members: [{ user_id: 'oliver' }, { user_id: 'sacha' }],
281
+ members: [{ user_id: oliverId }, { user_id: sachaId }],
261
282
  },
262
283
  });
263
284
 
@@ -275,6 +296,7 @@ describe('StreamVideoClient Ringing', () => {
275
296
  };
276
297
 
277
298
  it('should reject the call when the caller is busy', async () => {
299
+ await disconnectClients();
278
300
  [oliverClient, sachaClient, marceloClient] = await createClients(
279
301
  testUserIds,
280
302
  {
@@ -292,7 +314,7 @@ describe('StreamVideoClient Ringing', () => {
292
314
  await marceloCall.create({
293
315
  ring: true,
294
316
  data: {
295
- members: [{ user_id: 'marcelo' }, { user_id: 'oliver' }],
317
+ members: [{ user_id: marceloId }, { user_id: oliverId }],
296
318
  },
297
319
  });
298
320
 
@@ -301,11 +323,10 @@ describe('StreamVideoClient Ringing', () => {
301
323
  marceloCall.cid,
302
324
  );
303
325
  expect(oliverClient.state.calls.length).toBe(1);
304
-
305
- await disconnectClients();
306
326
  });
307
327
 
308
328
  it('should reject the call when the callee is busy', async () => {
329
+ await disconnectClients();
309
330
  [oliverClient, sachaClient, marceloClient] = await createClients(
310
331
  testUserIds,
311
332
  {
@@ -323,7 +344,7 @@ describe('StreamVideoClient Ringing', () => {
323
344
  await marceloCall.create({
324
345
  ring: true,
325
346
  data: {
326
- members: [{ user_id: 'marcelo' }, { user_id: 'sacha' }],
347
+ members: [{ user_id: marceloId }, { user_id: sachaId }],
327
348
  },
328
349
  });
329
350
 
@@ -332,8 +353,6 @@ describe('StreamVideoClient Ringing', () => {
332
353
  marceloCall.cid,
333
354
  );
334
355
  expect(sachaClient.state.calls.length).toBe(1);
335
-
336
- await disconnectClients();
337
356
  });
338
357
 
339
358
  it('should allow multiple simultaneous calls to the same caller when rejectCallWhenBusy is false', async () => {
@@ -347,7 +366,7 @@ describe('StreamVideoClient Ringing', () => {
347
366
  await marceloCall.create({
348
367
  ring: true,
349
368
  data: {
350
- members: [{ user_id: 'marcelo' }, { user_id: 'oliver' }],
369
+ members: [{ user_id: marceloId }, { user_id: oliverId }],
351
370
  },
352
371
  });
353
372
 
@@ -368,7 +387,7 @@ describe('StreamVideoClient Ringing', () => {
368
387
  await marceloCall.create({
369
388
  ring: true,
370
389
  data: {
371
- members: [{ user_id: 'marcelo' }, { user_id: 'sacha' }],
390
+ members: [{ user_id: marceloId }, { user_id: sachaId }],
372
391
  },
373
392
  });
374
393
 
@@ -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();
@@ -0,0 +1,115 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { StreamClient } from '../client';
3
+ import { StableWSConnection } from '../connection';
4
+ import { videoLoggerSystem } from '../../../logger';
5
+ import type { StreamVideoEvent } from '../types';
6
+
7
+ const event: StreamVideoEvent = { type: 'network.changed', online: true };
8
+
9
+ describe('event listener isolation', () => {
10
+ let client: StreamClient;
11
+ let sink: ReturnType<typeof vi.fn>;
12
+
13
+ beforeEach(() => {
14
+ sink = vi.fn();
15
+ videoLoggerSystem.configureLoggers({
16
+ coordinator: { sink, level: 'error' },
17
+ });
18
+ client = new StreamClient('key');
19
+ });
20
+
21
+ afterEach(() => {
22
+ videoLoggerSystem.restoreDefaults();
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ const errorLogs = () =>
27
+ sink.mock.calls.filter(([level]) => level === 'error');
28
+
29
+ it('keeps dispatching after a listener throws', () => {
30
+ const failure = new Error('listener blew up');
31
+ const first = vi.fn(() => {
32
+ throw failure;
33
+ });
34
+ const second = vi.fn();
35
+
36
+ client.on('network.changed', first);
37
+ client.on('network.changed', second);
38
+
39
+ expect(() => client.dispatchEvent(event)).not.toThrow();
40
+ expect(first).toHaveBeenCalledTimes(1);
41
+ expect(second).toHaveBeenCalledTimes(1);
42
+ });
43
+
44
+ it('reports a synchronous throw through the logger', () => {
45
+ const failure = new Error('listener blew up');
46
+ client.on('network.changed', () => {
47
+ throw failure;
48
+ });
49
+
50
+ client.dispatchEvent(event);
51
+
52
+ expect(errorLogs()).toEqual([
53
+ [
54
+ 'error',
55
+ '[coordinator]: Unhandled error in event listener',
56
+ failure,
57
+ event,
58
+ ],
59
+ ]);
60
+ });
61
+
62
+ it('reports a rejection from an async listener', async () => {
63
+ const failure = new Error('async listener blew up');
64
+ client.on('network.changed', async () => {
65
+ throw failure;
66
+ });
67
+ const next = vi.fn();
68
+ client.on('network.changed', next);
69
+
70
+ client.dispatchEvent(event);
71
+ expect(next).toHaveBeenCalledTimes(1);
72
+ expect(errorLogs()).toHaveLength(0);
73
+
74
+ await vi.waitFor(() => expect(errorLogs()).toHaveLength(1));
75
+ expect(errorLogs()[0]).toEqual([
76
+ 'error',
77
+ '[coordinator]: Unhandled error in event listener',
78
+ failure,
79
+ event,
80
+ ]);
81
+ });
82
+
83
+ it('isolates a faulty `all` listener from type specific listeners', () => {
84
+ const all = vi.fn(() => {
85
+ throw new Error('all listener blew up');
86
+ });
87
+ const typed = vi.fn();
88
+
89
+ client.on('all', all);
90
+ client.on('network.changed', typed);
91
+
92
+ client.dispatchEvent(event);
93
+
94
+ expect(all).toHaveBeenCalledTimes(1);
95
+ expect(typed).toHaveBeenCalledTimes(1);
96
+ expect(errorLogs()).toHaveLength(1);
97
+ });
98
+
99
+ it('schedules the connection check even when a listener throws', () => {
100
+ const connection = new StableWSConnection(client);
101
+ const scheduleConnectionCheck = vi
102
+ .spyOn(connection, 'scheduleConnectionCheck')
103
+ .mockImplementation(() => {});
104
+
105
+ client.on('all', () => {
106
+ throw new Error('listener blew up');
107
+ });
108
+
109
+ connection.onmessage(connection.wsID, {
110
+ data: JSON.stringify({ type: 'custom' }),
111
+ } as MessageEvent);
112
+
113
+ expect(scheduleConnectionCheck).toHaveBeenCalledTimes(1);
114
+ });
115
+ });
@@ -10,6 +10,7 @@ import { TokenManager } from './token_manager';
10
10
  import {
11
11
  addConnectionEventListeners,
12
12
  generateUUIDv4,
13
+ invokeEventListener,
13
14
  isErrorResponse,
14
15
  KnownCodes,
15
16
  removeConnectionEventListeners,
@@ -589,12 +590,12 @@ export class StreamClient {
589
590
 
590
591
  // call generic listeners
591
592
  for (const listener of this.listeners.all || []) {
592
- listener(event);
593
+ invokeEventListener(listener, event, this.logger);
593
594
  }
594
595
 
595
596
  // call type specific listeners
596
597
  for (const listener of this.listeners[event.type] || []) {
597
- listener(event);
598
+ invokeEventListener(listener, event, this.logger);
598
599
  }
599
600
  };
600
601
 
@@ -1,6 +1,7 @@
1
1
  import type { AxiosResponse } from 'axios';
2
2
  import type { APIErrorResponse } from './types';
3
3
  import type { ConnectionErrorEvent } from '../../gen/coordinator';
4
+ import type { ScopedLogger } from '../../logger';
4
5
 
5
6
  export const sleep = (m: number) => new Promise((r) => setTimeout(r, m));
6
7
 
@@ -118,3 +119,30 @@ export function isCloseEvent(
118
119
  ): res is CloseEvent {
119
120
  return (res as CloseEvent).code !== undefined;
120
121
  }
122
+
123
+ /**
124
+ * Invokes a single event listener in isolation, so that a faulty listener
125
+ * cannot abort the dispatch loop nor the bookkeeping that follows it.
126
+ *
127
+ * @param listener the listener to invoke.
128
+ * @param event the event to pass to the listener.
129
+ * @param logger the logger to report listener failures with.
130
+ */
131
+ export const invokeEventListener = <T>(
132
+ listener: (event: T) => unknown,
133
+ event: T,
134
+ logger: ScopedLogger,
135
+ ) => {
136
+ try {
137
+ const result = listener(event);
138
+ if (result && typeof (result as PromiseLike<unknown>).then === 'function') {
139
+ Promise.resolve(result).catch((error) => report(logger, error, event));
140
+ }
141
+ } catch (error) {
142
+ report(logger, error, event);
143
+ }
144
+ };
145
+
146
+ const report = (logger: ScopedLogger, error: unknown, event: unknown) => {
147
+ logger.error('Unhandled error in event listener', error, event);
148
+ };
@@ -167,8 +167,13 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
167
167
  *
168
168
  * @param settings the video settings to apply.
169
169
  * @param publish whether to publish the stream after applying the settings.
170
+ * @param forceDisabled whether to keep the camera disabled, regardless of server-side or local preferences.
170
171
  */
171
- async apply(settings: VideoSettingsResponse, publish: boolean) {
172
+ async apply(
173
+ settings: VideoSettingsResponse,
174
+ publish: boolean,
175
+ forceDisabled: boolean = false,
176
+ ): Promise<void> {
172
177
  // Wait for any in progress camera operation
173
178
  await this.statusChangeSettled();
174
179
  await this.selectTargetResolution(settings.target_resolution);
@@ -186,8 +191,10 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
186
191
  this.devicePersistence.enabled &&
187
192
  permissionState === 'granted'
188
193
  ) {
189
- persistedPreferencesApplied =
190
- await this.applyPersistedPreferences(enabledInCallType);
194
+ persistedPreferencesApplied = await this.applyPersistedPreferences(
195
+ enabledInCallType,
196
+ forceDisabled,
197
+ );
191
198
  }
192
199
 
193
200
  // apply a direction and enable the camera only if in "pristine" state,
@@ -199,7 +206,12 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
199
206
  await this.selectDirection(direction, { enableCamera: false });
200
207
  }
201
208
 
202
- if (canPublish && settings.camera_default_on && enabledInCallType) {
209
+ if (
210
+ !forceDisabled &&
211
+ canPublish &&
212
+ settings.camera_default_on &&
213
+ enabledInCallType
214
+ ) {
203
215
  await this.enable();
204
216
  }
205
217
  }