@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.
package/dist/index.cjs.js CHANGED
@@ -4026,8 +4026,6 @@ const retryable = async (rpc, signal, maxRetries = Number.POSITIVE_INFINITY) =>
4026
4026
  do {
4027
4027
  if (attempt > 0)
4028
4028
  await sleep(retryInterval(attempt));
4029
- if (signal?.aborted)
4030
- throw new Error(signal.reason);
4031
4029
  try {
4032
4030
  result = await rpc({ attempt });
4033
4031
  }
@@ -4425,6 +4423,21 @@ class Dispatcher {
4425
4423
  }
4426
4424
  }
4427
4425
 
4426
+ /**
4427
+ * NegotiationError is thrown when there is an error during the negotiation process.
4428
+ * It extends the built-in Error class and includes an SfuError object for more details.
4429
+ */
4430
+ class NegotiationError extends Error {
4431
+ /**
4432
+ * Creates an instance of NegotiationError.
4433
+ */
4434
+ constructor(error) {
4435
+ super(error.message);
4436
+ this.name = 'NegotiationError';
4437
+ this.error = error;
4438
+ }
4439
+ }
4440
+
4428
4441
  /**
4429
4442
  * A buffer for ICE Candidates. Used for ICE Trickle:
4430
4443
  * - https://bloggeek.me/webrtcglossary/trickle-ice/
@@ -6242,21 +6255,6 @@ class CallState {
6242
6255
  }
6243
6256
  }
6244
6257
 
6245
- /**
6246
- * NegotiationError is thrown when there is an error during the negotiation process.
6247
- * It extends the built-in Error class and includes an SfuError object for more details.
6248
- */
6249
- class NegotiationError extends Error {
6250
- /**
6251
- * Creates an instance of NegotiationError.
6252
- */
6253
- constructor(error) {
6254
- super(error.message);
6255
- this.name = 'NegotiationError';
6256
- this.error = error;
6257
- }
6258
- }
6259
-
6260
6258
  /**
6261
6259
  * Flatten the stats report into an array of stats objects.
6262
6260
  *
@@ -6304,7 +6302,7 @@ const getSdkVersion = (sdk) => {
6304
6302
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
6305
6303
  };
6306
6304
 
6307
- const version = "1.48.0";
6305
+ const version = "1.49.0";
6308
6306
  const [major, minor, patch] = version.split('.');
6309
6307
  let sdkInfo = {
6310
6308
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -7326,6 +7324,30 @@ class Tracer {
7326
7324
  }
7327
7325
  }
7328
7326
 
7327
+ /**
7328
+ * Canonical reasons the SDK uses to trigger a reconnection. Free-form strings
7329
+ * are still accepted at the callback boundary (e.g. when forwarding an SFU
7330
+ * error message), but only the members below influence reconnect-loop
7331
+ * behavior. In particular, `Call.reconnect` programmatically inspects
7332
+ * `ICE_NEVER_CONNECTED` to drive the unsupported-network detector — pass a
7333
+ * canonical member when you want the SDK to react to the reason; pass a
7334
+ * free-form string when the value is purely diagnostic.
7335
+ */
7336
+ const ReconnectReason = {
7337
+ /** ICE never reached `connected`/`completed`, escalate to REJOIN. */
7338
+ ICE_NEVER_CONNECTED: 'ice_never_connected',
7339
+ /** RTCPeerConnection.connectionState became `failed`. */
7340
+ CONNECTION_FAILED: 'connection_failed',
7341
+ /** `restartIce()` rejected. */
7342
+ RESTART_ICE_FAILED: 'restart_ice_failed',
7343
+ /** SFU `goAway` event, migrate to a new SFU. */
7344
+ GO_AWAY: 'go_away',
7345
+ /** Network came back online after going offline. */
7346
+ NETWORK_BACK_ONLINE: 'network_back_online',
7347
+ /** SFU error event with no descriptive message. */
7348
+ SFU_ERROR: 'sfu_error',
7349
+ };
7350
+
7329
7351
  /**
7330
7352
  * A base class for the `Publisher` and `Subscriber` classes.
7331
7353
  * @internal
@@ -7334,7 +7356,8 @@ class BasePeerConnection {
7334
7356
  /**
7335
7357
  * Constructs a new `BasePeerConnection` instance.
7336
7358
  */
