@stream-io/video-client 1.48.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.
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { anyString } from 'vitest-mock-extended';
5
5
  import { NegotiationError } from '../NegotiationError';
6
6
  import { Publisher } from '../Publisher';
7
+ import { ReconnectReason } from '../types';
7
8
  import { CallState } from '../../store';
8
9
  import { StreamSfuClient } from '../../StreamSfuClient';
9
10
  import { DispatchableMessage, Dispatcher } from '../Dispatcher';
@@ -87,6 +88,7 @@ describe('Publisher', () => {
87
88
  });
88
89
 
89
90
  afterEach(() => {
91
+ vi.useRealTimers();
90
92
  vi.clearAllMocks();
91
93
  vi.resetModules();
92
94
  publisher.dispose();
@@ -250,7 +252,14 @@ describe('Publisher', () => {
250
252
  expect(publisher['negotiate']).toHaveBeenCalled();
251
253
  });
252
254
 
255
+ const simulatePriorIceConnected = () => {
256
+ // @ts-expect-error private api
257
+ publisher['pc'].iceConnectionState = 'connected';
258
+ publisher['onIceConnectionStateChange']();
259
+ };
260
+
253
261
  it(`should perform ICE restart when connection state changes to 'failed'`, () => {
262
+ simulatePriorIceConnected();
254
263
  vi.spyOn(publisher, 'restartIce').mockResolvedValue();
255
264
  // @ts-expect-error private api
256
265
  publisher['pc'].iceConnectionState = 'failed';
@@ -259,6 +268,7 @@ describe('Publisher', () => {
259
268
  });
260
269
 
261
270
  it(`should perform rejoin when ICE restart fails after connection state changes to 'failed'`, async () => {
271
+ simulatePriorIceConnected();
262
272
  const { promise: lock, resolve: unlock } = promiseWithResolvers<void>();
263
273
  publisher['onReconnectionNeeded'] = vi
264
274
  .fn()
@@ -274,6 +284,7 @@ describe('Publisher', () => {
274
284
  });
275
285
 
276
286
  it(`should perform fast reconnect when ICE restart fails with SIGNAL_LOST error`, async () => {
287
+ simulatePriorIceConnected();
277
288
  const { promise: lock, resolve: unlock } = promiseWithResolvers<void>();
278
289
  publisher['onReconnectionNeeded'] = vi
279
290
  .fn()
@@ -325,6 +336,7 @@ describe('Publisher', () => {
325
336
  });
326
337
 
327
338
  it(`should perform REJOIN reconnect when ICE restart fails with any other error code`, async () => {
339
+ simulatePriorIceConnected();
328
340
  const { promise: lock, resolve: unlock } = promiseWithResolvers<void>();
329
341
  publisher['onReconnectionNeeded'] = vi
330
342
  .fn()
@@ -365,6 +377,7 @@ describe('Publisher', () => {
365
377
  });
366
378
 
367
379
  it(`should schedule ICE restart when connection state changes to 'disconnected'`, () => {
380
+ simulatePriorIceConnected();
368
381
  vi.spyOn(publisher, 'restartIce').mockResolvedValue();
369
382
  vi.useFakeTimers();
370
383
  // @ts-expect-error private api
@@ -376,6 +389,7 @@ describe('Publisher', () => {
376
389
  });
377
390
 
378
391
  it(`should perform rejoin when scheduled ICE restart fails`, async () => {
392
+ simulatePriorIceConnected();
379
393
  vi.spyOn(publisher, 'restartIce').mockRejectedValue('ICE restart failed');
380
394
  const { promise: lock, resolve } = promiseWithResolvers<void>();
381
395
  publisher['onReconnectionNeeded'] = vi
@@ -393,6 +407,202 @@ describe('Publisher', () => {
393
407
  expect(publisher['onReconnectionNeeded']).toHaveBeenCalled();
394
408
  });
395
409
 
410
+ it(`iceHasEverConnected is false before any connected state is observed`, () => {
411
+ expect(publisher['iceHasEverConnected']).toBe(false);
412
+ });
413
+
414
+ it(`iceHasEverConnected becomes true after 'connected' ICE state`, () => {
415
+ // @ts-expect-error private api
416
+ publisher['pc'].iceConnectionState = 'connected';
417
+ publisher['onIceConnectionStateChange']();
418
+ expect(publisher['iceHasEverConnected']).toBe(true);
419
+ });
420
+
421
+ it(`iceHasEverConnected also flips on 'completed' ICE state`, () => {
422
+ // @ts-expect-error private api
423
+ publisher['pc'].iceConnectionState = 'completed';
424
+ publisher['onIceConnectionStateChange']();
425
+ expect(publisher['iceHasEverConnected']).toBe(true);
426
+ });
427
+
428
+ it(`does NOT call restartIce when ICE never connected and state goes to 'failed' — emits REJOIN with 'ice_never_connected'`, () => {
429
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
430
+ publisher['onReconnectionNeeded'] = vi.fn();
431
+ // @ts-expect-error private api
432
+ publisher['pc'].iceConnectionState = 'failed';
433
+ publisher['onIceConnectionStateChange']();
434
+ expect(publisher.restartIce).not.toHaveBeenCalled();
435
+ expect(publisher['onReconnectionNeeded']).toHaveBeenCalledWith(
436
+ WebsocketReconnectStrategy.REJOIN,
437
+ ReconnectReason.ICE_NEVER_CONNECTED,
438
+ PeerType.PUBLISHER_UNSPECIFIED,
439
+ );
440
+ });
441
+
442
+ it(`pre-connect 'disconnected' does not restart or escalate immediately`, () => {
443
+ // ICE has never reached `connected`. A `disconnected` transition at
444
+ // this point is just the browser's checking phase wobbling; the
445
+ // browser may yet move back to checking/connected. The SDK should
446
+ // wait it out — no synchronous restart, no synchronous REJOIN. Only
447
+ // a terminal `failed` before connect, or the pre-connect watchdog
448
+ // expiring, should escalate via `ICE_NEVER_CONNECTED`.
449
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
450
+ publisher['onReconnectionNeeded'] = vi.fn();
451
+ // @ts-expect-error private api
452
+ publisher['pc'].iceConnectionState = 'disconnected';
453
+ publisher['onIceConnectionStateChange']();
454
+ expect(publisher.restartIce).not.toHaveBeenCalled();
455
+ expect(publisher['onReconnectionNeeded']).not.toHaveBeenCalled();
456
+ });
457
+
458
+ it(`pre-connect 'disconnected' watchdog escalates to REJOIN if state stays stuck`, () => {
459
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
460
+ publisher['onReconnectionNeeded'] = vi.fn();
461
+ vi.useFakeTimers();
462
+ const watchdogMs = publisher['iceRestartDelay'] * 2;
463
+
464
+ // @ts-expect-error private api
465
+ publisher['pc'].iceConnectionState = 'disconnected';
466
+ publisher['onIceConnectionStateChange']();
467
+ // before the watchdog fires, no escalation
468
+ vi.advanceTimersByTime(watchdogMs - 1);
469
+ expect(publisher['onReconnectionNeeded']).not.toHaveBeenCalled();
470
+
471
+ // watchdog fires; still stuck in disconnected → escalate
472
+ vi.advanceTimersByTime(2);
473
+ expect(publisher.restartIce).not.toHaveBeenCalled();
474
+ expect(publisher['onReconnectionNeeded']).toHaveBeenCalledWith(
475
+ WebsocketReconnectStrategy.REJOIN,
476
+ ReconnectReason.ICE_NEVER_CONNECTED,
477
+ PeerType.PUBLISHER_UNSPECIFIED,
478
+ );
479
+ });
480
+
481
+ it(`pre-connect 'disconnected' watchdog is canceled when ICE recovers to 'connected'`, () => {
482
+ publisher['onReconnectionNeeded'] = vi.fn();
483
+ vi.useFakeTimers();
484
+ const watchdogMs = publisher['iceRestartDelay'] * 2;
485
+
486
+ // @ts-expect-error private api
487
+ publisher['pc'].iceConnectionState = 'disconnected';
488
+ publisher['onIceConnectionStateChange']();
489
+ // recover before the watchdog window expires
490
+ // @ts-expect-error private api
491
+ publisher['pc'].iceConnectionState = 'connected';
492
+ publisher['onIceConnectionStateChange']();
493
+
494
+ // advance past the original watchdog window — must NOT fire now
495
+ vi.advanceTimersByTime(watchdogMs + 100);
496
+
497
+ expect(publisher['iceHasEverConnected']).toBe(true);
498
+ expect(publisher['onReconnectionNeeded']).not.toHaveBeenCalled();
499
+ });
500
+
501
+ it(`pre-connect 'disconnected' that recovers to 'connected' continues normally`, () => {
502
+ publisher['onReconnectionNeeded'] = vi.fn();
503
+ // @ts-expect-error private api
504
+ publisher['pc'].iceConnectionState = 'disconnected';
505
+ publisher['onIceConnectionStateChange']();
506
+ // browser now reaches connected — the normal connected branch runs
507
+ // and `iceHasEverConnected` flips to true.
508
+ // @ts-expect-error private api
509
+ publisher['pc'].iceConnectionState = 'connected';
510
+ publisher['onIceConnectionStateChange']();
511
+ expect(publisher['iceHasEverConnected']).toBe(true);
512
+ expect(publisher['onReconnectionNeeded']).not.toHaveBeenCalled();
513
+ });
514
+
515
+ it(`isStable() returns false when ICE is 'new'`, () => {
516
+ // @ts-expect-error private api
517
+ publisher['pc'].iceConnectionState = 'new';
518
+ // default connectionState in mock is 'connected'
519
+ expect(publisher.isStable()).toBe(false);
520
+ });
521
+
522
+ it(`isStable() returns true when ICE is 'connected' and connectionState is 'connected'`, () => {
523
+ // @ts-expect-error private api
524
+ publisher['pc'].iceConnectionState = 'connected';
525
+ // @ts-expect-error private api
526
+ publisher['pc'].connectionState = 'connected';
527
+ expect(publisher.isStable()).toBe(true);
528
+ });
529
+
530
+ it(`isStable() returns true when ICE is 'completed' and connectionState is 'connected'`, () => {
531
+ // @ts-expect-error private api
532
+ publisher['pc'].iceConnectionState = 'completed';
533
+ // @ts-expect-error private api
534
+ publisher['pc'].connectionState = 'connected';
535
+ expect(publisher.isStable()).toBe(true);
536
+ });
537
+
538
+ it(`isStable() returns false when ICE is 'disconnected'`, () => {
539
+ // @ts-expect-error private api
540
+ publisher['pc'].iceConnectionState = 'disconnected';
541
+ // @ts-expect-error private api
542
+ publisher['pc'].connectionState = 'connected';
543
+ expect(publisher.isStable()).toBe(false);
544
+ });
545
+
546
+ it(`after connected→disconnected→connected cycle, subsequent 'failed' DOES trigger ICE restart (flag stays true)`, () => {
547
+ // @ts-expect-error private api
548
+ publisher['pc'].iceConnectionState = 'connected';
549
+ publisher['onIceConnectionStateChange']();
550
+ // @ts-expect-error private api
551
+ publisher['pc'].iceConnectionState = 'disconnected';
552
+ publisher['onIceConnectionStateChange']();
553
+ // @ts-expect-error private api
554
+ publisher['pc'].iceConnectionState = 'connected';
555
+ publisher['onIceConnectionStateChange']();
556
+
557
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
558
+ publisher['onReconnectionNeeded'] = vi.fn();
559
+ // @ts-expect-error private api
560
+ publisher['pc'].iceConnectionState = 'failed';
561
+ publisher['onIceConnectionStateChange']();
562
+ expect(publisher.restartIce).toHaveBeenCalled();
563
+ // the reason here is the regular restart path, NOT 'ice_never_connected'
564
+ expect(publisher['onReconnectionNeeded']).not.toHaveBeenCalledWith(
565
+ WebsocketReconnectStrategy.REJOIN,
566
+ ReconnectReason.ICE_NEVER_CONNECTED,
567
+ PeerType.PUBLISHER_UNSPECIFIED,
568
+ );
569
+ });
570
+
571
+ it(`connection-state 'failed' (distinct from ICE state) still fires REJOIN even after ICE was connected`, () => {
572
+ // mark ICE as connected first
573
+ // @ts-expect-error private api
574
+ publisher['pc'].iceConnectionState = 'connected';
575
+ publisher['onIceConnectionStateChange']();
576
+
577
+ publisher['onReconnectionNeeded'] = vi.fn();
578
+ // @ts-expect-error private api
579
+ publisher['pc'].connectionState = 'failed';
580
+ publisher['onConnectionStateChange']();
581
+ expect(publisher['onReconnectionNeeded']).toHaveBeenCalledWith(
582
+ WebsocketReconnectStrategy.REJOIN,
583
+ ReconnectReason.CONNECTION_FAILED,
584
+ PeerType.PUBLISHER_UNSPECIFIED,
585
+ );
586
+ });
587
+
588
+ it(`'completed' state is treated as connectivity — subsequent 'failed' DOES trigger ICE restart`, () => {
589
+ // @ts-expect-error private api
590
+ publisher['pc'].iceConnectionState = 'completed';
591
+ publisher['onIceConnectionStateChange']();
592
+
593
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
594
+ publisher['onReconnectionNeeded'] = vi.fn();
595
+ // @ts-expect-error private api
596
+ publisher['pc'].iceConnectionState = 'failed';
597
+ publisher['onIceConnectionStateChange']();
598
+ expect(publisher.restartIce).toHaveBeenCalled();
599
+ expect(publisher['onReconnectionNeeded']).not.toHaveBeenCalledWith(
600
+ WebsocketReconnectStrategy.REJOIN,
601
+ ReconnectReason.ICE_NEVER_CONNECTED,
602
+ PeerType.PUBLISHER_UNSPECIFIED,
603
+ );
604
+ });
605
+
396
606
  it(`should schedule ICE restart but cancel it if connection recovers in the meantime`, () => {
397
607
  vi.spyOn(publisher, 'restartIce').mockResolvedValue();
398
608
  vi.useFakeTimers();
@@ -11,8 +11,10 @@ import {
11
11
  ErrorCode,
12
12
  PeerType,
13
13
  TrackType,
14
+ WebsocketReconnectStrategy,
14
15
  } from '../../gen/video/sfu/models/models';
15
16
  import { NegotiationError } from '../NegotiationError';
17
+ import { ReconnectReason } from '../types';
16
18
  import { IceTrickleBuffer } from '../IceTrickleBuffer';
17
19
  import { StreamClient } from '../../coordinator/connection/client';
18
20
 
@@ -62,6 +64,7 @@ describe('Subscriber', () => {
62
64
  });
63
65
 
64
66
  afterEach(() => {
67
+ vi.useRealTimers();
65
68
  vi.clearAllMocks();
66
69
  vi.resetModules();
67
70
  subscriber.dispose();
@@ -97,7 +100,14 @@ describe('Subscriber', () => {
97
100
  });
98
101
  });
99
102
 
103
+ const simulatePriorIceConnected = () => {
104
+ // @ts-expect-error - private field
105
+ subscriber['pc'].iceConnectionState = 'connected';
106
+ subscriber['onIceConnectionStateChange']();
107
+ };
108
+
100
109
  it(`should perform ICE restart when connection state changes to 'failed'`, () => {
110
+ simulatePriorIceConnected();
101
111
  vi.spyOn(subscriber, 'restartIce').mockResolvedValue();
102
112
  // @ts-expect-error - private field
103
113
  subscriber['pc'].iceConnectionState = 'failed';
@@ -106,6 +116,7 @@ describe('Subscriber', () => {
106
116
  });
107
117
 
108
118
  it(`should perform ICE restart when connection state changes to 'disconnected'`, () => {
119
+ simulatePriorIceConnected();
109
120
  vi.spyOn(subscriber, 'restartIce').mockResolvedValue();
110
121
  vi.useFakeTimers();
111
122
  // @ts-expect-error - private field
@@ -115,6 +126,51 @@ describe('Subscriber', () => {
115
126
  expect(subscriber.restartIce).toHaveBeenCalled();
116
127
  });
117
128
 
129
+ it(`does NOT perform ICE restart when ICE never connected and state goes to 'failed' — emits REJOIN with 'ice_never_connected'`, () => {
130
+ vi.spyOn(subscriber, 'restartIce').mockResolvedValue();
131
+ subscriber['onReconnectionNeeded'] = vi.fn();
132
+ // @ts-expect-error - private field
133
+ subscriber['pc'].iceConnectionState = 'failed';
134
+ subscriber['onIceConnectionStateChange']();
135
+ expect(subscriber.restartIce).not.toHaveBeenCalled();
136
+ expect(subscriber['onReconnectionNeeded']).toHaveBeenCalledWith(
137
+ WebsocketReconnectStrategy.REJOIN,
138
+ ReconnectReason.ICE_NEVER_CONNECTED,
139
+ PeerType.SUBSCRIBER,
140
+ );
141
+ });
142
+
143
+ it(`isStable() returns true only when ICE is connected/completed and connectionState is connected`, () => {
144
+ // @ts-expect-error - private field
145
+ subscriber['pc'].iceConnectionState = 'connected';
146
+ // @ts-expect-error - private field
147
+ subscriber['pc'].connectionState = 'connected';
148
+ expect(subscriber.isStable()).toBe(true);
149
+
150
+ // @ts-expect-error - private field
151
+ subscriber['pc'].iceConnectionState = 'completed';
152
+ expect(subscriber.isStable()).toBe(true);
153
+
154
+ // @ts-expect-error - private field
155
+ subscriber['pc'].iceConnectionState = 'disconnected';
156
+ expect(subscriber.isStable()).toBe(false);
157
+
158
+ // @ts-expect-error - private field
159
+ subscriber['pc'].iceConnectionState = 'new';
160
+ expect(subscriber.isStable()).toBe(false);
161
+ });
162
+
163
+ it(`iceHasEverConnected tracks lifetime connectivity`, () => {
164
+ expect(subscriber['iceHasEverConnected']).toBe(false);
165
+ simulatePriorIceConnected();
166
+ expect(subscriber['iceHasEverConnected']).toBe(true);
167
+ // going disconnected does not reset the flag
168
+ // @ts-expect-error - private field
169
+ subscriber['pc'].iceConnectionState = 'disconnected';
170
+ subscriber['onIceConnectionStateChange']();
171
+ expect(subscriber['iceHasEverConnected']).toBe(true);
172
+ });
173
+
118
174
  it(`should throw NegotiationError when SFU returns an error`, async () => {
119
175
  sfuClient.iceRestart = vi.fn().mockResolvedValue({
120
176
  response: {
package/src/rtc/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './codecs';
2
2
  export * from './Dispatcher';
3
+ export * from './NegotiationError';
3
4
  export * from './IceTrickleBuffer';
4
5
  export * from './Publisher';
5
6
  export * from './Subscriber';
package/src/rtc/types.ts CHANGED
@@ -10,18 +10,55 @@ import { Dispatcher } from './Dispatcher';
10
10
  import type { OptimalVideoLayer } from './layers';
11
11
  import type { ClientPublishOptions } from '../types';
12
12
 
13
+ /**
14
+ * Canonical reasons the SDK uses to trigger a reconnection. Free-form strings
15
+ * are still accepted at the callback boundary (e.g. when forwarding an SFU
16
+ * error message), but only the members below influence reconnect-loop
17
+ * behavior. In particular, `Call.reconnect` programmatically inspects
18
+ * `ICE_NEVER_CONNECTED` to drive the unsupported-network detector — pass a
19
+ * canonical member when you want the SDK to react to the reason; pass a
20
+ * free-form string when the value is purely diagnostic.
21
+ */
22
+ export const ReconnectReason = {
23
+ /** ICE never reached `connected`/`completed`, escalate to REJOIN. */
24
+ ICE_NEVER_CONNECTED: 'ice_never_connected',
25
+ /** RTCPeerConnection.connectionState became `failed`. */
26
+ CONNECTION_FAILED: 'connection_failed',
27
+ /** `restartIce()` rejected. */
28
+ RESTART_ICE_FAILED: 'restart_ice_failed',
29
+ /** SFU `goAway` event, migrate to a new SFU. */
30
+ GO_AWAY: 'go_away',
31
+ /** Network came back online after going offline. */
32
+ NETWORK_BACK_ONLINE: 'network_back_online',
33
+ /** SFU error event with no descriptive message. */
34
+ SFU_ERROR: 'sfu_error',
35
+ } as const;
36
+
37
+ export type ReconnectReason =
38
+ | (typeof ReconnectReason)[keyof typeof ReconnectReason]
39
+ | (string & {});
40
+
13
41
  export type OnReconnectionNeeded = (
14
42
  kind: WebsocketReconnectStrategy,
15
- reason: string,
43
+ reason: ReconnectReason,
16
44
  peerType: PeerType,
17
45
  ) => void;
18
46
 
47
+ /**
48
+ * Fires the first time a peer connection's ICE transport reaches
49
+ * `connected` or `completed` during its lifetime. Used by `Call` to reset
50
+ * the "ICE never connected" failure counter only when WebRTC has actually
51
+ * recovered, not merely when the SFU join handshake succeeded.
52
+ */
53
+ export type OnIceConnected = (peerType: PeerType) => void;
54
+
19
55
  export type BasePeerConnectionOpts = {
20
56
  sfuClient: StreamSfuClient;
21
57
  state: CallState;
22
58
  connectionConfig?: RTCConfiguration;
23
59
  dispatcher: Dispatcher;
24
60
  onReconnectionNeeded?: OnReconnectionNeeded;
61
+ onIceConnected?: OnIceConnected;
25
62
  tag: string;
26
63
  enableTracing: boolean;
27
64
  iceRestartDelay?: number;