@stream-io/video-client 1.59.0 → 1.60.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 +14 -0
  2. package/dist/index.browser.es.js +544 -111
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +544 -110
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +544 -111
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +23 -3
  9. package/dist/src/coordinator/connection/types.d.ts +16 -0
  10. package/dist/src/coordinator/connection/utils.d.ts +10 -0
  11. package/dist/src/events/call.d.ts +0 -11
  12. package/dist/src/gen/coordinator/index.d.ts +73 -0
  13. package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
  14. package/dist/src/ringing/RingStatePoller.d.ts +46 -0
  15. package/dist/src/ringing/RingTimeout.d.ts +27 -0
  16. package/dist/src/ringing/index.d.ts +4 -0
  17. package/dist/src/ringing/reconcileRingState.d.ts +18 -0
  18. package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
  19. package/dist/src/sorting/participants.d.ts +13 -0
  20. package/dist/src/stats/utils.d.ts +6 -0
  21. package/dist/src/store/CallState.d.ts +10 -1
  22. package/package.json +1 -1
  23. package/src/Call.ts +93 -61
  24. package/src/StreamSfuClient.ts +6 -2
  25. package/src/__tests__/Call.autodrop.test.ts +116 -64
  26. package/src/__tests__/Call.getRingState.test.ts +87 -0
  27. package/src/__tests__/Call.lifecycle.test.ts +23 -0
  28. package/src/__tests__/Call.ringSettled.test.ts +71 -0
  29. package/src/__tests__/StreamSfuClient.test.ts +48 -0
  30. package/src/__tests__/StreamVideoClient.ringing.test.ts +32 -20
  31. package/src/coordinator/connection/__tests__/eventListenerIsolation.test.ts +115 -0
  32. package/src/coordinator/connection/client.ts +3 -2
  33. package/src/coordinator/connection/types.ts +19 -0
  34. package/src/coordinator/connection/utils.ts +28 -0
  35. package/src/events/__tests__/call.test.ts +2 -217
  36. package/src/events/call.ts +1 -73
  37. package/src/events/callEventHandlers.ts +12 -23
  38. package/src/gen/coordinator/index.ts +67 -0
  39. package/src/reporting/ClientEventReporter.ts +30 -4
  40. package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
  41. package/src/ringing/RingStatePoller.ts +177 -0
  42. package/src/ringing/RingTimeout.ts +90 -0
  43. package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
  44. package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
  45. package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
  46. package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
  47. package/src/ringing/index.ts +4 -0
  48. package/src/ringing/reconcileRingState.ts +108 -0
  49. package/src/ringing/resolveOwnRingOutcome.ts +58 -0
  50. package/src/sorting/__tests__/presets.test.ts +188 -3
  51. package/src/sorting/__tests__/sorting.test.ts +22 -1
  52. package/src/sorting/participants.ts +20 -0
  53. package/src/sorting/presets.ts +4 -0
  54. package/src/stats/__tests__/utils.test.ts +33 -0
  55. package/src/stats/utils.ts +15 -0
  56. package/src/store/CallState.ts +25 -0
  57. package/src/store/__tests__/CallState.test.ts +61 -2
@@ -0,0 +1,71 @@
1
+ import '../rtc/__tests__/mocks/webrtc.mocks';
2
+
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { fromPartial } from '@total-typescript/shoehorn';
5
+ import { Call } from '../Call';
6
+ import { StreamClient } from '../coordinator/connection/client';
7
+ import { ClientEventReporter } from '../reporting';
8
+ import { generateUUIDv4 } from '../coordinator/connection/utils';
9
+ import { CallingState, StreamVideoWriteableStateStore } from '../store';
10
+ import { CallSessionResponse } from '../gen/coordinator';
11
+
12
+ const ME = 'jane';
13
+
14
+ // `resolveOwnRingOutcome` owns the decision and is tested on its own. This
15
+ // covers the effect around it: that it runs for a ringing call and that the
16
+ // `ringing` guard keeps it off every other call.
17
+ describe('Leaving a call settled by the current user', () => {
18
+ let call: Call;
19
+
20
+ const createCall = async (ringing: boolean) => {
21
+ const clientStore = new StreamVideoWriteableStateStore();
22
+ const streamClient = new StreamClient('abc');
23
+ call = new Call({
24
+ type: 'test',
25
+ id: generateUUIDv4(),
26
+ streamClient,
27
+ clientEventReporter: new ClientEventReporter({ streamClient }),
28
+ clientStore,
29
+ ringing,
30
+ });
31
+
32
+ clientStore.setConnectedUser(fromPartial({ id: ME }));
33
+ vi.spyOn(call, 'leave').mockResolvedValue(undefined);
34
+ await call.setup();
35
+ return call;
36
+ };
37
+
38
+ const settleByMe = () =>
39
+ call.state['sessionSubject'].next(
40
+ fromPartial<CallSessionResponse>({
41
+ id: 'session-1',
42
+ accepted_by: {},
43
+ rejected_by: { [ME]: new Date().toISOString() },
44
+ missed_by: {},
45
+ participants: [],
46
+ participants_count_by_role: {},
47
+ }),
48
+ );
49
+
50
+ afterEach(() => {
51
+ vi.restoreAllMocks();
52
+ });
53
+
54
+ it('leaves a ringing call the current user rejected', async () => {
55
+ await createCall(true);
56
+ expect(call.state.callingState).toBe(CallingState.RINGING);
57
+
58
+ settleByMe();
59
+
60
+ expect(call.leave).toHaveBeenCalled();
61
+ });
62
+
63
+ it('ignores the same session on a call that is not ringing', async () => {
64
+ await createCall(false);
65
+ expect(call.state.callingState).toBe(CallingState.IDLE);
66
+
67
+ settleByMe();
68
+
69
+ expect(call.leave).not.toHaveBeenCalled();
70
+ });
71
+ });
@@ -3,6 +3,8 @@ import { StreamSfuClient } from '../StreamSfuClient';
3
3
  import { Dispatcher } from '../rtc';