7337
- constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, }) {
7359
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, }) {
7360
+ this.iceHasEverConnected = false;
7338
7361
  this.isIceRestarting = false;
7339
7362
  this.isDisposed = false;
7340
7363
  this.trackIdToTrackType = new Map();
@@ -7357,13 +7380,12 @@ class BasePeerConnection {
7357
7380
  */
7358
7381
  this.tryRestartIce = () => {
7359
7382
  this.restartIce().catch((e) => {
7360
- const reason = 'restartICE() failed, initiating reconnect';
7361
- this.logger.error(reason, e);
7383
+ this.logger.error('restartICE() failed, initiating reconnect', e);
7362
7384
  const strategy = e instanceof NegotiationError &&
7363
7385
  e.error.code === ErrorCode.PARTICIPANT_SIGNAL_LOST
7364
7386
  ? WebsocketReconnectStrategy.FAST
7365
7387
  : WebsocketReconnectStrategy.REJOIN;
7366
- this.onReconnectionNeeded?.(strategy, reason, this.peerType);
7388
+ this.onReconnectionNeeded?.(strategy, ReconnectReason.RESTART_ICE_FAILED, this.peerType);
7367
7389
  });
7368
7390
  };
7369
7391
  /**
@@ -7432,6 +7454,17 @@ class BasePeerConnection {
7432
7454
  const connectionState = this.pc.connectionState;
7433
7455
  return !failedStates.has(iceState) && !failedStates.has(connectionState);
7434
7456
  };
7457
+ /**
7458
+ * Returns true only when the peer connection is currently fully established
7459
+ * (ICE `connected`/`completed` AND connection state `connected`).
7460
+ * Transient states like `disconnected`, `checking`, or `new` return false.
7461
+ */
7462
+ this.isStable = () => {
7463
+ const iceState = this.pc.iceConnectionState;
7464
+ const connectionState = this.pc.connectionState;
7465
+ return ((iceState === 'connected' || iceState === 'completed') &&
7466
+ connectionState === 'connected');
7467
+ };
7435
7468
  /**
7436
7469
  * Handles the ICECandidate event and
7437
7470
  * Initiates an ICE Trickle process with the SFU.
@@ -7481,7 +7514,7 @@ class BasePeerConnection {
7481
7514
  }
7482
7515
  // we can't recover from a failed connection state (contrary to ICE)
7483
7516
  if (state === 'failed') {
7484
- this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, 'Connection failed', this.peerType);
7517
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.CONNECTION_FAILED, this.peerType);
7485
7518
  return;
7486
7519
  }
7487
7520
  this.handleConnectionStateUpdate(state);
@@ -7503,6 +7536,41 @@ class BasePeerConnection {
7503
7536
  // do nothing when ICE is restarting
7504
7537
  if (this.isIceRestarting)
7505
7538
  return;
7539
+ // Pre-connect handling: ICE has never reached `connected`/`completed`.
7540
+ // Restart is futile here (the data plane was never established), but
7541
+ // these two terminal-ish states need different treatment:
7542
+ // - `failed` is terminal, escalate to REJOIN so a new SFU/credentials
7543
+ // /PC configuration gets a chance, and let `Call.reconnect` count
7544
+ // this toward the unsupported-network budget.
7545
+ // - `disconnected` is transient, the browser may yet move back to
7546
+ // `checking`/`connected`. Don't restart, don't escalate; wait it
7547
+ // out. If it ultimately fails, ICE will transition to `failed` and
7548
+ // the branch above will take over.
7549
+ if (!this.iceHasEverConnected) {
7550
+ if (state === 'failed') {
7551
+ this.logger.info('ICE failed before connected, escalating to REJOIN');
7552
+ clearTimeout(this.preConnectStuckTimeout);
7553
+ this.preConnectStuckTimeout = undefined;
7554
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED, this.peerType);
7555
+ return;
7556
+ }
7557
+ if (state === 'disconnected') {
7558
+ this.logger.info('ICE disconnected before connected, wait to recover');
7559
+ // Watchdog: if the browser stays in `disconnected` without ever
7560
+ // reaching `connected` or transitioning to `failed`, escalate to
7561
+ // REJOIN ourselves so we don't wait silently forever. Rare but
7562
+ // observed on flaky mobile networks.
7563
+ clearTimeout(this.preConnectStuckTimeout);
7564
+ this.preConnectStuckTimeout = setTimeout(() => {
7565
+ if (!this.iceHasEverConnected &&
7566
+ this.pc.iceConnectionState === 'disconnected') {
7567
+ this.logger.info('ICE stuck in pre-connect disconnected, escalating to REJOIN');
7568
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED, this.peerType);
7569
+ }
7570
+ }, this.iceRestartDelay * 2);
7571
+ return;
7572
+ }
7573
+ }
7506
7574
  switch (state) {
7507
7575
  case 'failed':
7508
7576
  // in the `failed` state, we try to restart ICE immediately
@@ -7522,12 +7590,24 @@ class BasePeerConnection {
7522
7590
  }, this.iceRestartDelay);
7523
7591
  break;
7524
7592
  case 'connected':
7525
- // in the `connected` state, we clear the ice restart timeout if it exists
7593
+ case 'completed':
7594
+ // Fire `onIceConnected` exactly once per peer-connection lifetime —
7595
+ // the first time ICE reaches `connected`/`completed` end-to-end.
7596
+ // Used by `Call` to reset the unsupported-network failure counter
7597
+ // only after WebRTC has actually recovered, not merely on SFU join.
7598
+ if (!this.iceHasEverConnected) {
7599
+ this.iceHasEverConnected = true;
7600
+ this.onIceConnected?.(this.peerType);
7601
+ }
7602
+ // clear any scheduled restartICE since the connection is healthy
7526
7603
  if (this.iceRestartTimeout) {
7527
7604
  this.logger.info('connected connection, canceling restartICE');
7528
7605
  clearTimeout(this.iceRestartTimeout);
7529
7606
  this.iceRestartTimeout = undefined;
7530
7607
  }
7608
+ // clear the pre-connect watchdog if it was armed
7609
+ clearTimeout(this.preConnectStuckTimeout);
7610
+ this.preConnectStuckTimeout = undefined;
7531
7611
  break;
7532
7612
  }
7533
7613
  };
@@ -7560,6 +7640,7 @@ class BasePeerConnection {
7560
7640
  this.clientPublishOptions = clientPublishOptions;
7561
7641
  this.tag = tag;
7562
7642
  this.onReconnectionNeeded = onReconnectionNeeded;
7643
+ this.onIceConnected = onIceConnected;
7563
7644
  this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
7564
7645
  this.pc = this.createPeerConnection(connectionConfig);
7565
7646
  this.stats = new StatsTracer(this.pc, peerType, this.trackIdToTrackType);
@@ -7578,7 +7659,10 @@ class BasePeerConnection {
7578
7659
  dispose() {
7579
7660
  clearTimeout(this.iceRestartTimeout);
7580
7661
  this.iceRestartTimeout = undefined;
7662
+ clearTimeout(this.preConnectStuckTimeout);
7663
+ this.preConnectStuckTimeout = undefined;
7581
7664
  this.onReconnectionNeeded = undefined;
7665
+ this.onIceConnected = undefined;
7582
7666
  this.isDisposed = true;
7583
7667
  this.detachEventHandlers();
7584
7668
  this.pc.close();
@@ -8232,7 +8316,8 @@ class Publisher extends BasePeerConnection {
8232
8316
  const trackInfos = [];
8233
8317
  for (const publishOption of this.publishOptions) {
8234
8318
  const bundle = this.transceiverCache.get(publishOption);
8235
- if (!bundle || !bundle.transceiver.sender.track)
8319
+ const track = bundle?.transceiver.sender.track;
8320
+ if (!bundle || !track || track.readyState !== 'live')
8236
8321
  continue;
8237
8322
  trackInfos.push(this.toTrackInfo(bundle, sdp));
8238
8323
  }
@@ -8577,6 +8662,20 @@ class SfuJoinError extends Error {
8577
8662
  }
8578
8663
  }
8579
8664
 
8665
+ /**
8666
+ * Creates a fresh `joinResponseTask` with a no-op rejection handler attached
8667
+ * to the underlying promise. The handler marks the rejection path as handled
8668
+ * so a teardown-time reject (e.g., from `close()` during disposal) does not
8669
+ * surface as an `UnhandledPromiseRejection`. Explicit awaiters of
8670
+ * `StreamSfuClient.joinTask` still observe the rejection through their own
8671
+ * `then`/`catch` chain. `.catch()` returns a new promise; the original is
8672
+ * unchanged.
8673
+ */
8674
+ const makeJoinResponseTask = () => {
8675
+ const task = promiseWithResolvers();
8676
+ task.promise.catch(() => { }); // see the comment above
8677
+ return task;
8678
+ };
8580
8679
  /**
8581
8680
  * The client used for exchanging information with the SFU.
8582
8681
  */
@@ -8608,9 +8707,10 @@ class StreamSfuClient {
8608
8707
  this.subscriptionsConcurrencyTag = Symbol('subscriptionsConcurrencyTag');
8609
8708
  /**
8610
8709
  * Promise that resolves when the JoinResponse is received.
8611
- * Rejects after a certain threshold if the response is not received.
8710
+ * Rejects after a certain threshold if the response is not received,
8711
+ * or when the SFU client is disposed before a join completes.
8612
8712
  */
8613
- this.joinResponseTask = promiseWithResolvers();
8713
+ this.joinResponseTask = makeJoinResponseTask();
8614
8714
  /**
8615
8715
  * A controller to abort the current requests.
8616
8716
  */
@@ -8680,14 +8780,21 @@ class StreamSfuClient {
8680
8780
  };
8681
8781
  this.close = (code = StreamSfuClient.NORMAL_CLOSURE, reason) => {
8682
8782
  this.isClosingClean = code !== StreamSfuClient.ERROR_CONNECTION_UNHEALTHY;
8683
- if (this.signalWs.readyState === WebSocket.OPEN) {
8783
+ // Close the WebSocket whether it has fully opened (`OPEN`) or is still
8784
+ // mid-handshake (`CONNECTING`). The WebSocket spec aborts the handshake
8785
+ // when `close()` is called on a CONNECTING socket. Without this, an
8786
+ // SFU socket that opens just after teardown would dispatch events into
8787
+ // a Call instance that has already moved on.
8788
+ const ws = this.signalWs;
8789
+ if (ws.readyState === WebSocket.OPEN ||
8790
+ ws.readyState === WebSocket.CONNECTING) {
8684
8791
  this.logger.debug(`Closing SFU WS connection: ${code} - ${reason}`);
8685
- this.signalWs.close(code, `js-client: ${reason}`);
8686
- this.signalWs.removeEventListener('close', this.handleWebSocketClose);
8792
+ ws.close(code, `js-client: ${reason}`);
8793
+ ws.removeEventListener('close', this.handleWebSocketClose);
8687
8794
  }
8688
- this.dispose();
8795
+ this.dispose(reason);
8689
8796
  };
8690
- this.dispose = () => {
8797
+ this.dispose = (reason) => {
8691
8798
  this.logger.debug('Disposing SFU client');
8692
8799
  this.unsubscribeIceTrickle();
8693
8800
  this.unsubscribeNetworkChanged();
@@ -8696,6 +8803,13 @@ class StreamSfuClient {
8696
8803
  clearTimeout(this.migrateAwayTimeout);
8697
8804
  this.abortController.abort();
8698
8805
  this.migrationTask?.resolve();
8806
+ // Settle a pending `joinResponseTask` so `leaveAndClose`, `join()`, and
8807
+ // any other awaiters (`await this.joinTask`) don't hang indefinitely
8808
+ // when the SFU client is torn down before the SFU sent a JoinResponse.
8809
+ if (!this.joinResponseTask.isResolved() &&
8810
+ !this.joinResponseTask.isRejected()) {
8811
+ this.joinResponseTask.reject(new Error(`SFU client disposed before join completed${reason ? `: ${reason}` : ''}`));
8812
+ }
8699
8813
  this.iceTrickleBuffer.dispose();
8700
8814
  };
8701
8815
  this.getTrace = () => {
@@ -8704,8 +8818,24 @@ class StreamSfuClient {
8704
8818
  this.leaveAndClose = async (reason) => {
8705
8819
  try {
8706
8820
  this.isLeaving = true;
8707
- await this.joinTask;
8708
- await this.notifyLeave(reason);
8821
+ // Best-effort: give an in-flight join a short grace period to complete
8822
+ // so we can send a graceful `leaveCallRequest`. Bounded so we never hang
8823
+ // here if the SFU is unresponsive. If the task settles either way during
8824
+ // the wait, the re-check below decides whether to notify.
8825
+ if (!this.joinResponseTask.isResolved() &&
8826
+ !this.joinResponseTask.isRejected()) {
8827
+ await Promise.race([
8828
+ // swallow rejection — we re-check `isResolved()` below to decide
8829
+ this.joinResponseTask.promise.catch(() => { }),
8830
+ sleep(StreamSfuClient.LEAVE_NOTIFY_GRACE_MS),
8831
+ ]);
8832
+ }
8833
+ if (this.joinResponseTask.isResolved()) {
8834
+ await this.notifyLeave(reason);
8835
+ }
8836
+ else {
8837
+ this.logger.debug('[leaveAndClose] join not completed within grace period, skipping notifyLeave');
8838
+ }
8709
8839
  }
8710
8840
  catch (err) {
8711
8841
  this.logger.debug('Error notifying SFU about leaving call', err);
@@ -8774,9 +8904,9 @@ class StreamSfuClient {
8774
8904
  this.joinResponseTask.isRejected()) {
8775
8905
  // we need to lock the RPC requests until we receive a JoinResponse.
8776
8906
  // that's why we have this primitive lock mechanism.
8777
- // the client starts with already initialized joinResponseTask,
8907
+ // the client starts with an already initialized joinResponseTask,
8778
8908
  // and this code creates a new one for the next join request.
8779
- this.joinResponseTask = promiseWithResolvers();
8909
+ this.joinResponseTask = makeJoinResponseTask();
8780
8910
  }
8781
8911
  // capture a reference to the current joinResponseTask as it might
8782
8912
  // be replaced with a new one in case a second join request is made
@@ -8949,6 +9079,12 @@ StreamSfuClient.DISPOSE_OLD_SOCKET = 4100;
8949
9079
  * The close code used when the client fails to join the call (on the SFU).
8950
9080
  */
8951
9081
  StreamSfuClient.JOIN_FAILED = 4101;
9082
+ /**
9083
+ * Best-effort grace period in `leaveAndClose` for an in-flight join to
9084
+ * complete before we give up and close without sending `leaveCallRequest`.
9085
+ * Bounded so a stuck join can never hang the leave path.
9086
+ */
9087
+ StreamSfuClient.LEAVE_NOTIFY_GRACE_MS = 1000;
8952
9088
 
8953
9089
  /**
8954
9090
  * Event handler that watched the delivery of `call.accepted`.
@@ -10344,6 +10480,50 @@ const CallTypes = new CallTypesRegistry([
10344
10480
  }),
10345
10481
  ]);
10346
10482
 
10483
+ /**
10484
+ * A generic sliding-window rate limiter.
10485
+ *
10486
+ * Allows at most `maxAttempts` registrations inside a rolling `windowMs`.
10487
+ * Attempts spaced further apart than `windowMs` are always allowed.
10488
+ */
10489
+ class SlidingWindowRateLimiter {
10490
+ constructor(maxAttempts, windowMs) {
10491
+ this.timestamps = [];
10492
+ /**
10493
+ * Attempts to register a new event at `now`. Returns `true` if the attempt
10494
+ * fits inside the budget (and records it), or `false` if the budget is
10495
+ * exhausted (in which case no timestamp is recorded).
10496
+ */
10497
+ this.tryRegister = (now = Date.now()) => {
10498
+ this.prune(now);
10499
+ if (this.timestamps.length >= this.maxAttempts)
10500
+ return false;
10501
+ this.timestamps.push(now);
10502
+ return true;
10503
+ };
10504
+ /**
10505
+ * Clears the attempt history.
10506
+ */
10507
+ this.reset = () => {
10508
+ this.timestamps = [];
10509
+ };
10510
+ /**
10511
+ * Updates the budget and window size. Existing timestamps are kept; they
10512
+ * will be pruned by the next `tryRegister` call.
10513
+ */
10514
+ this.setLimits = (maxAttempts, windowMs) => {
10515
+ this.maxAttempts = maxAttempts;
10516
+ this.windowMs = windowMs;
10517
+ };
10518
+ this.prune = (now) => {
10519
+ const cutoff = now - this.windowMs;
10520
+ this.timestamps = this.timestamps.filter((t) => t >= cutoff);
10521
+ };
10522
+ this.maxAttempts = maxAttempts;
10523
+ this.windowMs = windowMs;
10524
+ }
10525
+ }
10526
+
10347
10527
  /**
10348
10528
  * Deactivates MediaStream (stops and removes tracks) to be later garbage collected
10349
10529
  *
@@ -10725,7 +10905,6 @@ const getScreenShareStream = async (options, tracer) => {
10725
10905
  const tag = `navigator.mediaDevices.getDisplayMedia.${getDisplayMediaExecId++}.`;
10726
10906
  try {
10727
10907
  const constraints = {
10728
- // @ts-expect-error - not present in types yet
10729
10908
  systemAudio: 'include',
10730
10909
  ...options,
10731
10910
  video: typeof options?.video === 'boolean'
@@ -10740,6 +10919,8 @@ const getScreenShareStream = async (options, tracer) => {
10740
10919
  ? options.audio
10741
10920
  : {
10742
10921
  channelCount: { ideal: 2 },
10922
+ // @ts-expect-error not yet present in the types
10923
+ restrictOwnAudio: true,
10743
10924
  echoCancellation: false,
10744
10925
  autoGainControl: false,
10745
10926
  noiseSuppression: false,
@@ -11384,6 +11565,7 @@ class DeviceManagerState {
11384
11565
  this.statusSubject = new rxjs.BehaviorSubject(undefined);
11385
11566
  this.optimisticStatusSubject = new rxjs.BehaviorSubject(undefined);
11386
11567
  this.mediaStreamSubject = new rxjs.BehaviorSubject(undefined);
11568
+ this.rootMediaStreamSubject = new rxjs.BehaviorSubject(undefined);
11387
11569
  this.selectedDeviceSubject = new rxjs.BehaviorSubject(undefined);
11388
11570
  this.defaultConstraintsSubject = new rxjs.BehaviorSubject(undefined);
11389
11571
  /**
@@ -11391,6 +11573,12 @@ class DeviceManagerState {
11391
11573
  *
11392
11574
  */
11393
11575
  this.mediaStream$ = this.mediaStreamSubject.asObservable();
11576
+ /**
11577
+ * An Observable that emits the raw device media stream (before any filters are applied),
11578
+ * or `undefined` if the device is currently disabled. When no filters are active, this
11579
+ * emits the same stream as `mediaStream$`.
11580
+ */
11581
+ this.rootMediaStream$ = this.rootMediaStreamSubject.asObservable();
11394
11582
  /**
11395
11583
  * An Observable that emits the currently selected device
11396
11584
  */
@@ -11446,6 +11634,14 @@ class DeviceManagerState {
11446
11634
  get mediaStream() {
11447
11635
  return getCurrentValue(this.mediaStream$);
11448
11636
  }
11637
+ /**
11638
+ * The raw device media stream (before any filters are applied), or `undefined`
11639
+ * if the device is currently disabled. When no filters are active, this is the
11640
+ * same as `mediaStream`.
11641
+ */
11642
+ get rootMediaStream() {
11643
+ return getCurrentValue(this.rootMediaStream$);
11644
+ }
11449
11645
  /**
11450
11646
  * @internal
11451
11647
  * @param status
@@ -11470,6 +11666,7 @@ class DeviceManagerState {
11470
11666
  */
11471
11667
  setMediaStream(stream, rootStream) {
11472
11668
  setCurrentValue(this.mediaStreamSubject, stream);
11669
+ setCurrentValue(this.rootMediaStreamSubject, rootStream);
11473
11670
  if (rootStream) {
11474
11671
  this.setDevice(this.getDeviceIdFromStream(rootStream));
11475
11672
  }
@@ -12721,6 +12918,16 @@ class Call {
12721
12918
  this.fastReconnectDeadlineSeconds = 0;
12722
12919
  this.disconnectionTimeoutSeconds = 0;
12723
12920
  this.lastOfflineTimestamp = 0;
12921
+ // (10 attempts per rolling 120 s window).
12922
+ this.rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
12923
+ // "Network doesn't support WebRTC" detector: counts peer-connection
12924
+ // failures where ICE never reached `connected`/`completed`.
12925
+ this.maxIceFailuresWithoutConnect = 2;
12926
+ this.iceFailuresWithoutConnect = 0;
12927
+ // Consecutive-negotiation-failure detector: stops the reconnect loop when
12928
+ // the SFU keeps failing to negotiate SDP for us.
12929
+ this.maxConsecutiveNegotiationFailures = 3;
12930
+ this.consecutiveNegotiationFailures = 0;
12724
12931
  // maintain the order of publishing tracks to restore them after a reconnection
12725
12932
  // it shouldn't contain duplicates
12726
12933
  this.trackPublishOrder = [];
@@ -13023,6 +13230,19 @@ class Call {
13023
13230
  this.state.setCallingState(exports.CallingState.LEFT);
13024
13231
  this.state.setParticipants([]);
13025
13232
  this.state.dispose();
13233
+ // Reset reconnect-related accumulators so a future `call.join()` on the
13234
+ // same instance starts with a fresh budget. The `Call` may be reused
13235
+ // (see `Call.test.ts` "can reuse call instance") so this is required.
13236
+ // Strategy/reason/attempts must also be cleared: when `leave()` is
13237
+ // reached via `giveUpAndLeave()` the success-path reset at the end of
13238
+ // `joinFlow` never runs, leaving stale values that would make the next
13239
+ // fresh `join()` send a stale `ReconnectDetails` to the SFU.
13240
+ this.rejoinRateLimiter.reset();
13241
+ this.iceFailuresWithoutConnect = 0;
13242
+ this.consecutiveNegotiationFailures = 0;
13243
+ this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
13244
+ this.reconnectReason = '';
13245
+ this.reconnectAttempts = 0;
13026
13246
  // Call all leave call hooks, e.g. to clean up global event handlers
13027
13247
  this.leaveCallHooks.forEach((hook) => hook());
13028
13248
  this.initialized = false;
@@ -13373,9 +13593,20 @@ class Call {
13373
13593
  // when performing fast reconnect, or when we reuse the same SFU client,
13374
13594
  // (ws remained healthy), we just need to restore the ICE connection
13375
13595
  if (performingFastReconnect) {
13376
- // the SFU automatically issues an ICE restart on the subscriber
13377
- // we don't have to do it ourselves
13378
- await this.restoreICE(sfuClient, { includeSubscriber: false });
13596
+ // The SFU automatically issues an ICE restart on the subscriber,
13597
+ // so we only need to decide about the publisher. If the publisher's
13598
+ // peer connection is still stable (ICE still connected end-to-end),
13599
+ // the signal WebSocket drop was the only problem — the new WS alone
13600
+ // is enough, and restarting ICE would add unnecessary SDP/ICE churn.
13601
+ const publisherIsStable = this.publisher?.isStable() ?? true;
13602
+ const includePublisher = !!this.publisher?.isPublishing() && !publisherIsStable;
13603
+ if (!includePublisher && this.publisher?.isPublishing()) {
13604
+ this.logger.info('[Reconnect] FAST: skipping publisher ICE restart, publisher PC is stable');
13605
+ }
13606
+ await this.restoreICE(sfuClient, {
13607
+ includeSubscriber: false,
13608
+ includePublisher,
13609
+ });
13379
13610
  }
13380
13611
  else {
13381
13612
  const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
@@ -13418,6 +13649,15 @@ class Call {
13418
13649
  // reset the reconnect strategy to unspecified after a successful reconnection
13419
13650
  this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
13420
13651
  this.reconnectReason = '';
13652
+ // A successful SFU join handshake resets the consecutive-negotiation
13653
+ // counter (negotiation just succeeded). It does NOT reset
13654
+ // `iceFailuresWithoutConnect` or the rolling `rejoinRateLimiter`;
13655
+ // those track WebRTC-level health and rejoin frequency, which are not
13656
+ // proven by the SFU handshake alone. ICE-failures-without-connect is
13657
+ // cleared via the `onIceConnected` callback when the peer connection
13658
+ // actually reaches `connected`/`completed` end-to-end. The rejoin
13659
+ // rolling window decays naturally as old timestamps age out.
13660
+ this.consecutiveNegotiationFailures = 0;
13421
13661
  this.logger.info(`Joined call ${this.cid}`);
13422
13662
  };
13423
13663
  /**
@@ -13535,6 +13775,12 @@ class Call {
13535
13775
  this.logger.warn(message, err);
13536
13776
  });
13537
13777
  },
13778
+ onIceConnected: () => {
13779
+ // ICE has reached `connected`/`completed` end-to-end on at least
13780
+ // one peer connection, WebRTC is actually working, so the
13781
+ // "ICE never connected" failure budget can be cleared.
13782
+ this.iceFailuresWithoutConnect = 0;
13783
+ },
13538
13784
  };
13539
13785
  this.subscriber = new Subscriber(basePeerConnectionOptions);
13540
13786
  // anonymous users can't publish anything hence, there is no need
@@ -13640,7 +13886,9 @@ class Call {
13640
13886
  * @internal
13641
13887
  *
13642
13888
  * @param strategy the reconnection strategy to use.
13643
- * @param reason the reason for the reconnection.
13889
+ * @param reason the reason for the reconnection. Pass a `ReconnectReason.*`
13890
+ * constant when the SDK should react to it (e.g.
13891
+ * `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
13644
13892
  */
13645
13893
  this.reconnect = async (strategy, reason) => {
13646
13894
  if (this.state.callingState === exports.CallingState.RECONNECTING ||
@@ -13661,6 +13909,30 @@ class Call {
13661
13909
  this.state.setCallingState(exports.CallingState.RECONNECTING_FAILED);
13662
13910
  }
13663
13911
  };
13912
+ const giveUpAndLeave = async (message) => {
13913
+ this.logger.warn(`[Reconnect] Giving up: ${message}. Leaving the call.`);
13914
+ // If we're mid-iteration, the state can be JOINING; `Call.leave` would
13915
+ // then wait for JOINED before proceeding, but no more attempts will run
13916
+ // so JOINED never comes. Transition to RECONNECTING so `leave()` proceeds.
13917
+ if (this.state.callingState === exports.CallingState.JOINING) {
13918
+ this.state.setCallingState(exports.CallingState.RECONNECTING);
13919
+ }
13920
+ try {
13921
+ await this.leave({ message });
13922
+ }
13923
+ catch (err) {
13924
+ this.logger.warn(`[Reconnect] leave() failed after ${message}`, err);
13925
+ }
13926
+ };
13927
+ // Count this entry into reconnect if it was triggered by a peer
13928
+ // connection that never reached `connected`/`completed`.
13929
+ if (reason === ReconnectReason.ICE_NEVER_CONNECTED) {
13930
+ this.iceFailuresWithoutConnect++;
13931
+ if (this.iceFailuresWithoutConnect >= this.maxIceFailuresWithoutConnect) {
13932
+ await giveUpAndLeave('webrtc_unsupported_network');
13933
+ return;
13934
+ }
13935
+ }
13664
13936
  let attempt = 0;
13665
13937
  do {
13666
13938
  const reconnectingTime = Date.now() - reconnectStartTime;
@@ -13671,6 +13943,16 @@ class Call {
13671
13943
  await markAsReconnectingFailed();
13672
13944
  return;
13673
13945
  }
13946
+ // Rejoin rate limit: bound the number of REJOIN (and MIGRATE)
13947
+ // transitions inside a rolling window. FAST is not counted because
13948
+ // it does not issue a new backend `joinCall`.
13949
+ if (this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN ||
13950
+ this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE) {
13951
+ if (!this.rejoinRateLimiter.tryRegister()) {
13952
+ await giveUpAndLeave('rejoin_attempt_limit_exceeded');
13953
+ return;
13954
+ }
13955
+ }
13674
13956
  // we don't increment reconnect attempts for the FAST strategy.
13675
13957
  if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
13676
13958
  this.reconnectAttempts++;
@@ -13698,6 +13980,8 @@ class Call {
13698
13980
  ensureExhausted(this.reconnectStrategy, 'Unknown reconnection strategy');
13699
13981
  break;
13700
13982
  }
13983
+ // reconnection worked — reset the negotiation-failure streak.
13984
+ this.consecutiveNegotiationFailures = 0;
13701
13985
  break; // do-while loop, reconnection worked, exit the loop
13702
13986
  }
13703
13987
  catch (error) {
@@ -13712,7 +13996,16 @@ class Call {
13712
13996
  await markAsReconnectingFailed();
13713
13997
  return;
13714
13998
  }
13715
- await sleep(500);
13999
+ if (error instanceof NegotiationError) {
14000
+ this.consecutiveNegotiationFailures++;
14001
+ if (this.consecutiveNegotiationFailures >=
14002
+ this.maxConsecutiveNegotiationFailures) {
14003
+ await giveUpAndLeave('repeated_negotiation_failures');
14004
+ return;
14005
+ }
14006
+ }
14007
+ // exponential backoff with jitter, capped at 5 s
14008
+ await sleep(retryInterval(attempt));
13716
14009
  const wasMigrating = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
13717
14010
  const mustPerformRejoin = (Date.now() - reconnectStartTime) / 1000 >
13718
14011
  this.fastReconnectDeadlineSeconds;
@@ -13821,7 +14114,7 @@ class Call {
13821
14114
  this.registerReconnectHandlers = () => {
13822
14115
  // handles the legacy "goAway" event
13823
14116
  const unregisterGoAway = this.on('goAway', () => {
13824
- this.reconnect(WebsocketReconnectStrategy.MIGRATE, 'goAway').catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
14117
+ this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
13825
14118
  });
13826
14119
  // handles the "error" event, through which the SFU can request a reconnect
13827
14120
  const unregisterOnError = this.on('error', (e) => {
@@ -13840,7 +14133,7 @@ class Call {
13840
14133
  });
13841
14134
  }
13842
14135
  else {
13843
- this.reconnect(strategy, error?.message || 'SFU Error').catch((err) => {
14136
+ this.reconnect(strategy, error?.message || ReconnectReason.SFU_ERROR).catch((err) => {
13844
14137
  this.logger.warn('[Reconnect] Error reconnecting', err);
13845
14138
  });
13846
14139
  }
@@ -13864,7 +14157,7 @@ class Call {
13864
14157
  strategy = WebsocketReconnectStrategy.REJOIN;
13865
14158
  }
13866
14159
  }
13867
- this.reconnect(strategy, 'Going online').catch((err) => {
14160
+ this.reconnect(strategy, ReconnectReason.NETWORK_BACK_ONLINE).catch((err) => {
13868
14161
  this.logger.warn('[Reconnect] Error reconnecting after going online', err);
13869
14162
  });
13870
14163
  });
@@ -14007,10 +14300,12 @@ class Call {
14007
14300
  * @param trackTypes the track types to update the call state with.
14008
14301
  */
14009
14302
  this.updateLocalStreamState = async (mediaStream, ...trackTypes) => {
14010
- if (!this.sfuClient || !this.sfuClient.sessionId)
14303
+ const sessionId = this.sfuClient?.sessionId;
14304
+ if (!sessionId)
14011
14305
  return;
14012
14306
  await this.notifyTrackMuteState(!mediaStream, ...trackTypes);
14013
- const { sessionId } = this.sfuClient;
14307
+ if (this.sfuClient?.sessionId !== sessionId)
14308
+ return;
14014
14309
  for (const trackType of trackTypes) {
14015
14310
  const streamStateProp = trackTypeToParticipantStreamKey(trackType);
14016
14311
  if (!streamStateProp)
@@ -14806,6 +15101,39 @@ class Call {
14806
15101
  this.setDisconnectionTimeout = (timeoutSeconds) => {
14807
15102
  this.disconnectionTimeoutSeconds = timeoutSeconds;
14808
15103
  };
15104
+ /**
15105
+ * Configures the rolling-window limit for REJOIN/MIGRATE attempts. Once
15106
+ * `maxAttempts` rejoins have been registered inside `windowSeconds`, the
15107
+ * SDK stops retrying and transitions the call to `LEFT` with the
15108
+ * `rejoin_attempt_limit_exceeded` leave message.
15109
+ *
15110
+ * Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
15111
+ * Both arguments are clamped to a minimum of 1.
15112
+ */
15113
+ this.setRejoinAttemptLimit = (maxAttempts, windowSeconds) => {
15114
+ this.rejoinRateLimiter.setLimits(Math.max(1, maxAttempts), Math.max(1, windowSeconds) * 1000);
15115
+ };
15116
+ /**
15117
+ * Configures how many peer-connection failures where ICE never reached
15118
+ * `connected`/`completed` are tolerated before the SDK concludes that the
15119
+ * current network cannot support WebRTC and transitions the call to
15120
+ * `LEFT` with the `webrtc_unsupported_network` leave message.
15121
+ *
15122
+ * Default: 2. Clamped to a minimum of 1.
15123
+ */
15124
+ this.setMaxIceFailuresWithoutConnect = (n) => {
15125
+ this.maxIceFailuresWithoutConnect = Math.max(1, n);
15126
+ };
15127
+ /**
15128
+ * Configures how many consecutive SDP `NegotiationError`s are tolerated
15129
+ * before the SDK stops retrying and transitions the call to `LEFT` with
15130
+ * the `repeated_negotiation_failures` leave message.
15131
+ *
15132
+ * Default: 3. Clamped to a minimum of 1.
15133
+ */
15134
+ this.setMaxConsecutiveNegotiationFailures = (n) => {
15135
+ this.maxConsecutiveNegotiationFailures = Math.max(1, n);
15136
+ };
14809
15137
  /**
14810
15138
  * Enables the provided client capabilities.
14811
15139
  */
@@ -15986,7 +16314,7 @@ class StreamClient {
15986
16314
  this.getUserAgent = () => {
15987
16315
  if (!this.cachedUserAgent) {
15988
16316
  const { clientAppIdentifier = {} } = this.options;
15989
- const { sdkName = 'js', sdkVersion = "1.48.0", ...extras } = clientAppIdentifier;
16317
+ const { sdkName = 'js', sdkVersion = "1.49.0", ...extras } = clientAppIdentifier;
15990
16318
  this.cachedUserAgent = [
15991
16319
  `stream-video-${sdkName}-v${sdkVersion}`,
15992
16320
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),