@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.es.js CHANGED
@@ -4007,8 +4007,6 @@ const retryable = async (rpc, signal, maxRetries = Number.POSITIVE_INFINITY) =>
4007
4007
  do {
4008
4008
  if (attempt > 0)
4009
4009
  await sleep(retryInterval(attempt));
4010
- if (signal?.aborted)
4011
- throw new Error(signal.reason);
4012
4010
  try {
4013
4011
  result = await rpc({ attempt });
4014
4012
  }
@@ -4406,6 +4404,21 @@ class Dispatcher {
4406
4404
  }
4407
4405
  }
4408
4406
 
4407
+ /**
4408
+ * NegotiationError is thrown when there is an error during the negotiation process.
4409
+ * It extends the built-in Error class and includes an SfuError object for more details.
4410
+ */
4411
+ class NegotiationError extends Error {
4412
+ /**
4413
+ * Creates an instance of NegotiationError.
4414
+ */
4415
+ constructor(error) {
4416
+ super(error.message);
4417
+ this.name = 'NegotiationError';
4418
+ this.error = error;
4419
+ }
4420
+ }
4421
+
4409
4422
  /**
4410
4423
  * A buffer for ICE Candidates. Used for ICE Trickle:
4411
4424
  * - https://bloggeek.me/webrtcglossary/trickle-ice/
@@ -6223,21 +6236,6 @@ class CallState {
6223
6236
  }
6224
6237
  }
6225
6238
 
6226
- /**
6227
- * NegotiationError is thrown when there is an error during the negotiation process.
6228
- * It extends the built-in Error class and includes an SfuError object for more details.
6229
- */
6230
- class NegotiationError extends Error {
6231
- /**
6232
- * Creates an instance of NegotiationError.
6233
- */
6234
- constructor(error) {
6235
- super(error.message);
6236
- this.name = 'NegotiationError';
6237
- this.error = error;
6238
- }
6239
- }
6240
-
6241
6239
  /**
6242
6240
  * Flatten the stats report into an array of stats objects.
6243
6241
  *
@@ -6285,7 +6283,7 @@ const getSdkVersion = (sdk) => {
6285
6283
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
6286
6284
  };
6287
6285
 
6288
- const version = "1.48.0";
6286
+ const version = "1.49.0";
6289
6287
  const [major, minor, patch] = version.split('.');
6290
6288
  let sdkInfo = {
6291
6289
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -7307,6 +7305,30 @@ class Tracer {
7307
7305
  }
7308
7306
  }
7309
7307
 
7308
+ /**
7309
+ * Canonical reasons the SDK uses to trigger a reconnection. Free-form strings
7310
+ * are still accepted at the callback boundary (e.g. when forwarding an SFU
7311
+ * error message), but only the members below influence reconnect-loop
7312
+ * behavior. In particular, `Call.reconnect` programmatically inspects
7313
+ * `ICE_NEVER_CONNECTED` to drive the unsupported-network detector — pass a
7314
+ * canonical member when you want the SDK to react to the reason; pass a
7315
+ * free-form string when the value is purely diagnostic.
7316
+ */
7317
+ const ReconnectReason = {
7318
+ /** ICE never reached `connected`/`completed`, escalate to REJOIN. */
7319
+ ICE_NEVER_CONNECTED: 'ice_never_connected',
7320
+ /** RTCPeerConnection.connectionState became `failed`. */
7321
+ CONNECTION_FAILED: 'connection_failed',
7322
+ /** `restartIce()` rejected. */
7323
+ RESTART_ICE_FAILED: 'restart_ice_failed',
7324
+ /** SFU `goAway` event, migrate to a new SFU. */
7325
+ GO_AWAY: 'go_away',
7326
+ /** Network came back online after going offline. */
7327
+ NETWORK_BACK_ONLINE: 'network_back_online',
7328
+ /** SFU error event with no descriptive message. */
7329
+ SFU_ERROR: 'sfu_error',
7330
+ };
7331
+
7310
7332
  /**
7311
7333
  * A base class for the `Publisher` and `Subscriber` classes.
7312
7334
  * @internal
@@ -7315,7 +7337,8 @@ class BasePeerConnection {
7315
7337
  /**
7316
7338
  * Constructs a new `BasePeerConnection` instance.
7317
7339
  */
