@stream-io/video-client 1.47.0 → 1.49.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 (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/index.browser.es.js +383 -238
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +382 -238
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +0 -1
  7. package/dist/index.es.js +383 -238
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +35 -1
  10. package/dist/src/StreamSfuClient.d.ts +8 -1
  11. package/dist/src/devices/DeviceManagerState.d.ts +13 -0
  12. package/dist/src/devices/MicrophoneManager.d.ts +0 -1
  13. package/dist/src/helpers/SlidingWindowRateLimiter.d.ts +28 -0
  14. package/dist/src/rtc/BasePeerConnection.d.ts +11 -2
  15. package/dist/src/rtc/index.d.ts +1 -0
  16. package/dist/src/rtc/types.d.ts +33 -1
  17. package/dist/src/types.d.ts +11 -0
  18. package/index.ts +0 -1
  19. package/package.json +1 -1
  20. package/src/Call.ts +179 -18
  21. package/src/StreamSfuClient.ts +75 -12
  22. package/src/__tests__/Call.publishing.test.ts +103 -0
  23. package/src/__tests__/StreamSfuClient.test.ts +275 -0
  24. package/src/devices/DeviceManagerState.ts +20 -0
  25. package/src/devices/MicrophoneManager.ts +9 -5
  26. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +28 -29
  27. package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
  28. package/src/devices/devices.ts +2 -1
  29. package/src/helpers/SlidingWindowRateLimiter.ts +49 -0
  30. package/src/helpers/__tests__/SlidingWindowRateLimiter.test.ts +43 -0
  31. package/src/rpc/retryable.ts +0 -1
  32. package/src/rtc/BasePeerConnection.ts +96 -6
  33. package/src/rtc/Publisher.ts +2 -1
  34. package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
  35. package/src/rtc/__tests__/Publisher.test.ts +210 -0
  36. package/src/rtc/__tests__/Subscriber.test.ts +56 -0
  37. package/src/rtc/index.ts +1 -0
  38. package/src/rtc/types.ts +38 -1
  39. package/src/types.ts +9 -0
  40. package/dist/src/helpers/RNSpeechDetector.d.ts +0 -23
  41. package/src/helpers/RNSpeechDetector.ts +0 -224
  42. package/src/helpers/__tests__/RNSpeechDetector.test.ts +0 -52
@@ -12,11 +12,12 @@ import {
12
12
  import { of } from 'rxjs';
13
13
  import '../../rtc/__tests__/mocks/webrtc.mocks';
14
14
  import { OwnCapability } from '../../gen/coordinator';
15
- import { SoundStateChangeHandler } from '../../helpers/sound-detector';
16
15
  import { settled, withoutConcurrency } from '../../helpers/concurrency';
17
16
 
18
- let handler: SoundStateChangeHandler = () => {};
19
- let unsubscribeHandlers: ReturnType<typeof vi.fn>[] = [];
17
+ let speechActivityCallback:
18
+ | ((state: { isSoundDetected: boolean }) => void)
19
+ | null = null;
20
+ let unsubscribeMocks: ReturnType<typeof vi.fn>[] = [];
20
21
 
21
22
  vi.mock('../../helpers/platforms.ts', () => {
22
23
  return {
@@ -46,28 +47,21 @@ vi.mock('../../Call.ts', () => {
46
47
  };
47
48
  });
48
49
 
49
- vi.mock('../../helpers/RNSpeechDetector.ts', () => {
50
- console.log('MOCKING RNSpeechDetector');
51
- return {
52
- RNSpeechDetector: vi.fn().mockImplementation(() => ({
53
- start: vi.fn((callback) => {
54
- handler = callback;
55
- const unsubscribe = vi.fn();
56
- unsubscribeHandlers.push(unsubscribe);
57
- return unsubscribe;
58
- }),
59
- stop: vi.fn(),
60
- onSpeakingDetectedStateChange: vi.fn(),
61
- })),
62
- };
63
- });
64
-
65
50
  describe('MicrophoneManager React Native', () => {
66
51
  let manager: MicrophoneManager;
67
52
  let checkPermissionMock: ReturnType<typeof vi.fn>;
53
+ let subscribeMock: ReturnType<typeof vi.fn>;
54
+
68
55
  beforeEach(() => {
69
- unsubscribeHandlers = [];
56
+ speechActivityCallback = null;
57
+ unsubscribeMocks = [];
70
58
  checkPermissionMock = vi.fn(async () => true);
59
+ subscribeMock = vi.fn((cb) => {
60
+ speechActivityCallback = cb;
61
+ const unsub = vi.fn();
62
+ unsubscribeMocks.push(unsub);
63
+ return unsub;
64
+ });
71
65
 
72
66
  globalThis.streamRNVideoSDK = {
73
67
  callManager: {
@@ -78,6 +72,11 @@ describe('MicrophoneManager React Native', () => {
78
72
  permissions: {
79
73
  check: checkPermissionMock,
80
74
  },
75
+ nativeEvents: {
76
+ speechActivity: {
77
+ subscribe: subscribeMock,
78
+ },
79
+ },
81
80
  };
82
81
 
83
82
  const devicePersistence = { enabled: false, storageKey: '' };
@@ -100,7 +99,7 @@ describe('MicrophoneManager React Native', () => {
100
99
 
101
100
  await vi.waitUntil(() => fn.mock.calls.length > 0, { timeout: 100 });
102
101
  expect(fn).toHaveBeenCalled();
103
- expect(manager['rnSpeechDetector']?.start).toHaveBeenCalled();
102
+ expect(subscribeMock).toHaveBeenCalled();
104
103
  });
105
104
 
106
105
  it('should check native microphone permission before starting detection', async () => {
@@ -146,15 +145,15 @@ describe('MicrophoneManager React Native', () => {
146
145
 
147
146
  it('should update speaking while muted state', async () => {
148
147
  await manager['startSpeakingWhileMutedDetection']();
149
- expect(manager['rnSpeechDetector']?.start).toHaveBeenCalled();
148
+ expect(subscribeMock).toHaveBeenCalled();
150
149
 
151
150
  expect(manager.state.speakingWhileMuted).toBe(false);
152
151
 
153
- handler!({ isSoundDetected: true, audioLevel: 2 });
152
+ speechActivityCallback!({ isSoundDetected: true });
154
153
 
155
154
  expect(manager.state.speakingWhileMuted).toBe(true);
156
155
 
157
- handler!({ isSoundDetected: false, audioLevel: 0 });
156
+ speechActivityCallback!({ isSoundDetected: false });
158
157
 
159
158
  expect(manager.state.speakingWhileMuted).toBe(false);
160
159
  });
@@ -163,21 +162,21 @@ describe('MicrophoneManager React Native', () => {
163
162
  await manager['startSpeakingWhileMutedDetection']('device-1');
164
163
  await manager['startSpeakingWhileMutedDetection']('device-1');
165
164
 
166
- expect(unsubscribeHandlers).toHaveLength(1);
165
+ expect(unsubscribeMocks).toHaveLength(1);
167
166
 
168
167
  await manager['stopSpeakingWhileMutedDetection']();
169
- expect(unsubscribeHandlers[0]).toHaveBeenCalledTimes(1);
168
+ expect(unsubscribeMocks[0]).toHaveBeenCalledTimes(1);
170
169
  });
171
170
 
172
171
  it('should cleanup previous speech detector before starting a new one', async () => {
173
172
  await manager['startSpeakingWhileMutedDetection']('device-1');
174
173
  await manager['startSpeakingWhileMutedDetection']('device-2');
175
174
 
176
- expect(unsubscribeHandlers).toHaveLength(2);
177
- expect(unsubscribeHandlers[0]).toHaveBeenCalledTimes(1);
175
+ expect(unsubscribeMocks).toHaveLength(2);
176
+ expect(unsubscribeMocks[0]).toHaveBeenCalledTimes(1);
178
177
 
179
178
  await manager['stopSpeakingWhileMutedDetection']();
180
- expect(unsubscribeHandlers[1]).toHaveBeenCalledTimes(1);
179
+ expect(unsubscribeMocks[1]).toHaveBeenCalledTimes(1);
181
180
  });
182
181
 
183
182
  it('should stop speaking while muted notifications if user loses permission to send audio', async () => {
@@ -34,7 +34,6 @@ describe('ScreenShareManager', () => {
34
34
  let manager: ScreenShareManager;
35
35
 
36
36
  beforeEach(() => {
37
- const devicePersistence = { enabled: false, storageKey: '' };
38
37
  manager = new ScreenShareManager(
39
38
  new Call({
40
39
  id: '',
@@ -42,7 +41,6 @@ describe('ScreenShareManager', () => {
42
41
  streamClient: new StreamClient('abc123'),
43
42
  clientStore: new StreamVideoWriteableStateStore(),
44
43
  }),
45
- devicePersistence,
46
44
  );
47
45
  });
48
46
 
@@ -340,7 +340,6 @@ export const getScreenShareStream = async (
340
340
  const tag = `navigator.mediaDevices.getDisplayMedia.${getDisplayMediaExecId++}.`;
341
341
  try {
342
342
  const constraints: DisplayMediaStreamOptions = {
343
- // @ts-expect-error - not present in types yet
344
343
  systemAudio: 'include',
345
344
  ...options,
346
345
  video:
@@ -357,6 +356,8 @@ export const getScreenShareStream = async (
357
356
  ? options.audio
358
357
  : {
359
358
  channelCount: { ideal: 2 },
359
+ // @ts-expect-error not yet present in the types
360
+ restrictOwnAudio: true,
360
361
  echoCancellation: false,
361
362
  autoGainControl: false,
362
363
  noiseSuppression: false,
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A generic sliding-window rate limiter.
3
+ *
4
+ * Allows at most `maxAttempts` registrations inside a rolling `windowMs`.
5
+ * Attempts spaced further apart than `windowMs` are always allowed.
6
+ */
7
+ export class SlidingWindowRateLimiter {
8
+ private maxAttempts: number;
9
+ private windowMs: number;
10
+ private timestamps: number[] = [];
11
+
12
+ constructor(maxAttempts: number, windowMs: number) {
13
+ this.maxAttempts = maxAttempts;
14
+ this.windowMs = windowMs;
15
+ }
16
+
17
+ /**
18
+ * Attempts to register a new event at `now`. Returns `true` if the attempt
19
+ * fits inside the budget (and records it), or `false` if the budget is
20
+ * exhausted (in which case no timestamp is recorded).
21
+ */
22
+ tryRegister = (now: number = Date.now()): boolean => {
23
+ this.prune(now);
24
+ if (this.timestamps.length >= this.maxAttempts) return false;
25
+ this.timestamps.push(now);
26
+ return true;
27
+ };
28
+
29
+ /**
30
+ * Clears the attempt history.
31
+ */
32
+ reset = (): void => {
33
+ this.timestamps = [];
34
+ };
35
+
36
+ /**
37
+ * Updates the budget and window size. Existing timestamps are kept; they
38
+ * will be pruned by the next `tryRegister` call.
39
+ */
40
+ setLimits = (maxAttempts: number, windowMs: number): void => {
41
+ this.maxAttempts = maxAttempts;
42
+ this.windowMs = windowMs;
43
+ };
44
+
45
+ private prune = (now: number): void => {
46
+ const cutoff = now - this.windowMs;
47
+ this.timestamps = this.timestamps.filter((t) => t >= cutoff);
48
+ };
49
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { SlidingWindowRateLimiter } from '../SlidingWindowRateLimiter';
3
+
4
+ describe('SlidingWindowRateLimiter', () => {
5
+ it('allows up to the configured number of attempts inside the window', () => {
6
+ const limiter = new SlidingWindowRateLimiter(3, 1000);
7
+ expect(limiter.tryRegister(0)).toBe(true);
8
+ expect(limiter.tryRegister(10)).toBe(true);
9
+ expect(limiter.tryRegister(20)).toBe(true);
10
+ // fourth attempt inside the same window is denied
11
+ expect(limiter.tryRegister(30)).toBe(false);
12
+ });
13
+
14
+ it('prunes timestamps outside the rolling window so attempts after it pass', () => {
15
+ const limiter = new SlidingWindowRateLimiter(2, 1000);
16
+ expect(limiter.tryRegister(0)).toBe(true);
17
+ expect(limiter.tryRegister(500)).toBe(true);
18
+ expect(limiter.tryRegister(900)).toBe(false);
19
+ // at t=1501 cutoff=501, so both 0 and 500 fall out of window
20
+ expect(limiter.tryRegister(1501)).toBe(true);
21
+ });
22
+
23
+ it('reset() clears the attempt history', () => {
24
+ const limiter = new SlidingWindowRateLimiter(2, 1000);
25
+ limiter.tryRegister(0);
26
+ limiter.tryRegister(100);
27
+ expect(limiter.tryRegister(200)).toBe(false);
28
+ limiter.reset();
29
+ expect(limiter.tryRegister(300)).toBe(true);
30
+ expect(limiter.tryRegister(400)).toBe(true);
31
+ expect(limiter.tryRegister(500)).toBe(false);
32
+ });
33
+
34
+ it('setLimits() updates the budget and window in place', () => {
35
+ const limiter = new SlidingWindowRateLimiter(2, 1000);
36
+ limiter.tryRegister(0);
37
+ limiter.tryRegister(100);
38
+ expect(limiter.tryRegister(200)).toBe(false);
39
+ // raising the limit lets the next attempt through without reset
40
+ limiter.setLimits(5, 1000);
41
+ expect(limiter.tryRegister(300)).toBe(true);
42
+ });
43
+ });
@@ -44,7 +44,6 @@ export const retryable = async <
44
44
  let result: FinishedUnaryCall<I, O> | undefined = undefined;
45
45
  do {
46
46
  if (attempt > 0) await sleep(retryInterval(attempt));
47
- if (signal?.aborted) throw new Error(signal.reason);
48
47
 
49
48
  try {
50
49
  result = await rpc({ attempt });
@@ -13,7 +13,12 @@ import { StreamSfuClient } from '../StreamSfuClient';
13
13
  import { AllSfuEvents, Dispatcher } from './Dispatcher';
14
14
  import { withoutConcurrency } from '../helpers/concurrency';
15
15
  import { StatsTracer, Tracer, traceRTCPeerConnection } from '../stats';
16
- import type { BasePeerConnectionOpts, OnReconnectionNeeded } from './types';
16
+ import {
17
+ BasePeerConnectionOpts,
18
+ OnIceConnected,
19
+ OnReconnectionNeeded,
20
+ ReconnectReason,
21
+ } from './types';
17
22
  import type { ClientPublishOptions } from '../types';
18
23
 
19
24
  /**
@@ -31,8 +36,11 @@ export abstract class BasePeerConnection {
31
36
  protected sfuClient: StreamSfuClient;
32
37
 
33
38
  private onReconnectionNeeded?: OnReconnectionNeeded;
39
+ private onIceConnected?: OnIceConnected;
34
40
  private readonly iceRestartDelay: number;
41
+ private iceHasEverConnected = false;
35
42
  private iceRestartTimeout?: NodeJS.Timeout;
43
+ private preConnectStuckTimeout?: NodeJS.Timeout;
36
44
  protected isIceRestarting = false;
37
45
  private isDisposed = false;
38
46
 
@@ -56,6 +64,7 @@ export abstract class BasePeerConnection {
56
64
  state,
57
65
  dispatcher,
58
66
  onReconnectionNeeded,
67
+ onIceConnected,
59
68
  tag,
60
69
  enableTracing,
61
70
  clientPublishOptions,
@@ -70,6 +79,7 @@ export abstract class BasePeerConnection {
70
79
  this.clientPublishOptions = clientPublishOptions;
71
80
  this.tag = tag;
72
81
  this.onReconnectionNeeded = onReconnectionNeeded;
82
+ this.onIceConnected = onIceConnected;
73
83
  this.logger = videoLoggerSystem.getLogger(
74
84
  peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher',
75
85
  { tags: [tag] },
@@ -108,7 +118,10 @@ export abstract class BasePeerConnection {
108
118
  dispose() {
109
119
  clearTimeout(this.iceRestartTimeout);
110
120
  this.iceRestartTimeout = undefined;
121
+ clearTimeout(this.preConnectStuckTimeout);
122
+ this.preConnectStuckTimeout = undefined;
111
123
  this.onReconnectionNeeded = undefined;
124
+ this.onIceConnected = undefined;
112
125
  this.isDisposed = true;
113
126
  this.detachEventHandlers();
114
127
  this.pc.close();
@@ -145,14 +158,17 @@ export abstract class BasePeerConnection {
145
158
  */
146
159
  protected tryRestartIce = () => {
147
160
  this.restartIce().catch((e) => {
148
- const reason = 'restartICE() failed, initiating reconnect';
149
- this.logger.error(reason, e);
161
+ this.logger.error('restartICE() failed, initiating reconnect', e);
150
162
  const strategy =
151
163
  e instanceof NegotiationError &&
152
164
  e.error.code === ErrorCode.PARTICIPANT_SIGNAL_LOST
153
165
  ? WebsocketReconnectStrategy.FAST
154
166
  : WebsocketReconnectStrategy.REJOIN;
155
- this.onReconnectionNeeded?.(strategy, reason, this.peerType);
167
+ this.onReconnectionNeeded?.(
168
+ strategy,
169
+ ReconnectReason.RESTART_ICE_FAILED,
170
+ this.peerType,
171
+ );
156
172
  });
157
173
  };
158
174
 
@@ -239,6 +255,20 @@ export abstract class BasePeerConnection {
239
255
  return !failedStates.has(iceState) && !failedStates.has(connectionState);
240
256
  };
241
257
 
258
+ /**
259
+ * Returns true only when the peer connection is currently fully established
260
+ * (ICE `connected`/`completed` AND connection state `connected`).
261
+ * Transient states like `disconnected`, `checking`, or `new` return false.
262
+ */
263
+ isStable = () => {
264
+ const iceState = this.pc.iceConnectionState;
265
+ const connectionState = this.pc.connectionState;
266
+ return (
267
+ (iceState === 'connected' || iceState === 'completed') &&
268
+ connectionState === 'connected'
269
+ );
270
+ };
271
+
242
272
  /**
243
273
  * Handles the ICECandidate event and
244
274
  * Initiates an ICE Trickle process with the SFU.
@@ -292,7 +322,7 @@ export abstract class BasePeerConnection {
292
322
  if (state === 'failed') {
293
323
  this.onReconnectionNeeded?.(
294
324
  WebsocketReconnectStrategy.REJOIN,
295
- 'Connection failed',
325
+ ReconnectReason.CONNECTION_FAILED,
296
326
  this.peerType,
297
327
  );
298
328
  return;
@@ -320,6 +350,54 @@ export abstract class BasePeerConnection {
320
350
  // do nothing when ICE is restarting
321
351
  if (this.isIceRestarting) return;
322
352
 
353
+ // Pre-connect handling: ICE has never reached `connected`/`completed`.
354
+ // Restart is futile here (the data plane was never established), but
355
+ // these two terminal-ish states need different treatment:
356
+ // - `failed` is terminal, escalate to REJOIN so a new SFU/credentials
357
+ // /PC configuration gets a chance, and let `Call.reconnect` count
358
+ // this toward the unsupported-network budget.
359
+ // - `disconnected` is transient, the browser may yet move back to
360
+ // `checking`/`connected`. Don't restart, don't escalate; wait it
361
+ // out. If it ultimately fails, ICE will transition to `failed` and
362
+ // the branch above will take over.
363
+ if (!this.iceHasEverConnected) {
364
+ if (state === 'failed') {
365
+ this.logger.info('ICE failed before connected, escalating to REJOIN');
366
+ clearTimeout(this.preConnectStuckTimeout);
367
+ this.preConnectStuckTimeout = undefined;
368
+ this.onReconnectionNeeded?.(
369
+ WebsocketReconnectStrategy.REJOIN,
370
+ ReconnectReason.ICE_NEVER_CONNECTED,
371
+ this.peerType,
372
+ );
373
+ return;
374
+ }
375
+ if (state === 'disconnected') {
376
+ this.logger.info('ICE disconnected before connected, wait to recover');
377
+ // Watchdog: if the browser stays in `disconnected` without ever
378
+ // reaching `connected` or transitioning to `failed`, escalate to
379
+ // REJOIN ourselves so we don't wait silently forever. Rare but
380
+ // observed on flaky mobile networks.
381
+ clearTimeout(this.preConnectStuckTimeout);
382
+ this.preConnectStuckTimeout = setTimeout(() => {
383
+ if (
384
+ !this.iceHasEverConnected &&
385
+ this.pc.iceConnectionState === 'disconnected'
386
+ ) {
387
+ this.logger.info(
388
+ 'ICE stuck in pre-connect disconnected, escalating to REJOIN',
389
+ );
390
+ this.onReconnectionNeeded?.(
391
+ WebsocketReconnectStrategy.REJOIN,
392
+ ReconnectReason.ICE_NEVER_CONNECTED,
393
+ this.peerType,
394
+ );
395
+ }
396
+ }, this.iceRestartDelay * 2);
397
+ return;
398
+ }
399
+ }
400
+
323
401
  switch (state) {
324
402
  case 'failed':
325
403
  // in the `failed` state, we try to restart ICE immediately
@@ -341,12 +419,24 @@ export abstract class BasePeerConnection {
341
419
  break;
342
420
 
343
421
  case 'connected':
344
- // in the `connected` state, we clear the ice restart timeout if it exists
422
+ case 'completed':
423
+ // Fire `onIceConnected` exactly once per peer-connection lifetime —
424
+ // the first time ICE reaches `connected`/`completed` end-to-end.
425
+ // Used by `Call` to reset the unsupported-network failure counter
426
+ // only after WebRTC has actually recovered, not merely on SFU join.
427
+ if (!this.iceHasEverConnected) {
428
+ this.iceHasEverConnected = true;
429
+ this.onIceConnected?.(this.peerType);
430
+ }
431
+ // clear any scheduled restartICE since the connection is healthy
345
432
  if (this.iceRestartTimeout) {
346
433
  this.logger.info('connected connection, canceling restartICE');
347
434
  clearTimeout(this.iceRestartTimeout);
348
435
  this.iceRestartTimeout = undefined;
349
436
  }
437
+ // clear the pre-connect watchdog if it was armed
438
+ clearTimeout(this.preConnectStuckTimeout);
439
+ this.preConnectStuckTimeout = undefined;
350
440
  break;
351
441
  }
352
442
  };
@@ -460,7 +460,8 @@ export class Publisher extends BasePeerConnection {
460
460
  const trackInfos: TrackInfo[] = [];
461
461
  for (const publishOption of this.publishOptions) {
462
462
  const bundle = this.transceiverCache.get(publishOption);
463
- if (!bundle || !bundle.transceiver.sender.track) continue;
463
+ const track = bundle?.transceiver.sender.track;
464
+ if (!bundle || !track || track.readyState !== 'live') continue;
464
465
  trackInfos.push(this.toTrackInfo(bundle, sdp));
465
466
  }
466
467
  return trackInfos;