@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.
@@ -92,6 +92,11 @@ export declare class Call {
92
92
  private disconnectionTimeoutSeconds;
93
93
  private lastOfflineTimestamp;
94
94
  private networkAvailableTask;
95
+ private readonly rejoinRateLimiter;
96
+ private maxIceFailuresWithoutConnect;
97
+ private iceFailuresWithoutConnect;
98
+ private maxConsecutiveNegotiationFailures;
99
+ private consecutiveNegotiationFailures;
95
100
  private trackPublishOrder;
96
101
  private joinResponseTimeout?;
97
102
  private rpcRequestTimeout?;
@@ -298,7 +303,9 @@ export declare class Call {
298
303
  * @internal
299
304
  *
300
305
  * @param strategy the reconnection strategy to use.
301
- * @param reason the reason for the reconnection.
306
+ * @param reason the reason for the reconnection. Pass a `ReconnectReason.*`
307
+ * constant when the SDK should react to it (e.g.
308
+ * `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
302
309
  */
303
310
  private reconnect;
304
311
  /**
@@ -839,6 +846,33 @@ export declare class Call {
839
846
  * @param timeoutSeconds Timeout in seconds, or 0 to keep reconnecting indefinetely
840
847
  */
841
848
  setDisconnectionTimeout: (timeoutSeconds: number) => void;
849
+ /**
850
+ * Configures the rolling-window limit for REJOIN/MIGRATE attempts. Once
851
+ * `maxAttempts` rejoins have been registered inside `windowSeconds`, the
852
+ * SDK stops retrying and transitions the call to `LEFT` with the
853
+ * `rejoin_attempt_limit_exceeded` leave message.
854
+ *
855
+ * Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
856
+ * Both arguments are clamped to a minimum of 1.
857
+ */
858
+ setRejoinAttemptLimit: (maxAttempts: number, windowSeconds: number) => void;
859
+ /**
860
+ * Configures how many peer-connection failures where ICE never reached
861
+ * `connected`/`completed` are tolerated before the SDK concludes that the
862
+ * current network cannot support WebRTC and transitions the call to
863
+ * `LEFT` with the `webrtc_unsupported_network` leave message.
864
+ *
865
+ * Default: 2. Clamped to a minimum of 1.
866
+ */
867
+ setMaxIceFailuresWithoutConnect: (n: number) => void;
868
+ /**
869
+ * Configures how many consecutive SDP `NegotiationError`s are tolerated
870
+ * before the SDK stops retrying and transitions the call to `LEFT` with
871
+ * the `repeated_negotiation_failures` leave message.
872
+ *
873
+ * Default: 3. Clamped to a minimum of 1.
874
+ */
875
+ setMaxConsecutiveNegotiationFailures: (n: number) => void;
842
876
  /**
843
877
  * Enables the provided client capabilities.
844
878
  */
@@ -107,7 +107,8 @@ export declare class StreamSfuClient {
107
107
  private networkAvailableTask;
108
108
  /**
109
109
  * Promise that resolves when the JoinResponse is received.
110
- * Rejects after a certain threshold if the response is not received.
110
+ * Rejects after a certain threshold if the response is not received,
111
+ * or when the SFU client is disposed before a join completes.
111
112
  */
112
113
  private joinResponseTask;
113
114
  /**
@@ -140,6 +141,12 @@ export declare class StreamSfuClient {
140
141
  * The close code used when the client fails to join the call (on the SFU).
141
142
  */
142
143
  static JOIN_FAILED: number;
144
+ /**
145
+ * Best-effort grace period in `leaveAndClose` for an in-flight join to
146
+ * complete before we give up and close without sending `leaveCallRequest`.
147
+ * Bounded so a stuck join can never hang the leave path.
148
+ */
149
+ static LEAVE_NOTIFY_GRACE_MS: number;
143
150
  /**
144
151
  * Constructs a new SFU client.
145
152
  */
@@ -6,6 +6,7 @@ export declare abstract class DeviceManagerState<C = MediaTrackConstraints> {
6
6
  protected statusSubject: BehaviorSubject<InputDeviceStatus>;
7
7
  protected optimisticStatusSubject: BehaviorSubject<InputDeviceStatus>;
8
8
  protected mediaStreamSubject: BehaviorSubject<MediaStream | undefined>;
9
+ protected rootMediaStreamSubject: BehaviorSubject<MediaStream | undefined>;
9
10
  protected selectedDeviceSubject: BehaviorSubject<string | undefined>;
10
11
  protected defaultConstraintsSubject: BehaviorSubject<C | undefined>;
11
12
  /**
@@ -17,6 +18,12 @@ export declare abstract class DeviceManagerState<C = MediaTrackConstraints> {
17
18
  *
18
19
  */
19
20
  mediaStream$: Observable<MediaStream | undefined>;
21
+ /**
22
+ * An Observable that emits the raw device media stream (before any filters are applied),
23
+ * or `undefined` if the device is currently disabled. When no filters are active, this
24
+ * emits the same stream as `mediaStream$`.
25
+ */
26
+ rootMediaStream$: Observable<MediaStream | undefined>;
20
27
  /**
21
28
  * An Observable that emits the currently selected device
22
29
  */
@@ -73,6 +80,12 @@ export declare abstract class DeviceManagerState<C = MediaTrackConstraints> {
73
80
  * The current media stream, or `undefined` if the device is currently disabled.
74
81
  */
75
82
  get mediaStream(): MediaStream | undefined;
83
+ /**
84
+ * The raw device media stream (before any filters are applied), or `undefined`
85
+ * if the device is currently disabled. When no filters are active, this is the
86
+ * same as `mediaStream`.
87
+ */
88
+ get rootMediaStream(): MediaStream | undefined;
76
89
  /**
77
90
  * @internal
78
91
  * @param status
@@ -0,0 +1,28 @@
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 declare class SlidingWindowRateLimiter {
8
+ private maxAttempts;
9
+ private windowMs;
10
+ private timestamps;
11
+ constructor(maxAttempts: number, windowMs: number);
12
+ /**
13
+ * Attempts to register a new event at `now`. Returns `true` if the attempt
14
+ * fits inside the budget (and records it), or `false` if the budget is
15
+ * exhausted (in which case no timestamp is recorded).
16
+ */
17
+ tryRegister: (now?: number) => boolean;
18
+ /**
19
+ * Clears the attempt history.
20
+ */
21
+ reset: () => void;
22
+ /**
23
+ * Updates the budget and window size. Existing timestamps are kept; they
24
+ * will be pruned by the next `tryRegister` call.
25
+ */
26
+ setLimits: (maxAttempts: number, windowMs: number) => void;
27
+ private prune;
28
+ }
@@ -5,7 +5,7 @@ import { PeerType, TrackType } from '../gen/video/sfu/models/models';
5
5
  import { StreamSfuClient } from '../StreamSfuClient';
6
6
  import { AllSfuEvents, Dispatcher } from './Dispatcher';
7
7
  import { StatsTracer, Tracer } from '../stats';
8
- import type { BasePeerConnectionOpts } from './types';
8
+ import { BasePeerConnectionOpts } from './types';
9
9
  import type { ClientPublishOptions } from '../types';
10
10
  /**
11
11
  * A base class for the `Publisher` and `Subscriber` classes.
@@ -21,8 +21,11 @@ export declare abstract class BasePeerConnection {
21
21
  protected tag: string;
22
22
  protected sfuClient: StreamSfuClient;
23
23
  private onReconnectionNeeded?;
24
+ private onIceConnected?;
24
25
  private readonly iceRestartDelay;
26
+ private iceHasEverConnected;
25
27
  private iceRestartTimeout?;
28
+ private preConnectStuckTimeout?;
26
29
  protected isIceRestarting: boolean;
27
30
  private isDisposed;
28
31
  protected trackIdToTrackType: Map<string, TrackType>;
@@ -34,7 +37,7 @@ export declare abstract class BasePeerConnection {
34
37
  /**
35
38
  * Constructs a new `BasePeerConnection` instance.
36
39
  */
37
- protected constructor(peerType: PeerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, tag, enableTracing, clientPublishOptions, iceRestartDelay, }: BasePeerConnectionOpts);
40
+ protected constructor(peerType: PeerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, tag, enableTracing, clientPublishOptions, iceRestartDelay, }: BasePeerConnectionOpts);
38
41
  private createPeerConnection;
39
42
  /**
40
43
  * Disposes the `RTCPeerConnection` instance.
@@ -85,6 +88,12 @@ export declare abstract class BasePeerConnection {
85
88
  * it returns `false`, otherwise it returns `true`.
86
89
  */
87
90
  isHealthy: () => boolean;
91
+ /**
92
+ * Returns true only when the peer connection is currently fully established
93
+ * (ICE `connected`/`completed` AND connection state `connected`).
94
+ * Transient states like `disconnected`, `checking`, or `new` return false.
95
+ */
96
+ isStable: () => boolean;
88
97
  /**
89
98
  * Handles the ICECandidate event and
90
99
  * Initiates an ICE Trickle process with the SFU.
@@ -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';
@@ -4,13 +4,45 @@ import { CallState } from '../store';
4
4
  import { Dispatcher } from './Dispatcher';
5
5
  import type { OptimalVideoLayer } from './layers';
6
6
  import type { ClientPublishOptions } from '../types';
7
- export type OnReconnectionNeeded = (kind: WebsocketReconnectStrategy, reason: string, peerType: PeerType) => void;
7
+ /**
8
+ * Canonical reasons the SDK uses to trigger a reconnection. Free-form strings
9
+ * are still accepted at the callback boundary (e.g. when forwarding an SFU
10
+ * error message), but only the members below influence reconnect-loop
11
+ * behavior. In particular, `Call.reconnect` programmatically inspects
12
+ * `ICE_NEVER_CONNECTED` to drive the unsupported-network detector — pass a
13
+ * canonical member when you want the SDK to react to the reason; pass a
14
+ * free-form string when the value is purely diagnostic.
15
+ */
16
+ export declare const ReconnectReason: {
17
+ /** ICE never reached `connected`/`completed`, escalate to REJOIN. */
18
+ readonly ICE_NEVER_CONNECTED: "ice_never_connected";
19
+ /** RTCPeerConnection.connectionState became `failed`. */
20
+ readonly CONNECTION_FAILED: "connection_failed";
21
+ /** `restartIce()` rejected. */
22
+ readonly RESTART_ICE_FAILED: "restart_ice_failed";
23
+ /** SFU `goAway` event, migrate to a new SFU. */
24
+ readonly GO_AWAY: "go_away";
25
+ /** Network came back online after going offline. */
26
+ readonly NETWORK_BACK_ONLINE: "network_back_online";
27
+ /** SFU error event with no descriptive message. */
28
+ readonly SFU_ERROR: "sfu_error";
29
+ };
30
+ export type ReconnectReason = (typeof ReconnectReason)[keyof typeof ReconnectReason] | (string & {});
31
+ export type OnReconnectionNeeded = (kind: WebsocketReconnectStrategy, reason: ReconnectReason, peerType: PeerType) => void;
32
+ /**
33
+ * Fires the first time a peer connection's ICE transport reaches
34
+ * `connected` or `completed` during its lifetime. Used by `Call` to reset
35
+ * the "ICE never connected" failure counter only when WebRTC has actually
36
+ * recovered, not merely when the SFU join handshake succeeded.
37
+ */
38
+ export type OnIceConnected = (peerType: PeerType) => void;
8
39
  export type BasePeerConnectionOpts = {
9
40
  sfuClient: StreamSfuClient;
10
41
  state: CallState;
11
42
  connectionConfig?: RTCConfiguration;
12
43
  dispatcher: Dispatcher;
13
44
  onReconnectionNeeded?: OnReconnectionNeeded;
45
+ onIceConnected?: OnIceConnected;
14
46
  tag: string;
15
47
  enableTracing: boolean;
16
48
  iceRestartDelay?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.48.0",
3
+ "version": "1.49.0",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
package/src/Call.ts CHANGED
@@ -7,7 +7,9 @@ import {
7
7
  isAudioTrackType,
8
8
  isSfuEvent,
9
9
  muteTypeToTrackType,
10
+ NegotiationError,
10
11
  Publisher,
12
+ ReconnectReason,
11
13
  Subscriber,
12
14
  toRtcConfiguration,
13
15
  TrackPublishOptions,
@@ -148,6 +150,7 @@ import { PermissionsContext } from './permissions';
148
150
  import { CallTypes } from './CallType';
149
151
  import { StreamClient } from './coordinator/connection/client';
150
152
  import { retryInterval, sleep } from './coordinator/connection/utils';
153
+ import { SlidingWindowRateLimiter } from './helpers/SlidingWindowRateLimiter';
151
154
  import {
152
155
  AllCallEvents,
153
156
  CallEventListener,
@@ -271,6 +274,17 @@ export class Call {
271
274
  private disconnectionTimeoutSeconds: number = 0;
272
275
  private lastOfflineTimestamp: number = 0;
273
276
  private networkAvailableTask: PromiseWithResolvers<void> | undefined;
277
+
278
+ // (10 attempts per rolling 120 s window).
279
+ private readonly rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
280
+ // "Network doesn't support WebRTC" detector: counts peer-connection
281
+ // failures where ICE never reached `connected`/`completed`.
282
+ private maxIceFailuresWithoutConnect = 2;
283
+ private iceFailuresWithoutConnect = 0;
284
+ // Consecutive-negotiation-failure detector: stops the reconnect loop when
285
+ // the SFU keeps failing to negotiate SDP for us.
286
+ private maxConsecutiveNegotiationFailures = 3;
287
+ private consecutiveNegotiationFailures = 0;
274
288
  // maintain the order of publishing tracks to restore them after a reconnection
275
289
  // it shouldn't contain duplicates
276
290
  private trackPublishOrder: TrackType[] = [];
@@ -694,6 +708,20 @@ export class Call {
694
708
  this.state.setParticipants([]);
695
709
  this.state.dispose();
696
710
 
711
+ // Reset reconnect-related accumulators so a future `call.join()` on the
712
+ // same instance starts with a fresh budget. The `Call` may be reused
713
+ // (see `Call.test.ts` "can reuse call instance") so this is required.
714
+ // Strategy/reason/attempts must also be cleared: when `leave()` is
715
+ // reached via `giveUpAndLeave()` the success-path reset at the end of
716
+ // `joinFlow` never runs, leaving stale values that would make the next
717
+ // fresh `join()` send a stale `ReconnectDetails` to the SFU.
718
+ this.rejoinRateLimiter.reset();
719
+ this.iceFailuresWithoutConnect = 0;
720
+ this.consecutiveNegotiationFailures = 0;
721
+ this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
722
+ this.reconnectReason = '';
723
+ this.reconnectAttempts = 0;
724
+
697
725
  // Call all leave call hooks, e.g. to clean up global event handlers
698
726
  this.leaveCallHooks.forEach((hook) => hook());
699
727
  this.initialized = false;
@@ -1169,9 +1197,23 @@ export class Call {
1169
1197
  // when performing fast reconnect, or when we reuse the same SFU client,
1170
1198
  // (ws remained healthy), we just need to restore the ICE connection
1171
1199
  if (performingFastReconnect) {
1172
- // the SFU automatically issues an ICE restart on the subscriber
1173
- // we don't have to do it ourselves
1174
- await this.restoreICE(sfuClient, { includeSubscriber: false });
1200
+ // The SFU automatically issues an ICE restart on the subscriber,
1201
+ // so we only need to decide about the publisher. If the publisher's
1202
+ // peer connection is still stable (ICE still connected end-to-end),
1203
+ // the signal WebSocket drop was the only problem — the new WS alone
1204
+ // is enough, and restarting ICE would add unnecessary SDP/ICE churn.
1205
+ const publisherIsStable = this.publisher?.isStable() ?? true;
1206
+ const includePublisher =
1207
+ !!this.publisher?.isPublishing() && !publisherIsStable;
1208
+ if (!includePublisher && this.publisher?.isPublishing()) {
1209
+ this.logger.info(
1210
+ '[Reconnect] FAST: skipping publisher ICE restart, publisher PC is stable',
1211
+ );
1212
+ }
1213
+ await this.restoreICE(sfuClient, {
1214
+ includeSubscriber: false,
1215
+ includePublisher,
1216
+ });
1175
1217
  } else {
1176
1218
  const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
1177
1219
  this.initPublisherAndSubscriber({
@@ -1223,6 +1265,15 @@ export class Call {
1223
1265
  // reset the reconnect strategy to unspecified after a successful reconnection
1224
1266
  this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
1225
1267
  this.reconnectReason = '';
1268
+ // A successful SFU join handshake resets the consecutive-negotiation
1269
+ // counter (negotiation just succeeded). It does NOT reset
1270
+ // `iceFailuresWithoutConnect` or the rolling `rejoinRateLimiter`;
1271
+ // those track WebRTC-level health and rejoin frequency, which are not
1272
+ // proven by the SFU handshake alone. ICE-failures-without-connect is
1273
+ // cleared via the `onIceConnected` callback when the peer connection
1274
+ // actually reaches `connected`/`completed` end-to-end. The rejoin
1275
+ // rolling window decays naturally as old timestamps age out.
1276
+ this.consecutiveNegotiationFailures = 0;
1226
1277
 
1227
1278
  this.logger.info(`Joined call ${this.cid}`);
1228
1279
  };
@@ -1375,6 +1426,12 @@ export class Call {
1375
1426
  this.logger.warn(message, err);
1376
1427
  });
1377
1428
  },
1429
+ onIceConnected: () => {
1430
+ // ICE has reached `connected`/`completed` end-to-end on at least
1431
+ // one peer connection, WebRTC is actually working, so the
1432
+ // "ICE never connected" failure budget can be cleared.
1433
+ this.iceFailuresWithoutConnect = 0;
1434
+ },
1378
1435
  };
1379
1436
 
1380
1437
  this.subscriber = new Subscriber(basePeerConnectionOptions);
@@ -1501,11 +1558,13 @@ export class Call {
1501
1558
  * @internal
1502
1559
  *
1503
1560
  * @param strategy the reconnection strategy to use.
1504
- * @param reason the reason for the reconnection.
1561
+ * @param reason the reason for the reconnection. Pass a `ReconnectReason.*`
1562
+ * constant when the SDK should react to it (e.g.
1563
+ * `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
1505
1564
  */
1506
1565
  private reconnect = async (
1507
1566
  strategy: WebsocketReconnectStrategy,
1508
- reason: string,
1567
+ reason: ReconnectReason,
1509
1568
  ): Promise<void> => {
1510
1569
  if (
1511
1570
  this.state.callingState === CallingState.RECONNECTING ||
@@ -1529,6 +1588,35 @@ export class Call {
1529
1588
  }
1530
1589
  };
1531
1590
 
1591
+ const giveUpAndLeave = async (message: string) => {
1592
+ this.logger.warn(
1593
+ `[Reconnect] Giving up: ${message}. Leaving the call.`,
1594
+ );
1595
+ // If we're mid-iteration, the state can be JOINING; `Call.leave` would
1596
+ // then wait for JOINED before proceeding, but no more attempts will run
1597
+ // so JOINED never comes. Transition to RECONNECTING so `leave()` proceeds.
1598
+ if (this.state.callingState === CallingState.JOINING) {
1599
+ this.state.setCallingState(CallingState.RECONNECTING);
1600
+ }
1601
+ try {
1602
+ await this.leave({ message });
1603
+ } catch (err) {
1604
+ this.logger.warn(`[Reconnect] leave() failed after ${message}`, err);
1605
+ }
1606
+ };
1607
+
1608
+ // Count this entry into reconnect if it was triggered by a peer
1609
+ // connection that never reached `connected`/`completed`.
1610
+ if (reason === ReconnectReason.ICE_NEVER_CONNECTED) {
1611
+ this.iceFailuresWithoutConnect++;
1612
+ if (
1613
+ this.iceFailuresWithoutConnect >= this.maxIceFailuresWithoutConnect
1614
+ ) {
1615
+ await giveUpAndLeave('webrtc_unsupported_network');
1616
+ return;
1617
+ }
1618
+ }
1619
+
1532
1620
  let attempt = 0;
1533
1621
  do {
1534
1622
  const reconnectingTime = Date.now() - reconnectStartTime;
@@ -1544,6 +1632,19 @@ export class Call {
1544
1632
  return;
1545
1633
  }
1546
1634
 
1635
+ // Rejoin rate limit: bound the number of REJOIN (and MIGRATE)
1636
+ // transitions inside a rolling window. FAST is not counted because
1637
+ // it does not issue a new backend `joinCall`.
1638
+ if (
1639
+ this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN ||
1640
+ this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE
1641
+ ) {
1642
+ if (!this.rejoinRateLimiter.tryRegister()) {
1643
+ await giveUpAndLeave('rejoin_attempt_limit_exceeded');
1644
+ return;
1645
+ }
1646
+ }
1647
+
1547
1648
  // we don't increment reconnect attempts for the FAST strategy.
1548
1649
  if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
1549
1650
  this.reconnectAttempts++;
@@ -1581,6 +1682,8 @@ export class Call {
1581
1682
  );
1582
1683
  break;
1583
1684
  }
1685
+ // reconnection worked — reset the negotiation-failure streak.
1686
+ this.consecutiveNegotiationFailures = 0;
1584
1687
  break; // do-while loop, reconnection worked, exit the loop
1585
1688
  } catch (error) {
1586
1689
  if (this.state.callingState === CallingState.OFFLINE) {
@@ -1599,8 +1702,19 @@ export class Call {
1599
1702
  await markAsReconnectingFailed();
1600
1703
  return;
1601
1704
  }
1705
+ if (error instanceof NegotiationError) {
1706
+ this.consecutiveNegotiationFailures++;
1707
+ if (
1708
+ this.consecutiveNegotiationFailures >=
1709
+ this.maxConsecutiveNegotiationFailures
1710
+ ) {
1711
+ await giveUpAndLeave('repeated_negotiation_failures');
1712
+ return;
1713
+ }
1714
+ }
1602
1715
 
1603
- await sleep(500);
1716
+ // exponential backoff with jitter, capped at 5 s
1717
+ await sleep(retryInterval(attempt));
1604
1718
 
1605
1719
  const wasMigrating =
1606
1720
  this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
@@ -1741,9 +1855,10 @@ export class Call {
1741
1855
  private registerReconnectHandlers = () => {
1742
1856
  // handles the legacy "goAway" event
1743
1857
  const unregisterGoAway = this.on('goAway', () => {
1744
- this.reconnect(WebsocketReconnectStrategy.MIGRATE, 'goAway').catch(
1745
- (err) => this.logger.warn('[Reconnect] Error reconnecting', err),
1746
- );
1858
+ this.reconnect(
1859
+ WebsocketReconnectStrategy.MIGRATE,
1860
+ ReconnectReason.GO_AWAY,
1861
+ ).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
1747
1862
  });
1748
1863
 
1749
1864
  // handles the "error" event, through which the SFU can request a reconnect
@@ -1760,7 +1875,10 @@ export class Call {
1760
1875
  this.logger.warn(`Can't leave call after disconnect request`, err);
1761
1876
  });
1762
1877
  } else {
1763
- this.reconnect(strategy, error?.message || 'SFU Error').catch((err) => {
1878
+ this.reconnect(
1879
+ strategy,
1880
+ error?.message || ReconnectReason.SFU_ERROR,
1881
+ ).catch((err) => {
1764
1882
  this.logger.warn('[Reconnect] Error reconnecting', err);
1765
1883
  });
1766
1884
  }
@@ -1787,12 +1905,14 @@ export class Call {
1787
1905
  }
1788
1906
  }
1789
1907
 
1790
- this.reconnect(strategy, 'Going online').catch((err) => {
1791
- this.logger.warn(
1792
- '[Reconnect] Error reconnecting after going online',
1793
- err,
1794
- );
1795
- });
1908
+ this.reconnect(strategy, ReconnectReason.NETWORK_BACK_ONLINE).catch(
1909
+ (err) => {
1910
+ this.logger.warn(
1911
+ '[Reconnect] Error reconnecting after going online',
1912
+ err,
1913
+ );
1914
+ },
1915
+ );
1796
1916
  });
1797
1917
  this.networkAvailableTask = networkAvailableTask;
1798
1918
  this.sfuStatsReporter?.stop();
@@ -1958,10 +2078,12 @@ export class Call {
1958
2078
  mediaStream: MediaStream | undefined,
1959
2079
  ...trackTypes: TrackType[]
1960
2080
  ) => {
1961
- if (!this.sfuClient || !this.sfuClient.sessionId) return;
2081
+ const sessionId = this.sfuClient?.sessionId;
2082
+ if (!sessionId) return;
2083
+
1962
2084
  await this.notifyTrackMuteState(!mediaStream, ...trackTypes);
2085
+ if (this.sfuClient?.sessionId !== sessionId) return;
1963
2086
 
1964
- const { sessionId } = this.sfuClient;
1965
2087
  for (const trackType of trackTypes) {
1966
2088
  const streamStateProp = trackTypeToParticipantStreamKey(trackType);
1967
2089
  if (!streamStateProp) continue;
@@ -3058,6 +3180,45 @@ export class Call {
3058
3180
  this.disconnectionTimeoutSeconds = timeoutSeconds;
3059
3181
  };
3060
3182
 
3183
+ /**
3184
+ * Configures the rolling-window limit for REJOIN/MIGRATE attempts. Once
3185
+ * `maxAttempts` rejoins have been registered inside `windowSeconds`, the
3186
+ * SDK stops retrying and transitions the call to `LEFT` with the
3187
+ * `rejoin_attempt_limit_exceeded` leave message.
3188
+ *
3189
+ * Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
3190
+ * Both arguments are clamped to a minimum of 1.
3191
+ */
3192
+ setRejoinAttemptLimit = (maxAttempts: number, windowSeconds: number) => {
3193
+ this.rejoinRateLimiter.setLimits(
3194
+ Math.max(1, maxAttempts),
3195
+ Math.max(1, windowSeconds) * 1000,
3196
+ );
3197
+ };
3198
+
3199
+ /**
3200
+ * Configures how many peer-connection failures where ICE never reached
3201
+ * `connected`/`completed` are tolerated before the SDK concludes that the
3202
+ * current network cannot support WebRTC and transitions the call to
3203
+ * `LEFT` with the `webrtc_unsupported_network` leave message.
3204
+ *
3205
+ * Default: 2. Clamped to a minimum of 1.
3206
+ */
3207
+ setMaxIceFailuresWithoutConnect = (n: number) => {
3208
+ this.maxIceFailuresWithoutConnect = Math.max(1, n);
3209
+ };
3210
+
3211
+ /**
3212
+ * Configures how many consecutive SDP `NegotiationError`s are tolerated
3213
+ * before the SDK stops retrying and transitions the call to `LEFT` with
3214
+ * the `repeated_negotiation_failures` leave message.
3215
+ *
3216
+ * Default: 3. Clamped to a minimum of 1.
3217
+ */
3218
+ setMaxConsecutiveNegotiationFailures = (n: number) => {
3219
+ this.maxConsecutiveNegotiationFailures = Math.max(1, n);
3220
+ };
3221
+
3061
3222
  /**
3062
3223
  * Enables the provided client capabilities.
3063
3224
  */