@stream-io/video-client 1.60.0 → 1.61.1

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.d.ts CHANGED
@@ -22,6 +22,7 @@ export * from './src/helpers/sound-detector';
22
22
  export * from './src/helpers/loopback';
23
23
  export * from './src/helpers/MediaStreamRecorder';
24
24
  export * from './src/helpers/participantUtils';
25
+ export * from './src/helpers/TypedEventEmitter';
25
26
  export * from './src/rtc/e2ee/E2EEManager';
26
27
  export * from './src/rtc/e2ee/EncryptionManager';
27
28
  export * as Browsers from './src/helpers/browsers';
package/dist/index.es.js CHANGED
@@ -5082,7 +5082,7 @@ const toIceCandidate = (iceTrickle) => {
5082
5082
  }
5083
5083
  };
5084
5084
 
5085
- const version = "1.60.0";
5085
+ const version = "1.61.1";
5086
5086
  const [major, minor, patch] = version.split('.');
5087
5087
  let sdkInfo = {
5088
5088
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -8755,7 +8755,12 @@ class BasePeerConnection {
8755
8755
  }
8756
8756
  // we can't recover from a failed connection state (contrary to ICE)
8757
8757
  if (state === 'failed') {
8758
- this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.CONNECTION_FAILED, this.peerType);
8758
+ const reason = this.iceHasEverConnected
8759
+ ? ReconnectReason.CONNECTION_FAILED
8760
+ : ReconnectReason.ICE_NEVER_CONNECTED;
8761
+ clearTimeout(this.preConnectStuckTimeout);
8762
+ this.preConnectStuckTimeout = undefined;
8763
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, reason, this.peerType);
8759
8764
  return;
8760
8765
  }
8761
8766
  this.handleConnectionStateUpdate(state);
@@ -9147,6 +9152,25 @@ class TransceiverCache {
9147
9152
  this.cache.push(bundle);
9148
9153
  this.transceiverOrder.push(bundle.transceiver);
9149
9154
  };
9155
+ /**
9156
+ * Retires a failed sender from the reusable cache.
9157
+ *
9158
+ * A transceiver that never reached an m-section also leaves the creation
9159
+ * history: once stopped, it is left out of every later offer, so counting
9160
+ * it would shift the `mid` index of every transceiver added after it. One
9161
+ * that is already associated keeps its slot, since its m-section survives
9162
+ * as a rejected one.
9163
+ */
9164
+ remove = (transceiver) => {
9165
+ const index = this.cache.findIndex((b) => b.transceiver === transceiver);
9166
+ if (index !== -1)
9167
+ this.cache.splice(index, 1);
9168
+ if (transceiver.mid)
9169
+ return;
9170
+ const orderIndex = this.transceiverOrder.indexOf(transceiver);
9171
+ if (orderIndex !== -1)
9172
+ this.transceiverOrder.splice(orderIndex, 1);
9173
+ };
9150
9174
  /**
9151
9175
  * Gets the transceiver for the given publish option.
9152
9176
  */
