@stream-io/video-client 1.59.1 → 1.61.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 (62) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/index.browser.es.js +744 -156
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +744 -155
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.es.js +744 -156
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +34 -8
  10. package/dist/src/coordinator/connection/types.d.ts +16 -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/rtc/Publisher.d.ts +18 -0
  20. package/dist/src/rtc/TransceiverCache.d.ts +10 -0
  21. package/dist/src/rtc/e2ee/E2EEManager.d.ts +10 -1
  22. package/dist/src/stats/utils.d.ts +6 -0
  23. package/dist/src/store/CallState.d.ts +10 -1
  24. package/dist/src/types.d.ts +30 -1
  25. package/index.ts +1 -0
  26. package/package.json +1 -1
  27. package/src/Call.ts +272 -108
  28. package/src/StreamSfuClient.ts +6 -2
  29. package/src/__tests__/Call.autodrop.test.ts +116 -64
  30. package/src/__tests__/Call.getRingState.test.ts +87 -0
  31. package/src/__tests__/Call.lifecycle.test.ts +498 -5
  32. package/src/__tests__/Call.ringSettled.test.ts +71 -0
  33. package/src/__tests__/Call.ringingLifecycle.test.ts +242 -0
  34. package/src/__tests__/StreamSfuClient.test.ts +48 -0
  35. package/src/coordinator/connection/types.ts +19 -0
  36. package/src/events/__tests__/call.test.ts +2 -217
  37. package/src/events/call.ts +1 -73
  38. package/src/events/callEventHandlers.ts +12 -23
  39. package/src/gen/coordinator/index.ts +67 -0
  40. package/src/reporting/ClientEventReporter.ts +30 -4
  41. package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
  42. package/src/ringing/RingStatePoller.ts +177 -0
  43. package/src/ringing/RingTimeout.ts +90 -0
  44. package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
  45. package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
  46. package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
  47. package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
  48. package/src/ringing/index.ts +4 -0
  49. package/src/ringing/reconcileRingState.ts +108 -0
  50. package/src/ringing/resolveOwnRingOutcome.ts +58 -0
  51. package/src/rtc/Publisher.ts +66 -12
  52. package/src/rtc/TransceiverCache.ts +17 -0
  53. package/src/rtc/__tests__/Publisher.test.ts +209 -0
  54. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +1 -0
  55. package/src/rtc/e2ee/E2EEManager.ts +10 -1
  56. package/src/rtc/e2ee/EncryptionManager.ts +1 -1
  57. package/src/rtc/e2ee/SPEC.md +1 -1
  58. package/src/stats/__tests__/utils.test.ts +33 -0
  59. package/src/stats/utils.ts +15 -0
  60. package/src/store/CallState.ts +25 -0
  61. package/src/store/__tests__/CallState.test.ts +59 -0
  62. package/src/types.ts +37 -1
@@ -39,7 +39,8 @@ import {
39
39
  } from './helpers/promise';
40
40
  import { withoutConcurrency } from './helpers/concurrency';
41
41
  import { getTimers } from './timers';
42
- import { Tracer, TraceSlice } from './stats';
42
+ import { getSdkInfo } from './helpers/client-details';
43
+ import { getStreamClientId, Tracer, TraceSlice } from './stats';
43
44
  import { SfuJoinError, SfuTimeoutError } from './errors';
44
45
 