7318
- constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, }) {
7340
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, }) {
7341
+ this.iceHasEverConnected = false;
7319
7342
  this.isIceRestarting = false;
7320
7343
  this.isDisposed = false;
7321
7344
  this.trackIdToTrackType = new Map();
@@ -7338,13 +7361,12 @@ class BasePeerConnection {
7338
7361
  */
7339
7362
  this.tryRestartIce = () => {
7340
7363
  this.restartIce().catch((e) => {
7341
- const reason = 'restartICE() failed, initiating reconnect';
7342
- this.logger.error(reason, e);
7364
+ this.logger.error('restartICE() failed, initiating reconnect', e);
7343
7365
  const strategy = e instanceof NegotiationError &&
7344
7366
  e.error.code === ErrorCode.PARTICIPANT_SIGNAL_LOST
7345
7367
  ? WebsocketReconnectStrategy.FAST
7346
7368
  : WebsocketReconnectStrategy.REJOIN;
7347
- this.onReconnectionNeeded?.(strategy, reason, this.peerType);
7369
+ this.onReconnectionNeeded?.(strategy, ReconnectReason.RESTART_ICE_FAILED, this.peerType);
7348
7370
  });
7349
7371
  };
7350
7372
  /**
@@ -7413,6 +7435,17 @@ class BasePeerConnection {
7413
7435
  const connectionState = this.pc.connectionState;
7414
7436
  return !failedStates.has(iceState) && !failedStates.has(connectionState);
7415
7437
  };
7438
+ /**
7439
+ * Returns true only when the peer connection is currently fully established
7440
+ * (ICE `connected`/`completed` AND connection state `connected`).
7441
+ * Transient states like `disconnected`, `checking`, or `new` return false.
7442
+ */
7443
+ this.isStable = () => {
7444
+ const iceState = this.pc.iceConnectionState;
7445
+ const connectionState = this.pc.connectionState;
7446
+ return ((iceState === 'connected' || iceState === 'completed') &&
7447
+ connectionState === 'connected');
7448
+ };
7416
7449
  /**
7417
7450
  * Handles the ICECandidate event and
7418
7451
  * Initiates an ICE Trickle process with the SFU.
@@ -7462,7 +7495,7 @@ class BasePeerConnection {
7462
7495
  }
7463
7496
  // we can't recover from a failed connection state (contrary to ICE)
7464
7497
  if (state === 'failed') {
7465
- this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, 'Connection failed', this.peerType);
7498
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.CONNECTION_FAILED, this.peerType);
7466
7499
  return;
7467
7500
  }
7468
7501
  this.handleConnectionStateUpdate(state);
@@ -7484,6 +7517,41 @@ class BasePeerConnection {
7484
7517
  // do nothing when ICE is restarting
7485
7518
  if (this.isIceRestarting)
7486
7519
  return;
7520
+ // Pre-connect handling: ICE has never reached `connected`/`completed`.
7521
+ // Restart is futile here (the data plane was never established), but
7522
+ // these two terminal-ish states need different treatment:
7523
+ // - `failed` is terminal, escalate to REJOIN so a new SFU/credentials
7524
+ // /PC configuration gets a chance, and let `Call.reconnect` count
7525
+ // this toward the unsupported-network budget.
7526
+ // - `disconnected` is transient, the browser may yet move back to
7527
+ // `checking`/`connected`. Don't restart, don't escalate; wait it
7528
+ // out. If it ultimately fails, ICE will transition to `failed` and
7529
+ // the branch above will take over.
7530
+ if (!this.iceHasEverConnected) {
7531
+ if (state === 'failed') {
7532
+ this.logger.info('ICE failed before connected, escalating to REJOIN');
7533
+ clearTimeout(this.preConnectStuckTimeout);
7534
+ this.preConnectStuckTimeout = undefined;
7535
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED, this.peerType);
7536
+ return;
7537
+ }
7538
+ if (state === 'disconnected') {
7539
+ this.logger.info('ICE disconnected before connected, wait to recover');
7540
+ // Watchdog: if the browser stays in `disconnected` without ever
7541
+ // reaching `connected` or transitioning to `failed`, escalate to
7542
+ // REJOIN ourselves so we don't wait silently forever. Rare but
7543
+ // observed on flaky mobile networks.
7544
+ clearTimeout(this.preConnectStuckTimeout);
7545
+ this.preConnectStuckTimeout = setTimeout(() => {
7546
+ if (!this.iceHasEverConnected &&
7547
+ this.pc.iceConnectionState === 'disconnected') {
7548
+ this.logger.info('ICE stuck in pre-connect disconnected, escalating to REJOIN');
7549
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED, this.peerType);
7550
+ }
7551
+ }, this.iceRestartDelay * 2);
7552
+ return;
7553
+ }
7554
+ }
7487
7555
  switch (state) {
7488
7556
  case 'failed':
7489
7557
  // in the `failed` state, we try to restart ICE immediately
@@ -7503,12 +7571,24 @@ class BasePeerConnection {
7503
7571
  }, this.iceRestartDelay);
7504
7572
  break;
7505
7573
  case 'connected':
7506
- // in the `connected` state, we clear the ice restart timeout if it exists
7574
+ case 'completed':
7575
+ // Fire `onIceConnected` exactly once per peer-connection lifetime —
7576
+ // the first time ICE reaches `connected`/`completed` end-to-end.
7577
+ // Used by `Call` to reset the unsupported-network failure counter
7578
+ // only after WebRTC has actually recovered, not merely on SFU join.
7579
+ if (!this.iceHasEverConnected) {
7580
+ this.iceHasEverConnected = true;
7581
+ this.onIceConnected?.(this.peerType);
7582
+ }
7583
+ // clear any scheduled restartICE since the connection is healthy
7507
7584
  if (this.iceRestartTimeout) {
7508
7585
  this.logger.info('connected connection, canceling restartICE');
7509
7586
  clearTimeout(this.iceRestartTimeout);
7510
7587
  this.iceRestartTimeout = undefined;
7511
7588
  }
7589
+ // clear the pre-connect watchdog if it was armed
7590
+ clearTimeout(this.preConnectStuckTimeout);
7591
+ this.preConnectStuckTimeout = undefined;
7512
7592
  break;
7513
7593
  }
7514
7594
  };
@@ -7541,6 +7621,7 @@ class BasePeerConnection {
7541
7621
  this.clientPublishOptions = clientPublishOptions;
7542
7622
  this.tag = tag;
7543
7623
  this.onReconnectionNeeded = onReconnectionNeeded;
7624
+ this.onIceConnected = onIceConnected;
7544
7625
  this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
7545
7626
  this.pc = this.createPeerConnection(connectionConfig);
7546
7627
  this.stats = new StatsTracer(this.pc, peerType, this.trackIdToTrackType);
@@ -7559,7 +7640,10 @@ class BasePeerConnection {
7559
7640
  dispose() {
7560
7641
  clearTimeout(this.iceRestartTimeout);
7561
7642
  this.iceRestartTimeout = undefined;
7643
+ clearTimeout(this.preConnectStuckTimeout);
7644
+ this.preConnectStuckTimeout = undefined;
7562
7645
  this.onReconnectionNeeded = undefined;
7646
+ this.onIceConnected = undefined;
7563
7647
  this.isDisposed = true;
7564
7648
  this.detachEventHandlers();
7565
7649
  this.pc.close();
@@ -8213,7 +8297,8 @@ class Publisher extends BasePeerConnection {
8213
8297
  const trackInfos = [];
8214
8298
  for (const publishOption of this.publishOptions) {
8215
8299
  const bundle = this.transceiverCache.get(publishOption);
8216
- if (!bundle || !bundle.transceiver.sender.track)
8300
+ const track = bundle?.transceiver.sender.track;
8301
+ if (!bundle || !track || track.readyState !== 'live')
8217
8302
  continue;
8218
8303
  trackInfos.push(this.toTrackInfo(bundle, sdp));
8219
8304
  }
@@ -8558,6 +8643,20 @@ class SfuJoinError extends Error {
8558
8643
  }
8559
8644
  }
8560
8645
 
8646
+ /**
8647
+ * Creates a fresh `joinResponseTask` with a no-op rejection handler attached
8648
+ * to the underlying promise. The handler marks the rejection path as handled
8649
+ * so a teardown-time reject (e.g., from `close()` during disposal) does not
8650
+ * surface as an `UnhandledPromiseRejection`. Explicit awaiters of
8651
+ * `StreamSfuClient.joinTask` still observe the rejection through their own
8652
+ * `then`/`catch` chain. `.catch()` returns a new promise; the original is
8653
+ * unchanged.
8654
+ */
8655
+ const makeJoinResponseTask = () => {
8656
+ const task = promiseWithResolvers();
8657
+ task.promise.catch(() => { }); // see the comment above
8658
+ return task;
8659
+ };
8561
8660
  /**
8562
8661
  * The client used for exchanging information with the SFU.
8563
8662
  */
@@ -8589,9 +8688,10 @@ class StreamSfuClient {
8589
8688
  this.subscriptionsConcurrencyTag = Symbol('subscriptionsConcurrencyTag');
8590
8689
  /**
8591
8690
  * Promise that resolves when the JoinResponse is received.
8592
- * Rejects after a certain threshold if the response is not received.
8691
+ * Rejects after a certain threshold if the response is not received,
8692
+ * or when the SFU client is disposed before a join completes.
8593
8693
  */
8594
- this.joinResponseTask = promiseWithResolvers();
8694
+ this.joinResponseTask = makeJoinResponseTask();
8595
8695
  /**
8596
8696
  * A controller to abort the current requests.
8597
8697
  */
@@ -8661,14 +8761,21 @@ class StreamSfuClient {
8661
8761
  };
8662
8762
  this.close = (code = StreamSfuClient.NORMAL_CLOSURE, reason) => {
8663
8763
  this.isClosingClean = code !== StreamSfuClient.ERROR_CONNECTION_UNHEALTHY;
8664
- if (this.signalWs.readyState === WebSocket.OPEN) {
8764
+ // Close the WebSocket whether it has fully opened (`OPEN`) or is still
8765
+ // mid-handshake (`CONNECTING`). The WebSocket spec aborts the handshake
8766
+ // when `close()` is called on a CONNECTING socket. Without this, an
8767
+ // SFU socket that opens just after teardown would dispatch events into
8768
+ // a Call instance that has already moved on.
8769
+ const ws = this.signalWs;
8770
+ if (ws.readyState === WebSocket.OPEN ||
8771
+ ws.readyState === WebSocket.CONNECTING) {
8665
8772
  this.logger.debug(`Closing SFU WS connection: ${code} - ${reason}`);
8666
- this.signalWs.close(code, `js-client: ${reason}`);
8667
- this.signalWs.removeEventListener('close', this.handleWebSocketClose);
8773
+ ws.close(code, `js-client: ${reason}`);
8774
+ ws.removeEventListener('close', this.handleWebSocketClose);
8668
8775
  }
8669
- this.dispose();
8776
+ this.dispose(reason);
8670
8777
  };
8671
- this.dispose = () => {
8778
+ this.dispose = (reason) => {
8672
8779
  this.logger.debug('Disposing SFU client');
8673
8780
  this.unsubscribeIceTrickle();
8674
8781
  this.unsubscribeNetworkChanged();
@@ -8677,6 +8784,13 @@ class StreamSfuClient {
8677
8784
  clearTimeout(this.migrateAwayTimeout);
8678
8785
  this.abortController.abort();
8679
8786
  this.migrationTask?.resolve();
8787
+ // Settle a pending `joinResponseTask` so `leaveAndClose`, `join()`, and
8788
+ // any other awaiters (`await this.joinTask`) don't hang indefinitely
8789
+ // when the SFU client is torn down before the SFU sent a JoinResponse.
8790
+ if (!this.joinResponseTask.isResolved() &&
8791
+ !this.joinResponseTask.isRejected()) {
8792
+ this.joinResponseTask.reject(new Error(`SFU client disposed before join completed${reason ? `: ${reason}` : ''}`));
8793
+ }
8680
8794
  this.iceTrickleBuffer.dispose();
8681
8795
  };
8682
8796
  this.getTrace = () => {
@@ -8685,8 +8799,24 @@ class StreamSfuClient {
8685
8799
  this.leaveAndClose = async (reason) => {
8686
8800
  try {
8687
8801
  this.isLeaving = true;
8688
- await this.joinTask;
8689
- await this.notifyLeave(reason);
8802
+ // Best-effort: give an in-flight join a short grace period to complete
8803
+ // so we can send a graceful `leaveCallRequest`. Bounded so we never hang
8804
+ // here if the SFU is unresponsive. If the task settles either way during
8805
+ // the wait, the re-check below decides whether to notify.
8806
+ if (!this.joinResponseTask.isResolved() &&
8807
+ !this.joinResponseTask.isRejected()) {
8808
+ await Promise.race([
8809
+ // swallow rejection — we re-check `isResolved()` below to decide
8810
+ this.joinResponseTask.promise.catch(() => { }),
8811
+ sleep(StreamSfuClient.LEAVE_NOTIFY_GRACE_MS),
8812
+ ]);
8813
+ }
8814
+ if (this.joinResponseTask.isResolved()) {
8815
+ await this.notifyLeave(reason);
8816
+ }
8817
+ else {
8818
+ this.logger.debug('[leaveAndClose] join not completed within grace period, skipping notifyLeave');
8819
+ }
8690
8820
  }
8691
8821
  catch (err) {
8692
8822
  this.logger.debug('Error notifying SFU about leaving call', err);
@@ -8755,9 +8885,9 @@ class StreamSfuClient {
8755
8885
  this.joinResponseTask.isRejected()) {
8756
8886
  // we need to lock the RPC requests until we receive a JoinResponse.
8757
8887
  // that's why we have this primitive lock mechanism.
8758
- // the client starts with already initialized joinResponseTask,
8888
+ // the client starts with an already initialized joinResponseTask,
8759
8889
  // and this code creates a new one for the next join request.
8760
- this.joinResponseTask = promiseWithResolvers();
8890
+ this.joinResponseTask = makeJoinResponseTask();
8761
8891
  }
8762
8892
  // capture a reference to the current joinResponseTask as it might
8763
8893
  // be replaced with a new one in case a second join request is made
@@ -8930,6 +9060,12 @@ StreamSfuClient.DISPOSE_OLD_SOCKET = 4100;
8930
9060
  * The close code used when the client fails to join the call (on the SFU).
8931
9061
  */
8932
9062
  StreamSfuClient.JOIN_FAILED = 4101;
9063
+ /**
9064
+ * Best-effort grace period in `leaveAndClose` for an in-flight join to
9065
+ * complete before we give up and close without sending `leaveCallRequest`.
9066
+ * Bounded so a stuck join can never hang the leave path.
9067
+ */
9068
+ StreamSfuClient.LEAVE_NOTIFY_GRACE_MS = 1000;
8933
9069
 
8934
9070
  /**
8935
9071
  * Event handler that watched the delivery of `call.accepted`.
@@ -10325,6 +10461,50 @@ const CallTypes = new CallTypesRegistry([
10325
10461
  }),
10326
10462
  ]);
10327
10463
 
10464
+ /**
10465
+ * A generic sliding-window rate limiter.
10466
+ *
10467
+ * Allows at most `maxAttempts` registrations inside a rolling `windowMs`.
10468
+ * Attempts spaced further apart than `windowMs` are always allowed.
10469
+ */
10470
+ class SlidingWindowRateLimiter {
10471
+ constructor(maxAttempts, windowMs) {
10472
+ this.timestamps = [];
10473
+ /**
10474
+ * Attempts to register a new event at `now`. Returns `true` if the attempt
10475
+ * fits inside the budget (and records it), or `false` if the budget is
10476
+ * exhausted (in which case no timestamp is recorded).
10477
+ */
10478
+ this.tryRegister = (now = Date.now()) => {
10479
+ this.prune(now);
10480
+ if (this.timestamps.length >= this.maxAttempts)
10481
+ return false;
10482
+ this.timestamps.push(now);
10483
+ return true;
10484
+ };
10485
+ /**
10486
+ * Clears the attempt history.
10487
+ */
10488
+ this.reset = () => {
10489
+ this.timestamps = [];
10490
+ };
10491
+ /**
10492
+ * Updates the budget and window size. Existing timestamps are kept; they
10493
+ * will be pruned by the next `tryRegister` call.
10494
+ */
10495
+ this.setLimits = (maxAttempts, windowMs) => {
10496
+ this.maxAttempts = maxAttempts;
10497
+ this.windowMs = windowMs;
10498
+ };
10499
+ this.prune = (now) => {
10500
+ const cutoff = now - this.windowMs;
10501
+ this.timestamps = this.timestamps.filter((t) => t >= cutoff);
10502
+ };
10503
+ this.maxAttempts = maxAttempts;
10504
+ this.windowMs = windowMs;
10505
+ }
10506
+ }
10507
+
10328
10508
  /**
10329
10509
  * Deactivates MediaStream (stops and removes tracks) to be later garbage collected
10330
10510
  *
@@ -10706,7 +10886,6 @@ const getScreenShareStream = async (options, tracer) => {
10706
10886
  const tag = `navigator.mediaDevices.getDisplayMedia.${getDisplayMediaExecId++}.`;
10707
10887
  try {
10708
10888
  const constraints = {
10709
- // @ts-expect-error - not present in types yet
10710
10889
  systemAudio: 'include',
10711
10890
  ...options,
10712
10891
  video: typeof options?.video === 'boolean'
@@ -10721,6 +10900,8 @@ const getScreenShareStream = async (options, tracer) => {
10721
10900
  ? options.audio
10722
10901
  : {
10723
10902
  channelCount: { ideal: 2 },
10903
+ // @ts-expect-error not yet present in the types
10904
+ restrictOwnAudio: true,
10724
10905
  echoCancellation: false,
10725
10906
  autoGainControl: false,
10726
10907
  noiseSuppression: false,
@@ -11365,6 +11546,7 @@ class DeviceManagerState {
11365
11546
  this.statusSubject = new BehaviorSubject(undefined);
11366
11547
  this.optimisticStatusSubject = new BehaviorSubject(undefined);
11367
11548
  this.mediaStreamSubject = new BehaviorSubject(undefined);
11549
+ this.rootMediaStreamSubject = new BehaviorSubject(undefined);
11368
11550
  this.selectedDeviceSubject = new BehaviorSubject(undefined);
11369
11551
  this.defaultConstraintsSubject = new BehaviorSubject(undefined);
11370
11552
  /**
@@ -11372,6 +11554,12 @@ class DeviceManagerState {
11372
11554
  *
11373
11555
  */
11374
11556
  this.mediaStream$ = this.mediaStreamSubject.asObservable();
11557
+ /**
11558
+ * An Observable that emits the raw device media stream (before any filters are applied),
11559
+ * or `undefined` if the device is currently disabled. When no filters are active, this
11560
+ * emits the same stream as `mediaStream$`.
11561
+ */
11562
+ this.rootMediaStream$ = this.rootMediaStreamSubject.asObservable();
11375
11563
  /**
11376
11564
  * An Observable that emits the currently selected device
11377
11565
  */
@@ -11427,6 +11615,14 @@ class DeviceManagerState {
11427
11615
  get mediaStream() {
11428
11616
  return getCurrentValue(this.mediaStream$);
11429
11617
  }
11618
+ /**
11619
+ * The raw device media stream (before any filters are applied), or `undefined`
11620
+ * if the device is currently disabled. When no filters are active, this is the
11621
+ * same as `mediaStream`.
11622
+ */
11623
+ get rootMediaStream() {
11624
+ return getCurrentValue(this.rootMediaStream$);
11625
+ }
11430
11626
  /**
11431
11627
  * @internal
11432
11628
  * @param status
@@ -11451,6 +11647,7 @@ class DeviceManagerState {
11451
11647
  */
11452
11648
  setMediaStream(stream, rootStream) {
11453
11649
  setCurrentValue(this.mediaStreamSubject, stream);
11650
+ setCurrentValue(this.rootMediaStreamSubject, rootStream);
11454
11651
  if (rootStream) {
11455
11652
  this.setDevice(this.getDeviceIdFromStream(rootStream));
11456
11653
  }
@@ -12702,6 +12899,16 @@ class Call {
12702
12899
  this.fastReconnectDeadlineSeconds = 0;
12703
12900
  this.disconnectionTimeoutSeconds = 0;
12704
12901
  this.lastOfflineTimestamp = 0;
12902
+ // (10 attempts per rolling 120 s window).
12903
+ this.rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
12904
+ // "Network doesn't support WebRTC" detector: counts peer-connection
12905
+ // failures where ICE never reached `connected`/`completed`.
12906
+ this.maxIceFailuresWithoutConnect = 2;
12907
+ this.iceFailuresWithoutConnect = 0;
12908
+ // Consecutive-negotiation-failure detector: stops the reconnect loop when
12909
+ // the SFU keeps failing to negotiate SDP for us.
12910
+ this.maxConsecutiveNegotiationFailures = 3;
12911
+ this.consecutiveNegotiationFailures = 0;
12705
12912
  // maintain the order of publishing tracks to restore them after a reconnection
12706
12913
  // it shouldn't contain duplicates
12707
12914
  this.trackPublishOrder = [];
@@ -13004,6 +13211,19 @@ class Call {
13004
13211
  this.state.setCallingState(CallingState.LEFT);
13005
13212
  this.state.setParticipants([]);
13006
13213
  this.state.dispose();
13214
+ // Reset reconnect-related accumulators so a future `call.join()` on the
13215
+ // same instance starts with a fresh budget. The `Call` may be reused
13216
+ // (see `Call.test.ts` "can reuse call instance") so this is required.
13217
+ // Strategy/reason/attempts must also be cleared: when `leave()` is
13218
+ // reached via `giveUpAndLeave()` the success-path reset at the end of
13219
+ // `joinFlow` never runs, leaving stale values that would make the next
13220
+ // fresh `join()` send a stale `ReconnectDetails` to the SFU.
13221
+ this.rejoinRateLimiter.reset();
13222
+ this.iceFailuresWithoutConnect = 0;
13223
+ this.consecutiveNegotiationFailures = 0;
13224
+ this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
13225
+ this.reconnectReason = '';
13226
+ this.reconnectAttempts = 0;
13007
13227
  // Call all leave call hooks, e.g. to clean up global event handlers
13008
13228
  this.leaveCallHooks.forEach((hook) => hook());
13009
13229
  this.initialized = false;
@@ -13354,9 +13574,20 @@ class Call {
13354
13574
  // when performing fast reconnect, or when we reuse the same SFU client,
13355
13575
  // (ws remained healthy), we just need to restore the ICE connection
13356
13576
  if (performingFastReconnect) {
13357
- // the SFU automatically issues an ICE restart on the subscriber
13358
- // we don't have to do it ourselves
13359
- await this.restoreICE(sfuClient, { includeSubscriber: false });
13577
+ // The SFU automatically issues an ICE restart on the subscriber,
13578
+ // so we only need to decide about the publisher. If the publisher's
13579
+ // peer connection is still stable (ICE still connected end-to-end),
13580
+ // the signal WebSocket drop was the only problem — the new WS alone
13581
+ // is enough, and restarting ICE would add unnecessary SDP/ICE churn.
13582
+ const publisherIsStable = this.publisher?.isStable() ?? true;
13583
+ const includePublisher = !!this.publisher?.isPublishing() && !publisherIsStable;
13584
+ if (!includePublisher && this.publisher?.isPublishing()) {
13585
+ this.logger.info('[Reconnect] FAST: skipping publisher ICE restart, publisher PC is stable');
13586
+ }
13587
+ await this.restoreICE(sfuClient, {
13588
+ includeSubscriber: false,
13589
+ includePublisher,
13590
+ });
13360
13591
  }
13361
13592
  else {
13362
13593
  const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
@@ -13399,6 +13630,15 @@ class Call {
13399
13630
  // reset the reconnect strategy to unspecified after a successful reconnection
13400
13631
  this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
13401
13632
  this.reconnectReason = '';
13633
+ // A successful SFU join handshake resets the consecutive-negotiation
13634
+ // counter (negotiation just succeeded). It does NOT reset
13635
+ // `iceFailuresWithoutConnect` or the rolling `rejoinRateLimiter`;
13636
+ // those track WebRTC-level health and rejoin frequency, which are not
13637
+ // proven by the SFU handshake alone. ICE-failures-without-connect is
13638
+ // cleared via the `onIceConnected` callback when the peer connection
13639
+ // actually reaches `connected`/`completed` end-to-end. The rejoin
13640
+ // rolling window decays naturally as old timestamps age out.
13641
+ this.consecutiveNegotiationFailures = 0;
13402
13642
  this.logger.info(`Joined call ${this.cid}`);
13403
13643
  };
13404
13644
  /**
@@ -13516,6 +13756,12 @@ class Call {
13516
13756
  this.logger.warn(message, err);
13517
13757
  });
13518
13758
  },
13759
+ onIceConnected: () => {
13760
+ // ICE has reached `connected`/`completed` end-to-end on at least
13761
+ // one peer connection, WebRTC is actually working, so the
13762
+ // "ICE never connected" failure budget can be cleared.
13763
+ this.iceFailuresWithoutConnect = 0;
13764
+ },
13519
13765
  };
13520
13766
  this.subscriber = new Subscriber(basePeerConnectionOptions);
13521
13767
  // anonymous users can't publish anything hence, there is no need
@@ -13621,7 +13867,9 @@ class Call {
13621
13867
  * @internal
13622
13868
  *
13623
13869
  * @param strategy the reconnection strategy to use.
13624
- * @param reason the reason for the reconnection.
13870
+ * @param reason the reason for the reconnection. Pass a `ReconnectReason.*`
13871
+ * constant when the SDK should react to it (e.g.
13872
+ * `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
13625
13873
  */
13626
13874
  this.reconnect = async (strategy, reason) => {
13627
13875
  if (this.state.callingState === CallingState.RECONNECTING ||
@@ -13642,6 +13890,30 @@ class Call {
13642
13890
  this.state.setCallingState(CallingState.RECONNECTING_FAILED);
13643
13891
  }
13644
13892
  };
13893
+ const giveUpAndLeave = async (message) => {
13894
+ this.logger.warn(`[Reconnect] Giving up: ${message}. Leaving the call.`);
13895
+ // If we're mid-iteration, the state can be JOINING; `Call.leave` would
13896
+ // then wait for JOINED before proceeding, but no more attempts will run
13897
+ // so JOINED never comes. Transition to RECONNECTING so `leave()` proceeds.
13898
+ if (this.state.callingState === CallingState.JOINING) {
13899
+ this.state.setCallingState(CallingState.RECONNECTING);
13900
+ }
13901
+ try {
13902
+ await this.leave({ message });
13903
+ }
13904
+ catch (err) {
13905
+ this.logger.warn(`[Reconnect] leave() failed after ${message}`, err);
13906
+ }
13907
+ };
13908
+ // Count this entry into reconnect if it was triggered by a peer
13909
+ // connection that never reached `connected`/`completed`.
13910
+ if (reason === ReconnectReason.ICE_NEVER_CONNECTED) {
13911
+ this.iceFailuresWithoutConnect++;
13912
+ if (this.iceFailuresWithoutConnect >= this.maxIceFailuresWithoutConnect) {
13913
+ await giveUpAndLeave('webrtc_unsupported_network');
13914
+ return;
13915
+ }
13916
+ }
13645
13917
  let attempt = 0;
13646
13918
  do {
13647
13919
  const reconnectingTime = Date.now() - reconnectStartTime;
@@ -13652,6 +13924,16 @@ class Call {
13652
13924
  await markAsReconnectingFailed();
13653
13925
  return;
13654
13926
  }
13927
+ // Rejoin rate limit: bound the number of REJOIN (and MIGRATE)
13928
+ // transitions inside a rolling window. FAST is not counted because
13929
+ // it does not issue a new backend `joinCall`.
13930
+ if (this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN ||
13931
+ this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE) {
13932
+ if (!this.rejoinRateLimiter.tryRegister()) {
13933
+ await giveUpAndLeave('rejoin_attempt_limit_exceeded');
13934
+ return;
13935
+ }
13936
+ }
13655
13937
  // we don't increment reconnect attempts for the FAST strategy.
13656
13938
  if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
13657
13939
  this.reconnectAttempts++;
@@ -13679,6 +13961,8 @@ class Call {
13679
13961
  ensureExhausted(this.reconnectStrategy, 'Unknown reconnection strategy');
13680
13962
  break;
13681
13963
  }
13964
+ // reconnection worked — reset the negotiation-failure streak.
13965
+ this.consecutiveNegotiationFailures = 0;
13682
13966
  break; // do-while loop, reconnection worked, exit the loop
13683
13967
  }
13684
13968
  catch (error) {
@@ -13693,7 +13977,16 @@ class Call {
13693
13977
  await markAsReconnectingFailed();
13694
13978
  return;
13695
13979
  }
13696
- await sleep(500);
13980
+ if (error instanceof NegotiationError) {
13981
+ this.consecutiveNegotiationFailures++;
13982
+ if (this.consecutiveNegotiationFailures >=
13983
+ this.maxConsecutiveNegotiationFailures) {
13984
+ await giveUpAndLeave('repeated_negotiation_failures');
13985
+ return;
13986
+ }
13987
+ }
13988
+ // exponential backoff with jitter, capped at 5 s
13989
+ await sleep(retryInterval(attempt));
13697
13990
  const wasMigrating = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
13698
13991
  const mustPerformRejoin = (Date.now() - reconnectStartTime) / 1000 >
13699
13992
  this.fastReconnectDeadlineSeconds;
@@ -13802,7 +14095,7 @@ class Call {
13802
14095
  this.registerReconnectHandlers = () => {
13803
14096
  // handles the legacy "goAway" event
13804
14097
  const unregisterGoAway = this.on('goAway', () => {
13805
- this.reconnect(WebsocketReconnectStrategy.MIGRATE, 'goAway').catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
14098
+ this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
13806
14099
  });
13807
14100
  // handles the "error" event, through which the SFU can request a reconnect
13808
14101
  const unregisterOnError = this.on('error', (e) => {
@@ -13821,7 +14114,7 @@ class Call {
13821
14114
  });
13822
14115
  }
13823
14116
  else {
13824
- this.reconnect(strategy, error?.message || 'SFU Error').catch((err) => {
14117
+ this.reconnect(strategy, error?.message || ReconnectReason.SFU_ERROR).catch((err) => {
13825
14118
  this.logger.warn('[Reconnect] Error reconnecting', err);
13826
14119
  });
13827
14120
  }
@@ -13845,7 +14138,7 @@ class Call {
13845
14138
  strategy = WebsocketReconnectStrategy.REJOIN;
13846
14139
  }
13847
14140
  }
13848
- this.reconnect(strategy, 'Going online').catch((err) => {
14141
+ this.reconnect(strategy, ReconnectReason.NETWORK_BACK_ONLINE).catch((err) => {
13849
14142
  this.logger.warn('[Reconnect] Error reconnecting after going online', err);
13850
14143
  });
13851
14144
  });
@@ -13988,10 +14281,12 @@ class Call {
13988
14281
  * @param trackTypes the track types to update the call state with.
13989
14282
  */
13990
14283
  this.updateLocalStreamState = async (mediaStream, ...trackTypes) => {
13991
- if (!this.sfuClient || !this.sfuClient.sessionId)
14284
+ const sessionId = this.sfuClient?.sessionId;
14285
+ if (!sessionId)
13992
14286
  return;
13993
14287
  await this.notifyTrackMuteState(!mediaStream, ...trackTypes);
13994
- const { sessionId } = this.sfuClient;
14288
+ if (this.sfuClient?.sessionId !== sessionId)
14289
+ return;
13995
14290
  for (const trackType of trackTypes) {
13996
14291
  const streamStateProp = trackTypeToParticipantStreamKey(trackType);
13997
14292
  if (!streamStateProp)
@@ -14787,6 +15082,39 @@ class Call {
14787
15082
  this.setDisconnectionTimeout = (timeoutSeconds) => {
14788
15083
  this.disconnectionTimeoutSeconds = timeoutSeconds;
14789
15084
  };
15085
+ /**
15086
+ * Configures the rolling-window limit for REJOIN/MIGRATE attempts. Once
15087
+ * `maxAttempts` rejoins have been registered inside `windowSeconds`, the
15088
+ * SDK stops retrying and transitions the call to `LEFT` with the
15089
+ * `rejoin_attempt_limit_exceeded` leave message.
15090
+ *
15091
+ * Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
15092
+ * Both arguments are clamped to a minimum of 1.
15093
+ */
15094
+ this.setRejoinAttemptLimit = (maxAttempts, windowSeconds) => {
15095
+ this.rejoinRateLimiter.setLimits(Math.max(1, maxAttempts), Math.max(1, windowSeconds) * 1000);
15096
+ };
15097
+ /**
15098
+ * Configures how many peer-connection failures where ICE never reached
15099
+ * `connected`/`completed` are tolerated before the SDK concludes that the
15100
+ * current network cannot support WebRTC and transitions the call to
15101
+ * `LEFT` with the `webrtc_unsupported_network` leave message.
15102
+ *
15103
+ * Default: 2. Clamped to a minimum of 1.
15104
+ */
15105
+ this.setMaxIceFailuresWithoutConnect = (n) => {
15106
+ this.maxIceFailuresWithoutConnect = Math.max(1, n);
15107
+ };
15108
+ /**
15109
+ * Configures how many consecutive SDP `NegotiationError`s are tolerated
15110
+ * before the SDK stops retrying and transitions the call to `LEFT` with
15111
+ * the `repeated_negotiation_failures` leave message.
15112
+ *
15113
+ * Default: 3. Clamped to a minimum of 1.
15114
+ */
15115
+ this.setMaxConsecutiveNegotiationFailures = (n) => {
15116
+ this.maxConsecutiveNegotiationFailures = Math.max(1, n);
15117
+ };
14790
15118
  /**
14791
15119
  * Enables the provided client capabilities.
14792
15120
  */
@@ -15967,7 +16295,7 @@ class StreamClient {
15967
16295
  this.getUserAgent = () => {
15968
16296
  if (!this.cachedUserAgent) {
15969
16297
  const { clientAppIdentifier = {} } = this.options;
15970
- const { sdkName = 'js', sdkVersion = "1.48.0", ...extras } = clientAppIdentifier;
16298
+ const { sdkName = 'js', sdkVersion = "1.49.0", ...extras } = clientAppIdentifier;
15971
16299
  this.cachedUserAgent = [
15972
16300
  `stream-video-${sdkName}-v${sdkVersion}`,
15973
16301
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),