@@ -9318,7 +9342,9 @@ class Publisher extends BasePeerConnection {
9318
9342
  const sendEncodings = isSvcCodec(publishOption.codec?.name)
9319
9343
  ? toSvcEncodings(encodings)
9320
9344
  : encodings;
9321
- const transceiver = this.pc.addTransceiver(track, {
9345
+ // An E2EE sender must never have media before its transform is attached,
9346
+ // including when another track or an ICE restart negotiates this PC.
9347
+ const transceiver = this.pc.addTransceiver(this.e2ee ? track.kind : track, {
9322
9348
  direction: 'sendonly',
9323
9349
  sendEncodings,
9324
9350
  });
@@ -9328,16 +9354,63 @@ class Publisher extends BasePeerConnection {
9328
9354
  this.transceiverCache.add({ publishOption, transceiver, options });
9329
9355
  this.trackIdToTrackType.set(track.id, trackType);
9330
9356
  this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
9331
- const params = transceiver.sender.getParameters();
9357
+ if (this.e2ee) {
9358
+ await this.initEncryptedSender(transceiver, track, publishOption);
9359
+ }
9360
+ else {
9361
+ await this.setDegradationPreference(transceiver.sender, publishOption);
9362
+ }
9363
+ await this.negotiate();
9364
+ };
9365
+ /**
9366
+ * Attaches the encryptor, applies sender parameters, then attaches the track.
9367
+ *
9368
+ * `encrypt` must run before the first await: Chrome short-circuits a sender
9369
+ * whose encoded streams aren't created in the task that created it, and the
9370
+ * track goes on last so the sender has no media until both steps succeed.
9371
+ */
9372
+ initEncryptedSender = async (transceiver, track, publishOption) => {
9373
+ try {
9374
+ const { sender } = transceiver;
9375
+ const codec = publishOption.codec?.name.toLowerCase();
9376
+ this.e2ee.encrypt(sender, codec, TrackType[publishOption.trackType]);
9377
+ this.logger.debug('E2EE encryptor attached to sender');
9378
+ await this.setDegradationPreference(sender, publishOption);
9379
+ await sender.replaceTrack(track);
9380
+ }
9381
+ catch (err) {
9382
+ this.retireTransceiver(transceiver, track);
9383
+ throw err;
9384
+ }
9385
+ };
9386
+ setDegradationPreference = async (sender, publishOption) => {
9387
+ const params = sender.getParameters();
9332
9388
  params.degradationPreference =
9333
9389
  toRTCDegradationPreference(publishOption.degradationPreference) ??
9334
9390
  'maintain-framerate';
9335
- await transceiver.sender.setParameters(params);
9336
- if (this.e2ee) {
9337
- this.e2ee.encrypt(transceiver.sender, publishOption.codec?.name.toLowerCase(), TrackType[publishOption.trackType]);
9338
- this.logger.debug('E2EE encryptor attached to sender');
9391
+ await sender.setParameters(params);
9392
+ };
9393
+ /**
9394
+ * Discards a sender whose E2EE initialization failed.
9395
+ *
9396
+ * A partially initialized sender may carry no transform, so publish() must
9397
+ * not reuse it: retiring it from the cache is what keeps a later publish or
9398
+ * ICE restart from putting cleartext on the wire. Cleanup cannot throw, so
9399
+ * the initialization failure is what reaches the caller.
9400
+ */
9401
+ retireTransceiver = (transceiver, track) => {
9402
+ this.transceiverCache.remove(transceiver);
9403
+ this.trackIdToTrackType.delete(track.id);
9404
+ // on React Native a clone shares its native source, so stopping it here
9405
+ // would stop the track the caller passed in. dispose() releases it.
9406
+ if (!isReactNative())
9407
+ this.stopTrack(track);
9408
+ try {
9409
+ transceiver.stop();
9410
+ }
9411
+ catch (err) {
9412
+ this.logger.debug('Failed to stop a retired transceiver', err);
9339
9413
  }
9340
- await this.negotiate();
9341
9414
  };
9342
9415
  /**
9343
9416
  * Updates the transceiver with the given track and track type.
@@ -15903,6 +15976,11 @@ class Call {
15903
15976
  this.audioBindingsWatchdog?.dispose();
15904
15977
  await this.dynascaleManager?.dispose();
15905
15978
  this.state.setCallingState(CallingState.LEFT);
15979
+ // `ringingSubject` is cleared further down, so this still reads true for
15980
+ // a call that was ringing.
15981
+ if (this.ringing) {
15982
+ globalThis.streamRNVideoSDK?.ringingCallLifecycle?.onLeave(this);
15983
+ }
15906
15984
  this.state.setParticipants([]);
15907
15985
  this.state.dispose();
15908
15986
  // Reset reconnect-related accumulators so a future `call.join()` on the
@@ -16071,8 +16149,11 @@ class Call {
16071
16149
  * @param params.video if set to true, in a ringing scenario, mobile SDKs will show "incoming video call", audio only otherwise.
16072
16150
  */
16073
16151
  get = async (params) => {
16152
+ const getLeaveGeneration = this.leaveGeneration;
16074
16153
  await this.setup();
16075
16154
  const response = await this.streamClient.get(this.streamClientBasePath, params);
16155
+ if (this.leaveGeneration !== getLeaveGeneration)
16156
+ return response;
16076
16157
  this.updateFromCallStateResponse(response);
16077
16158
  if (params?.ring) {
16078
16159
  this.ringingSubject.next(true);
@@ -16177,42 +16258,80 @@ class Call {
16177
16258
  /**
16178
16259
  * Will start to watch for call related WebSocket events and initiate a call session with the server.
16179
16260
  *
16261
+ * One instance is one call flow: discard it after leaving, cancelling, or a
16262
+ * failed join, and create a fresh one for a later flow. Reconnection inside a
16263
+ * live call is handled here and needs no new instance.
16264
+ *
16180
16265
  * @returns a promise which resolves once the call join-flow has finished.
16181
16266
  */
16182
16267
  join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, joinSource, ...data } = {}) => {
16183
16268
  const callingState = this.state.callingState;
16269
+ // Ahead of the failure boundary below on purpose: a duplicate join on a
16270
+ // live call is refused without tearing that call down.
16184
16271
  if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
16185
16272
  throw new Error(`Illegal State: call.join() shall be called only once`);
16186
16273
  }
16274
+ // snapshot before the first await: a leave() landing at any point from
16275
+ // here on supersedes this join, and the retry loop must bail out instead
16276
+ // of resurrecting a call that leave() already tore down.
16277
+ const joinLeaveGeneration = this.leaveGeneration;
16278
+ const supersededByLeave = () => this.leaveGeneration !== joinLeaveGeneration;
16187
16279
  // we need this to be set before the callingx.joinCall() is
16188
16280
  // called to avoid registering the test call in the CallKit/Telecom
16189
16281
  this.allowOwnTracksLoopback = allowOwnTracksLoopback;
16190
16282
  if (data?.ring) {
16191
16283
  this.ringingSubject.next(true);
16192
16284
  }
16285
+ // A ringing call is joined by the SDK rather than by app code, so React
16286
+ // Native prepares it from in here - there is no earlier point at which the
16287
+ // app holds the call. Read after `data.ring` above, which is what makes an
16288
+ // outgoing call ringing in the first place.
16289
+ const ringingLifecycle = this.ringing
16290
+ ? globalThis.streamRNVideoSDK?.ringingCallLifecycle
16291
+ : undefined;
16193
16292
  const callingX = globalThis.streamRNVideoSDK?.callingX;
16194
- if (callingX) {
16195
- // for Android/iOS, we need to start the call in the callingx library as soon as possible
16196
- await callingX.joinCall(this, this.clientStore.calls);
16197
- }
16198
- await this.setup();
16199
- this.clientEventReporter.registerCall(this.cid, {
16200
- callType: this.type,
16201
- callId: this.id,
16202
- getCallSessionId: () => this.state.session?.id ?? '',
16203
- getSfuId: () => this.credentials?.server.edge_name ?? '',
16204
- });
16205
- this.joinResponseTimeout = joinResponseTimeout;
16206
- this.rpcRequestTimeout = rpcRequestTimeout;
16207
- // we will count the number of join failures per SFU.
16208
- // once the number of failures reaches 2, we will piggyback on the `migrating_from`
16209
- // field to force the coordinator to provide us another SFU
16210
- const sfuJoinFailures = new Map();
16211
- const joinData = data;
16212
- maxJoinRetries = Math.max(maxJoinRetries, 1);
16213
16293
  try {
16294
+ if (ringingLifecycle) {
16295
+ await ringingLifecycle.beforeJoin(this);
16296
+ if (supersededByLeave()) {
16297
+ this.logger.debug('Join superseded by leave; not joining');
16298
+ return;
16299
+ }
16300
+ }
16301
+ if (callingX) {
16302
+ // for Android/iOS, we need to start the call in the callingx library as soon as possible
16303
+ await callingX.joinCall(this, this.clientStore.calls, supersededByLeave);
16304
+ if (supersededByLeave()) {
16305
+ this.logger.debug('Join superseded by leave; not setting up');
16306
+ return;
16307
+ }
16308
+ }
16309
+ await this.setup();
16310
+ if (supersededByLeave()) {
16311
+ this.logger.debug('Join superseded by leave; not registering');
16312
+ return;
16313
+ }
16314
+ this.clientEventReporter.registerCall(this.cid, {
16315
+ callType: this.type,
16316
+ callId: this.id,
16317
+ getCallSessionId: () => this.state.session?.id ?? '',
16318
+ getSfuId: () => this.credentials?.server.edge_name ?? '',
16319
+ });
16320
+ this.joinResponseTimeout = joinResponseTimeout;
16321
+ this.rpcRequestTimeout = rpcRequestTimeout;
16322
+ // we will count the number of join failures per SFU.
16323
+ // once the number of failures reaches 2, we will piggyback on the `migrating_from`
16324
+ // field to force the coordinator to provide us another SFU
16325
+ const sfuJoinFailures = new Map();
16326
+ const joinData = data;
16327
+ maxJoinRetries = Math.max(maxJoinRetries, 1);
16214
16328
  await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'first-attempt', joinSource }, async () => {
16215
16329
  for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
16330
+ // Also covers a leave that lands during the backoff below.
16331
+ if (supersededByLeave()) {
16332
+ this.logger.debug('Join superseded by leave; not attempting');
16333
+ return;
16334
+ }
16216
16335
  try {
16217
16336
  this.logger.trace(`Joining call (${attempt})`, this.cid);
16218
16337
  await this.doJoin(data);
@@ -16221,6 +16340,10 @@ class Call {
16221
16340
  return;
16222
16341
  }
16223
16342
  catch (err) {
16343
+ if (supersededByLeave()) {
16344
+ this.logger.debug('Join superseded by leave; not retrying');
16345
+ return;
16346
+ }
16224
16347
  this.logger.warn(`Failed to join call (${attempt})`, this.cid);
16225
16348
  if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
16226
16349
  (err instanceof SfuJoinError && err.unrecoverable)) {
@@ -16250,6 +16373,9 @@ class Call {
16250
16373
  }
16251
16374
  catch (error) {
16252
16375
  callingX?.endCall(this, 'error');
16376
+ // Ends the failed ringing flow and releases what its setup installed.
16377
+ // Never rejects, so `error` is what the caller sees.
16378
+ await ringingLifecycle?.onJoinFailed(this);
16253
16379
  throw error;
16254
16380
  }
16255
16381
  });
@@ -16272,6 +16398,10 @@ class Call {
16272
16398
  // globals resolve to the call's factory. Idempotent across
16273
16399
  // reconnect/migration attempts.
16274
16400
  await this.ensureMediaFactory();
16401
+ if (supersededByLeave()) {
16402
+ this.logger.debug('Join superseded by leave; not wiring media');
16403
+ return;
16404
+ }
16275
16405
  const callingX = globalThis.streamRNVideoSDK?.callingX;
16276
16406
  if (callingX) {
16277
16407
  callingX.wireAudioEngineSubscription();
@@ -16287,11 +16417,17 @@ class Call {
16287
16417
  data?.migrating_from) {
16288
16418
  try {
16289
16419
  const joinResponse = await this.clientEventReporter.track(this.cid, 'CoordinatorJoin', () => this.doJoinRequest(data));
16420
+ if (!joinResponse || supersededByLeave()) {
16421
+ this.logger.debug('Join superseded by leave; not creating SFU client');
16422
+ return;
16423
+ }
16290
16424
  this.credentials = joinResponse.credentials;
16291
16425
  statsOptions = joinResponse.stats_options;
16292
16426
  this.lastStatsOptions = statsOptions;
16293
16427
  }
16294
16428
  catch (error) {
16429
+ if (supersededByLeave())
16430
+ return;
16295
16431
  // prevent triggering reconnect flow if the state is OFFLINE
16296
16432
  const avoidRestoreState = this.state.callingState === CallingState.OFFLINE;
16297
16433
  if (!avoidRestoreState) {
@@ -16339,6 +16475,10 @@ class Call {
16339
16475
  getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
16340
16476
  getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
16341
16477
  ]);
16478
+ if (supersededByLeave()) {
16479
+ this.logger.debug('Join superseded by leave; not joining SFU');
16480
+ return;
16481
+ }
16342
16482
  const isReconnecting = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
16343
16483
  const reconnectDetails = isReconnecting
16344
16484
  ? this.getReconnectDetails(previousSfuClient?.edgeName, previousSessionId)
@@ -16364,6 +16504,10 @@ class Call {
16364
16504
  capabilities,
16365
16505
  source: ParticipantSource.WEBRTC_UNSPECIFIED,
16366
16506
  }));
16507
+ if (supersededByLeave()) {
16508
+ this.logger.debug('Join superseded by leave; not applying SFU response');
16509
+ return;
16510
+ }
16367
16511
  this.currentPublishOptions = publishOptions;
16368
16512
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
16369
16513
  if (callState) {
@@ -16371,6 +16515,10 @@ class Call {
16371
16515
  }
16372
16516
  }
16373
16517
  catch (error) {
16518
+ if (supersededByLeave()) {
16519
+ this.logger.debug('Join superseded by leave; ignoring SFU failure');
16520
+ return;
16521
+ }
16374
16522
  this.logger.warn('Join SFU request failed', error);
16375
16523
  sfuClient.close(StreamSfuClient.JOIN_FAILED, 'Join request failed, connection considered unhealthy');
16376
16524
  // restore the previous call state if the join-flow fails
@@ -16378,8 +16526,8 @@ class Call {
16378
16526
  throw error;
16379
16527
  }
16380
16528
  }
16381
- // If the user left while this join was in flight, bail before re-setting JOINED and before
16382
- // peer-connection setup below (both run synchronously after this, so one check covers them).
16529
+ // If the user left while this join was in flight, bail before re-setting JOINED
16530
+ // or starting peer-connection setup below.
16383
16531
  if (supersededByLeave()) {
16384
16532
  this.logger.debug('Join superseded by leave; aborting join flow');
16385
16533
  return;
@@ -16408,6 +16556,10 @@ class Call {
16408
16556
  unifiedSessionId: this.unifiedSessionId,
16409
16557
  });
16410
16558
  }
16559
+ if (supersededByLeave()) {
16560
+ this.logger.debug('Join superseded by leave; not completing join flow');
16561
+ return;
16562
+ }
16411
16563
  // make sure we only track connection timing if we are not calling this method as part of a reconnection flow
16412
16564
  if (!performingRejoin && !performingFastReconnect && !performingMigration) {
16413
16565
  this.sfuStatsReporter?.sendConnectionTime((Date.now() - connectStartTime) / 1000);
@@ -16553,15 +16705,20 @@ class Call {
16553
16705
  * @internal
16554
16706
  */
16555
16707
  initPublisherAndSubscriber = async (opts) => {
16708
+ const joinLeaveGeneration = this.leaveGeneration;
16556
16709
  const { sfuClient, connectionConfig, clientDetails, statsOptions, publishOptions, closePreviousInstances, unifiedSessionId, } = opts;
16557
16710
  const { enable_rtc_stats: enableTracing, reporting_interval_ms: reportingIntervalMs, } = statsOptions;
16558
16711
  // Flush the previous reporter's final sample while its peer connections are
16559
16712
  // still alive, before we dispose them below. Awaits only the sampling step.
16560
16713
  await this.sfuStatsReporter?.flush();
16714
+ if (this.leaveGeneration !== joinLeaveGeneration)
16715
+ return;
16561
16716
  this.sfuStatsReporter?.stop();
16562
16717
  this.sfuStatsReporter = undefined;
16563
16718
  if (closePreviousInstances && this.subscriber) {
16564
16719
  await this.subscriber.dispose();
16720
+ if (this.leaveGeneration !== joinLeaveGeneration)
16721
+ return;
16565
16722
  this.state.removeAllOrphanedTracks();
16566
16723
  }
16567
16724
  const basePeerConnectionOptions = {
@@ -16604,6 +16761,8 @@ class Call {
16604
16761
  if (!isAnonymous) {
16605
16762
  if (closePreviousInstances && this.publisher) {
16606
16763
  await this.publisher.dispose();
16764
+ if (this.leaveGeneration !== joinLeaveGeneration)
16765
+ return;
16607
16766
  }
16608
16767
  this.publisher = new Publisher(basePeerConnectionOptions, publishOptions, {
16609
16768
  selfSubEnabled: this.allowOwnTracksLoopback,
@@ -16640,12 +16799,22 @@ class Call {
16640
16799
  *
16641
16800
  * @internal
16642
16801
  * @param data the join call data.
16802
+ * @returns The coordinator response, or undefined if leave superseded the request before it was sent.
16643
16803
  */
16644
16804
  doJoinRequest = async (data) => {
16805
+ const joinLeaveGeneration = this.leaveGeneration;
16645
16806
  const location = await this.streamClient.getLocationHint();
16807
+ if (this.leaveGeneration !== joinLeaveGeneration) {
16808
+ this.logger.debug('Join superseded by leave; not sending coordinator request');
16809
+ return;
16810
+ }
16646
16811
  const e2ee = !!this.e2eeManager;
16647
16812
  const request = { ...data, location, e2ee };
16648
16813
  const joinResponse = await this.streamClient.post(`${this.streamClientBasePath}/join`, request);
16814
+ if (this.leaveGeneration !== joinLeaveGeneration) {
16815
+ this.logger.debug('Join superseded by leave; not applying coordinator response');
16816
+ return joinResponse;
16817
+ }
16649
16818
  this.state.updateFromCallResponse(joinResponse.call);
16650
16819
  this.state.setMembers(joinResponse.members);
16651
16820
  this.state.setOwnCapabilities(joinResponse.own_capabilities);
@@ -16656,6 +16825,8 @@ class Call {
16656
16825
  if (!isReconnecting && this.ringing && !this.isCreatedByMe) {
16657
16826
  // signals other users that I have accepted the incoming call.
16658
16827
  await this.accept();
16828
+ if (this.leaveGeneration !== joinLeaveGeneration)
16829
+ return joinResponse;
16659
16830
  }
16660
16831
  if (this.streamClient._hasConnectionID()) {
16661
16832
  this.watching = true;
@@ -16745,6 +16916,7 @@ class Call {
16745
16916
  if (callingState === CallingState.JOINING ||
16746
16917
  callingState === CallingState.RECONNECTING ||
16747
16918
  callingState === CallingState.MIGRATING ||
16919
+ callingState === CallingState.LEFT ||
16748
16920
  callingState === CallingState.RECONNECTING_FAILED)
16749
16921
  return;
16750
16922
  // Drop redundant reconnect calls. If a reconnect is already queued or
@@ -16753,19 +16925,27 @@ class Call {
16753
16925
  // `POST /join` per entry) once the call is already healthy again.
16754
16926
  if (hasPending(this.reconnectConcurrencyTag))
16755
16927
  return;
16928
+ const reconnectLeaveGeneration = this.leaveGeneration;
16929
+ const supersededByLeave = () => this.leaveGeneration !== reconnectLeaveGeneration;
16756
16930
  return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
16931
+ if (supersededByLeave())
16932
+ return;
16757
16933
  const reconnectStartTime = Date.now();
16758
16934
  this.reconnectStrategy = strategy;
16759
16935
  this.reconnectReason = reason;
16760
16936
  const sfuRejoinFailures = new Map();
16761
16937
  const markAsReconnectingFailed = async () => {
16938
+ if (supersededByLeave())
16939
+ return;
16762
16940
  try {
16763
16941
  // attempt to fetch the call data from the server, as the call
16764
16942
  // state might have changed while we were reconnecting or were offline
16765
16943
  await this.get();
16766
16944
  }
16767
16945
  finally {
16768
- this.state.setCallingState(CallingState.RECONNECTING_FAILED);
16946
+ if (!supersededByLeave()) {
16947
+ this.state.setCallingState(CallingState.RECONNECTING_FAILED);
16948
+ }
16769
16949
  }
16770
16950
  };
16771
16951
  const giveUpAndLeave = async (message) => {
@@ -16821,6 +17001,8 @@ class Call {
16821
17001
  try {
16822
17002
  // wait until the network is available
16823
17003
  await this.networkAvailableTask?.promise;
17004
+ if (supersededByLeave())
17005
+ return;
16824
17006
  this.logger.info(`[Reconnect] Reconnecting with strategy ${WebsocketReconnectStrategy[this.reconnectStrategy]}`);
16825
17007
  switch (this.reconnectStrategy) {
16826
17008
  case WebsocketReconnectStrategy.UNSPECIFIED:
@@ -16860,6 +17042,8 @@ class Call {
16860
17042
  break; // do-while loop, reconnection worked, exit the loop
16861
17043
  }
16862
17044
  catch (error) {
17045
+ if (supersededByLeave())
17046
+ return;
16863
17047
  if (attemptedStrategy === WebsocketReconnectStrategy.REJOIN) {
16864
17048
  const failedSfu = this.credentials?.server.edge_name;
16865
17049
  if (failedSfu) {
@@ -16890,6 +17074,8 @@ class Call {
16890
17074
  }
16891
17075
  // exponential backoff with jitter, capped at 5 s
16892
17076
  await sleep(retryInterval(attempt));
17077
+ if (supersededByLeave())
17078
+ return;
16893
17079
  const wasMigrating = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
16894
17080
  const mustPerformRejoin = (Date.now() - reconnectStartTime) / 1000 >
16895
17081
  this.fastReconnectDeadlineSeconds;
@@ -16908,7 +17094,8 @@ class Call {
16908
17094
  this.reconnectStrategy = nextStrategy;
16909
17095
  this.logger.info(`[Reconnect] ${currentStrategy} (${this.reconnectAttempts}) failed. Attempting with ${WebsocketReconnectStrategy[nextStrategy]}`, error);
16910
17096
  }
16911
- } while (this.state.callingState !== CallingState.JOINED &&
17097
+ } while (!supersededByLeave() &&
17098
+ this.state.callingState !== CallingState.JOINED &&
16912
17099
  this.state.callingState !== CallingState.RECONNECTING_FAILED &&
16913
17100
  this.state.callingState !== CallingState.LEFT);
16914
17101
  this.logger.info('[Reconnect] Reconnection flow finished');
@@ -16919,10 +17106,13 @@ class Call {
16919
17106
  * @internal
16920
17107
  */
16921
17108
  reconnectFast = async () => {
17109
+ const reconnectLeaveGeneration = this.leaveGeneration;
16922
17110
  const reconnectStartTime = Date.now();
16923
17111
  this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
16924
17112
  this.state.setCallingState(CallingState.RECONNECTING);
16925
17113
  await this.doJoin(this.joinCallData);
17114
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17115
+ return;
16926
17116
  await this.get(); // fetch the latest call state, as it might have changed
16927
17117
  this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.FAST, (Date.now() - reconnectStartTime) / 1000);
16928
17118
  };
@@ -16931,6 +17121,7 @@ class Call {
16931
17121
  * @internal
16932
17122
  */
16933
17123
  reconnectRejoin = async () => {
17124
+ const reconnectLeaveGeneration = this.leaveGeneration;
16934
17125
  const reconnectStartTime = Date.now();
16935
17126
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
16936
17127
  this.state.setCallingState(CallingState.RECONNECTING);
@@ -16938,7 +17129,11 @@ class Call {
16938
17129
  ? 'network-available'
16939
17130
  : 'full-rejoin';
16940
17131
  await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason }, () => this.doJoin(this.joinCallData));
17132
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17133
+ return;
16941
17134
  await this.restorePublishedTracks();
17135
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17136
+ return;
16942
17137
  this.restoreSubscribedTracks();
16943
17138
  this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
16944
17139
  };
@@ -16947,6 +17142,7 @@ class Call {
16947
17142
  * @internal
16948
17143
  */
16949
17144
  reconnectMigrate = async () => {
17145
+ const reconnectLeaveGeneration = this.leaveGeneration;
16950
17146
  const reconnectStartTime = Date.now();
16951
17147
  const currentSfuClient = this.sfuClient;
16952
17148
  if (!currentSfuClient) {
@@ -16960,26 +17156,32 @@ class Call {
16960
17156
  currentPublisher?.detachEventHandlers();
16961
17157
  const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
16962
17158
  try {
16963
- const currentSfu = currentSfuClient.edgeName;
16964
- await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'migration' }, () => this.doJoin({
16965
- ...this.joinCallData,
16966
- migrating_from: currentSfu,
16967
- migrating_from_list: [currentSfu],
16968
- }));
16969
- }
16970
- finally {
16971
- // cleanup the migration_from field after the migration is complete or failed
16972
- // as we don't want to keep dirty data in the join call data
16973
- delete this.joinCallData?.migrating_from;
16974
- delete this.joinCallData?.migrating_from_list;
16975
- }
16976
- await this.restorePublishedTracks();
16977
- this.restoreSubscribedTracks();
16978
- try {
17159
+ try {
17160
+ const currentSfu = currentSfuClient.edgeName;
17161
+ await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'migration' }, () => this.doJoin({
17162
+ ...this.joinCallData,
17163
+ migrating_from: currentSfu,
17164
+ migrating_from_list: [currentSfu],
17165
+ }));
17166
+ }
17167
+ finally {
17168
+ // cleanup the migration_from field after the migration is complete or failed
17169
+ // as we don't want to keep dirty data in the join call data
17170
+ delete this.joinCallData?.migrating_from;
17171
+ delete this.joinCallData?.migrating_from_list;
17172
+ }
17173
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17174
+ return;
17175
+ await this.restorePublishedTracks();
17176
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17177
+ return;
17178
+ this.restoreSubscribedTracks();
16979
17179
  // Wait for the migration to complete, then close the previous SFU client
16980
17180
  // and the peer connection instances. In case of failure, the migration
16981
17181
  // task would throw an error and REJOIN would be attempted.
16982
17182
  await migrationTask();
17183
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17184
+ return;
16983
17185
  // in MIGRATE, we can consider the call as joined only after
16984
17186
  // `participantMigrationComplete` event is received, signaled by
16985
17187
  // the `migrationTask`
@@ -17264,10 +17466,11 @@ class Call {
17264
17466
  * Must be called before {@link join} so the RTCPeerConnection can be
17265
17467
  * configured for E2EE.
17266
17468
  *
17267
- * The manager is kept across {@link leave} so a rejoin of this same instance
17268
- * stays encrypted: do not dispose it while this call may be joined again.
17269
- * A disposed manager throws from `encrypt`/`decrypt` rather than silently
17270
- * publishing nothing, so re-attach a fresh one instead of reusing it.
17469
+ * The application owns the manager's lifetime - the SDK never disposes it, and
17470
+ * closing the peer connections does not release it. Discard the manager
17471
+ * together with this call instance once its flow has ended, and create a fresh
17472
+ * pair for a later flow: a disposed manager throws from `encrypt`/`decrypt`
17473
+ * rather than silently publishing nothing.
17271
17474
  *
17272
17475
  * @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
17273
17476
  * built-in AES-GCM scheme, or pass your own implementation.
@@ -19434,11 +19637,11 @@ class StreamClient {
19434
19637
  return await this.wsConnection.connect(this.defaultWSTimeout);
19435
19638
  };
19436
19639
  getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
19437
- "1.60.0";
19640
+ "1.61.1";
19438
19641
  getUserAgent = () => {
19439
19642
  if (!this.cachedUserAgent) {
19440
19643
  const { clientAppIdentifier = {} } = this.options;
19441
- const { sdkName = 'js', sdkVersion = "1.60.0", ...extras } = clientAppIdentifier;
19644
+ const { sdkName = 'js', sdkVersion = "1.61.1", ...extras } = clientAppIdentifier;
19442
19645
  this.cachedUserAgent = [
19443
19646
  `stream-video-${sdkName}-v${sdkVersion}`,
19444
19647
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -19973,7 +20176,11 @@ class ClientEventReporter {
19973
20176
  return;
19974
20177
  }
19975
20178
  if (event.state === 'failed') {
19976
- this.emitPeerConnectionFailure(cid, role, 'DTLS_CONNECTIVITY_FAILED', 'DTLS connectivity checks failed');
20179
+ const iceConnected = event.iceConnectionState === 'connected' ||
20180
+ event.iceConnectionState === 'completed';
20181
+ this.emitPeerConnectionFailure(cid, role, iceConnected ? 'DTLS_CONNECTIVITY_FAILED' : 'ICE_CONNECTIVITY_FAILED', iceConnected
20182
+ ? 'DTLS connectivity checks failed'
20183
+ : 'ICE connectivity checks failed');
19977
20184
  return;
19978
20185
  }
19979
20186
  switch (event.state) {
@@ -21147,9 +21354,9 @@ class EncryptionManager extends TypedEventEmitter {
21147
21354
  }
21148
21355
  if ((this.piped ??= new WeakSet()).has(target))
21149
21356
  return;
21150
- this.piped.add(target);
21151
21357
  // @ts-expect-error createEncodedStreams is not in the standard typedefs
21152
21358
  const { readable, writable } = target.createEncodedStreams();
21359
+ this.piped.add(target);
21153
21360
  this.worker.postMessage({ type: 'cmd.setup_transform', ...options, readable, writable }, [readable, writable]);
21154
21361
  };
21155
21362
  /**
@@ -21238,5 +21445,5 @@ var e2eeWorker$1 = /*#__PURE__*/Object.freeze({
21238
21445
  e2eeWorker: e2eeWorker
21239
21446
  });
21240
21447
 
21241
- export { AudioSettingsRequestDefaultDeviceEnum, AudioSettingsResponseDefaultDeviceEnum, browsers as Browsers, Call, CallRecordingFailedEventRecordingTypeEnum, CallRecordingReadyEventRecordingTypeEnum, CallRecordingStartedEventRecordingTypeEnum, CallRecordingStoppedEventRecordingTypeEnum, CallState, CallType, CallTypes, CallingState, CameraManager, CameraManagerState, CreateDeviceRequestPushProviderEnum, DEFAULT_LOOPBACK_RECORDING_DURATION_MS, DebounceType, DeviceManager, DeviceManagerState, DynascaleManager, EncryptionManager, EncryptionSettingsRequestModeEnum, EncryptionSettingsResponseModeEnum, ErrorFromResponse, FrameRecordingSettingsRequestModeEnum, FrameRecordingSettingsRequestQualityEnum, FrameRecordingSettingsResponseModeEnum, HLSSettingsRequestQualityTracksEnum, IndividualRecordingSettingsRequestModeEnum, IndividualRecordingSettingsRequestOutputTypesEnum, IndividualRecordingSettingsResponseModeEnum, IngressAudioEncodingOptionsRequestChannelsEnum, IngressSourceRequestFpsEnum, IngressVideoLayerRequestCodecEnum, LOOPBACK_STREAMS_WAIT_TIMEOUT_MS, LayoutSettingsRequestNameEnum, LoopbackStreamsTimeoutError, MAX_LOOPBACK_RECORDING_DURATION_MS, MIN_LOOPBACK_RECORDING_DURATION_MS, MediaStreamRecorder, MicrophoneManager, MicrophoneManagerState, NoiseCancellationSettingsModeEnum, OwnCapability, RTMPBroadcastRequestQualityEnum, RTMPSettingsRequestQualityEnum, RawRecordingSettingsRequestModeEnum, RawRecordingSettingsResponseModeEnum, RecordSettingsRequestModeEnum, RecordSettingsRequestQualityEnum, RequestPermissionRequestPermissionsEnum, rxUtils as RxUtils, ScreenShareManager, ScreenShareState, events as SfuEvents, SfuJoinError, models as SfuModels, SfuTimeoutError, SpeakerManager, SpeakerState, StartClosedCaptionsRequestLanguageEnum, StartTranscriptionRequestLanguageEnum, StreamSfuClient, StreamVideoClient, StreamVideoReadOnlyStateStore, StreamVideoWriteableStateStore, TranscriptionSettingsRequestClosedCaptionModeEnum, TranscriptionSettingsRequestLanguageEnum, TranscriptionSettingsRequestModeEnum, TranscriptionSettingsResponseClosedCaptionModeEnum, TranscriptionSettingsResponseLanguageEnum, TranscriptionSettingsResponseModeEnum, TranslationSettingsLanguagesEnum, UpdateUserPermissionsRequestGrantPermissionsEnum, UpdateUserPermissionsRequestRevokePermissionsEnum, VideoSettingsRequestCameraFacingEnum, VideoSettingsResponseCameraFacingEnum, ViewportTracker, VisibilityState, WSAuthMessageProductsEnum, checkIfAudioOutputChangeSupported, clampLoopbackRecordingDuration, combineComparators, conditional, createSoundDetector, defaultSortPreset, descending, deviceIds$, disposeOfMediaStream, dominantSpeaker, enableLoopbackAudio, getAudioBrowserPermission, getAudioDevices, getAudioOutputDevices, getAudioStream, getCallMediaEngineProvider, getClientDetails, getDeviceState, getLoopbackStreams, getLoopbackTracks, getScreenShareStream, getSdkInfo, getVideoBrowserPermission, getVideoDevices, getVideoStream, getWebRTCInfo, hasAudio$1 as hasAudio, hasInterruptedTrack, hasPausedTrack, hasScreenShare, hasScreenShareAudio, hasVideo, humanize, isPinned, livestreamOrAudioRoomSortPreset, logToConsole, name, noopComparator, paginatedLayoutSortPreset, pinned, publishing, publishingAudio, publishingVideo, reactionType, resolveDeviceId, role, screenSharing, setCallMediaEngineProvider, setDeviceInfo, setOSInfo, setPowerState, setSdkInfo, setThermalState, setWebRTCInfo, speakerLayoutSortPreset, speaking, videoLoggerSystem, waitForLoopbackStreams, withLoopbackAudioEnabled, withParticipantSource };
21448
+ export { AudioSettingsRequestDefaultDeviceEnum, AudioSettingsResponseDefaultDeviceEnum, browsers as Browsers, Call, CallRecordingFailedEventRecordingTypeEnum, CallRecordingReadyEventRecordingTypeEnum, CallRecordingStartedEventRecordingTypeEnum, CallRecordingStoppedEventRecordingTypeEnum, CallState, CallType, CallTypes, CallingState, CameraManager, CameraManagerState, CreateDeviceRequestPushProviderEnum, DEFAULT_LOOPBACK_RECORDING_DURATION_MS, DebounceType, DeviceManager, DeviceManagerState, DynascaleManager, EncryptionManager, EncryptionSettingsRequestModeEnum, EncryptionSettingsResponseModeEnum, ErrorFromResponse, FrameRecordingSettingsRequestModeEnum, FrameRecordingSettingsRequestQualityEnum, FrameRecordingSettingsResponseModeEnum, HLSSettingsRequestQualityTracksEnum, IndividualRecordingSettingsRequestModeEnum, IndividualRecordingSettingsRequestOutputTypesEnum, IndividualRecordingSettingsResponseModeEnum, IngressAudioEncodingOptionsRequestChannelsEnum, IngressSourceRequestFpsEnum, IngressVideoLayerRequestCodecEnum, LOOPBACK_STREAMS_WAIT_TIMEOUT_MS, LayoutSettingsRequestNameEnum, LoopbackStreamsTimeoutError, MAX_LOOPBACK_RECORDING_DURATION_MS, MIN_LOOPBACK_RECORDING_DURATION_MS, MediaStreamRecorder, MicrophoneManager, MicrophoneManagerState, NoiseCancellationSettingsModeEnum, OwnCapability, RTMPBroadcastRequestQualityEnum, RTMPSettingsRequestQualityEnum, RawRecordingSettingsRequestModeEnum, RawRecordingSettingsResponseModeEnum, RecordSettingsRequestModeEnum, RecordSettingsRequestQualityEnum, RequestPermissionRequestPermissionsEnum, rxUtils as RxUtils, ScreenShareManager, ScreenShareState, events as SfuEvents, SfuJoinError, models as SfuModels, SfuTimeoutError, SpeakerManager, SpeakerState, StartClosedCaptionsRequestLanguageEnum, StartTranscriptionRequestLanguageEnum, StreamSfuClient, StreamVideoClient, StreamVideoReadOnlyStateStore, StreamVideoWriteableStateStore, TranscriptionSettingsRequestClosedCaptionModeEnum, TranscriptionSettingsRequestLanguageEnum, TranscriptionSettingsRequestModeEnum, TranscriptionSettingsResponseClosedCaptionModeEnum, TranscriptionSettingsResponseLanguageEnum, TranscriptionSettingsResponseModeEnum, TranslationSettingsLanguagesEnum, TypedEventEmitter, UpdateUserPermissionsRequestGrantPermissionsEnum, UpdateUserPermissionsRequestRevokePermissionsEnum, VideoSettingsRequestCameraFacingEnum, VideoSettingsResponseCameraFacingEnum, ViewportTracker, VisibilityState, WSAuthMessageProductsEnum, checkIfAudioOutputChangeSupported, clampLoopbackRecordingDuration, combineComparators, conditional, createSoundDetector, defaultSortPreset, descending, deviceIds$, disposeOfMediaStream, dominantSpeaker, enableLoopbackAudio, getAudioBrowserPermission, getAudioDevices, getAudioOutputDevices, getAudioStream, getCallMediaEngineProvider, getClientDetails, getDeviceState, getLoopbackStreams, getLoopbackTracks, getScreenShareStream, getSdkInfo, getVideoBrowserPermission, getVideoDevices, getVideoStream, getWebRTCInfo, hasAudio$1 as hasAudio, hasInterruptedTrack, hasPausedTrack, hasScreenShare, hasScreenShareAudio, hasVideo, humanize, isPinned, livestreamOrAudioRoomSortPreset, logToConsole, name, noopComparator, paginatedLayoutSortPreset, pinned, publishing, publishingAudio, publishingVideo, reactionType, resolveDeviceId, role, screenSharing, setCallMediaEngineProvider, setDeviceInfo, setOSInfo, setPowerState, setSdkInfo, setThermalState, setWebRTCInfo, speakerLayoutSortPreset, speaking, videoLoggerSystem, waitForLoopbackStreams, withLoopbackAudioEnabled, withParticipantSource };
21242
21449
  //# sourceMappingURL=index.es.js.map