4
4
  import { StreamClient } from '../coordinator/connection/client';
5
5
  import { getTimers } from '../timers';
6
+ import { getSdkInfo, setSdkInfo } from '../helpers/client-details';
7
+ import { SdkType } from '../gen/video/sfu/models/models';
6
8
 
7
9
  /**
8
10
  * Minimal `WebSocket` stub used to drive `StreamSfuClient.close()` while the
@@ -431,3 +433,49 @@ describe('StreamSfuClient.leaveAndClose()', () => {
431
433
  expect(notifyLeaveSpy).not.toHaveBeenCalled();
432
434
  });
433
435
  });
436
+
437
+ describe('StreamSfuClient RPC headers', () => {
438
+ beforeEach(() => {
439
+ CapturingWebSocket.instances = [];
440
+ vi.stubGlobal('WebSocket', CapturingWebSocket);
441
+ });
442
+
443
+ afterEach(() => {
444
+ vi.unstubAllGlobals();
445
+ vi.restoreAllMocks();
446
+ });
447
+
448
+ it('identifies the SDK and its version in every RPC request', async () => {
449
+ const sdkInfo = getSdkInfo();
450
+ setSdkInfo({
451
+ type: SdkType.REACT,
452
+ major: '1',
453
+ minor: '2',
454
+ patch: '3',
455
+ });
456
+ const fetchMock = vi
457
+ .fn()
458
+ .mockResolvedValue(
459
+ new Response('{}', { headers: { 'content-type': 'application/json' } }),
460
+ );
461
+ vi.stubGlobal('fetch', fetchMock);
462
+
463
+ try {
464
+ const sfuClient = buildSfuClient();
465
+ (
466
+ sfuClient as unknown as {
467
+ joinResponseTask: { resolve: (v: unknown) => void };
468
+ }
469
+ ).joinResponseTask.resolve({});
470
+
471
+ await sfuClient.startNoiseCancellation();
472
+
473
+ const [, init] = fetchMock.mock.calls[0];
474
+ expect(new Headers(init.headers).get('x-stream-client')).toBe(
475
+ 'stream-video-react-v1.2.3',
476
+ );
477
+ } finally {
478
+ setSdkInfo(sdkInfo!);
479
+ }
480
+ });
481
+ });
@@ -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;
@@ -54,6 +61,11 @@ describe('StreamVideoClient Ringing', () => {
54
61
  };
55
62
 
56
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];
57
69
  [oliverClient, sachaClient, marceloClient] =
58
70
  await createClients(testUserIds);
59
71
  });
@@ -75,11 +87,11 @@ describe('StreamVideoClient Ringing', () => {
75
87
  await call.create({
76
88
  ring: true,
77
89
  data: {
78
- created_by_id: 'oliver',
90
+ created_by_id: oliverId,
79
91
  members: [
80
- { user_id: 'oliver' },
81
- { user_id: 'sacha' },
82
- { user_id: 'marcelo' },
92
+ { user_id: oliverId },
93
+ { user_id: sachaId },
94
+ { user_id: marceloId },
83
95
  ],
84
96
  },
85
97
  });
@@ -112,9 +124,9 @@ describe('StreamVideoClient Ringing', () => {
112
124
  ring: true,
113
125
  data: {
114
126
  members: [
115
- { user_id: 'oliver' },
116
- { user_id: 'sacha' },
117
- { user_id: 'marcelo' },
127
+ { user_id: oliverId },
128
+ { user_id: sachaId },
129
+ { user_id: marceloId },
118
130
  ],
119
131
  },
120
132
  });
@@ -144,9 +156,9 @@ describe('StreamVideoClient Ringing', () => {
144
156
  ring: false, // don't ring all members by default
145
157
  data: {
146
158
  members: [
147
- { user_id: 'oliver' },
148
- { user_id: 'sacha' },
149
- { user_id: 'marcelo' },
159
+ { user_id: oliverId },
160
+ { user_id: sachaId },
161
+ { user_id: marceloId },
150
162
  ],
151
163
  },
152
164
  });
@@ -162,7 +174,7 @@ describe('StreamVideoClient Ringing', () => {
162
174
  // oliver is calling sacha. only sacha should get a ring event
163
175
  const sachaIndividualRing = expectEvent(sachaClient, 'call.ring');
164
176
  const marceloIndividualRing = expectEvent(marceloClient, 'call.ring');
165
- await oliverCall.ring({ members_ids: ['sacha'] });
177
+ await oliverCall.ring({ members_ids: [sachaId] });
166
178
  await expect(sachaIndividualRing).resolves.toHaveProperty(
167
179
  'call.cid',
168
180
  oliverCall.cid,
@@ -175,7 +187,7 @@ describe('StreamVideoClient Ringing', () => {
175
187
  // sacha is calling marcelo. only marcelo should get a ring event
176
188
  const oliverIndividualRing = expectEvent(oliverClient, 'call.ring');
177
189
  const marceloIndividualRing2 = expectEvent(marceloClient, 'call.ring');
178
- await sachaCall.ring({ members_ids: ['marcelo'] });
190
+ await sachaCall.ring({ members_ids: [marceloId] });
179
191
  await expect(marceloIndividualRing2).resolves.toHaveProperty(
180
192
  'call.cid',
181
193
  sachaCall.cid,
@@ -241,8 +253,8 @@ describe('StreamVideoClient Ringing', () => {
241
253
  const serverCall = serverClient.video.call('default', callId);
242
254
  await serverCall.create({
243
255
  data: {
244
- created_by_id: 'oliver',
245
- members: [{ user_id: 'oliver' }, { user_id: 'sacha' }],
256
+ created_by_id: oliverId,
257
+ members: [{ user_id: oliverId }, { user_id: sachaId }],
246
258
  },
247
259
  });
248
260
 
@@ -266,7 +278,7 @@ describe('StreamVideoClient Ringing', () => {
266
278
  await oliverCall.create({
267
279
  ring: true,
268
280
  data: {
269
- members: [{ user_id: 'oliver' }, { user_id: 'sacha' }],
281
+ members: [{ user_id: oliverId }, { user_id: sachaId }],
270
282
  },
271
283
  });
272
284
 
@@ -302,7 +314,7 @@ describe('StreamVideoClient Ringing', () => {
302
314
  await marceloCall.create({
303
315
  ring: true,
304
316
  data: {
305
- members: [{ user_id: 'marcelo' }, { user_id: 'oliver' }],
317
+ members: [{ user_id: marceloId }, { user_id: oliverId }],
306
318
  },
307
319
  });
308
320
 
@@ -332,7 +344,7 @@ describe('StreamVideoClient Ringing', () => {
332
344
  await marceloCall.create({
333
345
  ring: true,
334
346
  data: {
335
- members: [{ user_id: 'marcelo' }, { user_id: 'sacha' }],
347
+ members: [{ user_id: marceloId }, { user_id: sachaId }],
336
348
  },
337
349
  });
338
350
 
@@ -354,7 +366,7 @@ describe('StreamVideoClient Ringing', () => {
354
366
  await marceloCall.create({
355
367
  ring: true,
356
368
  data: {
357
- members: [{ user_id: 'marcelo' }, { user_id: 'oliver' }],
369
+ members: [{ user_id: marceloId }, { user_id: oliverId }],
358
370
  },
359
371
  });
360
372
 
@@ -375,7 +387,7 @@ describe('StreamVideoClient Ringing', () => {
375
387
  await marceloCall.create({
376
388
  ring: true,
377
389
  data: {
378
- members: [{ user_id: 'marcelo' }, { user_id: 'sacha' }],
390
+ members: [{ user_id: marceloId }, { user_id: sachaId }],
379
391
  },
380
392
  });
381
393
 
@@ -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
 
@@ -200,6 +200,18 @@ export type Logger = (
200
200
  ...args: unknown[]
201
201
  ) => void;
202
202
 
203
+ export type RingStatePollingOptions = {
204
+ /**
205
+ * Quiet time after the ring starts, before the first poll. Defaults to 15_000.
206
+ */
207
+ startAfterMs?: number;
208
+
209
+ /**
210
+ * The interval between polls. Defaults to 5_000.
211
+ */
212
+ intervalMs?: number;
213
+ };
214
+
203
215
  export type StreamClientOptions = Partial<AxiosRequestConfig> & {
204
216
  /**
205
217
  * Used to disable warnings that are triggered by using connectUser or connectAnonymousUser server-side.
@@ -305,6 +317,13 @@ export type StreamClientOptions = Partial<AxiosRequestConfig> & {
305
317
  */
306
318
  rejectCallWhenBusy?: boolean;
307
319
 
320
+ /**
321
+ * Caller-side polling for the ring outcome, used when the `call.accepted`,
322
+ * `call.rejected` or `call.missed` event never arrives. Enabled by default;
323
+ * set to `false` to disable, or pass an object to tune the timings.
324
+ */
325
+ ringStatePolling?: false | RingStatePollingOptions;
326
+
308
327
  /**
309
328
  * Device persistence preference options (web only).
310
329
  */
@@ -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
+ };