@stream-io/video-client 1.42.1 → 1.42.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).
4
4
 
5
+ ## [1.42.2](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.42.1...@stream-io/video-client-1.42.2) (2026-02-13)
6
+
7
+ ### Bug Fixes
8
+
9
+ - improve the handling of join errors and prevent cross-socket event leaking ([#2121](https://github.com/GetStream/stream-video-js/issues/2121)) ([72d0834](https://github.com/GetStream/stream-video-js/commit/72d08343243990f14f29103734eea6f7cb6092c9))
10
+
5
11
  ## [1.42.1](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.42.0...@stream-io/video-client-1.42.1) (2026-02-10)
6
12
 
7
13
  ### Bug Fixes
@@ -4271,6 +4271,12 @@ const sfuEventKinds = {
4271
4271
  changePublishOptions: undefined,
4272
4272
  inboundStateNotification: undefined,
4273
4273
  };
4274
+ /**
4275
+ * Determines if a given event name belongs to the category of SFU events.
4276
+ *
4277
+ * @param eventName the name of the event to check.
4278
+ * @returns true if the event name is an SFU event, otherwise false.
4279
+ */
4274
4280
  const isSfuEvent = (eventName) => {
4275
4281
  return Object.prototype.hasOwnProperty.call(sfuEventKinds, eventName);
4276
4282
  };
@@ -4278,33 +4284,70 @@ class Dispatcher {
4278
4284
  constructor() {
4279
4285
  this.logger = videoLoggerSystem.getLogger('Dispatcher');
4280
4286
  this.subscribers = {};
4281
- this.dispatch = (message, tag = '0') => {
4287
+ /**
4288
+ * Dispatch an event to all subscribers.
4289
+ *
4290
+ * @param message the event payload to dispatch.
4291
+ * @param tag for scoping events to a specific tag. Use `*` dispatch to every tag.
4292
+ */
4293
+ this.dispatch = (message, tag = '*') => {
4282
4294
  const eventKind = message.eventPayload.oneofKind;
4283
4295
  if (!eventKind)
4284
4296
  return;
4285
4297
  const payload = message.eventPayload[eventKind];
4286
4298
  this.logger.debug(`Dispatching ${eventKind}, tag=${tag}`, payload);
4287
- const listeners = this.subscribers[eventKind];
4288
- if (!listeners)
4299
+ const handlers = this.subscribers[eventKind];
4300
+ if (!handlers)
4289
4301
  return;
4290
- for (const fn of listeners) {
4302
+ this.emit(payload, handlers[tag]);
4303
+ if (tag !== '*')
4304
+ this.emit(payload, handlers['*']);
4305
+ };
4306
+ /**
4307
+ * Emit an event to a list of listeners.
4308
+ *
4309
+ * @param payload the event payload to emit.
4310
+ * @param listeners the list of listeners to emit the event to.
4311
+ */
4312
+ this.emit = (payload, listeners = []) => {
4313
+ for (const listener of listeners) {
4291
4314
  try {
4292
- fn(payload);
4315
+ listener(payload);
4293
4316
  }
4294
4317
  catch (e) {
4295
4318
  this.logger.warn('Listener failed with error', e);
4296
4319
  }
4297
4320
  }
4298
4321
  };
4299
- this.on = (eventName, fn) => {
4322
+ /**
4323
+ * Subscribe to an event.
4324
+ *
4325
+ * @param eventName the name of the event to subscribe to.
4326
+ * @param tag for scoping events to a specific tag. Use `*` dispatch to every tag.
4327
+ * @param fn the callback function to invoke when the event is emitted.
4328
+ * @returns a function that can be called to unsubscribe from the event.
4329
+ */
4330
+ this.on = (eventName, tag, fn) => {
4300
4331
  var _a;
4301
- ((_a = this.subscribers)[eventName] ?? (_a[eventName] = [])).push(fn);
4332
+ const bucket = ((_a = this.subscribers)[eventName] ?? (_a[eventName] = {}));
4333
+ (bucket[tag] ?? (bucket[tag] = [])).push(fn);
4302
4334
  return () => {
4303
- this.off(eventName, fn);
4335
+ this.off(eventName, tag, fn);
4304
4336
  };
4305
4337
  };
4306
- this.off = (eventName, fn) => {
4307
- this.subscribers[eventName] = (this.subscribers[eventName] || []).filter((f) => f !== fn);
4338
+ /**
4339
+ * Unsubscribe from an event.
4340
+ *
4341
+ * @param eventName the name of the event to unsubscribe from.
4342
+ * @param tag for scoping events to a specific tag. Use `*` dispatch to every tag.
4343
+ * @param fn the callback function to remove from the event listeners.
4344
+ */
4345
+ this.off = (eventName, tag, fn) => {
4346
+ const bucket = this.subscribers[eventName];
4347
+ const listeners = bucket?.[tag];
4348
+ if (!listeners)
4349
+ return;
4350
+ bucket[tag] = listeners.filter((f) => f !== fn);
4308
4351
  };
4309
4352
  }
4310
4353
  }
@@ -6188,7 +6231,7 @@ const getSdkVersion = (sdk) => {
6188
6231
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
6189
6232
  };
6190
6233
 
6191
- const version = "1.42.1";
6234
+ const version = "1.42.2";
6192
6235
  const [major, minor, patch] = version.split('.');
6193
6236
  let sdkInfo = {
6194
6237
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -7255,7 +7298,7 @@ class BasePeerConnection {
7255
7298
  * Consecutive events are queued and executed one after the other.
7256
7299
  */
7257
7300
  this.on = (event, fn) => {
7258
- this.subscriptions.push(this.dispatcher.on(event, (e) => {
7301
+ this.subscriptions.push(this.dispatcher.on(event, this.tag, (e) => {
7259
7302
  const lockKey = `pc.${this.lock}.${event}`;
7260
7303
  withoutConcurrency(lockKey, async () => fn(e)).catch((err) => {
7261
7304
  if (this.isDisposed)
@@ -7440,6 +7483,7 @@ class BasePeerConnection {
7440
7483
  this.dispatcher = dispatcher;
7441
7484
  this.iceRestartDelay = iceRestartDelay;
7442
7485
  this.clientPublishOptions = clientPublishOptions;
7486
+ this.tag = tag;
7443
7487
  this.onReconnectionNeeded = onReconnectionNeeded;
7444
7488
  this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
7445
7489
  this.pc = this.createPeerConnection(connectionConfig);
@@ -8442,6 +8486,21 @@ const getTimers = lazy(() => {
8442
8486
  return new WorkerTimer({ useWorker: timerWorkerEnabled });
8443
8487
  });
8444
8488
 
8489
+ class SfuJoinError extends Error {
8490
+ constructor(event) {
8491
+ super(event.error?.message || 'Join Error');
8492
+ this.errorEvent = event;
8493
+ this.unrecoverable =
8494
+ event.reconnectStrategy === WebsocketReconnectStrategy.DISCONNECT;
8495
+ }
8496
+ static isJoinErrorCode(event) {
8497
+ const code = event.error?.code;
8498
+ return (code === ErrorCode.SFU_FULL ||
8499
+ code === ErrorCode.SFU_SHUTTING_DOWN ||
8500
+ code === ErrorCode.CALL_PARTICIPANT_LIMIT_REACHED);
8501
+ }
8502
+ }
8503
+
8445
8504
  /**
8446
8505
  * The client used for exchanging information with the SFU.
8447
8506
  */
@@ -8618,7 +8677,7 @@ class StreamSfuClient {
8618
8677
  const { timeout = 7 * 1000 } = opts;
8619
8678
  this.migrationTask?.reject(new Error('Cancelled previous migration'));
8620
8679
  const task = (this.migrationTask = promiseWithResolvers());
8621
- const unsubscribe = this.dispatcher.on('participantMigrationComplete', () => {
8680
+ const unsubscribe = this.dispatcher.on('participantMigrationComplete', this.tag, () => {
8622
8681
  unsubscribe();
8623
8682
  clearTimeout(this.migrateAwayTimeout);
8624
8683
  task.resolve();
@@ -8644,27 +8703,29 @@ class StreamSfuClient {
8644
8703
  // be replaced with a new one in case a second join request is made
8645
8704
  const current = this.joinResponseTask;
8646
8705
  let timeoutId = undefined;
8647
- const unsubscribeJoinErrorEvents = this.dispatcher.on('error', (event) => {
8648
- const { error, reconnectStrategy } = event;
8649
- if (!error)
8650
- return;
8651
- if (reconnectStrategy === WebsocketReconnectStrategy.DISCONNECT) {
8652
- clearTimeout(timeoutId);
8653
- unsubscribe?.();
8654
- unsubscribeJoinErrorEvents();
8706
+ let unsubscribeJoinResponse = undefined;
8707
+ let unsubscribeJoinErrorEvents = undefined;
8708
+ const cleanupJoinSubscriptions = () => {
8709
+ clearTimeout(timeoutId);
8710
+ timeoutId = undefined;
8711
+ unsubscribeJoinErrorEvents?.();
8712
+ unsubscribeJoinErrorEvents = undefined;
8713
+ unsubscribeJoinResponse?.();
8714
+ unsubscribeJoinResponse = undefined;
8715
+ };
8716
+ unsubscribeJoinErrorEvents = this.dispatcher.on('error', this.tag, (event) => {
8717
+ if (SfuJoinError.isJoinErrorCode(event)) {
8718
+ cleanupJoinSubscriptions();
8655
8719
  current.reject(new SfuJoinError(event));
8656
8720
  }
8657
8721
  });
8658
- const unsubscribe = this.dispatcher.on('joinResponse', (joinResponse) => {
8659
- clearTimeout(timeoutId);
8660
- unsubscribe();
8661
- unsubscribeJoinErrorEvents();
8722
+ unsubscribeJoinResponse = this.dispatcher.on('joinResponse', this.tag, (joinResponse) => {
8723
+ cleanupJoinSubscriptions();
8662
8724
  this.keepAlive();
8663
8725
  current.resolve(joinResponse);
8664
8726
  });
8665
8727
  timeoutId = setTimeout(() => {
8666
- unsubscribe();
8667
- unsubscribeJoinErrorEvents();
8728
+ cleanupJoinSubscriptions();
8668
8729
  const message = `Waiting for "joinResponse" has timed out after ${this.joinResponseTimeout}ms`;
8669
8730
  this.tracer?.trace('joinRequestTimeout', message);
8670
8731
  current.reject(new Error(message));
@@ -8759,8 +8820,8 @@ class StreamSfuClient {
8759
8820
  // In that case, those events (ICE candidates) need to be buffered
8760
8821
  // and later added to the appropriate PeerConnection
8761
8822
  // once the remoteDescription is known and set.
8762
- this.unsubscribeIceTrickle = dispatcher.on('iceTrickle', (iceTrickle) => {
8763
- this.iceTrickleBuffer.push(iceTrickle);
8823
+ this.unsubscribeIceTrickle = dispatcher.on('iceTrickle', tag, (t) => {
8824
+ this.iceTrickleBuffer.push(t);
8764
8825
  });
8765
8826
  // listen to network changes to handle offline state
8766
8827
  // we shouldn't attempt to recover websocket connection when offline
@@ -8809,14 +8870,6 @@ StreamSfuClient.DISPOSE_OLD_SOCKET = 4100;
8809
8870
  * The close code used when the client fails to join the call (on the SFU).
8810
8871
  */
8811
8872
  StreamSfuClient.JOIN_FAILED = 4101;
8812
- class SfuJoinError extends Error {
8813
- constructor(event) {
8814
- super(event.error?.message || 'Join Error');
8815
- this.errorEvent = event;
8816
- this.unrecoverable =
8817
- event.reconnectStrategy === WebsocketReconnectStrategy.DISCONNECT;
8818
- }
8819
- }
8820
8873
 
8821
8874
  /**
8822
8875
  * Event handler that watched the delivery of `call.accepted`.
@@ -8965,7 +9018,7 @@ const removeFromIfPresent = (arr, ...values) => {
8965
9018
  };
8966
9019
 
8967
9020
  const watchConnectionQualityChanged = (dispatcher, state) => {
8968
- return dispatcher.on('connectionQualityChanged', (e) => {
9021
+ return dispatcher.on('connectionQualityChanged', '*', (e) => {
8969
9022
  const { connectionQualityUpdates } = e;
8970
9023
  if (!connectionQualityUpdates)
8971
9024
  return;
@@ -8983,7 +9036,7 @@ const watchConnectionQualityChanged = (dispatcher, state) => {
8983
9036
  * health check events that our SFU sends.
8984
9037
  */
8985
9038
  const watchParticipantCountChanged = (dispatcher, state) => {
8986
- return dispatcher.on('healthCheckResponse', (e) => {
9039
+ return dispatcher.on('healthCheckResponse', '*', (e) => {
8987
9040
  const { participantCount } = e;
8988
9041
  if (participantCount) {
8989
9042
  state.setParticipantCount(participantCount.total);
@@ -8992,7 +9045,7 @@ const watchParticipantCountChanged = (dispatcher, state) => {
8992
9045
  });
8993
9046
  };
8994
9047
  const watchLiveEnded = (dispatcher, call) => {
8995
- return dispatcher.on('error', (e) => {
9048
+ return dispatcher.on('error', '*', (e) => {
8996
9049
  if (e.error && e.error.code !== ErrorCode.LIVE_ENDED)
8997
9050
  return;
8998
9051
  call.state.setBackstage(true);
@@ -9007,7 +9060,7 @@ const watchLiveEnded = (dispatcher, call) => {
9007
9060
  * Watches and logs the errors reported by the currently connected SFU.
9008
9061
  */
9009
9062
  const watchSfuErrorReports = (dispatcher) => {
9010
- return dispatcher.on('error', (e) => {
9063
+ return dispatcher.on('error', '*', (e) => {
9011
9064
  if (!e.error)
9012
9065
  return;
9013
9066
  const logger = videoLoggerSystem.getLogger('SfuClient');
@@ -9206,7 +9259,7 @@ const reconcileOrphanedTracks = (state, participant) => {
9206
9259
  * Watches for `dominantSpeakerChanged` events.
9207
9260
  */
9208
9261
  const watchDominantSpeakerChanged = (dispatcher, state) => {
9209
- return dispatcher.on('dominantSpeakerChanged', (e) => {
9262
+ return dispatcher.on('dominantSpeakerChanged', '*', (e) => {
9210
9263
  const { sessionId } = e;
9211
9264
  if (sessionId === state.dominantSpeaker?.sessionId)
9212
9265
  return;
@@ -9233,7 +9286,7 @@ const watchDominantSpeakerChanged = (dispatcher, state) => {
9233
9286
  * Watches for `audioLevelChanged` events.
9234
9287
  */
9235
9288
  const watchAudioLevelChanged = (dispatcher, state) => {
9236
- return dispatcher.on('audioLevelChanged', (e) => {
9289
+ return dispatcher.on('audioLevelChanged', '*', (e) => {
9237
9290
  const { audioLevels } = e;
9238
9291
  state.updateParticipants(audioLevels.reduce((patches, current) => {
9239
9292
  patches[current.sessionId] = {
@@ -12589,7 +12642,7 @@ class Call {
12589
12642
  */
12590
12643
  this.on = (eventName, fn) => {
12591
12644
  if (isSfuEvent(eventName)) {
12592
- return this.dispatcher.on(eventName, fn);
12645
+ return this.dispatcher.on(eventName, '*', fn);
12593
12646
  }
12594
12647
  const offHandler = this.streamClient.on(eventName, (e) => {
12595
12648
  const event = e;
@@ -12611,7 +12664,7 @@ class Call {
12611
12664
  */
12612
12665
  this.off = (eventName, fn) => {
12613
12666
  if (isSfuEvent(eventName)) {
12614
- return this.dispatcher.off(eventName, fn);
12667
+ return this.dispatcher.off(eventName, '*', fn);
12615
12668
  }
12616
12669
  // unsubscribe from the stream client event by using the 'off' reference
12617
12670
  const registeredOffHandler = this.streamClientEventHandlers.get(fn);
@@ -12847,6 +12900,7 @@ class Call {
12847
12900
  this.logger.trace(`Joining call (${attempt})`, this.cid);
12848
12901
  await this.doJoin(data);
12849
12902
  delete joinData.migrating_from;
12903
+ delete joinData.migrating_from_list;
12850
12904
  break;
12851
12905
  }
12852
12906
  catch (err) {
@@ -12858,11 +12912,15 @@ class Call {
12858
12912
  // to join the call due to some reason (e.g., ended call, expired token...)
12859
12913
  throw err;
12860
12914
  }
12915
+ // immediately switch to a different SFU in case of recoverable join error
12916
+ const switchSfu = err instanceof SfuJoinError &&
12917
+ SfuJoinError.isJoinErrorCode(err.errorEvent);
12861
12918
  const sfuId = this.credentials?.server.edge_name || '';
12862
12919
  const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
12863
12920
  sfuJoinFailures.set(sfuId, failures);
12864
- if (failures >= 2) {
12921
+ if (switchSfu || failures >= 2) {
12865
12922
  joinData.migrating_from = sfuId;
12923
+ joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
12866
12924
  }
12867
12925
  if (attempt === maxJoinRetries - 1) {
12868
12926
  throw err;
@@ -13390,12 +13448,17 @@ class Call {
13390
13448
  const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
13391
13449
  try {
13392
13450
  const currentSfu = currentSfuClient.edgeName;
13393
- await this.doJoin({ ...this.joinCallData, migrating_from: currentSfu });
13451
+ await this.doJoin({
13452
+ ...this.joinCallData,
13453
+ migrating_from: currentSfu,
13454
+ migrating_from_list: [currentSfu],
13455
+ });
13394
13456
  }
13395
13457
  finally {
13396
13458
  // cleanup the migration_from field after the migration is complete or failed
13397
13459
  // as we don't want to keep dirty data in the join call data
13398
13460
  delete this.joinCallData?.migrating_from;
13461
+ delete this.joinCallData?.migrating_from_list;
13399
13462
  }
13400
13463
  await this.restorePublishedTracks();
13401
13464
  this.restoreSubscribedTracks();
@@ -13430,6 +13493,11 @@ class Call {
13430
13493
  // handles the "error" event, through which the SFU can request a reconnect
13431
13494
  const unregisterOnError = this.on('error', (e) => {
13432
13495
  const { reconnectStrategy: strategy, error } = e;
13496
+ // SFU_FULL is a join error, and when emitted, although it specifies a
13497
+ // `migrate` strategy, we should actually perform a REJOIN to a new SFU.
13498
+ // This is now handled separately in the `call.join()` method.
13499
+ if (SfuJoinError.isJoinErrorCode(e))
13500
+ return;
13433
13501
  if (strategy === WebsocketReconnectStrategy.UNSPECIFIED)
13434
13502
  return;
13435
13503
  if (strategy === WebsocketReconnectStrategy.DISCONNECT) {
@@ -15528,7 +15596,7 @@ class StreamClient {
15528
15596
  this.getUserAgent = () => {
15529
15597
  if (!this.cachedUserAgent) {
15530
15598
  const { clientAppIdentifier = {} } = this.options;
15531
- const { sdkName = 'js', sdkVersion = "1.42.1", ...extras } = clientAppIdentifier;
15599
+ const { sdkName = 'js', sdkVersion = "1.42.2", ...extras } = clientAppIdentifier;
15532
15600
  this.cachedUserAgent = [
15533
15601
  `stream-video-${sdkName}-v${sdkVersion}`,
15534
15602
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),