@stream-io/video-client 1.59.1 → 1.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/index.browser.es.js +744 -156
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +744 -155
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.es.js +744 -156
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +34 -8
  10. package/dist/src/coordinator/connection/types.d.ts +16 -0
  11. package/dist/src/events/call.d.ts +0 -11
  12. package/dist/src/gen/coordinator/index.d.ts +73 -0
  13. package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
  14. package/dist/src/ringing/RingStatePoller.d.ts +46 -0
  15. package/dist/src/ringing/RingTimeout.d.ts +27 -0
  16. package/dist/src/ringing/index.d.ts +4 -0
  17. package/dist/src/ringing/reconcileRingState.d.ts +18 -0
  18. package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
  19. package/dist/src/rtc/Publisher.d.ts +18 -0
  20. package/dist/src/rtc/TransceiverCache.d.ts +10 -0
  21. package/dist/src/rtc/e2ee/E2EEManager.d.ts +10 -1
  22. package/dist/src/stats/utils.d.ts +6 -0
  23. package/dist/src/store/CallState.d.ts +10 -1
  24. package/dist/src/types.d.ts +30 -1
  25. package/index.ts +1 -0
  26. package/package.json +1 -1
  27. package/src/Call.ts +272 -108
  28. package/src/StreamSfuClient.ts +6 -2
  29. package/src/__tests__/Call.autodrop.test.ts +116 -64
  30. package/src/__tests__/Call.getRingState.test.ts +87 -0
  31. package/src/__tests__/Call.lifecycle.test.ts +498 -5
  32. package/src/__tests__/Call.ringSettled.test.ts +71 -0
  33. package/src/__tests__/Call.ringingLifecycle.test.ts +242 -0
  34. package/src/__tests__/StreamSfuClient.test.ts +48 -0
  35. package/src/coordinator/connection/types.ts +19 -0
  36. package/src/events/__tests__/call.test.ts +2 -217
  37. package/src/events/call.ts +1 -73
  38. package/src/events/callEventHandlers.ts +12 -23
  39. package/src/gen/coordinator/index.ts +67 -0
  40. package/src/reporting/ClientEventReporter.ts +30 -4
  41. package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
  42. package/src/ringing/RingStatePoller.ts +177 -0
  43. package/src/ringing/RingTimeout.ts +90 -0
  44. package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
  45. package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
  46. package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
  47. package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
  48. package/src/ringing/index.ts +4 -0
  49. package/src/ringing/reconcileRingState.ts +108 -0
  50. package/src/ringing/resolveOwnRingOutcome.ts +58 -0
  51. package/src/rtc/Publisher.ts +66 -12
  52. package/src/rtc/TransceiverCache.ts +17 -0
  53. package/src/rtc/__tests__/Publisher.test.ts +209 -0
  54. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +1 -0
  55. package/src/rtc/e2ee/E2EEManager.ts +10 -1
  56. package/src/rtc/e2ee/EncryptionManager.ts +1 -1
  57. package/src/rtc/e2ee/SPEC.md +1 -1
  58. package/src/stats/__tests__/utils.test.ts +33 -0
  59. package/src/stats/utils.ts +15 -0
  60. package/src/store/CallState.ts +25 -0
  61. package/src/store/__tests__/CallState.test.ts +59 -0
  62. package/src/types.ts +37 -1
package/dist/index.cjs.js CHANGED
@@ -5101,7 +5101,7 @@ const toIceCandidate = (iceTrickle) => {
5101
5101
  }
5102
5102
  };
5103
5103
 
5104
- const version = "1.59.1";
5104
+ const version = "1.61.0";
5105
5105
  const [major, minor, patch] = version.split('.');
