@stream-io/video-client 1.42.0 → 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,18 @@
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
+
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)
12
+
13
+ ### Bug Fixes
14
+
15
+ - respect device permissions when detecting speech while muted ([#2115](https://github.com/GetStream/stream-video-js/issues/2115)) ([fe98768](https://github.com/GetStream/stream-video-js/commit/fe98768a9bf695fc5355905939884594c11ac2b9)), closes [#2110](https://github.com/GetStream/stream-video-js/issues/2110)
16
+
5
17
  ## [1.42.0](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.41.3...@stream-io/video-client-1.42.0) (2026-02-06)
6
18
 
7
19
  ### Features
@@ -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.0";
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] = {
@@ -11692,7 +11745,8 @@ class MicrophoneManager extends AudioDeviceManager {
11692
11745
  this.call.state.ownCapabilities$,
11693
11746
  this.state.selectedDevice$,
11694
11747
  this.state.status$,
11695
- ]), async ([callingState, ownCapabilities, deviceId, status]) => {
11748
+ this.state.browserPermissionState$,
11749
+ ]), async ([callingState, ownCapabilities, deviceId, status, permissionState,]) => {
11696
11750
  try {
11697
11751
  if (callingState === CallingState.LEFT) {
11698
11752
  await this.stopSpeakingWhileMutedDetection();
@@ -11702,7 +11756,8 @@ class MicrophoneManager extends AudioDeviceManager {
11702
11756
  if (!this.speakingWhileMutedNotificationEnabled)
11703
11757
  return;
11704
11758
  if (ownCapabilities.includes(OwnCapability.SEND_AUDIO)) {
11705
- if (status !== 'enabled') {
11759
+ const hasPermission = await this.hasPermission(permissionState);
11760
+ if (hasPermission && status !== 'enabled') {
11706
11761
  await this.startSpeakingWhileMutedDetection(deviceId);
11707
11762
  }
11708
11763
  else {
@@ -12001,6 +12056,20 @@ class MicrophoneManager extends AudioDeviceManager {
12001
12056
  await soundDetectorCleanup();
12002
12057
  });
12003
12058
  }
12059
+ async hasPermission(permissionState) {
12060
+ if (!isReactNative())
12061
+ return permissionState === 'granted';
12062
+ const nativePermissions = globalThis.streamRNVideoSDK?.permissions;
12063
+ if (!nativePermissions)
12064
+ return true; // assume granted
12065
+ try {
12066
+ return await nativePermissions.check('microphone');
12067
+ }
12068
+ catch (err) {
12069
+ this.logger.warn('Failed to check permission', err);
12070
+ return false;
12071
+ }
12072
+ }
12004
12073
  }
12005
12074
 
12006
12075
  class ScreenShareState extends AudioDeviceManagerState {
@@ -12573,7 +12642,7 @@ class Call {
12573
12642
  */
12574
12643
  this.on = (eventName, fn) => {
12575
12644
  if (isSfuEvent(eventName)) {
12576
- return this.dispatcher.on(eventName, fn);
12645
+ return this.dispatcher.on(eventName, '*', fn);
12577
12646
  }
12578
12647
  const offHandler = this.streamClient.on(eventName, (e) => {
12579
12648
  const event = e;
@@ -12595,7 +12664,7 @@ class Call {
12595
12664
  */
12596
12665
  this.off = (eventName, fn) => {
12597
12666
  if (isSfuEvent(eventName)) {
12598
- return this.dispatcher.off(eventName, fn);
12667
+ return this.dispatcher.off(eventName, '*', fn);
12599
12668
  }
12600
12669
  // unsubscribe from the stream client event by using the 'off' reference
12601
12670
  const registeredOffHandler = this.streamClientEventHandlers.get(fn);
@@ -12831,6 +12900,7 @@ class Call {
12831
12900
  this.logger.trace(`Joining call (${attempt})`, this.cid);
12832
12901
  await this.doJoin(data);
12833
12902
  delete joinData.migrating_from;
12903
+ delete joinData.migrating_from_list;
12834
12904
  break;
12835
12905
  }
12836
12906
  catch (err) {
@@ -12842,11 +12912,15 @@ class Call {
12842
12912
  // to join the call due to some reason (e.g., ended call, expired token...)
12843
12913
  throw err;
12844
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);
12845
12918
  const sfuId = this.credentials?.server.edge_name || '';
12846
12919
  const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
12847
12920
  sfuJoinFailures.set(sfuId, failures);
12848
- if (failures >= 2) {
12921
+ if (switchSfu || failures >= 2) {
12849
12922
  joinData.migrating_from = sfuId;
12923
+ joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
12850
12924
  }
12851
12925
  if (attempt === maxJoinRetries - 1) {
12852
12926
  throw err;
@@ -13374,12 +13448,17 @@ class Call {
13374
13448
  const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
13375
13449
  try {
13376
13450
  const currentSfu = currentSfuClient.edgeName;
13377
- 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
+ });
13378
13456
  }
13379
13457
  finally {
13380
13458
  // cleanup the migration_from field after the migration is complete or failed
13381
13459
  // as we don't want to keep dirty data in the join call data
13382
13460
  delete this.joinCallData?.migrating_from;
13461
+ delete this.joinCallData?.migrating_from_list;
13383
13462
  }
13384
13463
  await this.restorePublishedTracks();
13385
13464
  this.restoreSubscribedTracks();
@@ -13414,6 +13493,11 @@ class Call {
13414
13493
  // handles the "error" event, through which the SFU can request a reconnect
13415
13494
  const unregisterOnError = this.on('error', (e) => {
13416
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;
13417
13501
  if (strategy === WebsocketReconnectStrategy.UNSPECIFIED)
13418
13502
  return;
13419
13503
  if (strategy === WebsocketReconnectStrategy.DISCONNECT) {
@@ -15512,7 +15596,7 @@ class StreamClient {
15512
15596
  this.getUserAgent = () => {
15513
15597
  if (!this.cachedUserAgent) {
15514
15598
  const { clientAppIdentifier = {} } = this.options;
15515
- const { sdkName = 'js', sdkVersion = "1.42.0", ...extras } = clientAppIdentifier;
15599
+ const { sdkName = 'js', sdkVersion = "1.42.2", ...extras } = clientAppIdentifier;
15516
15600
  this.cachedUserAgent = [
15517
15601
  `stream-video-${sdkName}-v${sdkVersion}`,
15518
15602
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),