@stream-io/video-client 1.60.0 → 1.61.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/index.browser.es.js +264 -57
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +264 -56
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +264 -57
- 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/reporting/ClientEventReporter.ts +7 -2
- package/src/reporting/__tests__/ClientEventReporter.test.ts +2 -2
- package/src/rtc/BasePeerConnection.ts +6 -1
- package/src/rtc/Publisher.ts +66 -12
- package/src/rtc/TransceiverCache.ts +17 -0
- package/src/rtc/__tests__/Publisher.test.ts +259 -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.1";
|
|
5105
5105
|
const [major, minor, patch] = version.split('.');
|
|
5106
5106
|
let sdkInfo = {
|
|
5107
5107
|
type: SdkType.PLAIN_JAVASCRIPT,
|
|
@@ -8774,7 +8774,12 @@ class BasePeerConnection {
|
|
|
8774
8774
|
}
|
|
8775
8775
|
// we can't recover from a failed connection state (contrary to ICE)
|
|
8776
8776
|
if (state === 'failed') {
|
|
8777
|
-
|
|
8777
|
+
const reason = this.iceHasEverConnected
|
|
8778
|
+
? ReconnectReason.CONNECTION_FAILED
|
|
8779
|
+
: ReconnectReason.ICE_NEVER_CONNECTED;
|
|
8780
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
8781
|
+
this.preConnectStuckTimeout = undefined;
|
|
8782
|
+
this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, reason, this.peerType);
|
|
8778
8783
|
return;
|
|
8779
8784
|
}
|
|
8780
8785
|
this.handleConnectionStateUpdate(state);
|
|
@@ -9166,6 +9171,25 @@ class TransceiverCache {
|
|
|
9166
9171
|
this.cache.push(bundle);
|
|
9167
9172
|
this.transceiverOrder.push(bundle.transceiver);
|
|
9168
9173
|
};
|
|
9174
|
+
/**
|
|
9175
|
+
* Retires a failed sender from the reusable cache.
|
|
9176
|
+
*
|
|
9177
|
+
* A transceiver that never reached an m-section also leaves the creation
|
|
9178
|
+
* history: once stopped, it is left out of every later offer, so counting
|
|
9179
|
+
* it would shift the `mid` index of every transceiver added after it. One
|
|
9180
|
+
* that is already associated keeps its slot, since its m-section survives
|
|
9181
|
+
* as a rejected one.
|
|
9182
|
+
*/
|
|
9183
|
+
remove = (transceiver) => {
|
|
9184
|
+
const index = this.cache.findIndex((b) => b.transceiver === transceiver);
|
|
9185
|
+
if (index !== -1)
|
|
9186
|
+
this.cache.splice(index, 1);
|
|
9187
|
+
if (transceiver.mid)
|
|
9188
|
+
return;
|
|
9189
|
+
const orderIndex = this.transceiverOrder.indexOf(transceiver);
|
|
9190
|
+
if (orderIndex !== -1)
|
|
9191
|
+
this.transceiverOrder.splice(orderIndex, 1);
|
|
9192
|
+
};
|
|
9169
9193
|
/**
|
|
9170
9194
|
* Gets the transceiver for the given publish option.
|
|
9171
9195
|
*/
|
|
@@ -9337,7 +9361,9 @@ class Publisher extends BasePeerConnection {
|
|
|
9337
9361
|
const sendEncodings = isSvcCodec(publishOption.codec?.name)
|
|
9338
9362
|
? toSvcEncodings(encodings)
|
|
9339
9363
|
: encodings;
|
|
9340
|
-
|
|
9364
|
+
// An E2EE sender must never have media before its transform is attached,
|
|
9365
|
+
// including when another track or an ICE restart negotiates this PC.
|
|
9366
|
+
const transceiver = this.pc.addTransceiver(this.e2ee ? track.kind : track, {
|
|
9341
9367
|
direction: 'sendonly',
|
|
9342
9368
|
sendEncodings,
|
|
9343
9369
|
});
|
|
@@ -9347,16 +9373,63 @@ class Publisher extends BasePeerConnection {
|
|
|
9347
9373
|
this.transceiverCache.add({ publishOption, transceiver, options });
|
|
9348
9374
|
this.trackIdToTrackType.set(track.id, trackType);
|
|
9349
9375
|
this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
|
|
9350
|
-
|
|
9376
|
+
if (this.e2ee) {
|
|
9377
|
+
await this.initEncryptedSender(transceiver, track, publishOption);
|
|
9378
|
+
}
|
|
9379
|
+
else {
|
|
9380
|
+
await this.setDegradationPreference(transceiver.sender, publishOption);
|
|
9381
|
+
}
|
|
9382
|
+
await this.negotiate();
|
|
9383
|
+
};
|
|
9384
|
+
/**
|
|
9385
|
+
* Attaches the encryptor, applies sender parameters, then attaches the track.
|
|
9386
|
+
*
|
|
9387
|
+
* `encrypt` must run before the first await: Chrome short-circuits a sender
|
|
9388
|
+
* whose encoded streams aren't created in the task that created it, and the
|
|
9389
|
+
* track goes on last so the sender has no media until both steps succeed.
|
|
9390
|
+
*/
|
|
9391
|
+
initEncryptedSender = async (transceiver, track, publishOption) => {
|
|
9392
|
+
try {
|
|
9393
|
+
const { sender } = transceiver;
|
|
9394
|
+
const codec = publishOption.codec?.name.toLowerCase();
|
|
9395
|
+
this.e2ee.encrypt(sender, codec, TrackType[publishOption.trackType]);
|
|
9396
|
+
this.logger.debug('E2EE encryptor attached to sender');
|
|
9397
|
+
await this.setDegradationPreference(sender, publishOption);
|
|
9398
|
+
await sender.replaceTrack(track);
|
|
9399
|
+
}
|
|
9400
|
+
catch (err) {
|
|
9401
|
+
this.retireTransceiver(transceiver, track);
|
|
9402
|
+
throw err;
|
|
9403
|
+
}
|
|
9404
|
+
};
|
|
9405
|
+
setDegradationPreference = async (sender, publishOption) => {
|
|
9406
|
+
const params = sender.getParameters();
|
|
9351
9407
|
params.degradationPreference =
|
|
9352
9408
|
toRTCDegradationPreference(publishOption.degradationPreference) ??
|
|
9353
9409
|
'maintain-framerate';
|
|
9354
|
-
await
|
|
9355
|
-
|
|
9356
|
-
|
|
9357
|
-
|
|
9410
|
+
await sender.setParameters(params);
|
|
9411
|
+
};
|
|
9412
|
+
/**
|
|
9413
|
+
* Discards a sender whose E2EE initialization failed.
|
|
9414
|
+
*
|
|
9415
|
+
* A partially initialized sender may carry no transform, so publish() must
|
|
9416
|
+
* not reuse it: retiring it from the cache is what keeps a later publish or
|
|
9417
|
+
* ICE restart from putting cleartext on the wire. Cleanup cannot throw, so
|
|
9418
|
+
* the initialization failure is what reaches the caller.
|
|
9419
|
+
*/
|
|
9420
|
+
retireTransceiver = (transceiver, track) => {
|
|
9421
|
+
this.transceiverCache.remove(transceiver);
|
|
9422
|
+
this.trackIdToTrackType.delete(track.id);
|
|
9423
|
+
// on React Native a clone shares its native source, so stopping it here
|
|
9424
|
+
// would stop the track the caller passed in. dispose() releases it.
|
|
9425
|
+
if (!isReactNative())
|
|
9426
|
+
this.stopTrack(track);
|
|
9427
|
+
try {
|
|
9428
|
+
transceiver.stop();
|
|
9429
|
+
}
|
|
9430
|
+
catch (err) {
|
|
9431
|
+
this.logger.debug('Failed to stop a retired transceiver', err);
|
|
9358
9432
|
}
|
|
9359
|
-
await this.negotiate();
|
|
9360
9433
|
};
|
|
9361
9434
|
/**
|
|
9362
9435
|
* Updates the transceiver with the given track and track type.
|
|
@@ -15922,6 +15995,11 @@ class Call {
|
|
|
15922
15995
|
this.audioBindingsWatchdog?.dispose();
|
|
15923
15996
|
await this.dynascaleManager?.dispose();
|
|
15924
15997
|
this.state.setCallingState(exports.CallingState.LEFT);
|
|
15998
|
+
// `ringingSubject` is cleared further down, so this still reads true for
|
|
15999
|
+
// a call that was ringing.
|
|
16000
|
+
if (this.ringing) {
|
|
16001
|
+
globalThis.streamRNVideoSDK?.ringingCallLifecycle?.onLeave(this);
|
|
16002
|
+
}
|
|
15925
16003
|
this.state.setParticipants([]);
|
|
15926
16004
|
this.state.dispose();
|
|
15927
16005
|
// Reset reconnect-related accumulators so a future `call.join()` on the
|
|
@@ -16090,8 +16168,11 @@ class Call {
|
|
|
16090
16168
|
* @param params.video if set to true, in a ringing scenario, mobile SDKs will show "incoming video call", audio only otherwise.
|
|
16091
16169
|
*/
|
|
16092
16170
|
get = async (params) => {
|
|
16171
|
+
const getLeaveGeneration = this.leaveGeneration;
|
|
16093
16172
|
await this.setup();
|
|
16094
16173
|
const response = await this.streamClient.get(this.streamClientBasePath, params);
|
|
16174
|
+
if (this.leaveGeneration !== getLeaveGeneration)
|
|
16175
|
+
return response;
|
|
16095
16176
|
this.updateFromCallStateResponse(response);
|
|
16096
16177
|
if (params?.ring) {
|
|
16097
16178
|
this.ringingSubject.next(true);
|
|
@@ -16196,42 +16277,80 @@ class Call {
|
|
|
16196
16277
|
/**
|
|
16197
16278
|
* Will start to watch for call related WebSocket events and initiate a call session with the server.
|
|
16198
16279
|
*
|
|
16280
|
+
* One instance is one call flow: discard it after leaving, cancelling, or a
|
|
16281
|
+
* failed join, and create a fresh one for a later flow. Reconnection inside a
|
|
16282
|
+
* live call is handled here and needs no new instance.
|
|
16283
|
+
*
|
|
16199
16284
|
* @returns a promise which resolves once the call join-flow has finished.
|
|
16200
16285
|
*/
|
|
16201
16286
|
join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, joinSource, ...data } = {}) => {
|
|
16202
16287
|
const callingState = this.state.callingState;
|
|
16288
|
+
// Ahead of the failure boundary below on purpose: a duplicate join on a
|
|
16289
|
+
// live call is refused without tearing that call down.
|
|
16203
16290
|
if ([exports.CallingState.JOINED, exports.CallingState.JOINING].includes(callingState)) {
|
|
16204
16291
|
throw new Error(`Illegal State: call.join() shall be called only once`);
|
|
16205
16292
|
}
|
|
16293
|
+
// snapshot before the first await: a leave() landing at any point from
|
|
16294
|
+
// here on supersedes this join, and the retry loop must bail out instead
|
|
16295
|
+
// of resurrecting a call that leave() already tore down.
|
|
16296
|
+
const joinLeaveGeneration = this.leaveGeneration;
|
|
16297
|
+
const supersededByLeave = () => this.leaveGeneration !== joinLeaveGeneration;
|
|
16206
16298
|
// we need this to be set before the callingx.joinCall() is
|
|
16207
16299
|
// called to avoid registering the test call in the CallKit/Telecom
|
|
16208
16300
|
this.allowOwnTracksLoopback = allowOwnTracksLoopback;
|
|
16209
16301
|
if (data?.ring) {
|
|
16210
16302
|
this.ringingSubject.next(true);
|
|
16211
16303
|
}
|
|
16304
|
+
// A ringing call is joined by the SDK rather than by app code, so React
|
|
16305
|
+
// Native prepares it from in here - there is no earlier point at which the
|
|
16306
|
+
// app holds the call. Read after `data.ring` above, which is what makes an
|
|
16307
|
+
// outgoing call ringing in the first place.
|
|
16308
|
+
const ringingLifecycle = this.ringing
|
|
16309
|
+
? globalThis.streamRNVideoSDK?.ringingCallLifecycle
|
|
16310
|
+
: undefined;
|
|
16212
16311
|
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
16312
|
try {
|
|
16313
|
+
if (ringingLifecycle) {
|
|
16314
|
+
await ringingLifecycle.beforeJoin(this);
|
|
16315
|
+
if (supersededByLeave()) {
|
|
16316
|
+
this.logger.debug('Join superseded by leave; not joining');
|
|
16317
|
+
return;
|
|
16318
|
+
}
|
|
16319
|
+
}
|
|
16320
|
+
if (callingX) {
|
|
16321
|
+
// for Android/iOS, we need to start the call in the callingx library as soon as possible
|
|
16322
|
+
await callingX.joinCall(this, this.clientStore.calls, supersededByLeave);
|
|
16323
|
+
if (supersededByLeave()) {
|
|
16324
|
+
this.logger.debug('Join superseded by leave; not setting up');
|
|
16325
|
+
return;
|
|
16326
|
+
}
|
|
16327
|
+
}
|
|
16328
|
+
await this.setup();
|
|
16329
|
+
if (supersededByLeave()) {
|
|
16330
|
+
this.logger.debug('Join superseded by leave; not registering');
|
|
16331
|
+
return;
|
|
16332
|
+
}
|
|
16333
|
+
this.clientEventReporter.registerCall(this.cid, {
|
|
16334
|
+
callType: this.type,
|
|
16335
|
+
callId: this.id,
|
|
16336
|
+
getCallSessionId: () => this.state.session?.id ?? '',
|
|
16337
|
+
getSfuId: () => this.credentials?.server.edge_name ?? '',
|
|
16338
|
+
});
|
|
16339
|
+
this.joinResponseTimeout = joinResponseTimeout;
|
|
16340
|
+
this.rpcRequestTimeout = rpcRequestTimeout;
|
|
16341
|
+
// we will count the number of join failures per SFU.
|
|
16342
|
+
// once the number of failures reaches 2, we will piggyback on the `migrating_from`
|
|
16343
|
+
// field to force the coordinator to provide us another SFU
|
|
16344
|
+
const sfuJoinFailures = new Map();
|
|
16345
|
+
const joinData = data;
|
|
16346
|
+
maxJoinRetries = Math.max(maxJoinRetries, 1);
|
|
16233
16347
|
await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'first-attempt', joinSource }, async () => {
|
|
16234
16348
|
for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
|
|
16349
|
+
// Also covers a leave that lands during the backoff below.
|
|
16350
|
+
if (supersededByLeave()) {
|
|
16351
|
+
this.logger.debug('Join superseded by leave; not attempting');
|
|
16352
|
+
return;
|
|
16353
|
+
}
|
|
16235
16354
|
try {
|
|
16236
16355
|
this.logger.trace(`Joining call (${attempt})`, this.cid);
|
|
16237
16356
|
await this.doJoin(data);
|
|
@@ -16240,6 +16359,10 @@ class Call {
|
|
|
16240
16359
|
return;
|
|
16241
16360
|
}
|
|
16242
16361
|
catch (err) {
|
|
16362
|
+
if (supersededByLeave()) {
|
|
16363
|
+
this.logger.debug('Join superseded by leave; not retrying');
|
|
16364
|
+
return;
|
|
16365
|
+
}
|
|
16243
16366
|
this.logger.warn(`Failed to join call (${attempt})`, this.cid);
|
|
16244
16367
|
if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
|
|
16245
16368
|
(err instanceof SfuJoinError && err.unrecoverable)) {
|
|
@@ -16269,6 +16392,9 @@ class Call {
|
|
|
16269
16392
|
}
|
|
16270
16393
|
catch (error) {
|
|
16271
16394
|
callingX?.endCall(this, 'error');
|
|
16395
|
+
// Ends the failed ringing flow and releases what its setup installed.
|
|
16396
|
+
// Never rejects, so `error` is what the caller sees.
|
|
16397
|
+
await ringingLifecycle?.onJoinFailed(this);
|
|
16272
16398
|
throw error;
|
|
16273
16399
|
}
|
|
16274
16400
|
});
|
|
@@ -16291,6 +16417,10 @@ class Call {
|
|
|
16291
16417
|
// globals resolve to the call's factory. Idempotent across
|
|
16292
16418
|
// reconnect/migration attempts.
|
|
16293
16419
|
await this.ensureMediaFactory();
|
|
16420
|
+
if (supersededByLeave()) {
|
|
16421
|
+
this.logger.debug('Join superseded by leave; not wiring media');
|
|
16422
|
+
return;
|
|
16423
|
+
}
|
|
16294
16424
|
const callingX = globalThis.streamRNVideoSDK?.callingX;
|
|
16295
16425
|
if (callingX) {
|
|
16296
16426
|
callingX.wireAudioEngineSubscription();
|
|
@@ -16306,11 +16436,17 @@ class Call {
|
|
|
16306
16436
|
data?.migrating_from) {
|
|
16307
16437
|
try {
|
|
16308
16438
|
const joinResponse = await this.clientEventReporter.track(this.cid, 'CoordinatorJoin', () => this.doJoinRequest(data));
|
|
16439
|
+
if (!joinResponse || supersededByLeave()) {
|
|
16440
|
+
this.logger.debug('Join superseded by leave; not creating SFU client');
|
|
16441
|
+
return;
|
|
16442
|
+
}
|
|
16309
16443
|
this.credentials = joinResponse.credentials;
|
|
16310
16444
|
statsOptions = joinResponse.stats_options;
|
|
16311
16445
|
this.lastStatsOptions = statsOptions;
|
|
16312
16446
|
}
|
|
16313
16447
|
catch (error) {
|
|
16448
|
+
if (supersededByLeave())
|
|
16449
|
+
return;
|
|
16314
16450
|
// prevent triggering reconnect flow if the state is OFFLINE
|
|
16315
16451
|
const avoidRestoreState = this.state.callingState === exports.CallingState.OFFLINE;
|
|
16316
16452
|
if (!avoidRestoreState) {
|
|
@@ -16358,6 +16494,10 @@ class Call {
|
|
|
16358
16494
|
getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
|
|
16359
16495
|
getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
|
|
16360
16496
|
]);
|
|
16497
|
+
if (supersededByLeave()) {
|
|
16498
|
+
this.logger.debug('Join superseded by leave; not joining SFU');
|
|
16499
|
+
return;
|
|
16500
|
+
}
|
|
16361
16501
|
const isReconnecting = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
|
|
16362
16502
|
const reconnectDetails = isReconnecting
|
|
16363
16503
|
? this.getReconnectDetails(previousSfuClient?.edgeName, previousSessionId)
|
|
@@ -16383,6 +16523,10 @@ class Call {
|
|
|
16383
16523
|
capabilities,
|
|
16384
16524
|
source: ParticipantSource.WEBRTC_UNSPECIFIED,
|
|
16385
16525
|
}));
|
|
16526
|
+
if (supersededByLeave()) {
|
|
16527
|
+
this.logger.debug('Join superseded by leave; not applying SFU response');
|
|
16528
|
+
return;
|
|
16529
|
+
}
|
|
16386
16530
|
this.currentPublishOptions = publishOptions;
|
|
16387
16531
|
this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
|
|
16388
16532
|
if (callState) {
|
|
@@ -16390,6 +16534,10 @@ class Call {
|
|
|
16390
16534
|
}
|
|
16391
16535
|
}
|
|
16392
16536
|
catch (error) {
|
|
16537
|
+
if (supersededByLeave()) {
|
|
16538
|
+
this.logger.debug('Join superseded by leave; ignoring SFU failure');
|
|
16539
|
+
return;
|
|
16540
|
+
}
|
|
16393
16541
|
this.logger.warn('Join SFU request failed', error);
|
|
16394
16542
|
sfuClient.close(StreamSfuClient.JOIN_FAILED, 'Join request failed, connection considered unhealthy');
|
|
16395
16543
|
// restore the previous call state if the join-flow fails
|
|
@@ -16397,8 +16545,8 @@ class Call {
|
|
|
16397
16545
|
throw error;
|
|
16398
16546
|
}
|
|
16399
16547
|
}
|
|
16400
|
-
// If the user left while this join was in flight, bail before re-setting JOINED
|
|
16401
|
-
// peer-connection setup below
|
|
16548
|
+
// If the user left while this join was in flight, bail before re-setting JOINED
|
|
16549
|
+
// or starting peer-connection setup below.
|
|
16402
16550
|
if (supersededByLeave()) {
|
|
16403
16551
|
this.logger.debug('Join superseded by leave; aborting join flow');
|
|
16404
16552
|
return;
|
|
@@ -16427,6 +16575,10 @@ class Call {
|
|
|
16427
16575
|
unifiedSessionId: this.unifiedSessionId,
|
|
16428
16576
|
});
|
|
16429
16577
|
}
|
|
16578
|
+
if (supersededByLeave()) {
|
|
16579
|
+
this.logger.debug('Join superseded by leave; not completing join flow');
|
|
16580
|
+
return;
|
|
16581
|
+
}
|
|
16430
16582
|
// make sure we only track connection timing if we are not calling this method as part of a reconnection flow
|
|
16431
16583
|
if (!performingRejoin && !performingFastReconnect && !performingMigration) {
|
|
16432
16584
|
this.sfuStatsReporter?.sendConnectionTime((Date.now() - connectStartTime) / 1000);
|
|
@@ -16572,15 +16724,20 @@ class Call {
|
|
|
16572
16724
|
* @internal
|
|
16573
16725
|
*/
|
|
16574
16726
|
initPublisherAndSubscriber = async (opts) => {
|
|
16727
|
+
const joinLeaveGeneration = this.leaveGeneration;
|
|
16575
16728
|
const { sfuClient, connectionConfig, clientDetails, statsOptions, publishOptions, closePreviousInstances, unifiedSessionId, } = opts;
|
|
16576
16729
|
const { enable_rtc_stats: enableTracing, reporting_interval_ms: reportingIntervalMs, } = statsOptions;
|
|
16577
16730
|
// Flush the previous reporter's final sample while its peer connections are
|
|
16578
16731
|
// still alive, before we dispose them below. Awaits only the sampling step.
|
|
16579
16732
|
await this.sfuStatsReporter?.flush();
|
|
16733
|
+
if (this.leaveGeneration !== joinLeaveGeneration)
|
|
16734
|
+
return;
|
|
16580
16735
|
this.sfuStatsReporter?.stop();
|
|
16581
16736
|
this.sfuStatsReporter = undefined;
|
|
16582
16737
|
if (closePreviousInstances && this.subscriber) {
|
|
16583
16738
|
await this.subscriber.dispose();
|
|
16739
|
+
if (this.leaveGeneration !== joinLeaveGeneration)
|
|
16740
|
+
return;
|
|
16584
16741
|
this.state.removeAllOrphanedTracks();
|
|
16585
16742
|
}
|
|
16586
16743
|
const basePeerConnectionOptions = {
|
|
@@ -16623,6 +16780,8 @@ class Call {
|
|
|
16623
16780
|
if (!isAnonymous) {
|
|
16624
16781
|
if (closePreviousInstances && this.publisher) {
|
|
16625
16782
|
await this.publisher.dispose();
|
|
16783
|
+
if (this.leaveGeneration !== joinLeaveGeneration)
|
|
16784
|
+
return;
|
|
16626
16785
|
}
|
|
16627
16786
|
this.publisher = new Publisher(basePeerConnectionOptions, publishOptions, {
|
|
16628
16787
|
selfSubEnabled: this.allowOwnTracksLoopback,
|
|
@@ -16659,12 +16818,22 @@ class Call {
|
|
|
16659
16818
|
*
|
|
16660
16819
|
* @internal
|
|
16661
16820
|
* @param data the join call data.
|
|
16821
|
+
* @returns The coordinator response, or undefined if leave superseded the request before it was sent.
|
|
16662
16822
|
*/
|
|
16663
16823
|
doJoinRequest = async (data) => {
|
|
16824
|
+
const joinLeaveGeneration = this.leaveGeneration;
|
|
16664
16825
|
const location = await this.streamClient.getLocationHint();
|
|
16826
|
+
if (this.leaveGeneration !== joinLeaveGeneration) {
|
|
16827
|
+
this.logger.debug('Join superseded by leave; not sending coordinator request');
|
|
16828
|
+
return;
|
|
16829
|
+
}
|
|
16665
16830
|
const e2ee = !!this.e2eeManager;
|
|
16666
16831
|
const request = { ...data, location, e2ee };
|
|
16667
16832
|
const joinResponse = await this.streamClient.post(`${this.streamClientBasePath}/join`, request);
|
|
16833
|
+
if (this.leaveGeneration !== joinLeaveGeneration) {
|
|
16834
|
+
this.logger.debug('Join superseded by leave; not applying coordinator response');
|
|
16835
|
+
return joinResponse;
|
|
16836
|
+
}
|
|
16668
16837
|
this.state.updateFromCallResponse(joinResponse.call);
|
|
16669
16838
|
this.state.setMembers(joinResponse.members);
|
|
16670
16839
|
this.state.setOwnCapabilities(joinResponse.own_capabilities);
|
|
@@ -16675,6 +16844,8 @@ class Call {
|
|
|
16675
16844
|
if (!isReconnecting && this.ringing && !this.isCreatedByMe) {
|
|
16676
16845
|
// signals other users that I have accepted the incoming call.
|
|
16677
16846
|
await this.accept();
|
|
16847
|
+
if (this.leaveGeneration !== joinLeaveGeneration)
|
|
16848
|
+
return joinResponse;
|
|
16678
16849
|
}
|
|
16679
16850
|
if (this.streamClient._hasConnectionID()) {
|
|
16680
16851
|
this.watching = true;
|
|
@@ -16764,6 +16935,7 @@ class Call {
|
|
|
16764
16935
|
if (callingState === exports.CallingState.JOINING ||
|
|
16765
16936
|
callingState === exports.CallingState.RECONNECTING ||
|
|
16766
16937
|
callingState === exports.CallingState.MIGRATING ||
|
|
16938
|
+
callingState === exports.CallingState.LEFT ||
|
|
16767
16939
|
callingState === exports.CallingState.RECONNECTING_FAILED)
|
|
16768
16940
|
return;
|
|
16769
16941
|
// Drop redundant reconnect calls. If a reconnect is already queued or
|
|
@@ -16772,19 +16944,27 @@ class Call {
|
|
|
16772
16944
|
// `POST /join` per entry) once the call is already healthy again.
|
|
16773
16945
|
if (hasPending(this.reconnectConcurrencyTag))
|
|
16774
16946
|
return;
|
|
16947
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
16948
|
+
const supersededByLeave = () => this.leaveGeneration !== reconnectLeaveGeneration;
|
|
16775
16949
|
return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
|
|
16950
|
+
if (supersededByLeave())
|
|
16951
|
+
return;
|
|
16776
16952
|
const reconnectStartTime = Date.now();
|
|
16777
16953
|
this.reconnectStrategy = strategy;
|
|
16778
16954
|
this.reconnectReason = reason;
|
|
16779
16955
|
const sfuRejoinFailures = new Map();
|
|
16780
16956
|
const markAsReconnectingFailed = async () => {
|
|
16957
|
+
if (supersededByLeave())
|
|
16958
|
+
return;
|
|
16781
16959
|
try {
|
|
16782
16960
|
// attempt to fetch the call data from the server, as the call
|
|
16783
16961
|
// state might have changed while we were reconnecting or were offline
|
|
16784
16962
|
await this.get();
|
|
16785
16963
|
}
|
|
16786
16964
|
finally {
|
|
16787
|
-
|
|
16965
|
+
if (!supersededByLeave()) {
|
|
16966
|
+
this.state.setCallingState(exports.CallingState.RECONNECTING_FAILED);
|
|
16967
|
+
}
|
|
16788
16968
|
}
|
|
16789
16969
|
};
|
|
16790
16970
|
const giveUpAndLeave = async (message) => {
|
|
@@ -16840,6 +17020,8 @@ class Call {
|
|
|
16840
17020
|
try {
|
|
16841
17021
|
// wait until the network is available
|
|
16842
17022
|
await this.networkAvailableTask?.promise;
|
|
17023
|
+
if (supersededByLeave())
|
|
17024
|
+
return;
|
|
16843
17025
|
this.logger.info(`[Reconnect] Reconnecting with strategy ${WebsocketReconnectStrategy[this.reconnectStrategy]}`);
|
|
16844
17026
|
switch (this.reconnectStrategy) {
|
|
16845
17027
|
case WebsocketReconnectStrategy.UNSPECIFIED:
|
|
@@ -16879,6 +17061,8 @@ class Call {
|
|
|
16879
17061
|
break; // do-while loop, reconnection worked, exit the loop
|
|
16880
17062
|
}
|
|
16881
17063
|
catch (error) {
|
|
17064
|
+
if (supersededByLeave())
|
|
17065
|
+
return;
|
|
16882
17066
|
if (attemptedStrategy === WebsocketReconnectStrategy.REJOIN) {
|
|
16883
17067
|
const failedSfu = this.credentials?.server.edge_name;
|
|
16884
17068
|
if (failedSfu) {
|
|
@@ -16909,6 +17093,8 @@ class Call {
|
|
|
16909
17093
|
}
|
|
16910
17094
|
// exponential backoff with jitter, capped at 5 s
|
|
16911
17095
|
await sleep(retryInterval(attempt));
|
|
17096
|
+
if (supersededByLeave())
|
|
17097
|
+
return;
|
|
16912
17098
|
const wasMigrating = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
|
|
16913
17099
|
const mustPerformRejoin = (Date.now() - reconnectStartTime) / 1000 >
|
|
16914
17100
|
this.fastReconnectDeadlineSeconds;
|
|
@@ -16927,7 +17113,8 @@ class Call {
|
|
|
16927
17113
|
this.reconnectStrategy = nextStrategy;
|
|
16928
17114
|
this.logger.info(`[Reconnect] ${currentStrategy} (${this.reconnectAttempts}) failed. Attempting with ${WebsocketReconnectStrategy[nextStrategy]}`, error);
|
|
16929
17115
|
}
|
|
16930
|
-
} while (
|
|
17116
|
+
} while (!supersededByLeave() &&
|
|
17117
|
+
this.state.callingState !== exports.CallingState.JOINED &&
|
|
16931
17118
|
this.state.callingState !== exports.CallingState.RECONNECTING_FAILED &&
|
|
16932
17119
|
this.state.callingState !== exports.CallingState.LEFT);
|
|
16933
17120
|
this.logger.info('[Reconnect] Reconnection flow finished');
|
|
@@ -16938,10 +17125,13 @@ class Call {
|
|
|
16938
17125
|
* @internal
|
|
16939
17126
|
*/
|
|
16940
17127
|
reconnectFast = async () => {
|
|
17128
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
16941
17129
|
const reconnectStartTime = Date.now();
|
|
16942
17130
|
this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
|
|
16943
17131
|
this.state.setCallingState(exports.CallingState.RECONNECTING);
|
|
16944
17132
|
await this.doJoin(this.joinCallData);
|
|
17133
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration)
|
|
17134
|
+
return;
|
|
16945
17135
|
await this.get(); // fetch the latest call state, as it might have changed
|
|
16946
17136
|
this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.FAST, (Date.now() - reconnectStartTime) / 1000);
|
|
16947
17137
|
};
|
|
@@ -16950,6 +17140,7 @@ class Call {
|
|
|
16950
17140
|
* @internal
|
|
16951
17141
|
*/
|
|
16952
17142
|
reconnectRejoin = async () => {
|
|
17143
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
16953
17144
|
const reconnectStartTime = Date.now();
|
|
16954
17145
|
this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
|
|
16955
17146
|
this.state.setCallingState(exports.CallingState.RECONNECTING);
|
|
@@ -16957,7 +17148,11 @@ class Call {
|
|
|
16957
17148
|
? 'network-available'
|
|
16958
17149
|
: 'full-rejoin';
|
|
16959
17150
|
await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason }, () => this.doJoin(this.joinCallData));
|
|
17151
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration)
|
|
17152
|
+
return;
|
|
16960
17153
|
await this.restorePublishedTracks();
|
|
17154
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration)
|
|
17155
|
+
return;
|
|
16961
17156
|
this.restoreSubscribedTracks();
|
|
16962
17157
|
this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
|
|
16963
17158
|
};
|
|
@@ -16966,6 +17161,7 @@ class Call {
|
|
|
16966
17161
|
* @internal
|
|
16967
17162
|
*/
|
|
16968
17163
|
reconnectMigrate = async () => {
|
|
17164
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
16969
17165
|
const reconnectStartTime = Date.now();
|
|
16970
17166
|
const currentSfuClient = this.sfuClient;
|
|
16971
17167
|
if (!currentSfuClient) {
|
|
@@ -16979,26 +17175,32 @@ class Call {
|
|
|
16979
17175
|
currentPublisher?.detachEventHandlers();
|
|
16980
17176
|
const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
|
|
16981
17177
|
try {
|
|
16982
|
-
|
|
16983
|
-
|
|
16984
|
-
|
|
16985
|
-
|
|
16986
|
-
|
|
16987
|
-
|
|
16988
|
-
|
|
16989
|
-
|
|
16990
|
-
|
|
16991
|
-
|
|
16992
|
-
|
|
16993
|
-
|
|
16994
|
-
|
|
16995
|
-
|
|
16996
|
-
|
|
16997
|
-
|
|
17178
|
+
try {
|
|
17179
|
+
const currentSfu = currentSfuClient.edgeName;
|
|
17180
|
+
await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'migration' }, () => this.doJoin({
|
|
17181
|
+
...this.joinCallData,
|
|
17182
|
+
migrating_from: currentSfu,
|
|
17183
|
+
migrating_from_list: [currentSfu],
|
|
17184
|
+
}));
|
|
17185
|
+
}
|
|
17186
|
+
finally {
|
|
17187
|
+
// cleanup the migration_from field after the migration is complete or failed
|
|
17188
|
+
// as we don't want to keep dirty data in the join call data
|
|
17189
|
+
delete this.joinCallData?.migrating_from;
|
|
17190
|
+
delete this.joinCallData?.migrating_from_list;
|
|
17191
|
+
}
|
|
17192
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration)
|
|
17193
|
+
return;
|
|
17194
|
+
await this.restorePublishedTracks();
|
|
17195
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration)
|
|
17196
|
+
return;
|
|
17197
|
+
this.restoreSubscribedTracks();
|
|
16998
17198
|
// Wait for the migration to complete, then close the previous SFU client
|
|
16999
17199
|
// and the peer connection instances. In case of failure, the migration
|
|
17000
17200
|
// task would throw an error and REJOIN would be attempted.
|
|
17001
17201
|
await migrationTask();
|
|
17202
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration)
|
|
17203
|
+
return;
|
|
17002
17204
|
// in MIGRATE, we can consider the call as joined only after
|
|
17003
17205
|
// `participantMigrationComplete` event is received, signaled by
|
|
17004
17206
|
// the `migrationTask`
|
|
@@ -17283,10 +17485,11 @@ class Call {
|
|
|
17283
17485
|
* Must be called before {@link join} so the RTCPeerConnection can be
|
|
17284
17486
|
* configured for E2EE.
|
|
17285
17487
|
*
|
|
17286
|
-
* The
|
|
17287
|
-
*
|
|
17288
|
-
*
|
|
17289
|
-
*
|
|
17488
|
+
* The application owns the manager's lifetime - the SDK never disposes it, and
|
|
17489
|
+
* closing the peer connections does not release it. Discard the manager
|
|
17490
|
+
* together with this call instance once its flow has ended, and create a fresh
|
|
17491
|
+
* pair for a later flow: a disposed manager throws from `encrypt`/`decrypt`
|
|
17492
|
+
* rather than silently publishing nothing.
|
|
17290
17493
|
*
|
|
17291
17494
|
* @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
|
|
17292
17495
|
* built-in AES-GCM scheme, or pass your own implementation.
|
|
@@ -19453,11 +19656,11 @@ class StreamClient {
|
|
|
19453
19656
|
return await this.wsConnection.connect(this.defaultWSTimeout);
|
|
19454
19657
|
};
|
|
19455
19658
|
getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
|
|
19456
|
-
"1.
|
|
19659
|
+
"1.61.1";
|
|
19457
19660
|
getUserAgent = () => {
|
|
19458
19661
|
if (!this.cachedUserAgent) {
|
|
19459
19662
|
const { clientAppIdentifier = {} } = this.options;
|
|
19460
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
19663
|
+
const { sdkName = 'js', sdkVersion = "1.61.1", ...extras } = clientAppIdentifier;
|
|
19461
19664
|
this.cachedUserAgent = [
|
|
19462
19665
|
`stream-video-${sdkName}-v${sdkVersion}`,
|
|
19463
19666
|
...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
|
|
@@ -19992,7 +20195,11 @@ class ClientEventReporter {
|
|
|
19992
20195
|
return;
|
|
19993
20196
|
}
|
|
19994
20197
|
if (event.state === 'failed') {
|
|
19995
|
-
|
|
20198
|
+
const iceConnected = event.iceConnectionState === 'connected' ||
|
|
20199
|
+
event.iceConnectionState === 'completed';
|
|
20200
|
+
this.emitPeerConnectionFailure(cid, role, iceConnected ? 'DTLS_CONNECTIVITY_FAILED' : 'ICE_CONNECTIVITY_FAILED', iceConnected
|
|
20201
|
+
? 'DTLS connectivity checks failed'
|
|
20202
|
+
: 'ICE connectivity checks failed');
|
|
19996
20203
|
return;
|
|
19997
20204
|
}
|
|
19998
20205
|
switch (event.state) {
|
|
@@ -21166,9 +21373,9 @@ class EncryptionManager extends TypedEventEmitter {
|
|
|
21166
21373
|
}
|
|
21167
21374
|
if ((this.piped ??= new WeakSet()).has(target))
|
|
21168
21375
|
return;
|
|
21169
|
-
this.piped.add(target);
|
|
21170
21376
|
// @ts-expect-error createEncodedStreams is not in the standard typedefs
|
|
21171
21377
|
const { readable, writable } = target.createEncodedStreams();
|
|
21378
|
+
this.piped.add(target);
|
|
21172
21379
|
this.worker.postMessage({ type: 'cmd.setup_transform', ...options, readable, writable }, [readable, writable]);
|
|
21173
21380
|
};
|
|
21174
21381
|
/**
|
|
@@ -21336,6 +21543,7 @@ exports.TranscriptionSettingsResponseClosedCaptionModeEnum = TranscriptionSettin
|
|
|
21336
21543
|
exports.TranscriptionSettingsResponseLanguageEnum = TranscriptionSettingsResponseLanguageEnum;
|
|
21337
21544
|
exports.TranscriptionSettingsResponseModeEnum = TranscriptionSettingsResponseModeEnum;
|
|
21338
21545
|
exports.TranslationSettingsLanguagesEnum = TranslationSettingsLanguagesEnum;
|
|
21546
|
+
exports.TypedEventEmitter = TypedEventEmitter;
|
|
21339
21547
|
exports.UpdateUserPermissionsRequestGrantPermissionsEnum = UpdateUserPermissionsRequestGrantPermissionsEnum;
|
|
21340
21548
|
exports.UpdateUserPermissionsRequestRevokePermissionsEnum = UpdateUserPermissionsRequestRevokePermissionsEnum;
|
|
21341
21549
|
exports.VideoSettingsRequestCameraFacingEnum = VideoSettingsRequestCameraFacingEnum;
|