@stream-io/video-client 1.42.1 → 1.42.3

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,18 @@
2
2
 
3
3
  This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).
4
4
 
5
+ ## [1.42.3](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.42.2...@stream-io/video-client-1.42.3) (2026-02-16)
6
+
7
+ ### Bug Fixes
8
+
9
+ - guard from parallel accept/reject invocations ([#2127](https://github.com/GetStream/stream-video-js/issues/2127)) ([621218f](https://github.com/GetStream/stream-video-js/commit/621218f4ab6b4623370fd66f1b02b8cb7cb1baad))
10
+
11
+ ## [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)
12
+
13
+ ### Bug Fixes
14
+
15
+ - 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))
16
+
5
17
  ## [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
18
 
7
19
  ### 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.3";
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] = {
@@ -12393,6 +12446,7 @@ class Call {
12393
12446
  this.hasJoinedOnce = false;
12394
12447
  this.deviceSettingsAppliedOnce = false;
12395
12448
  this.initialized = false;
12449
+ this.acceptRejectConcurrencyTag = Symbol('acceptRejectTag');
12396
12450
  this.joinLeaveConcurrencyTag = Symbol('joinLeaveConcurrencyTag');
12397
12451
  /**
12398
12452
  * A list hooks/functions to invoke when the call is left.
@@ -12589,7 +12643,7 @@ class Call {
12589
12643
  */
12590
12644
  this.on = (eventName, fn) => {
12591
12645
  if (isSfuEvent(eventName)) {
12592
- return this.dispatcher.on(eventName, fn);
12646
+ return this.dispatcher.on(eventName, '*', fn);
12593
12647
  }
12594
12648
  const offHandler = this.streamClient.on(eventName, (e) => {
12595
12649
  const event = e;
@@ -12611,7 +12665,7 @@ class Call {
12611
12665
  */
12612
12666
  this.off = (eventName, fn) => {
12613
12667
  if (isSfuEvent(eventName)) {
12614
- return this.dispatcher.off(eventName, fn);
12668
+ return this.dispatcher.off(eventName, '*', fn);
12615
12669
  }
12616
12670
  // unsubscribe from the stream client event by using the 'off' reference
12617
12671
  const registeredOffHandler = this.streamClientEventHandlers.get(fn);
@@ -12807,8 +12861,10 @@ class Call {
12807
12861
  * Unless you are implementing a custom "ringing" flow, you should not use this method.
12808
12862
  */
12809
12863
  this.accept = async () => {
12810
- this.tracer.trace('call.accept', '');
12811
- return this.streamClient.post(`${this.streamClientBasePath}/accept`);
12864
+ return withoutConcurrency(this.acceptRejectConcurrencyTag, () => {
12865
+ this.tracer.trace('call.accept', '');
12866
+ return this.streamClient.post(`${this.streamClientBasePath}/accept`);
12867
+ });
12812
12868
  };
12813
12869
  /**
12814
12870
  * Marks the incoming call as rejected.
@@ -12820,8 +12876,10 @@ class Call {
12820
12876
  * @param reason the reason for rejecting the call.
12821
12877
  */
12822
12878
  this.reject = async (reason = 'decline') => {
12823
- this.tracer.trace('call.reject', reason);
12824
- return this.streamClient.post(`${this.streamClientBasePath}/reject`, { reason: reason });
12879
+ return withoutConcurrency(this.acceptRejectConcurrencyTag, () => {
12880
+ this.tracer.trace('call.reject', reason);
12881
+ return this.streamClient.post(`${this.streamClientBasePath}/reject`, { reason });
12882
+ });
12825
12883
  };
12826
12884
  /**
12827
12885
  * Will start to watch for call related WebSocket events and initiate a call session with the server.
@@ -12847,6 +12905,7 @@ class Call {
12847
12905
  this.logger.trace(`Joining call (${attempt})`, this.cid);
12848
12906
  await this.doJoin(data);
12849
12907
  delete joinData.migrating_from;
12908
+ delete joinData.migrating_from_list;
12850
12909
  break;
12851
12910
  }
12852
12911
  catch (err) {
@@ -12858,11 +12917,15 @@ class Call {
12858
12917
  // to join the call due to some reason (e.g., ended call, expired token...)
12859
12918
  throw err;
12860
12919
  }
12920
+ // immediately switch to a different SFU in case of recoverable join error
12921
+ const switchSfu = err instanceof SfuJoinError &&
12922
+ SfuJoinError.isJoinErrorCode(err.errorEvent);
12861
12923
  const sfuId = this.credentials?.server.edge_name || '';
12862
12924
  const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
12863
12925
  sfuJoinFailures.set(sfuId, failures);
12864
- if (failures >= 2) {
12926
+ if (switchSfu || failures >= 2) {
12865
12927
  joinData.migrating_from = sfuId;
12928
+ joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
12866
12929
  }
12867
12930
  if (attempt === maxJoinRetries - 1) {
12868
12931
  throw err;
@@ -13390,12 +13453,17 @@ class Call {
13390
13453
  const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
13391
13454
  try {
13392
13455
  const currentSfu = currentSfuClient.edgeName;
13393
- await this.doJoin({ ...this.joinCallData, migrating_from: currentSfu });
13456
+ await this.doJoin({
13457
+ ...this.joinCallData,
13458
+ migrating_from: currentSfu,
13459
+ migrating_from_list: [currentSfu],
13460
+ });
13394
13461
  }
13395
13462
  finally {
13396
13463
  // cleanup the migration_from field after the migration is complete or failed
13397
13464
  // as we don't want to keep dirty data in the join call data
13398
13465
  delete this.joinCallData?.migrating_from;
13466
+ delete this.joinCallData?.migrating_from_list;
13399
13467
  }
13400
13468
  await this.restorePublishedTracks();
13401
13469
  this.restoreSubscribedTracks();
@@ -13430,6 +13498,11 @@ class Call {
13430
13498
  // handles the "error" event, through which the SFU can request a reconnect
13431
13499
  const unregisterOnError = this.on('error', (e) => {
13432
13500
  const { reconnectStrategy: strategy, error } = e;
13501
+ // SFU_FULL is a join error, and when emitted, although it specifies a
13502
+ // `migrate` strategy, we should actually perform a REJOIN to a new SFU.
13503
+ // This is now handled separately in the `call.join()` method.
13504
+ if (SfuJoinError.isJoinErrorCode(e))
13505
+ return;
13433
13506
  if (strategy === WebsocketReconnectStrategy.UNSPECIFIED)
13434
13507
  return;
13435
13508
  if (strategy === WebsocketReconnectStrategy.DISCONNECT) {
@@ -15528,7 +15601,7 @@ class StreamClient {
15528
15601
  this.getUserAgent = () => {
15529
15602
  if (!this.cachedUserAgent) {
15530
15603
  const { clientAppIdentifier = {} } = this.options;
15531
- const { sdkName = 'js', sdkVersion = "1.42.1", ...extras } = clientAppIdentifier;
15604
+ const { sdkName = 'js', sdkVersion = "1.42.3", ...extras } = clientAppIdentifier;
15532
15605
  this.cachedUserAgent = [
15533
15606
  `stream-video-${sdkName}-v${sdkVersion}`,
15534
15607
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),