45
46
  export type StreamSfuClientConstructor = {
@@ -271,7 +272,10 @@ export class StreamSfuClient {
271
272
  this.rpc = createSignalClient({
272
273
  baseUrl: server.url,
273
274
  interceptors: [
274
- withHeaders({ Authorization: `Bearer ${token}` }),
275
+ withHeaders({
276
+ Authorization: `Bearer ${token}`,
277
+ 'X-Stream-Client': getStreamClientId(getSdkInfo()),
278
+ }),
275
279
  this.tracer && withRequestTracer(this.tracer.trace),
276
280
  this.logger.getLogLevel() === 'trace' && withRequestLogger(this.logger),
277
281
  withTimeout(rpcRequestTimeout, this.tracer?.trace),
@@ -1,104 +1,156 @@
1
1
  import '../rtc/__tests__/mocks/webrtc.mocks';
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { fromPartial } from '@total-typescript/shoehorn';
4
5
  import { Call } from '../Call';
5
6
  import { StreamClient } from '../coordinator/connection/client';
6
7
  import { ClientEventReporter } from '../reporting';
7
8
  import { generateUUIDv4 } from '../coordinator/connection/utils';
8
9
  import { CallingState, StreamVideoWriteableStateStore } from '../store';
10
+ import { CallSettingsResponse } from '../gen/coordinator';
9
11
 
12
+ const TIMEOUT_MS = 30_000;
13
+
14
+ // The timeout's own behaviour is covered by `ringing/__tests__/RingTimeout`.
15
+ // This file covers the wiring: that a ringing call arms one and that leaving
16
+ // cancels it.
10
17
  describe('Auto drop ringing calls', () => {
11
- let call: Call;
12
18
  const userId = 'jane';
19
+ let call: Call;
13
20
 
14
- beforeEach(async () => {
15
- vi.useFakeTimers();
16
-
21
+ const ringingCall = () => {
17
22
  const clientStore = new StreamVideoWriteableStateStore();
18
23
  const streamClient = new StreamClient('abc');
19
- call = new Call({
24
+ const newCall = new Call({
20
25
  type: 'test',
21
26
  id: generateUUIDv4(),
22
27
  streamClient,
23
28
  clientEventReporter: new ClientEventReporter({ streamClient }),
24
- clientStore: clientStore,
29
+ clientStore,
30
+ ringing: true,
25
31
  });
26
32
 
27
- // @ts-expect-error mocking only what we need for the test
28
- clientStore.connectedUserSubject.next({
29
- id: userId,
30
- });
31
-
32
- call.state['callingStateSubject'].next(CallingState.RINGING);
33
-
34
- vi.spyOn(call, 'leave').mockImplementation(async () => {
35
- console.log(`TEST: leave() called`);
36
- });
37
- });
38
-
39
- it('caller should drop ringing calls after a timeout if no one accepted', async () => {
40
- call.state['settingsSubject'].next({
41
- // @ts-expect-error mocking only what we need for the test, we use fake timers, so undefined for timeout works
42
- ring: {},
43
- // @ts-expect-error mocking only what we need for the test
44
- screensharing: {
45
- enabled: false,
46
- target_resolution: {
47
- width: 100,
48
- height: 100,
33
+ clientStore.setConnectedUser(fromPartial({ id: userId }));
34
+ newCall.state['createdBySubject'].next(fromPartial({ id: userId }));
35
+ newCall.state['settingsSubject'].next(
36
+ fromPartial<CallSettingsResponse>({
37
+ ring: {
38
+ auto_cancel_timeout_ms: TIMEOUT_MS,
39
+ incoming_call_timeout_ms: TIMEOUT_MS,
40
+ missed_call_timeout_ms: TIMEOUT_MS,
49
41
  },
50
- },
51
- });
42
+ screensharing: {
43
+ enabled: false,
44
+ target_resolution: { width: 100, height: 100 },
45
+ },
46
+ }),
47
+ );
48
+ newCall.state['sessionSubject'].next(
49
+ fromPartial({
50
+ id: 'session-1',
51
+ accepted_by: {},
52
+ rejected_by: {},
53
+ missed_by: {},
54
+ }),
55
+ );
56
+ newCall.state['callingStateSubject'].next(CallingState.RINGING);
57
+
58
+ vi.spyOn(newCall, 'leave').mockResolvedValue(undefined);
59
+ return newCall;
60
+ };
61
+
62
+ beforeEach(() => {
63
+ vi.useFakeTimers();
64
+ call = ringingCall();
65
+ });
52
66
 
53
- // @ts-expect-error mocking only what we need for the test
54
- call.state['createdBySubject'].next({
55
- id: userId,
56
- });
67
+ afterEach(() => {
68
+ call['cancelAutoDrop']();
69
+ vi.useRealTimers();
70
+ vi.restoreAllMocks();
71
+ });
57
72
 
58
- // black-box test, calling private method
59
- call['scheduleAutoDrop']();
73
+ it('is armed when a call starts ringing', async () => {
74
+ call.state['callingStateSubject'].next(CallingState.IDLE);
60
75
 
61
- await vi.runAllTimersAsync();
76
+ call['handleRingingCall']();
62
77
 
78
+ expect(call['ringTimeout']).toBeDefined();
79
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
63
80
  expect(call.leave).toHaveBeenCalledWith({
64
81
  reject: true,
65
82
  reason: 'timeout',
66
- message: `ringing timeout - no one accepted`,
83
+ message: 'ringing timeout - no one accepted',
67
84
  });
68
85
  });
69
86
 
70
- it(`callee should drop ringing calls after a timeout if user didn't interact with incoming call screen`, async () => {
71
- call.state['settingsSubject'].next({
72
- // @ts-expect-error mocking only what we need for the test, we use fake timers, so undefined for timeout works
73
- ring: {},
74
- // @ts-expect-error mocking only what we need for the test
75
- screensharing: {
76
- enabled: false,
77
- target_resolution: {
78
- width: 100,
79
- height: 100,
80
- },
81
- },
82
- });
87
+ it('is cancelled by cancelAutoDrop', async () => {
88
+ call['scheduleAutoDrop']();
83
89
 
84
- // @ts-expect-error mocking only what we need for the test
85
- call.state['createdBySubject'].next({
86
- id: 'not-' + userId,
87
- });
90
+ call['cancelAutoDrop']();
88
91
 
89
- // black-box test, calling private method
92
+ expect(call['ringTimeout']).toBeUndefined();
93
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
94
+ expect(call.leave).not.toHaveBeenCalled();
95
+ });
96
+
97
+ // the calling state stays RINGING well into teardown, so the watchdogs have
98
+ // to be paused before `leave` awaits anything
99
+ it('pauses the watchdogs synchronously when leave starts', async () => {
90
100
  call['scheduleAutoDrop']();
101
+ call['scheduleRingStatePolling']();
102
+ const timeout = call['ringTimeout'];
103
+ vi.spyOn(call, 'leave').mockRestore();
104
+ vi.spyOn(call, 'reject').mockResolvedValue(fromPartial({}));
91
105
 
92
- await vi.runAllTimersAsync();
106
+ const poller = call['ringStatePoller'];
93
107
 
94
- expect(call.leave).toHaveBeenCalledWith({
95
- reject: true,
96
- reason: 'timeout',
97
- message: `ringing timeout - user didn't interact with incoming call screen`,
98
- });
108
+ const leaving = call.leave({ reject: false });
109
+
110
+ // paused, not cancelled: both keep the window this leave is ending
111
+ expect(call['ringTimeout']).toBe(timeout);
112
+ expect(call['ringStatePoller']).toBe(poller);
113
+ expect(timeout!['timeoutId']).toBeUndefined();
114
+ expect(timeout!['stopped']).toBe(false);
115
+ expect(poller!['idleTimeoutId']).toBeUndefined();
116
+ expect(poller!['stopped']).toBe(false);
117
+
118
+ await leaving.catch(() => {});
119
+
120
+ expect(timeout!['stopped']).toBe(true);
121
+ expect(poller!['stopped']).toBe(true);
99
122
  });
100
123
 
101
- afterEach(() => {
102
- vi.useRealTimers();
124
+ it('restores both watchdogs when rejecting the ring fails', async () => {
125
+ call['scheduleAutoDrop']();
126
+ call['scheduleRingStatePolling']();
127
+ const timeout = call['ringTimeout'];
128
+ const deadlineAt = timeout!['deadlineAt'];
129
+ const poller = call['ringStatePoller'];
130
+ const pollDeadlineAt = poller!['deadlineAt'];
131
+ vi.spyOn(call, 'leave').mockRestore();
132
+ vi.spyOn(call, 'reject').mockRejectedValueOnce(new Error('transient'));
133
+
134
+ await expect(call.leave({ reject: true })).rejects.toThrow('transient');
135
+
136
+ expect(call.state.callingState).toBe(CallingState.RINGING);
137
+ expect(call['ringTimeout']).toBe(timeout);
138
+ expect(timeout!['timeoutId']).toBeDefined();
139
+ expect(timeout!['deadlineAt']).toBe(deadlineAt);
140
+ expect(call['ringStatePoller']).toBe(poller);
141
+ expect(poller!['deadlineAt']).toBe(pollDeadlineAt);
142
+ expect(poller!['stopped']).toBe(false);
143
+ });
144
+
145
+ it('replaces a previously armed timeout', async () => {
146
+ call['scheduleAutoDrop']();
147
+ const first = call['ringTimeout'];
148
+
149
+ call['scheduleAutoDrop']();
150
+
151
+ expect(call['ringTimeout']).not.toBe(first);
152
+ expect(first!['stopped']).toBe(true);
153
+ await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
154
+ expect(call.leave).toHaveBeenCalledTimes(1);
103
155
  });
104
156
  });
@@ -0,0 +1,87 @@
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 { StreamVideoWriteableStateStore } from '../store';
10
+ import { CallSessionResponse } from '../gen/coordinator';
11
+
12
+ describe('Call.getRingState', () => {
13
+ const callId = generateUUIDv4();
14
+
15
+ const fakeCall = (sessionId?: string) => {
16
+ const streamClient = new StreamClient('abc');
17
+ const call = new Call({
18
+ type: 'test',
19
+ id: callId,
20
+ streamClient,
21
+ clientEventReporter: new ClientEventReporter({ streamClient }),
22
+ clientStore: new StreamVideoWriteableStateStore(),
23
+ });
24
+
25
+ if (sessionId) {
26
+ call.state['sessionSubject'].next(
27
+ fromPartial<CallSessionResponse>({ id: sessionId }),
28
+ );
29
+ }
30
+
31
+ const get = vi
32
+ .spyOn(streamClient, 'get')
33
+ .mockResolvedValue(fromPartial({ session_id: sessionId }));
34
+
35
+ return { call, get };
36
+ };
37
+
38
+ afterEach(() => {
39
+ vi.restoreAllMocks();
40
+ });
41
+
42
+ it('reads the ring state of the current session', async () => {
43
+ const { call, get } = fakeCall('session-1');
44
+
45
+ await call.getRingState();
46
+
47
+ expect(get).toHaveBeenCalledWith(`/call/test/${callId}/ring_state`, {
48
+ call_session_id: 'session-1',
49
+ });
50
+ });
51
+
52
+ it('reads the ring state of an explicitly named session', async () => {
53
+ // ending a call clears its current session, so a caller reconciling after
54
+ // `call.ended` has to name the session it rang on
55
+ const { call, get } = fakeCall('current-session');
56
+
57
+ await call.getRingState('ended-session');
58
+
59
+ expect(get).toHaveBeenCalledWith(`/call/test/${callId}/ring_state`, {
60
+ call_session_id: 'ended-session',
61
+ });
62
+ });
63
+
64
+ it('returns the coordinator response', async () => {
65
+ const { call, get } = fakeCall('session-1');
66
+ get.mockResolvedValue(
67
+ fromPartial({
68
+ session_id: 'session-1',
69
+ accepted_by: { bob: '2026-08-24T10:00:04Z' },
70
+ }),
71
+ );
72
+
73
+ await expect(call.getRingState()).resolves.toMatchObject({
74
+ session_id: 'session-1',
75
+ accepted_by: { bob: '2026-08-24T10:00:04Z' },
76
+ });
77
+ });
78
+
79
+ it('rejects when the call has no session to read', async () => {
80
+ const { call, get } = fakeCall();
81
+
82
+ await expect(call.getRingState()).rejects.toThrow(
83
+ 'Cannot read the ring state: the call has no session',
84
+ );
85
+ expect(get).not.toHaveBeenCalled();
86
+ });
87
+ });