5106
5106
  let sdkInfo = {
5107
5107
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -7134,6 +7134,30 @@ class CallState {
7134
7134
  this.setCurrentValue(this.captioningSubject, call.captioning);
7135
7135
  this.setCurrentValue(this.thumbnailsSubject, call.thumbnails);
7136
7136
  };
7137
+ /**
7138
+ * Merges a polled ring state into the current session. Only the ring fields
7139
+ * are touched: the endpoint does not return the session roster.
7140
+ *
7141
+ * @internal
7142
+ *
7143
+ * @param ringState the ring state as returned by the coordinator.
7144
+ */
7145
+ updateFromRingState = (ringState) => {
7146
+ this.setCurrentValue(this.sessionSubject, (session) => {
7147
+ if (!session || session.id !== ringState.session_id)
7148
+ return session;
7149
+ return {
7150
+ ...session,
7151
+ accepted_by: ringState.accepted_by,
7152
+ rejected_by: ringState.rejected_by,
7153
+ missed_by: ringState.missed_by,
7154
+ ended_at: ringState.session_ended_at ?? session.ended_at,
7155
+ };
7156
+ });
7157
+ if (ringState.call_ended_at) {
7158
+ this.setEndedAt(new Date(ringState.call_ended_at));
7159
+ }
7160
+ };
7137
7161
  /**
7138
7162
  * Updates the call state with the data received from the SFU server.
7139
7163
  *
@@ -7445,6 +7469,19 @@ const getSdkName = (sdk) => {
7445
7469
  const getSdkVersion = (sdk) => {
7446
7470
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
7447
7471
  };
7472
+ /**
7473
+ * Returns the identifier the SFU receives in the `X-Stream-Client` header,
7474
+ * e.g. `stream-video-react-v1.43.1`. Matches the format the coordinator
7475
+ * already gets through the client's user agent.
7476
+ */
7477
+ const getStreamClientId = (sdk) => {
7478
+ const sdkPlatforms = {
7479
+ [SdkType.REACT]: 'react',
7480
+ [SdkType.REACT_NATIVE]: 'react-native',
7481
+ };
7482
+ const platform = (sdk && sdkPlatforms[sdk.type]) || 'js';
7483
+ return `stream-video-${platform}-v${getSdkVersion(sdk)}`;
7484
+ };
7448
7485
 
7449
7486
  /**
7450
7487
  * Creates a new StatsReporter instance that collects metrics about the ongoing call and reports them to the state store
@@ -9129,6 +9166,25 @@ class TransceiverCache {
9129
9166
  this.cache.push(bundle);
9130
9167
  this.transceiverOrder.push(bundle.transceiver);
9131
9168
  };
9169
+ /**
9170
+ * Retires a failed sender from the reusable cache.
9171
+ *
9172
+ * A transceiver that never reached an m-section also leaves the creation
9173
+ * history: once stopped, it is left out of every later offer, so counting
9174
+ * it would shift the `mid` index of every transceiver added after it. One
9175
+ * that is already associated keeps its slot, since its m-section survives
9176
+ * as a rejected one.
9177
+ */
9178
+ remove = (transceiver) => {
9179
+ const index = this.cache.findIndex((b) => b.transceiver === transceiver);
9180
+ if (index !== -1)
9181
+ this.cache.splice(index, 1);
9182
+ if (transceiver.mid)
9183
+ return;
9184
+ const orderIndex = this.transceiverOrder.indexOf(transceiver);
9185
+ if (orderIndex !== -1)
9186
+ this.transceiverOrder.splice(orderIndex, 1);
9187
+ };
9132
9188
  /**
9133
9189
  * Gets the transceiver for the given publish option.
9134
9190
  */
@@ -9300,7 +9356,9 @@ class Publisher extends BasePeerConnection {
9300
9356
  const sendEncodings = isSvcCodec(publishOption.codec?.name)
9301
9357
  ? toSvcEncodings(encodings)
9302
9358
  : encodings;
9303
- const transceiver = this.pc.addTransceiver(track, {
9359
+ // An E2EE sender must never have media before its transform is attached,
9360
+ // including when another track or an ICE restart negotiates this PC.
9361
+ const transceiver = this.pc.addTransceiver(this.e2ee ? track.kind : track, {
9304
9362
  direction: 'sendonly',
9305
9363
  sendEncodings,
9306
9364
  });
@@ -9310,16 +9368,63 @@ class Publisher extends BasePeerConnection {
9310
9368
  this.transceiverCache.add({ publishOption, transceiver, options });
9311
9369
  this.trackIdToTrackType.set(track.id, trackType);
9312
9370
  this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
9313
- const params = transceiver.sender.getParameters();
9371
+ if (this.e2ee) {
9372
+ await this.initEncryptedSender(transceiver, track, publishOption);
9373
+ }
9374
+ else {
9375
+ await this.setDegradationPreference(transceiver.sender, publishOption);
9376
+ }
9377
+ await this.negotiate();
9378
+ };
9379
+ /**
9380
+ * Attaches the encryptor, applies sender parameters, then attaches the track.
9381
+ *
9382
+ * `encrypt` must run before the first await: Chrome short-circuits a sender
9383
+ * whose encoded streams aren't created in the task that created it, and the
9384
+ * track goes on last so the sender has no media until both steps succeed.
9385
+ */
9386
+ initEncryptedSender = async (transceiver, track, publishOption) => {
9387
+ try {
9388
+ const { sender } = transceiver;
9389
+ const codec = publishOption.codec?.name.toLowerCase();
9390
+ this.e2ee.encrypt(sender, codec, TrackType[publishOption.trackType]);
9391
+ this.logger.debug('E2EE encryptor attached to sender');
9392
+ await this.setDegradationPreference(sender, publishOption);
9393
+ await sender.replaceTrack(track);
9394
+ }
9395
+ catch (err) {
9396
+ this.retireTransceiver(transceiver, track);
9397
+ throw err;
9398
+ }
9399
+ };
9400
+ setDegradationPreference = async (sender, publishOption) => {
9401
+ const params = sender.getParameters();
9314
9402
  params.degradationPreference =
9315
9403
  toRTCDegradationPreference(publishOption.degradationPreference) ??
9316
9404
  'maintain-framerate';
9317
- await transceiver.sender.setParameters(params);
9318
- if (this.e2ee) {
9319
- this.e2ee.encrypt(transceiver.sender, publishOption.codec?.name.toLowerCase(), TrackType[publishOption.trackType]);
9320
- this.logger.debug('E2EE encryptor attached to sender');
9405
+ await sender.setParameters(params);
9406
+ };
9407
+ /**
9408
+ * Discards a sender whose E2EE initialization failed.
9409
+ *
9410
+ * A partially initialized sender may carry no transform, so publish() must
9411
+ * not reuse it: retiring it from the cache is what keeps a later publish or
9412
+ * ICE restart from putting cleartext on the wire. Cleanup cannot throw, so
9413
+ * the initialization failure is what reaches the caller.
9414
+ */
9415
+ retireTransceiver = (transceiver, track) => {
9416
+ this.transceiverCache.remove(transceiver);
9417
+ this.trackIdToTrackType.delete(track.id);
9418
+ // on React Native a clone shares its native source, so stopping it here
9419
+ // would stop the track the caller passed in. dispose() releases it.
9420
+ if (!isReactNative())
9421
+ this.stopTrack(track);
9422
+ try {
9423
+ transceiver.stop();
9424
+ }
9425
+ catch (err) {
9426
+ this.logger.debug('Failed to stop a retired transceiver', err);
9321
9427
  }
9322
- await this.negotiate();
9323
9428
  };
9324
9429
  /**
9325
9430
  * Updates the transceiver with the given track and track type.
@@ -10326,7 +10431,10 @@ class StreamSfuClient {
10326
10431
  this.rpc = createSignalClient({
10327
10432
  baseUrl: server.url,
10328
10433
  interceptors: [
10329
- withHeaders({ Authorization: `Bearer ${token}` }),
10434
+ withHeaders({
10435
+ Authorization: `Bearer ${token}`,
10436
+ 'X-Stream-Client': getStreamClientId(getSdkInfo()),
10437
+ }),
10330
10438
  this.tracer && withRequestTracer(this.tracer.trace),
10331
10439
  this.logger.getLogLevel() === 'trace' && withRequestLogger(this.logger),
10332
10440
  withTimeout(rpcRequestTimeout, this.tracer?.trace),
@@ -10663,64 +10771,370 @@ class StreamSfuClient {
10663
10771
  }
10664
10772
 
10665
10773
  /**
10666
- * Event handler that watched the delivery of `call.accepted`.
10667
- * Once the event is received, the call is joined.
10668
- */
10669
- const watchCallAccepted = (call) => {
10670
- return async function onCallAccepted(event) {
10671
- // We want to discard the event if it's from the current user
10672
- if (event.user.id === call.currentUserId)
10673
- return;
10674
- const { state } = call;
10675
- if (event.call.created_by.id === call.currentUserId &&
10676
- state.callingState === exports.CallingState.RINGING) {
10677
- await call.join();
10774
+ * Decides what a ringing call should do next, based on the current call state.
10775
+ *
10776
+ * The `call.accepted`, `call.rejected` and `call.missed` handlers and the ring
10777
+ * state poller both run this. They differ only in how the state got there: the
10778
+ * handlers rely on `CallState.updateFromEvent`, which runs before them, and the
10779
+ * poller applies the polled ring state itself.
10780
+ *
10781
+ * @param call the call to reconcile.
10782
+ * @param joinSource which of the two triggered this run, reported on the
10783
+ * caller's join: `ring-ws` for the event handlers, `ring-poll-api` for the
10784
+ * poller.
10785
+ * @returns whether the ring reached a terminal state. A failed join is not
10786
+ * terminal: the caller should keep trying while the ring is open.
10787
+ */
10788
+ const reconcileRingState = async (call, joinSource) => {
10789
+ if (call.state.callingState !== exports.CallingState.RINGING)
10790
+ return true;
10791
+ return call.isCreatedByMe
10792
+ ? reconcileAsCaller(call, joinSource)
10793
+ : reconcileAsCallee(call);
10794
+ };
10795
+ const reconcileAsCaller = async (call, joinSource) => {
10796
+ const { session, members, endedAt } = call.state;
10797
+ const currentUserId = call.currentUserId;
10798
+ // checked before `accepted_by`: an ended session cannot be joined
10799
+ if (endedAt || session?.ended_at) {
10800
+ call.logger.info('ring: the call has ended, leaving');
10801
+ // `leave` reports the remote end to callingx off `reason: 'ended'`
10802
+ return leave(call, {
10803
+ reject: false,
10804
+ reason: 'ended',
10805
+ message: 'ring: call ended',
10806
+ });
10807
+ }
10808
+ const acceptedBy = session?.accepted_by ?? {};
10809
+ if (Object.keys(acceptedBy).some((userId) => userId !== currentUserId)) {
10810
+ call.logger.info('ring: the call was accepted, joining');
10811
+ try {
10812
+ await call.join({ joinSource });
10678
10813
  }
10679
- };
10814
+ catch (err) {
10815
+ // `doJoin` restores the ringing state when a join fails, so the ring is
10816
+ // still open. Report it unsettled and let the next poll retry.
10817
+ call.logger.error('Failed to join an accepted call', err);
10818
+ return false;
10819
+ }
10820
+ return true;
10821
+ }
10822
+ const otherMembers = members
10823
+ .filter((member) => member.user_id !== currentUserId)
10824
+ .map((member) => member.user_id);
10825
+ if (otherMembers.length === 0)
10826
+ return false;
10827
+ const rejectedBy = session?.rejected_by ?? {};
10828
+ if (otherMembers.every((userId) => rejectedBy[userId])) {
10829
+ call.logger.info('ring: everyone rejected, leaving');
10830
+ return leave(call, {
10831
+ reject: true,
10832
+ reason: 'cancel',
10833
+ message: 'ring: everyone rejected',
10834
+ });
10835
+ }
10836
+ return false;
10837
+ };
10838
+ // the current user's own accept or reject, on this or another device, is
10839
+ // handled by `resolveOwnRingOutcome`, and `call.ended` by `watchCallEnded`.
10840
+ const reconcileAsCallee = async (call) => {
10841
+ const createdById = call.state.createdBy?.id;
10842
+ const rejectedBy = call.state.session?.rejected_by ?? {};
10843
+ if (createdById && rejectedBy[createdById]) {
10844
+ call.logger.info('ring: the caller cancelled, leaving');
10845
+ return leave(call, {
10846
+ reason: 'ended',
10847
+ message: 'ring: creator rejected',
10848
+ });
10849
+ }
10850
+ return false;
10680
10851
  };
10852
+ // `false` when the call could not be left, so the ring stays open for a retry.
10853
+ const leave = async (call, options) => {
10854
+ try {
10855
+ await call.leave(options);
10856
+ return true;
10857
+ }
10858
+ catch (err) {
10859
+ call.logger.error('Failed to leave a ringing call', err);
10860
+ return false;
10861
+ }
10862
+ };
10863
+
10864
+ // `doJoin` restores the previous calling state when a join fails, so JOINING can
10865
+ // go back to RINGING. Only a state the ring cannot return from ends the poller.
10866
+ const ringIsOver = (callingState) => callingState !== exports.CallingState.RINGING &&
10867
+ callingState !== exports.CallingState.JOINING;
10681
10868
  /**
10682
- * Event handler that watches delivery of `call.rejected` Websocket event.
10683
- * Once the event is received, the call is left.
10869
+ * Polls the coordinator for the outcome of a ring the current user started.
10870
+ *
10871
+ * `call.accepted`, `call.rejected` and `call.missed` are delivered best-effort,
10872
+ * with no store-and-forward, so a caller that drops one is left on a ringing
10873
+ * screen while the callee is already in the call. After a quiet period this
10874
+ * reads the ring state until a terminal outcome or the end of the ring window.
10684
10875
  */
10685
- const watchCallRejected = (call) => {
10686
- return async function onCallRejected(event) {
10687
- // We want to discard the event if it's from the current user
10688
- if (event.user.id === call.currentUserId)
10876
+ class RingStatePoller {
10877
+ logger = videoLoggerSystem.getLogger('RingStatePoller');
10878
+ call;
10879
+ startAfterMs;
10880
+ intervalMs;
10881
+ sessionId;
10882
+ deadlineAt = 0;
10883
+ idleTimeoutId;
10884
+ intervalId;
10885
+ stopped = false;
10886
+ wasPolling = false;
10887
+ inFlight = false;
10888
+ unsubscribe = [];
10889
+ constructor(call, options = {}) {
10890
+ this.call = call;
10891
+ this.startAfterMs = options.startAfterMs ?? 15_000;
10892
+ this.intervalMs = options.intervalMs ?? 5_000;
10893
+ }
10894
+ /**
10895
+ * Starts polling. Does nothing when the call has no session yet.
10896
+ */
10897
+ start = () => {
10898
+ if (this.stopped || this.sessionId)
10899
+ return;
10900
+ if (this.call.state.callingState !== exports.CallingState.RINGING)
10689
10901
  return;
10690
- const { call: eventCall } = event;
10691
- const { session: callSession } = eventCall;
10692
- if (!callSession) {
10693
- call.logger.warn('No call session provided. Ignoring call.rejected event.', event);
10902
+ // captured once: `call.ended` clears the call's current session
10903
+ const sessionId = this.call.state.session?.id;
10904
+ if (!sessionId) {
10905
+ this.logger.warn('the call has no session');
10694
10906
  return;
10695
10907
  }
10696
- const rejectedBy = callSession.rejected_by;
10697
- const { members, callingState } = call.state;
10698
- if (callingState !== exports.CallingState.RINGING) {
10699
- call.logger.info('Call is not in ringing mode (it is either accepted or rejected already). Ignoring call.rejected event.', event);
10908
+ this.sessionId = sessionId;
10909
+ const ring = this.call.state.settings?.ring;
10910
+ const maxDurationMs = ring?.auto_cancel_timeout_ms || ring?.missed_call_timeout_ms || 30_000;
10911
+ this.deadlineAt = Date.now() + maxDurationMs;
10912
+ // an incoming event proves the socket is alive, so the quiet period starts
10913
+ // over: in a group ring a single rejection does not settle the ring.
10914
+ this.unsubscribe.push(this.call.on('call.accepted', () => this.armIdleWindow()), this.call.on('call.rejected', () => this.armIdleWindow()), this.call.on('call.missed', () => this.armIdleWindow()),
10915
+ // the ring is over, whichever way it went. Joining an accepted call
10916
+ // never goes through `leave`, so this is the only signal for it.
10917
+ createSubscription(this.call.state.callingState$, (callingState) => {
10918
+ if (ringIsOver(callingState))
10919
+ this.stop();
10920
+ }));
10921
+ this.armIdleWindow();
10922
+ };
10923
+ /**
10924
+ * Pauses polling, keeping the captured session, the deadline and the event
10925
+ * subscriptions. Resuming does not extend the ring window.
10926
+ */
10927
+ pause = () => {
10928
+ if (this.stopped)
10929
+ return;
10930
+ this.wasPolling = this.intervalId !== undefined;
10931
+ const timers = getTimers();
10932
+ timers.clearTimeout(this.idleTimeoutId);
10933
+ timers.clearInterval(this.intervalId);
10934
+ this.idleTimeoutId = undefined;
10935
+ this.intervalId = undefined;
10936
+ };
10937
+ /**
10938
+ * Resumes a paused poller. Goes straight back to polling if it was already
10939
+ * past the quiet period, so a pause does not buy the ring another one.
10940
+ */
10941
+ resume = () => {
10942
+ if (this.stopped || !this.sessionId)
10943
+ return;
10944
+ if (this.idleTimeoutId !== undefined || this.intervalId !== undefined) {
10700
10945
  return;
10701
10946
  }
10702
- if (call.isCreatedByMe) {
10703
- const everyoneElseRejected = members
10704
- .filter((m) => m.user_id !== call.currentUserId)
10705
- .every((m) => rejectedBy[m.user_id]);
10706
- if (everyoneElseRejected) {
10707
- call.logger.info('everyone rejected, leaving the call');
10708
- await call.leave({
10709
- reject: true,
10710
- reason: 'cancel',
10711
- message: 'ring: everyone rejected',
10712
- });
10713
- }
10947
+ if (!this.wasPolling) {
10948
+ this.armIdleWindow();
10949
+ return;
10714
10950
  }
10715
- else {
10716
- if (rejectedBy[eventCall.created_by.id]) {
10717
- call.logger.info('call creator rejected, leaving call');
10718
- globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
10719
- await call.leave({ message: 'ring: creator rejected' });
10951
+ this.intervalId = getTimers().setInterval(this.runTick, this.intervalMs);
10952
+ this.runTick();
10953
+ };
10954
+ /**
10955
+ * Stops polling. The poller cannot be restarted.
10956
+ */
10957
+ stop = () => {
10958
+ if (this.stopped)
10959
+ return;
10960
+ this.stopped = true;
10961
+ const timers = getTimers();
10962
+ timers.clearTimeout(this.idleTimeoutId);
10963
+ timers.clearInterval(this.intervalId);
10964
+ this.idleTimeoutId = undefined;
10965
+ this.intervalId = undefined;
10966
+ this.unsubscribe.forEach((off) => off());
10967
+ this.unsubscribe = [];
10968
+ };
10969
+ armIdleWindow = () => {
10970
+ if (this.stopped)
10971
+ return;
10972
+ const timers = getTimers();
10973
+ timers.clearTimeout(this.idleTimeoutId);
10974
+ timers.clearInterval(this.intervalId);
10975
+ this.intervalId = undefined;
10976
+ this.idleTimeoutId = timers.setTimeout(() => {
10977
+ this.idleTimeoutId = undefined;
10978
+ if (this.stopped)
10979
+ return;
10980
+ this.intervalId = timers.setInterval(this.runTick, this.intervalMs);
10981
+ this.runTick();
10982
+ }, this.startAfterMs);
10983
+ };
10984
+ runTick = () => {
10985
+ this.tick().catch((err) => {
10986
+ this.logger.warn('Failed to poll the ring state', err);
10987
+ });
10988
+ };
10989
+ tick = async () => {
10990
+ if (this.stopped || this.inFlight || !this.sessionId)
10991
+ return;
10992
+ const { callingState } = this.call.state;
10993
+ if (ringIsOver(callingState) || Date.now() >= this.deadlineAt) {
10994
+ this.stop();
10995
+ return;
10996
+ }
10997
+ // mid-join: nothing to reconcile until we know whether it succeeded
10998
+ if (callingState !== exports.CallingState.RINGING)
10999
+ return;
11000
+ this.inFlight = true;
11001
+ try {
11002
+ const ringState = await this.call.getRingState(this.sessionId);
11003
+ if (this.stopped)
11004
+ return;
11005
+ this.call.state.updateFromRingState(ringState);
11006
+ if (await reconcileRingState(this.call, 'ring-poll-api'))
11007
+ this.stop();
11008
+ }
11009
+ catch (err) {
11010
+ // a missing session, or one of another call, will never resolve
11011
+ const status = err instanceof ErrorFromResponse ? err.status : undefined;
11012
+ if (status === 400 || status === 404) {
11013
+ this.logger.warn('Stopped polling the ring state', err);
11014
+ this.stop();
11015
+ }
11016
+ else {
11017
+ this.logger.debug('Failed to poll the ring state', err);
10720
11018
  }
10721
11019
  }
11020
+ finally {
11021
+ this.inFlight = false;
11022
+ }
11023
+ };
11024
+ }
11025
+
11026
+ /**
11027
+ * Drops a call that has been ringing for too long: the caller cancels after
11028
+ * `auto_cancel_timeout_ms`, a callee after `incoming_call_timeout_ms`.
11029
+ *
11030
+ * Either timeout being `0` means the call rings until something else settles it.
11031
+ */
11032
+ class RingTimeout {
11033
+ call;
11034
+ timeoutId;
11035
+ deadlineAt;
11036
+ stopped = false;
11037
+ constructor(call) {
11038
+ this.call = call;
11039
+ }
11040
+ /**
11041
+ * Schedules an auto-drop timeout based on the call settings.
11042
+ * Applicable only for ringing calls.
11043
+ */
11044
+ start = () => {
11045
+ if (this.stopped || this.timeoutId !== undefined)
11046
+ return;
11047
+ // ignore if the call is not ringing
11048
+ if (this.call.state.callingState !== exports.CallingState.RINGING)
11049
+ return;
11050
+ const ring = this.call.state.settings?.ring;
11051
+ if (!ring)
11052
+ return;
11053
+ const isCaller = this.call.isCreatedByMe;
11054
+ const timeoutMs = isCaller
11055
+ ? ring.auto_cancel_timeout_ms
11056
+ : ring.incoming_call_timeout_ms;
11057
+ // 0 means no auto-drop
11058
+ if (timeoutMs <= 0)
11059
+ return;
11060
+ const now = Date.now();
11061
+ this.deadlineAt ??= now + timeoutMs;
11062
+ const delayMs = Math.max(0, this.deadlineAt - now);
11063
+ const timers = getTimers();
11064
+ this.timeoutId = timers.setTimeout(() => {
11065
+ this.timeoutId = undefined;
11066
+ // A failed timeout-triggered leave can arm a fresh retry window.
11067
+ this.deadlineAt = undefined;
11068
+ if (this.stopped)
11069
+ return;
11070
+ // the call might have stopped ringing by this point, e.g. it was already
11071
+ // accepted and joined
11072
+ if (this.call.state.callingState !== exports.CallingState.RINGING)
11073
+ return;
11074
+ this.call
11075
+ .leave({
11076
+ reject: true,
11077
+ reason: 'timeout',
11078
+ message: `ringing timeout - ${isCaller
11079
+ ? 'no one accepted'
11080
+ : `user didn't interact with incoming call screen`}`,
11081
+ })
11082
+ .catch((err) => {
11083
+ videoLoggerSystem
11084
+ .getLogger('RingTimeout')
11085
+ .error('Failed to drop the call', err);
11086
+ });
11087
+ }, delayMs);
11088
+ };
11089
+ /**
11090
+ * Pauses the timeout while preserving its original deadline.
11091
+ */
11092
+ pause = () => {
11093
+ if (this.stopped || this.timeoutId === undefined)
11094
+ return;
11095
+ getTimers().clearTimeout(this.timeoutId);
11096
+ this.timeoutId = undefined;
11097
+ };
11098
+ /**
11099
+ * Cancels a scheduled auto-drop timeout. It cannot be armed again.
11100
+ */
11101
+ stop = () => {
11102
+ if (this.stopped)
11103
+ return;
11104
+ this.stopped = true;
11105
+ getTimers().clearTimeout(this.timeoutId);
11106
+ this.timeoutId = undefined;
11107
+ this.deadlineAt = undefined;
11108
+ };
11109
+ }
11110
+
11111
+ /**
11112
+ * Decides what a ringing call should do about the current user's own accept or
11113
+ * reject, which may have happened on another device.
11114
+ *
11115
+ * Accepting on *this* device also lands in `accepted_by`, so an acceptance only
11116
+ * means another device took the call when this one is still ringing.
11117
+ *
11118
+ * Only meaningful for a ringing call; the caller checks that.
11119
+ */
11120
+ const resolveOwnRingOutcome = ({ session, currentUserId, callingState, }) => {
11121
+ if (!currentUserId)
11122
+ return { settledByMe: false };
11123
+ const acceptedByMe = Boolean(session?.accepted_by[currentUserId]);
11124
+ const rejectedByMe = Boolean(session?.rejected_by[currentUserId]);
11125
+ if (!acceptedByMe && !rejectedByMe)
11126
+ return { settledByMe: false };
11127
+ const answeredElsewhere = acceptedByMe && callingState === exports.CallingState.RINGING;
11128
+ return {
11129
+ settledByMe: true,
11130
+ leaveReason: answeredElsewhere
11131
+ ? 'answeredElsewhere'
11132
+ : rejectedByMe
11133
+ ? 'rejected'
11134
+ : undefined,
10722
11135
  };
10723
11136
  };
11137
+
10724
11138
  /**
10725
11139
  * Event handler that watches the delivery of `call.ended` Websocket event.
10726
11140
  */
@@ -11121,14 +11535,18 @@ const registerEventHandlers = (call, dispatcher) => {
11121
11535
  * @param call the call to register event handlers for.
11122
11536
  */
11123
11537
  const registerRingingCallEventHandlers = (call) => {
11124
- const coordinatorRingEvents = {
11125
- 'call.accepted': watchCallAccepted(call),
11126
- 'call.rejected': watchCallRejected(call),
11538
+ const reconcile = () => {
11539
+ reconcileRingState(call, 'ring-ws').catch((err) => {
11540
+ call.logger.error('Failed to reconcile the ring state', err);
11541
+ });
11127
11542
  };
11128
- const eventHandlers = Object.keys(coordinatorRingEvents).map((event) => {
11129
- const eventName = event;
11130
- return call.on(eventName, coordinatorRingEvents[eventName]);
11131
- });
11543
+ // each event needs its own closure. `call.missed` is deliberately absent:
11544
+ // nothing in the reconciler acts on `missed_by`, so the auto-drop owns the
11545
+ // "nobody answered" case until the server-owned ring timeout lands.
11546
+ const eventHandlers = [
11547
+ call.on('call.accepted', () => reconcile()),
11548
+ call.on('call.rejected', () => reconcile()),
11549
+ ];
11132
11550
  return () => {
11133
11551
  eventHandlers.forEach((unsubscribe) => unsubscribe());
11134
11552
  };
@@ -15176,7 +15594,8 @@ class Call {
15176
15594
  statsReporter;
15177
15595
  sfuStatsReporter;
15178
15596
  lastStatsOptions;
15179
- dropTimeout;
15597
+ ringTimeout;
15598
+ ringStatePoller;
15180
15599
  clientStore;
15181
15600
  streamClient;
15182
15601
  clientEventReporter;
@@ -15354,22 +15773,19 @@ class Call {
15354
15773
  createSubscription(this.state.session$, (session) => {
15355
15774
  if (!this.ringing)
15356
15775
  return;
15357
- const receiverId = this.clientStore.connectedUser?.id;
15358
- if (!receiverId)
15359
- return;
15360
- const isAcceptedByMe = Boolean(session?.accepted_by[receiverId]);
15361
- const isRejectedByMe = Boolean(session?.rejected_by[receiverId]);
15362
- if (isAcceptedByMe || isRejectedByMe) {
15776
+ const { settledByMe, leaveReason } = resolveOwnRingOutcome({
15777
+ session,
15778
+ currentUserId: this.currentUserId,
15779
+ callingState: this.state.callingState,
15780
+ });
15781
+ if (settledByMe)
15363
15782
  this.cancelAutoDrop();
15364
- }
15365
- const isAcceptedElsewhere = isAcceptedByMe && this.state.callingState === exports.CallingState.RINGING;
15366
- if ((isAcceptedElsewhere || isRejectedByMe) &&
15367
- !hasPending(this.joinLeaveConcurrencyTag)) {
15368
- globalThis.streamRNVideoSDK?.callingX?.endCall(this, isAcceptedElsewhere ? 'answeredElsewhere' : 'rejected');
15369
- this.leave().catch(() => {
15370
- this.logger.error('Could not leave a call that was accepted or rejected elsewhere');
15371
- });
15372
- }
15783
+ if (!leaveReason || hasPending(this.joinLeaveConcurrencyTag))
15784
+ return;
15785
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, leaveReason);
15786
+ this.leave().catch(() => {
15787
+ this.logger.error('Could not leave a call that was accepted or rejected elsewhere');
15788
+ });
15373
15789
  }));
15374
15790
  };
15375
15791
  handleRingingCall = () => {
@@ -15415,6 +15831,7 @@ class Call {
15415
15831
  this.state.setCallingState(exports.CallingState.RINGING);
15416
15832
  }
15417
15833
  this.scheduleAutoDrop();
15834
+ this.scheduleRingStatePolling();
15418
15835
  this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
15419
15836
  }
15420
15837
  };
@@ -15501,6 +15918,12 @@ class Call {
15501
15918
  if (this.state.callingState === exports.CallingState.LEFT) {
15502
15919
  throw new Error('Cannot leave call that has already been left.');
15503
15920
  }
15921
+ // before the first await: the calling state stays RINGING well into the
15922
+ // teardown, so pause both watchdogs before they can race this leave. They
15923
+ // keep their deadlines, so a failed leave resumes them without handing the
15924
+ // ring another window.
15925
+ this.ringTimeout?.pause();
15926
+ this.ringStatePoller?.pause();
15504
15927
  await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
15505
15928
  const callingState = this.state.callingState;
15506
15929
  if (callingState === exports.CallingState.LEFT) {
@@ -15567,6 +15990,11 @@ class Call {
15567
15990
  this.audioBindingsWatchdog?.dispose();
15568
15991
  await this.dynascaleManager?.dispose();
15569
15992
  this.state.setCallingState(exports.CallingState.LEFT);
15993
+ // `ringingSubject` is cleared further down, so this still reads true for
15994
+ // a call that was ringing.
15995
+ if (this.ringing) {
15996
+ globalThis.streamRNVideoSDK?.ringingCallLifecycle?.onLeave(this);
15997
+ }
15570
15998
  this.state.setParticipants([]);
15571
15999
  this.state.dispose();
15572
16000
  // Reset reconnect-related accumulators so a future `call.join()` on the
@@ -15590,6 +16018,7 @@ class Call {
15590
16018
  this.unifiedSessionId = undefined;
15591
16019
  this.ringingSubject.next(false);
15592
16020
  this.cancelAutoDrop();
16021
+ this.cancelRingStatePolling();
15593
16022
  this.clientStore.unregisterCall(this);
15594
16023
  globalThis.streamRNVideoSDK?.callManager.stop({
15595
16024
  isRingingTypeCall: this.ringing,
@@ -15628,6 +16057,15 @@ class Call {
15628
16057
  this.logger.warn('Failed to dispose media engine', err);
15629
16058
  });
15630
16059
  }
16060
+ }).catch((err) => {
16061
+ if (!hasPending(this.joinLeaveConcurrencyTag) &&
16062
+ this.state.callingState === exports.CallingState.RINGING) {
16063
+ // resume, never re-arm: a fresh watchdog would restart the ring
16064
+ // window this leave was already most of the way through
16065
+ this.ringTimeout?.start();
16066
+ this.ringStatePoller?.resume();
16067
+ }
16068
+ throw err;
15631
16069
  });
15632
16070
  };
15633
16071
  /**
@@ -15725,8 +16163,11 @@ class Call {
15725
16163
  * @param params.video if set to true, in a ringing scenario, mobile SDKs will show "incoming video call", audio only otherwise.
15726
16164
  */
15727
16165
  get = async (params) => {
16166
+ const getLeaveGeneration = this.leaveGeneration;
15728
16167
  await this.setup();
15729
16168
  const response = await this.streamClient.get(this.streamClientBasePath, params);
16169
+ if (this.leaveGeneration !== getLeaveGeneration)
16170
+ return response;
15730
16171
  this.updateFromCallStateResponse(response);
15731
16172
  if (params?.ring) {
15732
16173
  this.ringingSubject.next(true);
@@ -15778,6 +16219,21 @@ class Call {
15778
16219
  ring = async (data = {}) => {
15779
16220
  return this.streamClient.post(`${this.streamClientBasePath}/ring`, data);
15780
16221
  };
16222
+ /**
16223
+ * Returns who accepted, rejected or missed the ring for a call session.
16224
+ * Safe to poll: it performs no writes and emits no events.
16225
+ *
16226
+ * @param callSessionId the call session to read. Defaults to the current one.
16227
+ * Pass it explicitly to read a session that has already ended, as ending a
16228
+ * call clears its current session.
16229
+ */
16230
+ getRingState = async (callSessionId) => {
16231
+ const sessionId = callSessionId ?? this.state.session?.id;
16232
+ if (!sessionId) {
16233
+ throw new Error('Cannot read the ring state: the call has no session');
16234
+ }
16235
+ return this.streamClient.get(`${this.streamClientBasePath}/ring_state`, { call_session_id: sessionId });
16236
+ };
15781
16237
  /**
15782
16238
  * A shortcut for {@link Call.get} with `notify` parameter set to `true`.
15783
16239
  * Will send a `call.notification` event to the call members.
@@ -15816,42 +16272,80 @@ class Call {
15816
16272
  /**
15817
16273
  * Will start to watch for call related WebSocket events and initiate a call session with the server.
15818
16274
  *
16275
+ * One instance is one call flow: discard it after leaving, cancelling, or a
16276
+ * failed join, and create a fresh one for a later flow. Reconnection inside a
16277
+ * live call is handled here and needs no new instance.
16278
+ *
15819
16279
  * @returns a promise which resolves once the call join-flow has finished.
15820
16280
  */
15821
- join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, ...data } = {}) => {
16281
+ join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, joinSource, ...data } = {}) => {
15822
16282
  const callingState = this.state.callingState;
16283
+ // Ahead of the failure boundary below on purpose: a duplicate join on a
16284
+ // live call is refused without tearing that call down.
15823
16285
  if ([exports.CallingState.JOINED, exports.CallingState.JOINING].includes(callingState)) {
15824
16286
  throw new Error(`Illegal State: call.join() shall be called only once`);
15825
16287
  }
16288
+ // snapshot before the first await: a leave() landing at any point from
16289
+ // here on supersedes this join, and the retry loop must bail out instead
16290
+ // of resurrecting a call that leave() already tore down.
16291
+ const joinLeaveGeneration = this.leaveGeneration;
16292
+ const supersededByLeave = () => this.leaveGeneration !== joinLeaveGeneration;
15826
16293
  // we need this to be set before the callingx.joinCall() is
15827
16294
  // called to avoid registering the test call in the CallKit/Telecom
15828
16295
  this.allowOwnTracksLoopback = allowOwnTracksLoopback;
15829
16296
  if (data?.ring) {
15830
16297
  this.ringingSubject.next(true);
15831
16298
  }
16299
+ // A ringing call is joined by the SDK rather than by app code, so React
16300
+ // Native prepares it from in here - there is no earlier point at which the
16301
+ // app holds the call. Read after `data.ring` above, which is what makes an
16302
+ // outgoing call ringing in the first place.
16303
+ const ringingLifecycle = this.ringing
16304
+ ? globalThis.streamRNVideoSDK?.ringingCallLifecycle
16305
+ : undefined;
15832
16306
  const callingX = globalThis.streamRNVideoSDK?.callingX;
15833
- if (callingX) {
15834
- // for Android/iOS, we need to start the call in the callingx library as soon as possible
15835
- await callingX.joinCall(this, this.clientStore.calls);
15836
- }
15837
- await this.setup();
15838
- this.clientEventReporter.registerCall(this.cid, {
15839
- callType: this.type,
15840
- callId: this.id,
15841
- getCallSessionId: () => this.state.session?.id ?? '',
15842
- getSfuId: () => this.credentials?.server.edge_name ?? '',
15843
- });
15844
- this.joinResponseTimeout = joinResponseTimeout;
15845
- this.rpcRequestTimeout = rpcRequestTimeout;
15846
- // we will count the number of join failures per SFU.
15847
- // once the number of failures reaches 2, we will piggyback on the `migrating_from`
15848
- // field to force the coordinator to provide us another SFU
15849
- const sfuJoinFailures = new Map();
15850
- const joinData = data;
15851
- maxJoinRetries = Math.max(maxJoinRetries, 1);
15852
16307
  try {
15853
- await this.clientEventReporter.withJoinLifecycle(this.cid, 'first-attempt', async () => {
16308
+ if (ringingLifecycle) {
16309
+ await ringingLifecycle.beforeJoin(this);
16310
+ if (supersededByLeave()) {
16311
+ this.logger.debug('Join superseded by leave; not joining');
16312
+ return;
16313
+ }
16314
+ }
16315
+ if (callingX) {
16316
+ // for Android/iOS, we need to start the call in the callingx library as soon as possible
16317
+ await callingX.joinCall(this, this.clientStore.calls, supersededByLeave);
16318
+ if (supersededByLeave()) {
16319
+ this.logger.debug('Join superseded by leave; not setting up');
16320
+ return;
16321
+ }
16322
+ }
16323
+ await this.setup();
16324
+ if (supersededByLeave()) {
16325
+ this.logger.debug('Join superseded by leave; not registering');
16326
+ return;
16327
+ }
16328
+ this.clientEventReporter.registerCall(this.cid, {
16329
+ callType: this.type,
16330
+ callId: this.id,
16331
+ getCallSessionId: () => this.state.session?.id ?? '',
16332
+ getSfuId: () => this.credentials?.server.edge_name ?? '',
16333
+ });
16334
+ this.joinResponseTimeout = joinResponseTimeout;
16335
+ this.rpcRequestTimeout = rpcRequestTimeout;
16336
+ // we will count the number of join failures per SFU.
16337
+ // once the number of failures reaches 2, we will piggyback on the `migrating_from`
16338
+ // field to force the coordinator to provide us another SFU
16339
+ const sfuJoinFailures = new Map();
16340
+ const joinData = data;
16341
+ maxJoinRetries = Math.max(maxJoinRetries, 1);
16342
+ await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'first-attempt', joinSource }, async () => {
15854
16343
  for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
16344
+ // Also covers a leave that lands during the backoff below.
16345
+ if (supersededByLeave()) {
16346
+ this.logger.debug('Join superseded by leave; not attempting');
16347
+ return;
16348
+ }
15855
16349
  try {
15856
16350
  this.logger.trace(`Joining call (${attempt})`, this.cid);
15857
16351
  await this.doJoin(data);
@@ -15860,6 +16354,10 @@ class Call {
15860
16354
  return;
15861
16355
  }
15862
16356
  catch (err) {
16357
+ if (supersededByLeave()) {
16358
+ this.logger.debug('Join superseded by leave; not retrying');
16359
+ return;
16360
+ }
15863
16361
  this.logger.warn(`Failed to join call (${attempt})`, this.cid);
15864
16362
  if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
15865
16363
  (err instanceof SfuJoinError && err.unrecoverable)) {
@@ -15889,6 +16387,9 @@ class Call {
15889
16387
  }
15890
16388
  catch (error) {
15891
16389
  callingX?.endCall(this, 'error');
16390
+ // Ends the failed ringing flow and releases what its setup installed.
16391
+ // Never rejects, so `error` is what the caller sees.
16392
+ await ringingLifecycle?.onJoinFailed(this);
15892
16393
  throw error;
15893
16394
  }
15894
16395
  });
@@ -15911,6 +16412,10 @@ class Call {
15911
16412
  // globals resolve to the call's factory. Idempotent across
15912
16413
  // reconnect/migration attempts.
15913
16414
  await this.ensureMediaFactory();
16415
+ if (supersededByLeave()) {
16416
+ this.logger.debug('Join superseded by leave; not wiring media');
16417
+ return;
16418
+ }
15914
16419
  const callingX = globalThis.streamRNVideoSDK?.callingX;
15915
16420
  if (callingX) {
15916
16421
  callingX.wireAudioEngineSubscription();
@@ -15926,11 +16431,17 @@ class Call {
15926
16431
  data?.migrating_from) {
15927
16432
  try {
15928
16433
  const joinResponse = await this.clientEventReporter.track(this.cid, 'CoordinatorJoin', () => this.doJoinRequest(data));
16434
+ if (!joinResponse || supersededByLeave()) {
16435
+ this.logger.debug('Join superseded by leave; not creating SFU client');
16436
+ return;
16437
+ }
15929
16438
  this.credentials = joinResponse.credentials;
15930
16439
  statsOptions = joinResponse.stats_options;
15931
16440
  this.lastStatsOptions = statsOptions;
15932
16441
  }
15933
16442
  catch (error) {
16443
+ if (supersededByLeave())
16444
+ return;
15934
16445
  // prevent triggering reconnect flow if the state is OFFLINE
15935
16446
  const avoidRestoreState = this.state.callingState === exports.CallingState.OFFLINE;
15936
16447
  if (!avoidRestoreState) {
@@ -15978,6 +16489,10 @@ class Call {
15978
16489
  getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
15979
16490
  getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
15980
16491
  ]);
16492
+ if (supersededByLeave()) {
16493
+ this.logger.debug('Join superseded by leave; not joining SFU');
16494
+ return;
16495
+ }
15981
16496
  const isReconnecting = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
15982
16497
  const reconnectDetails = isReconnecting
15983
16498
  ? this.getReconnectDetails(previousSfuClient?.edgeName, previousSessionId)
@@ -16003,6 +16518,10 @@ class Call {
16003
16518
  capabilities,
16004
16519
  source: ParticipantSource.WEBRTC_UNSPECIFIED,
16005
16520
  }));
16521
+ if (supersededByLeave()) {
16522
+ this.logger.debug('Join superseded by leave; not applying SFU response');
16523
+ return;
16524
+ }
16006
16525
  this.currentPublishOptions = publishOptions;
16007
16526
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
16008
16527
  if (callState) {
@@ -16010,6 +16529,10 @@ class Call {
16010
16529
  }
16011
16530
  }
16012
16531
  catch (error) {
16532
+ if (supersededByLeave()) {
16533
+ this.logger.debug('Join superseded by leave; ignoring SFU failure');
16534
+ return;
16535
+ }
16013
16536
  this.logger.warn('Join SFU request failed', error);
16014
16537
  sfuClient.close(StreamSfuClient.JOIN_FAILED, 'Join request failed, connection considered unhealthy');
16015
16538
  // restore the previous call state if the join-flow fails
@@ -16017,8 +16540,8 @@ class Call {
16017
16540
  throw error;
16018
16541
  }
16019
16542
  }
16020
- // If the user left while this join was in flight, bail before re-setting JOINED and before
16021
- // peer-connection setup below (both run synchronously after this, so one check covers them).
16543
+ // If the user left while this join was in flight, bail before re-setting JOINED
16544
+ // or starting peer-connection setup below.
16022
16545
  if (supersededByLeave()) {
16023
16546
  this.logger.debug('Join superseded by leave; aborting join flow');
16024
16547
  return;
@@ -16047,6 +16570,10 @@ class Call {
16047
16570
  unifiedSessionId: this.unifiedSessionId,
16048
16571
  });
16049
16572
  }
16573
+ if (supersededByLeave()) {
16574
+ this.logger.debug('Join superseded by leave; not completing join flow');
16575
+ return;
16576
+ }
16050
16577
  // make sure we only track connection timing if we are not calling this method as part of a reconnection flow
16051
16578
  if (!performingRejoin && !performingFastReconnect && !performingMigration) {
16052
16579
  this.sfuStatsReporter?.sendConnectionTime((Date.now() - connectStartTime) / 1000);
@@ -16192,15 +16719,20 @@ class Call {
16192
16719
  * @internal
16193
16720
  */
16194
16721
  initPublisherAndSubscriber = async (opts) => {
16722
+ const joinLeaveGeneration = this.leaveGeneration;
16195
16723
  const { sfuClient, connectionConfig, clientDetails, statsOptions, publishOptions, closePreviousInstances, unifiedSessionId, } = opts;
16196
16724
  const { enable_rtc_stats: enableTracing, reporting_interval_ms: reportingIntervalMs, } = statsOptions;
16197
16725
  // Flush the previous reporter's final sample while its peer connections are
16198
16726
  // still alive, before we dispose them below. Awaits only the sampling step.
16199
16727
  await this.sfuStatsReporter?.flush();
16728
+ if (this.leaveGeneration !== joinLeaveGeneration)
16729
+ return;
16200
16730
  this.sfuStatsReporter?.stop();
16201
16731
  this.sfuStatsReporter = undefined;
16202
16732
  if (closePreviousInstances && this.subscriber) {
16203
16733
  await this.subscriber.dispose();
16734
+ if (this.leaveGeneration !== joinLeaveGeneration)
16735
+ return;
16204
16736
  this.state.removeAllOrphanedTracks();
16205
16737
  }
16206
16738
  const basePeerConnectionOptions = {
@@ -16243,6 +16775,8 @@ class Call {
16243
16775
  if (!isAnonymous) {
16244
16776
  if (closePreviousInstances && this.publisher) {
16245
16777
  await this.publisher.dispose();
16778
+ if (this.leaveGeneration !== joinLeaveGeneration)
16779
+ return;
16246
16780
  }
16247
16781
  this.publisher = new Publisher(basePeerConnectionOptions, publishOptions, {
16248
16782
  selfSubEnabled: this.allowOwnTracksLoopback,
@@ -16279,12 +16813,22 @@ class Call {
16279
16813
  *
16280
16814
  * @internal
16281
16815
  * @param data the join call data.
16816
+ * @returns The coordinator response, or undefined if leave superseded the request before it was sent.
16282
16817
  */
16283
16818
  doJoinRequest = async (data) => {
16819
+ const joinLeaveGeneration = this.leaveGeneration;
16284
16820
  const location = await this.streamClient.getLocationHint();
16821
+ if (this.leaveGeneration !== joinLeaveGeneration) {
16822
+ this.logger.debug('Join superseded by leave; not sending coordinator request');
16823
+ return;
16824
+ }
16285
16825
  const e2ee = !!this.e2eeManager;
16286
16826
  const request = { ...data, location, e2ee };
16287
16827
  const joinResponse = await this.streamClient.post(`${this.streamClientBasePath}/join`, request);
16828
+ if (this.leaveGeneration !== joinLeaveGeneration) {
16829
+ this.logger.debug('Join superseded by leave; not applying coordinator response');
16830
+ return joinResponse;
16831
+ }
16288
16832
  this.state.updateFromCallResponse(joinResponse.call);
16289
16833
  this.state.setMembers(joinResponse.members);
16290
16834
  this.state.setOwnCapabilities(joinResponse.own_capabilities);
@@ -16295,6 +16839,8 @@ class Call {
16295
16839
  if (!isReconnecting && this.ringing && !this.isCreatedByMe) {
16296
16840
  // signals other users that I have accepted the incoming call.
16297
16841
  await this.accept();
16842
+ if (this.leaveGeneration !== joinLeaveGeneration)
16843
+ return joinResponse;
16298
16844
  }
16299
16845
  if (this.streamClient._hasConnectionID()) {
16300
16846
  this.watching = true;
@@ -16384,6 +16930,7 @@ class Call {
16384
16930
  if (callingState === exports.CallingState.JOINING ||
16385
16931
  callingState === exports.CallingState.RECONNECTING ||
16386
16932
  callingState === exports.CallingState.MIGRATING ||
16933
+ callingState === exports.CallingState.LEFT ||
16387
16934
  callingState === exports.CallingState.RECONNECTING_FAILED)
16388
16935
  return;
16389
16936
  // Drop redundant reconnect calls. If a reconnect is already queued or
@@ -16392,19 +16939,27 @@ class Call {
16392
16939
  // `POST /join` per entry) once the call is already healthy again.
16393
16940
  if (hasPending(this.reconnectConcurrencyTag))
16394
16941
  return;
16942
+ const reconnectLeaveGeneration = this.leaveGeneration;
16943
+ const supersededByLeave = () => this.leaveGeneration !== reconnectLeaveGeneration;
16395
16944
  return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
16945
+ if (supersededByLeave())
16946
+ return;
16396
16947
  const reconnectStartTime = Date.now();
16397
16948
  this.reconnectStrategy = strategy;
16398
16949
  this.reconnectReason = reason;
16399
16950
  const sfuRejoinFailures = new Map();
16400
16951
  const markAsReconnectingFailed = async () => {
16952
+ if (supersededByLeave())
16953
+ return;
16401
16954
  try {
16402
16955
  // attempt to fetch the call data from the server, as the call
16403
16956
  // state might have changed while we were reconnecting or were offline
16404
16957
  await this.get();
16405
16958
  }
16406
16959
  finally {
16407
- this.state.setCallingState(exports.CallingState.RECONNECTING_FAILED);
16960
+ if (!supersededByLeave()) {
16961
+ this.state.setCallingState(exports.CallingState.RECONNECTING_FAILED);
16962
+ }
16408
16963
  }
16409
16964
  };
16410
16965
  const giveUpAndLeave = async (message) => {
@@ -16460,6 +17015,8 @@ class Call {
16460
17015
  try {
16461
17016
  // wait until the network is available
16462
17017
  await this.networkAvailableTask?.promise;
17018
+ if (supersededByLeave())
17019
+ return;
16463
17020
  this.logger.info(`[Reconnect] Reconnecting with strategy ${WebsocketReconnectStrategy[this.reconnectStrategy]}`);
16464
17021
  switch (this.reconnectStrategy) {
16465
17022
  case WebsocketReconnectStrategy.UNSPECIFIED:
@@ -16499,6 +17056,8 @@ class Call {
16499
17056
  break; // do-while loop, reconnection worked, exit the loop
16500
17057
  }
16501
17058
  catch (error) {
17059
+ if (supersededByLeave())
17060
+ return;
16502
17061
  if (attemptedStrategy === WebsocketReconnectStrategy.REJOIN) {
16503
17062
  const failedSfu = this.credentials?.server.edge_name;
16504
17063
  if (failedSfu) {
@@ -16529,6 +17088,8 @@ class Call {
16529
17088
  }
16530
17089
  // exponential backoff with jitter, capped at 5 s
16531
17090
  await sleep(retryInterval(attempt));
17091
+ if (supersededByLeave())
17092
+ return;
16532
17093
  const wasMigrating = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
16533
17094
  const mustPerformRejoin = (Date.now() - reconnectStartTime) / 1000 >
16534
17095
  this.fastReconnectDeadlineSeconds;
@@ -16547,7 +17108,8 @@ class Call {
16547
17108
  this.reconnectStrategy = nextStrategy;
16548
17109
  this.logger.info(`[Reconnect] ${currentStrategy} (${this.reconnectAttempts}) failed. Attempting with ${WebsocketReconnectStrategy[nextStrategy]}`, error);
16549
17110
  }
16550
- } while (this.state.callingState !== exports.CallingState.JOINED &&
17111
+ } while (!supersededByLeave() &&
17112
+ this.state.callingState !== exports.CallingState.JOINED &&
16551
17113
  this.state.callingState !== exports.CallingState.RECONNECTING_FAILED &&
16552
17114
  this.state.callingState !== exports.CallingState.LEFT);
16553
17115
  this.logger.info('[Reconnect] Reconnection flow finished');
@@ -16558,10 +17120,13 @@ class Call {
16558
17120
  * @internal
16559
17121
  */
16560
17122
  reconnectFast = async () => {
17123
+ const reconnectLeaveGeneration = this.leaveGeneration;
16561
17124
  const reconnectStartTime = Date.now();
16562
17125
  this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
16563
17126
  this.state.setCallingState(exports.CallingState.RECONNECTING);
16564
17127
  await this.doJoin(this.joinCallData);
17128
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17129
+ return;
16565
17130
  await this.get(); // fetch the latest call state, as it might have changed
16566
17131
  this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.FAST, (Date.now() - reconnectStartTime) / 1000);
16567
17132
  };
@@ -16570,14 +17135,19 @@ class Call {
16570
17135
  * @internal
16571
17136
  */
16572
17137
  reconnectRejoin = async () => {
17138
+ const reconnectLeaveGeneration = this.leaveGeneration;
16573
17139
  const reconnectStartTime = Date.now();
16574
17140
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
16575
17141
  this.state.setCallingState(exports.CallingState.RECONNECTING);
16576
17142
  const joinReason = this.reconnectReason === ReconnectReason.NETWORK_BACK_ONLINE
16577
17143
  ? 'network-available'
16578
17144
  : 'full-rejoin';
16579
- await this.clientEventReporter.withJoinLifecycle(this.cid, joinReason, () => this.doJoin(this.joinCallData));
17145
+ await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason }, () => this.doJoin(this.joinCallData));
17146
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17147
+ return;
16580
17148
  await this.restorePublishedTracks();
17149
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17150
+ return;
16581
17151
  this.restoreSubscribedTracks();
16582
17152
  this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
16583
17153
  };
@@ -16586,6 +17156,7 @@ class Call {
16586
17156
  * @internal
16587
17157
  */
16588
17158
  reconnectMigrate = async () => {
17159
+ const reconnectLeaveGeneration = this.leaveGeneration;
16589
17160
  const reconnectStartTime = Date.now();
16590
17161
  const currentSfuClient = this.sfuClient;
16591
17162
  if (!currentSfuClient) {
@@ -16599,26 +17170,32 @@ class Call {
16599
17170
  currentPublisher?.detachEventHandlers();
16600
17171
  const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
16601
17172
  try {
16602
- const currentSfu = currentSfuClient.edgeName;
16603
- await this.clientEventReporter.withJoinLifecycle(this.cid, 'migration', () => this.doJoin({
16604
- ...this.joinCallData,
16605
- migrating_from: currentSfu,
16606
- migrating_from_list: [currentSfu],
16607
- }));
16608
- }
16609
- finally {
16610
- // cleanup the migration_from field after the migration is complete or failed
16611
- // as we don't want to keep dirty data in the join call data
16612
- delete this.joinCallData?.migrating_from;
16613
- delete this.joinCallData?.migrating_from_list;
16614
- }
16615
- await this.restorePublishedTracks();
16616
- this.restoreSubscribedTracks();
16617
- try {
17173
+ try {
17174
+ const currentSfu = currentSfuClient.edgeName;
17175
+ await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'migration' }, () => this.doJoin({
17176
+ ...this.joinCallData,
17177
+ migrating_from: currentSfu,
17178
+ migrating_from_list: [currentSfu],
17179
+ }));
17180
+ }
17181
+ finally {
17182
+ // cleanup the migration_from field after the migration is complete or failed
17183
+ // as we don't want to keep dirty data in the join call data
17184
+ delete this.joinCallData?.migrating_from;
17185
+ delete this.joinCallData?.migrating_from_list;
17186
+ }
17187
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17188
+ return;
17189
+ await this.restorePublishedTracks();
17190
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17191
+ return;
17192
+ this.restoreSubscribedTracks();
16618
17193
  // Wait for the migration to complete, then close the previous SFU client
16619
17194
  // and the peer connection instances. In case of failure, the migration
16620
17195
  // task would throw an error and REJOIN would be attempted.
16621
17196
  await migrationTask();
17197
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17198
+ return;
16622
17199
  // in MIGRATE, we can consider the call as joined only after
16623
17200
  // `participantMigrationComplete` event is received, signaled by
16624
17201
  // the `migrationTask`
@@ -16903,10 +17480,11 @@ class Call {
16903
17480
  * Must be called before {@link join} so the RTCPeerConnection can be
16904
17481
  * configured for E2EE.
16905
17482
  *
16906
- * The manager is kept across {@link leave} so a rejoin of this same instance
16907
- * stays encrypted: do not dispose it while this call may be joined again.
16908
- * A disposed manager throws from `encrypt`/`decrypt` rather than silently
16909
- * publishing nothing, so re-attach a fresh one instead of reusing it.
17483
+ * The application owns the manager's lifetime - the SDK never disposes it, and
17484
+ * closing the peer connections does not release it. Discard the manager
17485
+ * together with this call instance once its flow has ended, and create a fresh
17486
+ * pair for a later flow: a disposed manager throws from `encrypt`/`decrypt`
17487
+ * rather than silently publishing nothing.
16910
17488
  *
16911
17489
  * @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
16912
17490
  * built-in AES-GCM scheme, or pass your own implementation.
@@ -17373,40 +17951,36 @@ class Call {
17373
17951
  */
17374
17952
  scheduleAutoDrop = () => {
17375
17953
  this.cancelAutoDrop();
17376
- const settings = this.state.settings;
17377
- if (!settings)
17378
- return;
17379
- // ignore if the call is not ringing
17380
- if (this.state.callingState !== exports.CallingState.RINGING)
17381
- return;
17382
- const timeoutInMs = this.isCreatedByMe
17383
- ? settings.ring.auto_cancel_timeout_ms
17384
- : settings.ring.incoming_call_timeout_ms;
17385
- // 0 means no auto-drop
17386
- if (timeoutInMs <= 0)
17387
- return;
17388
- this.dropTimeout = setTimeout(() => {
17389
- // the call might have stopped ringing by this point,
17390
- // e.g. it was already accepted and joined
17391
- if (this.state.callingState !== exports.CallingState.RINGING)
17392
- return;
17393
- this.leave({
17394
- reject: true,
17395
- reason: 'timeout',
17396
- message: `ringing timeout - ${this.isCreatedByMe
17397
- ? 'no one accepted'
17398
- : `user didn't interact with incoming call screen`}`,
17399
- }).catch((err) => {
17400
- this.logger.error('Failed to drop call', err);
17401
- });
17402
- }, timeoutInMs);
17954
+ this.ringTimeout = new RingTimeout(this);
17955
+ this.ringTimeout.start();
17403
17956
  };
17404
17957
  /**
17405
17958
  * Cancels a scheduled auto-drop timeout.
17406
17959
  */
17407
17960
  cancelAutoDrop = () => {
17408
- clearTimeout(this.dropTimeout);
17409
- this.dropTimeout = undefined;
17961
+ this.ringTimeout?.stop();
17962
+ this.ringTimeout = undefined;
17963
+ };
17964
+ /**
17965
+ * Starts polling for the ring outcome. Applicable only to ringing calls the
17966
+ * current user created.
17967
+ */
17968
+ scheduleRingStatePolling = () => {
17969
+ this.cancelRingStatePolling();
17970
+ if (!this.isCreatedByMe)
17971
+ return;
17972
+ const options = this.streamClient.options.ringStatePolling;
17973
+ if (options === false)
17974
+ return;
17975
+ this.ringStatePoller = new RingStatePoller(this, options);
17976
+ this.ringStatePoller.start();
17977
+ };
17978
+ /**
17979
+ * Cancels the ring state polling.
17980
+ */
17981
+ cancelRingStatePolling = () => {
17982
+ this.ringStatePoller?.stop();
17983
+ this.ringStatePoller = undefined;
17410
17984
  };
17411
17985
  /**
17412
17986
  * Retrieves the list of recordings for the current call or call session.
@@ -19077,11 +19651,11 @@ class StreamClient {
19077
19651
  return await this.wsConnection.connect(this.defaultWSTimeout);
19078
19652
  };
19079
19653
  getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
19080
- "1.59.1";
19654
+ "1.61.0";
19081
19655
  getUserAgent = () => {
19082
19656
  if (!this.cachedUserAgent) {
19083
19657
  const { clientAppIdentifier = {} } = this.options;
19084
- const { sdkName = 'js', sdkVersion = "1.59.1", ...extras } = clientAppIdentifier;
19658
+ const { sdkName = 'js', sdkVersion = "1.61.0", ...extras } = clientAppIdentifier;
19085
19659
  this.cachedUserAgent = [
19086
19660
  `stream-video-${sdkName}-v${sdkVersion}`,
19087
19661
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -19214,6 +19788,7 @@ class ClientEventReporter {
19214
19788
  callContexts = new Map();
19215
19789
  joinAttemptIds = new Map();
19216
19790
  joinReasons = new Map();
19791
+ joinSources = new Map();
19217
19792
  coordinatorPairs = new Map();
19218
19793
  wsPairs = new Map();
19219
19794
  peerConnectionPairs = new Map();
@@ -19328,6 +19903,7 @@ class ClientEventReporter {
19328
19903
  this.callContexts.delete(cid);
19329
19904
  this.joinAttemptIds.delete(cid);
19330
19905
  this.joinReasons.delete(cid);
19906
+ this.joinSources.delete(cid);
19331
19907
  this.coordinatorPairs.delete(cid);
19332
19908
  this.wsPairs.delete(cid);
19333
19909
  this.firstFrameReported.delete(`${cid}:FirstVideoFrame`);
@@ -19352,7 +19928,12 @@ class ClientEventReporter {
19352
19928
  this.logger.warn('Failed to start join correlation', err);
19353
19929
  }
19354
19930
  };
19355
- withJoinLifecycle = async (cid, joinReason, op) => {
19931
+ withJoinLifecycle = async (cid, options, op) => {
19932
+ const { joinReason, joinSource } = options;
19933
+ if (joinSource)
19934
+ this.joinSources.set(cid, joinSource);
19935
+ else
19936
+ this.joinSources.delete(cid);
19356
19937
  this.startCorrelation(cid, joinReason);
19357
19938
  try {
19358
19939
  return await op();
@@ -19361,6 +19942,9 @@ class ClientEventReporter {
19361
19942
  this.closeCallPairs(cid);
19362
19943
  throw err;
19363
19944
  }
19945
+ finally {
19946
+ this.joinSources.delete(cid);
19947
+ }
19364
19948
  };
19365
19949
  track = async (cid, stage, op) => {
19366
19950
  this.beginAttempt(cid, stage);
@@ -19484,6 +20068,7 @@ class ClientEventReporter {
19484
20068
  startedAt: Date.now(),
19485
20069
  joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
19486
20070
  joinReasonSnapshot: this.joinReasons.get(cid),
20071
+ joinSourceSnapshot: this.joinSources.get(cid),
19487
20072
  };
19488
20073
  this.coordinatorPairs.set(cid, pair);
19489
20074
  this.sendForCall(cid, {
@@ -19491,6 +20076,7 @@ class ClientEventReporter {
19491
20076
  ...(pair.joinReasonSnapshot && {
19492
20077
  join_reason: pair.joinReasonSnapshot,
19493
20078
  }),
20079
+ ...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
19494
20080
  event_type: 'initiated',
19495
20081
  });
19496
20082
  }
@@ -19505,6 +20091,7 @@ class ClientEventReporter {
19505
20091
  ...this.buildCommon(cid, 'CoordinatorJoin', pair),
19506
20092
  ...this.sessionIdField(cid),
19507
20093
  ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
20094
+ ...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
19508
20095
  event_type: 'completed',
19509
20096
  outcome: 'success',
19510
20097
  retry_count_attempt: pair.attempts - 1,
@@ -19523,6 +20110,7 @@ class ClientEventReporter {
19523
20110
  ...this.buildCommon(cid, 'CoordinatorJoin', pair),
19524
20111
  ...this.sessionIdField(cid),
19525
20112
  ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
20113
+ ...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
19526
20114
  event_type: 'completed',
19527
20115
  outcome: 'failure',
19528
20116
  retry_count_attempt: pair.attempts - 1,
@@ -20776,9 +21364,9 @@ class EncryptionManager extends TypedEventEmitter {
20776
21364
  }
20777
21365
  if ((this.piped ??= new WeakSet()).has(target))
20778
21366
  return;
20779
- this.piped.add(target);
20780
21367
  // @ts-expect-error createEncodedStreams is not in the standard typedefs
20781
21368
  const { readable, writable } = target.createEncodedStreams();
21369
+ this.piped.add(target);
20782
21370
  this.worker.postMessage({ type: 'cmd.setup_transform', ...options, readable, writable }, [readable, writable]);
20783
21371
  };
20784
21372
  /**
@@ -20946,6 +21534,7 @@ exports.TranscriptionSettingsResponseClosedCaptionModeEnum = TranscriptionSettin
20946
21534
  exports.TranscriptionSettingsResponseLanguageEnum = TranscriptionSettingsResponseLanguageEnum;
20947
21535
  exports.TranscriptionSettingsResponseModeEnum = TranscriptionSettingsResponseModeEnum;
20948
21536
  exports.TranslationSettingsLanguagesEnum = TranslationSettingsLanguagesEnum;
21537
+ exports.TypedEventEmitter = TypedEventEmitter;
20949
21538
  exports.UpdateUserPermissionsRequestGrantPermissionsEnum = UpdateUserPermissionsRequestGrantPermissionsEnum;
20950
21539
  exports.UpdateUserPermissionsRequestRevokePermissionsEnum = UpdateUserPermissionsRequestRevokePermissionsEnum;
20951
21540
  exports.VideoSettingsRequestCameraFacingEnum = VideoSettingsRequestCameraFacingEnum;