@stream-io/video-client 1.60.0 → 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.
- package/CHANGELOG.md +11 -0
- package/dist/index.browser.es.js +253 -55
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +253 -54
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +253 -55
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +11 -5
- package/dist/src/rtc/Publisher.d.ts +18 -0
- package/dist/src/rtc/TransceiverCache.d.ts +10 -0
- package/dist/src/rtc/e2ee/E2EEManager.d.ts +10 -1
- package/dist/src/types.d.ts +30 -1
- package/index.ts +1 -0
- package/package.json +1 -1
- package/src/Call.ts +180 -48
- package/src/__tests__/Call.lifecycle.test.ts +479 -9
- package/src/__tests__/Call.ringingLifecycle.test.ts +242 -0
- package/src/rtc/Publisher.ts +66 -12
- package/src/rtc/TransceiverCache.ts +17 -0
- package/src/rtc/__tests__/Publisher.test.ts +209 -0
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +1 -0
- package/src/rtc/e2ee/E2EEManager.ts +10 -1
- package/src/rtc/e2ee/EncryptionManager.ts +1 -1
- package/src/rtc/e2ee/SPEC.md +1 -1
- 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.
|
|
5104
|
+
const version = "1.61.0";
|
|
5105
5105
|
const [major, minor, patch] = version.split('.');
|
|
5106
5106
|
let sdkInfo = {
|
|
5107
5107
|
type: SdkType.PLAIN_JAVASCRIPT,
|
|
@@ -9166,6 +9166,25 @@ class TransceiverCache {
|
|
|
9166
9166
|
this.cache.push(bundle);
|
|
9167
9167
|
this.transceiverOrder.push(bundle.transceiver);
|
|
9168
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
|
+
};
|
|
9169
9188
|
/**
|
|
9170
9189
|
* Gets the transceiver for the given publish option.
|
|
9171
9190
|
*/
|
|
@@ -9337,7 +9356,9 @@ class Publisher extends BasePeerConnection {
|
|
|
9337
9356
|
const sendEncodings = isSvcCodec(publishOption.codec?.name)
|
|
9338
9357
|
? toSvcEncodings(encodings)
|
|
9339
9358
|
: encodings;
|
|
9340
|
-
|
|
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, {
|
|
9341
9362
|
direction: 'sendonly',
|
|
9342
9363
|
sendEncodings,
|
|
9343
9364
|
});
|
|
@@ -9347,16 +9368,63 @@ class Publisher extends BasePeerConnection {
|
|
|
9347
9368
|
this.transceiverCache.add({ publishOption, transceiver, options });
|
|
9348
9369
|
this.trackIdToTrackType.set(track.id, trackType);
|
|
9349
9370
|
this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
|
|
9350
|
-
|
|
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();
|
|
9351
9402
|
params.degradationPreference =
|
|
9352
9403
|
toRTCDegradationPreference(publishOption.degradationPreference) ??
|
|
9353
9404
|
'maintain-framerate';
|
|
9354
|
-
await
|
|
9355
|
-
|
|
9356
|
-
|
|
9357
|
-
|
|
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);
|
|
9358
9427
|
}
|
|
9359
|
-
await this.negotiate();
|
|
9360
9428
|
};
|
|
9361
9429
|
/**
|
|
9362
9430
|
* Updates the transceiver with the given track and track type.
|
|
@@ -15922,6 +15990,11 @@ class Call {
|
|
|
15922
15990
|
this.audioBindingsWatchdog?.dispose();
|
|
15923
15991
|
await this.dynascaleManager?.dispose();
|
|
15924
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
|
+
}
|
|
15925
15998
|
this.state.setParticipants([]);
|
|
15926
15999
|
this.state.dispose();
|
|
15927
16000
|
// Reset reconnect-related accumulators so a future `call.join()` on the
|
|
@@ -16090,8 +16163,11 @@ class Call {
|
|
|
16090
16163
|
* @param params.video if set to true, in a ringing scenario, mobile SDKs will show "incoming video call", audio only otherwise.
|
|
16091
16164
|
*/
|
|
16092
16165
|
get = async (params) => {
|
|
16166
|
+
const getLeaveGeneration = this.leaveGeneration;
|
|
16093
16167
|
await this.setup();
|
|
16094
16168
|
const response = await this.streamClient.get(this.streamClientBasePath, params);
|
|
16169
|
+
if (this.leaveGeneration !== getLeaveGeneration)
|
|
16170
|
+
return response;
|
|
16095
16171
|
this.updateFromCallStateResponse(response);
|
|
16096
16172
|
if (params?.ring) {
|
|
16097
16173
|
this.ringingSubject.next(true);
|
|
@@ -16196,42 +16272,80 @@ class Call {
|
|
|
16196
16272
|
/**
|
|
16197
16273
|
* Will start to watch for call related WebSocket events and initiate a call session with the server.
|
|
16198
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
|
+
*
|
|
16199
16279
|
* @returns a promise which resolves once the call join-flow has finished.
|
|
16200
16280
|
*/
|
|
16201
16281
|
join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, joinSource, ...data } = {}) => {
|
|
16202
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.
|
|
16203
16285
|
if ([exports.CallingState.JOINED, exports.CallingState.JOINING].includes(callingState)) {
|
|
16204
16286
|
throw new Error(`Illegal State: call.join() shall be called only once`);
|
|
16205
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;
|
|
16206
16293
|
// we need this to be set before the callingx.joinCall() is
|
|
16207
16294
|
// called to avoid registering the test call in the CallKit/Telecom
|
|
16208
16295
|
this.allowOwnTracksLoopback = allowOwnTracksLoopback;
|
|
16209
16296
|
if (data?.ring) {
|
|
16210
16297
|
this.ringingSubject.next(true);
|
|
16211
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;
|
|
16212
16306
|
const callingX = globalThis.streamRNVideoSDK?.callingX;
|
|
16213
|
-
if (callingX) {
|
|
16214
|
-
// for Android/iOS, we need to start the call in the callingx library as soon as possible
|
|
16215
|
-
await callingX.joinCall(this, this.clientStore.calls);
|
|
16216
|
-
}
|
|
16217
|
-
await this.setup();
|
|
16218
|
-
this.clientEventReporter.registerCall(this.cid, {
|
|
16219
|
-
callType: this.type,
|
|
16220
|
-
callId: this.id,
|
|
16221
|
-
getCallSessionId: () => this.state.session?.id ?? '',
|
|
16222
|
-
getSfuId: () => this.credentials?.server.edge_name ?? '',
|
|
16223
|
-
});
|
|
16224
|
-
this.joinResponseTimeout = joinResponseTimeout;
|
|
16225
|
-
this.rpcRequestTimeout = rpcRequestTimeout;
|
|
16226
|
-
// we will count the number of join failures per SFU.
|
|
16227
|
-
// once the number of failures reaches 2, we will piggyback on the `migrating_from`
|
|
16228
|
-
// field to force the coordinator to provide us another SFU
|
|
16229
|
-
const sfuJoinFailures = new Map();
|
|
16230
|
-
const joinData = data;
|
|
16231
|
-
maxJoinRetries = Math.max(maxJoinRetries, 1);
|
|
16232
16307
|
try {
|
|
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);
|
|
16233
16342
|
await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'first-attempt', joinSource }, async () => {
|
|
16234
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
|
+
}
|
|
16235
16349
|
try {
|
|
16236
16350
|
this.logger.trace(`Joining call (${attempt})`, this.cid);
|
|
16237
16351
|
await this.doJoin(data);
|
|
@@ -16240,6 +16354,10 @@ class Call {
|
|
|
16240
16354
|
return;
|
|
16241
16355
|
}
|
|
16242
16356
|
catch (err) {
|
|
16357
|
+
if (supersededByLeave()) {
|
|
16358
|
+
this.logger.debug('Join superseded by leave; not retrying');
|
|
16359
|
+
return;
|
|
16360
|
+
}
|
|
16243
16361
|
this.logger.warn(`Failed to join call (${attempt})`, this.cid);
|
|
16244
16362
|
if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
|
|
16245
16363
|
(err instanceof SfuJoinError && err.unrecoverable)) {
|
|
@@ -16269,6 +16387,9 @@ class Call {
|
|
|
16269
16387
|
}
|
|
16270
16388
|
catch (error) {
|
|
16271
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);
|
|
16272
16393
|
throw error;
|
|
16273
16394
|
}
|
|
16274
16395
|
});
|
|
@@ -16291,6 +16412,10 @@ class Call {
|
|
|
16291
16412
|
// globals resolve to the call's factory. Idempotent across
|
|
16292
16413
|
// reconnect/migration attempts.
|
|
16293
16414
|
await this.ensureMediaFactory();
|
|
16415
|
+
if (supersededByLeave()) {
|
|
16416
|
+
this.logger.debug('Join superseded by leave; not wiring media');
|
|
16417
|
+
return;
|
|
16418
|
+
}
|
|
16294
16419
|
const callingX = globalThis.streamRNVideoSDK?.callingX;
|
|
16295
16420
|
if (callingX) {
|
|
16296
16421
|
callingX.wireAudioEngineSubscription();
|
|
@@ -16306,11 +16431,17 @@ class Call {
|
|
|
16306
16431
|
data?.migrating_from) {
|
|
16307
16432
|
try {
|
|
16308
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
|
+
}
|
|
16309
16438
|
this.credentials = joinResponse.credentials;
|
|
16310
16439
|
statsOptions = joinResponse.stats_options;
|
|
16311
16440
|
this.lastStatsOptions = statsOptions;
|
|
16312
16441
|
}
|
|
16313
16442
|
catch (error) {
|
|
16443
|
+
if (supersededByLeave())
|
|
16444
|
+
return;
|
|
16314
16445
|
// prevent triggering reconnect flow if the state is OFFLINE
|
|
16315
16446
|
const avoidRestoreState = this.state.callingState === exports.CallingState.OFFLINE;
|
|
16316
16447
|
if (!avoidRestoreState) {
|
|
@@ -16358,6 +16489,10 @@ class Call {
|
|
|
16358
16489
|
getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
|
|
16359
16490
|
getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
|
|
16360
16491
|
]);
|
|
16492
|
+
if (supersededByLeave()) {
|
|
16493
|
+
this.logger.debug('Join superseded by leave; not joining SFU');
|
|
16494
|
+
return;
|
|
16495
|
+
}
|
|
16361
16496
|
const isReconnecting = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
|
|
16362
16497
|
const reconnectDetails = isReconnecting
|
|
16363
16498
|
? this.getReconnectDetails(previousSfuClient?.edgeName, previousSessionId)
|
|
@@ -16383,6 +16518,10 @@ class Call {
|
|
|
16383
16518
|
capabilities,
|
|
16384
16519
|
source: ParticipantSource.WEBRTC_UNSPECIFIED,
|
|
16385
16520
|
}));
|
|
16521
|
+
if (supersededByLeave()) {
|
|
16522
|
+
this.logger.debug('Join superseded by leave; not applying SFU response');
|
|
16523
|
+
return;
|
|
16524
|
+
}
|
|
16386
16525
|
this.currentPublishOptions = publishOptions;
|
|
16387
16526
|
this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
|
|
16388
16527
|
if (callState) {
|
|
@@ -16390,6 +16529,10 @@ class Call {
|
|
|
16390
16529
|
}
|
|
16391
16530
|
}
|
|
16392
16531
|
catch (error) {
|
|
16532
|
+
if (supersededByLeave()) {
|
|
16533
|
+
this.logger.debug('Join superseded by leave; ignoring SFU failure');
|
|
16534
|
+
return;
|
|
16535
|
+
}
|
|
16393
16536
|
this.logger.warn('Join SFU request failed', error);
|
|
16394
16537
|
sfuClient.close(StreamSfuClient.JOIN_FAILED, 'Join request failed, connection considered unhealthy');
|
|
16395
16538
|
// restore the previous call state if the join-flow fails
|
|
@@ -16397,8 +16540,8 @@ class Call {
|
|
|
16397
16540
|
throw error;
|
|
16398
16541
|
}
|
|
16399
16542
|
}
|
|
16400
|
-
// If the user left while this join was in flight, bail before re-setting JOINED
|
|
16401
|
-
// peer-connection setup below
|
|
16543
|
+
// If the user left while this join was in flight, bail before re-setting JOINED
|
|
16544
|
+
// or starting peer-connection setup below.
|
|
16402
16545
|
if (supersededByLeave()) {
|
|
16403
16546
|
this.logger.debug('Join superseded by leave; aborting join flow');
|
|
16404
16547
|
return;
|
|
@@ -16427,6 +16570,10 @@ class Call {
|
|
|
16427
16570
|
unifiedSessionId: this.unifiedSessionId,
|
|
16428
16571
|
});
|
|
16429
16572
|
}
|
|
16573
|
+
if (supersededByLeave()) {
|
|
16574
|
+
this.logger.debug('Join superseded by leave; not completing join flow');
|
|
16575
|
+
return;
|
|
16576
|
+
}
|
|
16430
16577
|
// make sure we only track connection timing if we are not calling this method as part of a reconnection flow
|
|
16431
16578
|
if (!performingRejoin && !performingFastReconnect && !performingMigration) {
|
|
16432
16579
|
this.sfuStatsReporter?.sendConnectionTime((Date.now() - connectStartTime) / 1000);
|
|
@@ -16572,15 +16719,20 @@ class Call {
|
|
|
16572
16719
|
* @internal
|
|
16573
16720
|
*/
|
|
16574
16721
|
initPublisherAndSubscriber = async (opts) => {
|
|
16722
|
+
const joinLeaveGeneration = this.leaveGeneration;
|
|
16575
16723
|
const { sfuClient, connectionConfig, clientDetails, statsOptions, publishOptions, closePreviousInstances, unifiedSessionId, } = opts;
|
|
16576
16724
|
const { enable_rtc_stats: enableTracing, reporting_interval_ms: reportingIntervalMs, } = statsOptions;
|
|
16577
16725
|
// Flush the previous reporter's final sample while its peer connections are
|
|
16578
16726
|
// still alive, before we dispose them below. Awaits only the sampling step.
|
|
16579
16727
|
await this.sfuStatsReporter?.flush();
|
|
16728
|
+
if (this.leaveGeneration !== joinLeaveGeneration)
|
|
16729
|
+
return;
|
|
16580
16730
|
this.sfuStatsReporter?.stop();
|
|
16581
16731
|
this.sfuStatsReporter = undefined;
|
|
16582
16732
|
if (closePreviousInstances && this.subscriber) {
|
|
16583
16733
|
await this.subscriber.dispose();
|
|
16734
|
+
if (this.leaveGeneration !== joinLeaveGeneration)
|
|
16735
|
+
return;
|
|
16584
16736
|
this.state.removeAllOrphanedTracks();
|
|
16585
16737
|
}
|
|
16586
16738
|
const basePeerConnectionOptions = {
|
|
@@ -16623,6 +16775,8 @@ class Call {
|
|
|
16623
16775
|
if (!isAnonymous) {
|
|
16624
16776
|
if (closePreviousInstances && this.publisher) {
|
|
16625
16777
|
await this.publisher.dispose();
|
|
16778
|
+
if (this.leaveGeneration !== joinLeaveGeneration)
|
|
16779
|
+
return;
|
|
16626
16780
|
}
|
|
16627
16781
|
this.publisher = new Publisher(basePeerConnectionOptions, publishOptions, {
|
|
16628
16782
|
selfSubEnabled: this.allowOwnTracksLoopback,
|
|
@@ -16659,12 +16813,22 @@ class Call {
|
|
|
16659
16813
|
*
|
|
16660
16814
|
* @internal
|
|
16661
16815
|
* @param data the join call data.
|
|
16816
|
+
* @returns The coordinator response, or undefined if leave superseded the request before it was sent.
|
|
16662
16817
|
*/
|
|
16663
16818
|
doJoinRequest = async (data) => {
|
|
16819
|
+
const joinLeaveGeneration = this.leaveGeneration;
|
|
16664
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
|
+
}
|
|
16665
16825
|
const e2ee = !!this.e2eeManager;
|
|
16666
16826
|
const request = { ...data, location, e2ee };
|
|
16667
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
|
+
}
|
|
16668
16832
|
this.state.updateFromCallResponse(joinResponse.call);
|
|
16669
16833
|
this.state.setMembers(joinResponse.members);
|
|
16670
16834
|
this.state.setOwnCapabilities(joinResponse.own_capabilities);
|
|
@@ -16675,6 +16839,8 @@ class Call {
|
|
|
16675
16839
|
if (!isReconnecting && this.ringing && !this.isCreatedByMe) {
|
|
16676
16840
|
// signals other users that I have accepted the incoming call.
|
|
16677
16841
|
await this.accept();
|
|
16842
|
+
if (this.leaveGeneration !== joinLeaveGeneration)
|
|
16843
|
+
return joinResponse;
|
|
16678
16844
|
}
|
|
16679
16845
|
if (this.streamClient._hasConnectionID()) {
|
|
16680
16846
|
this.watching = true;
|
|
@@ -16764,6 +16930,7 @@ class Call {
|
|
|
16764
16930
|
if (callingState === exports.CallingState.JOINING ||
|
|
16765
16931
|
callingState === exports.CallingState.RECONNECTING ||
|
|
16766
16932
|
callingState === exports.CallingState.MIGRATING ||
|
|
16933
|
+
callingState === exports.CallingState.LEFT ||
|
|
16767
16934
|
callingState === exports.CallingState.RECONNECTING_FAILED)
|
|
16768
16935
|
return;
|
|
16769
16936
|
// Drop redundant reconnect calls. If a reconnect is already queued or
|
|
@@ -16772,19 +16939,27 @@ class Call {
|
|
|
16772
16939
|
// `POST /join` per entry) once the call is already healthy again.
|
|
16773
16940
|
if (hasPending(this.reconnectConcurrencyTag))
|
|
16774
16941
|
return;
|
|
16942
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
16943
|
+
const supersededByLeave = () => this.leaveGeneration !== reconnectLeaveGeneration;
|
|
16775
16944
|
return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
|
|
16945
|
+
if (supersededByLeave())
|
|
16946
|
+
return;
|
|
16776
16947
|
const reconnectStartTime = Date.now();
|
|
16777
16948
|
this.reconnectStrategy = strategy;
|
|
16778
16949
|
this.reconnectReason = reason;
|
|
16779
16950
|
const sfuRejoinFailures = new Map();
|
|
16780
16951
|
const markAsReconnectingFailed = async () => {
|
|
16952
|
+
if (supersededByLeave())
|
|
16953
|
+
return;
|
|
16781
16954
|
try {
|
|
16782
16955
|
// attempt to fetch the call data from the server, as the call
|
|
16783
16956
|
// state might have changed while we were reconnecting or were offline
|
|
16784
16957
|
await this.get();
|
|
16785
16958
|
}
|
|
16786
16959
|
finally {
|
|
16787
|
-
|
|
16960
|
+
if (!supersededByLeave()) {
|
|
16961
|
+
this.state.setCallingState(exports.CallingState.RECONNECTING_FAILED);
|
|
16962
|
+
}
|
|
16788
16963
|
}
|
|
16789
16964
|
};
|
|
16790
16965
|
const giveUpAndLeave = async (message) => {
|
|
@@ -16840,6 +17015,8 @@ class Call {
|
|
|
16840
17015
|
try {
|
|
16841
17016
|
// wait until the network is available
|
|
16842
17017
|
await this.networkAvailableTask?.promise;
|
|
17018
|
+
if (supersededByLeave())
|
|
17019
|
+
return;
|
|
16843
17020
|
this.logger.info(`[Reconnect] Reconnecting with strategy ${WebsocketReconnectStrategy[this.reconnectStrategy]}`);
|
|
16844
17021
|
switch (this.reconnectStrategy) {
|
|
16845
17022
|
case WebsocketReconnectStrategy.UNSPECIFIED:
|
|
@@ -16879,6 +17056,8 @@ class Call {
|
|
|
16879
17056
|
break; // do-while loop, reconnection worked, exit the loop
|
|
16880
17057
|
}
|
|
16881
17058
|
catch (error) {
|
|
17059
|
+
if (supersededByLeave())
|
|
17060
|
+
return;
|
|
16882
17061
|
if (attemptedStrategy === WebsocketReconnectStrategy.REJOIN) {
|
|
16883
17062
|
const failedSfu = this.credentials?.server.edge_name;
|
|
16884
17063
|
if (failedSfu) {
|
|
@@ -16909,6 +17088,8 @@ class Call {
|
|
|
16909
17088
|
}
|
|
16910
17089
|
// exponential backoff with jitter, capped at 5 s
|
|
16911
17090
|
await sleep(retryInterval(attempt));
|
|
17091
|
+
if (supersededByLeave())
|
|
17092
|
+
return;
|
|
16912
17093
|
const wasMigrating = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
|
|
16913
17094
|
const mustPerformRejoin = (Date.now() - reconnectStartTime) / 1000 >
|
|
16914
17095
|
this.fastReconnectDeadlineSeconds;
|
|
@@ -16927,7 +17108,8 @@ class Call {
|
|
|
16927
17108
|
this.reconnectStrategy = nextStrategy;
|
|
16928
17109
|
this.logger.info(`[Reconnect] ${currentStrategy} (${this.reconnectAttempts}) failed. Attempting with ${WebsocketReconnectStrategy[nextStrategy]}`, error);
|
|
16929
17110
|
}
|
|
16930
|
-
} while (
|
|
17111
|
+
} while (!supersededByLeave() &&
|
|
17112
|
+
this.state.callingState !== exports.CallingState.JOINED &&
|
|
16931
17113
|
this.state.callingState !== exports.CallingState.RECONNECTING_FAILED &&
|
|
16932
17114
|
this.state.callingState !== exports.CallingState.LEFT);
|
|
16933
17115
|
this.logger.info('[Reconnect] Reconnection flow finished');
|
|
@@ -16938,10 +17120,13 @@ class Call {
|
|
|
16938
17120
|
* @internal
|
|
16939
17121
|
*/
|
|
16940
17122
|
reconnectFast = async () => {
|
|
17123
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
16941
17124
|
const reconnectStartTime = Date.now();
|
|
16942
17125
|
this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
|
|
16943
17126
|
this.state.setCallingState(exports.CallingState.RECONNECTING);
|
|
16944
17127
|
await this.doJoin(this.joinCallData);
|
|
17128
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration)
|
|
17129
|
+
return;
|
|
16945
17130
|
await this.get(); // fetch the latest call state, as it might have changed
|
|
16946
17131
|
this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.FAST, (Date.now() - reconnectStartTime) / 1000);
|
|
16947
17132
|
};
|
|
@@ -16950,6 +17135,7 @@ class Call {
|
|
|
16950
17135
|
* @internal
|
|
16951
17136
|
*/
|
|
16952
17137
|
reconnectRejoin = async () => {
|
|
17138
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
16953
17139
|
const reconnectStartTime = Date.now();
|
|
16954
17140
|
this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
|
|
16955
17141
|
this.state.setCallingState(exports.CallingState.RECONNECTING);
|
|
@@ -16957,7 +17143,11 @@ class Call {
|
|
|
16957
17143
|
? 'network-available'
|
|
16958
17144
|
: 'full-rejoin';
|
|
16959
17145
|
await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason }, () => this.doJoin(this.joinCallData));
|
|
17146
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration)
|
|
17147
|
+
return;
|
|
16960
17148
|
await this.restorePublishedTracks();
|
|
17149
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration)
|
|
17150
|
+
return;
|
|
16961
17151
|
this.restoreSubscribedTracks();
|
|
16962
17152
|
this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
|
|
16963
17153
|
};
|
|
@@ -16966,6 +17156,7 @@ class Call {
|
|
|
16966
17156
|
* @internal
|
|
16967
17157
|
*/
|
|
16968
17158
|
reconnectMigrate = async () => {
|
|
17159
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
16969
17160
|
const reconnectStartTime = Date.now();
|
|
16970
17161
|
const currentSfuClient = this.sfuClient;
|
|
16971
17162
|
if (!currentSfuClient) {
|
|
@@ -16979,26 +17170,32 @@ class Call {
|
|
|
16979
17170
|
currentPublisher?.detachEventHandlers();
|
|
16980
17171
|
const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
|
|
16981
17172
|
try {
|
|
16982
|
-
|
|
16983
|
-
|
|
16984
|
-
|
|
16985
|
-
|
|
16986
|
-
|
|
16987
|
-
|
|
16988
|
-
|
|
16989
|
-
|
|
16990
|
-
|
|
16991
|
-
|
|
16992
|
-
|
|
16993
|
-
|
|
16994
|
-
|
|
16995
|
-
|
|
16996
|
-
|
|
16997
|
-
|
|
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();
|
|
16998
17193
|
// Wait for the migration to complete, then close the previous SFU client
|
|
16999
17194
|
// and the peer connection instances. In case of failure, the migration
|
|
17000
17195
|
// task would throw an error and REJOIN would be attempted.
|
|
17001
17196
|
await migrationTask();
|
|
17197
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration)
|
|
17198
|
+
return;
|
|
17002
17199
|
// in MIGRATE, we can consider the call as joined only after
|
|
17003
17200
|
// `participantMigrationComplete` event is received, signaled by
|
|
17004
17201
|
// the `migrationTask`
|
|
@@ -17283,10 +17480,11 @@ class Call {
|
|
|
17283
17480
|
* Must be called before {@link join} so the RTCPeerConnection can be
|
|
17284
17481
|
* configured for E2EE.
|
|
17285
17482
|
*
|
|
17286
|
-
* The
|
|
17287
|
-
*
|
|
17288
|
-
*
|
|
17289
|
-
*
|
|
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.
|
|
17290
17488
|
*
|
|
17291
17489
|
* @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
|
|
17292
17490
|
* built-in AES-GCM scheme, or pass your own implementation.
|
|
@@ -19453,11 +19651,11 @@ class StreamClient {
|
|
|
19453
19651
|
return await this.wsConnection.connect(this.defaultWSTimeout);
|
|
19454
19652
|
};
|
|
19455
19653
|
getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
|
|
19456
|
-
"1.
|
|
19654
|
+
"1.61.0";
|
|
19457
19655
|
getUserAgent = () => {
|
|
19458
19656
|
if (!this.cachedUserAgent) {
|
|
19459
19657
|
const { clientAppIdentifier = {} } = this.options;
|
|
19460
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
19658
|
+
const { sdkName = 'js', sdkVersion = "1.61.0", ...extras } = clientAppIdentifier;
|
|
19461
19659
|
this.cachedUserAgent = [
|
|
19462
19660
|
`stream-video-${sdkName}-v${sdkVersion}`,
|
|
19463
19661
|
...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
|
|
@@ -21166,9 +21364,9 @@ class EncryptionManager extends TypedEventEmitter {
|
|
|
21166
21364
|
}
|
|
21167
21365
|
if ((this.piped ??= new WeakSet()).has(target))
|
|
21168
21366
|
return;
|
|
21169
|
-
this.piped.add(target);
|
|
21170
21367
|
// @ts-expect-error createEncodedStreams is not in the standard typedefs
|
|
21171
21368
|
const { readable, writable } = target.createEncodedStreams();
|
|
21369
|
+
this.piped.add(target);
|
|
21172
21370
|
this.worker.postMessage({ type: 'cmd.setup_transform', ...options, readable, writable }, [readable, writable]);
|
|
21173
21371
|
};
|
|
21174
21372
|
/**
|
|
@@ -21336,6 +21534,7 @@ exports.TranscriptionSettingsResponseClosedCaptionModeEnum = TranscriptionSettin
|
|
|
21336
21534
|
exports.TranscriptionSettingsResponseLanguageEnum = TranscriptionSettingsResponseLanguageEnum;
|
|
21337
21535
|
exports.TranscriptionSettingsResponseModeEnum = TranscriptionSettingsResponseModeEnum;
|
|
21338
21536
|
exports.TranslationSettingsLanguagesEnum = TranslationSettingsLanguagesEnum;
|
|
21537
|
+
exports.TypedEventEmitter = TypedEventEmitter;
|
|
21339
21538
|
exports.UpdateUserPermissionsRequestGrantPermissionsEnum = UpdateUserPermissionsRequestGrantPermissionsEnum;
|
|
21340
21539
|
exports.UpdateUserPermissionsRequestRevokePermissionsEnum = UpdateUserPermissionsRequestRevokePermissionsEnum;
|
|
21341
21540
|
exports.VideoSettingsRequestCameraFacingEnum = VideoSettingsRequestCameraFacingEnum;
|