@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/src/Call.d.ts
CHANGED
|
@@ -301,6 +301,10 @@ export declare class Call {
|
|
|
301
301
|
/**
|
|
302
302
|
* Will start to watch for call related WebSocket events and initiate a call session with the server.
|
|
303
303
|
*
|
|
304
|
+
* One instance is one call flow: discard it after leaving, cancelling, or a
|
|
305
|
+
* failed join, and create a fresh one for a later flow. Reconnection inside a
|
|
306
|
+
* live call is handled here and needs no new instance.
|
|
307
|
+
*
|
|
304
308
|
* @returns a promise which resolves once the call join-flow has finished.
|
|
305
309
|
*/
|
|
306
310
|
join: (args_0?: (JoinCallData & {
|
|
@@ -355,8 +359,9 @@ export declare class Call {
|
|
|
355
359
|
*
|
|
356
360
|
* @internal
|
|
357
361
|
* @param data the join call data.
|
|
362
|
+
* @returns The coordinator response, or undefined if leave superseded the request before it was sent.
|
|
358
363
|
*/
|
|
359
|
-
doJoinRequest: (data?: JoinCallData) => Promise<JoinCallResponse>;
|
|
364
|
+
doJoinRequest: (data?: JoinCallData) => Promise<JoinCallResponse | undefined>;
|
|
360
365
|
/**
|
|
361
366
|
* Whether the per-call media engine currently exists. True from join until leave.
|
|
362
367
|
*
|
|
@@ -484,10 +489,11 @@ export declare class Call {
|
|
|
484
489
|
* Must be called before {@link join} so the RTCPeerConnection can be
|
|
485
490
|
* configured for E2EE.
|
|
486
491
|
*
|
|
487
|
-
* The
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
492
|
+
* The application owns the manager's lifetime - the SDK never disposes it, and
|
|
493
|
+
* closing the peer connections does not release it. Discard the manager
|
|
494
|
+
* together with this call instance once its flow has ended, and create a fresh
|
|
495
|
+
* pair for a later flow: a disposed manager throws from `encrypt`/`decrypt`
|
|
496
|
+
* rather than silently publishing nothing.
|
|
491
497
|
*
|
|
492
498
|
* @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
|
|
493
499
|
* built-in AES-GCM scheme, or pass your own implementation.
|
|
@@ -37,6 +37,24 @@ export declare class Publisher extends BasePeerConnection {
|
|
|
37
37
|
* Adds a new transceiver carrying the given track to the peer connection.
|
|
38
38
|
*/
|
|
39
39
|
private addTransceiver;
|
|
40
|
+
/**
|
|
41
|
+
* Attaches the encryptor, applies sender parameters, then attaches the track.
|
|
42
|
+
*
|
|
43
|
+
* `encrypt` must run before the first await: Chrome short-circuits a sender
|
|
44
|
+
* whose encoded streams aren't created in the task that created it, and the
|
|
45
|
+
* track goes on last so the sender has no media until both steps succeed.
|
|
46
|
+
*/
|
|
47
|
+
private initEncryptedSender;
|
|
48
|
+
private setDegradationPreference;
|
|
49
|
+
/**
|
|
50
|
+
* Discards a sender whose E2EE initialization failed.
|
|
51
|
+
*
|
|
52
|
+
* A partially initialized sender may carry no transform, so publish() must
|
|
53
|
+
* not reuse it: retiring it from the cache is what keeps a later publish or
|
|
54
|
+
* ICE restart from putting cleartext on the wire. Cleanup cannot throw, so
|
|
55
|
+
* the initialization failure is what reaches the caller.
|
|
56
|
+
*/
|
|
57
|
+
private retireTransceiver;
|
|
40
58
|
/**
|
|
41
59
|
* Updates the transceiver with the given track and track type.
|
|
42
60
|
*/
|
|
@@ -14,6 +14,16 @@ export declare class TransceiverCache {
|
|
|
14
14
|
* Adds a transceiver to the cache.
|
|
15
15
|
*/
|
|
16
16
|
add: (bundle: PublishBundle) => void;
|
|
17
|
+
/**
|
|
18
|
+
* Retires a failed sender from the reusable cache.
|
|
19
|
+
*
|
|
20
|
+
* A transceiver that never reached an m-section also leaves the creation
|
|
21
|
+
* history: once stopped, it is left out of every later offer, so counting
|
|
22
|
+
* it would shift the `mid` index of every transceiver added after it. One
|
|
23
|
+
* that is already associated keeps its slot, since its m-section survives
|
|
24
|
+
* as a rejected one.
|
|
25
|
+
*/
|
|
26
|
+
remove: (transceiver: RTCRtpTransceiver) => void;
|
|
17
27
|
/**
|
|
18
28
|
* Gets the transceiver for the given publish option.
|
|
19
29
|
*/
|
|
@@ -5,7 +5,16 @@
|
|
|
5
5
|
* attaching their own encoded transform in these two methods.
|
|
6
6
|
*/
|
|
7
7
|
export interface E2EEManager {
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Synchronously attach a transform that encrypts the sender's outgoing frames.
|
|
10
|
+
* Must support a sender without a track, and throw if attachment fails.
|
|
11
|
+
*
|
|
12
|
+
* `sender.track` is always null here: the track is attached only after this
|
|
13
|
+
* returns, so implementations must not read it. Use `codec` and `trackType`
|
|
14
|
+
* instead.
|
|
15
|
+
*
|
|
16
|
+
* `trackType` only groups perf stats, keeping a camera and screen share apart.
|
|
17
|
+
*/
|
|
9
18
|
encrypt(sender: RTCRtpSender, codec?: string, trackType?: string): void;
|
|
10
19
|
/** `trackType` only groups perf stats, keeping a peer's audio and video apart. */
|
|
11
20
|
decrypt(receiver: RTCRtpReceiver, userId: string, trackType?: string): void;
|
package/dist/src/types.d.ts
CHANGED
|
@@ -375,14 +375,43 @@ type StreamRNVideoSDKEndCallReason =
|
|
|
375
375
|
/** Unknown or unspecified disconnect reason. */
|
|
376
376
|
| 'unknown';
|
|
377
377
|
type StreamRNVideoSDKCallingX = {
|
|
378
|
-
|
|
378
|
+
/**
|
|
379
|
+
* @param isCancelled - polled around the bridge's waits: registration is
|
|
380
|
+
* skipped, or undone, when it returns true. Supplied by the join attempt
|
|
381
|
+
* because the call's own state cannot distinguish a fresh join starting
|
|
382
|
+
* from `LEFT` from an abandoned one that reached `LEFT` while waiting.
|
|
383
|
+
*/
|
|
384
|
+
joinCall: (call: Call, activeCalls: Call[], isCancelled?: () => boolean) => Promise<void>;
|
|
379
385
|
endCall: (call: Call, reason?: StreamRNVideoSDKEndCallReason) => Promise<void>;
|
|
380
386
|
registerOutgoingCall: (call: Call) => Promise<void>;
|
|
381
387
|
wireAudioEngineSubscription: () => void;
|
|
382
388
|
unwireAudioEngineSubscription: () => void;
|
|
383
389
|
};
|
|
390
|
+
/**
|
|
391
|
+
* React Native's preparation and cleanup for a ringing call's join.
|
|
392
|
+
*
|
|
393
|
+
* A ringing call is joined by the SDK rather than by app code, so there is no
|
|
394
|
+
* point at which the app holds the call and can still set it up. These are that
|
|
395
|
+
* point, and the matching release.
|
|
396
|
+
*/
|
|
397
|
+
type StreamRNVideoSDKRingingCallLifecycle = {
|
|
398
|
+
/**
|
|
399
|
+
* Runs the app's pre-join setup and resolves once it is done, bounded by a
|
|
400
|
+
* deadline. Rejecting fails the join closed rather than joining with nothing
|
|
401
|
+
* installed.
|
|
402
|
+
*/
|
|
403
|
+
beforeJoin: (call: Call) => Promise<void>;
|
|
404
|
+
/**
|
|
405
|
+
* The join failed terminally. Ends the ringing flow and releases what the
|
|
406
|
+
* setup installed. Never rejects, so the original join error survives.
|
|
407
|
+
*/
|
|
408
|
+
onJoinFailed: (call: Call) => Promise<void>;
|
|
409
|
+
/** The call has ended; release whatever the pre-join hook installed. */
|
|
410
|
+
onLeave: (call: Call) => void;
|
|
411
|
+
};
|
|
384
412
|
export type StreamRNVideoSDKGlobals = {
|
|
385
413
|
callingX: StreamRNVideoSDKCallingX;
|
|
414
|
+
ringingCallLifecycle: StreamRNVideoSDKRingingCallLifecycle;
|
|
386
415
|
callManager: {
|
|
387
416
|
/**
|
|
388
417
|
* Sets up the in call manager.
|
package/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ export * from './src/helpers/sound-detector';
|
|
|
26
26
|
export * from './src/helpers/loopback';
|
|
27
27
|
export * from './src/helpers/MediaStreamRecorder';
|
|
28
28
|
export * from './src/helpers/participantUtils';
|
|
29
|
+
export * from './src/helpers/TypedEventEmitter';
|
|
29
30
|
export * from './src/rtc/e2ee/E2EEManager';
|
|
30
31
|
export * from './src/rtc/e2ee/EncryptionManager';
|
|
31
32
|
export * as Browsers from './src/helpers/browsers';
|
package/package.json
CHANGED
package/src/Call.ts
CHANGED
|
@@ -794,6 +794,11 @@ export class Call {
|
|
|
794
794
|
await this.dynascaleManager?.dispose();
|
|
795
795
|
|
|
796
796
|
this.state.setCallingState(CallingState.LEFT);
|
|
797
|
+
// `ringingSubject` is cleared further down, so this still reads true for
|
|
798
|
+
// a call that was ringing.
|
|
799
|
+
if (this.ringing) {
|
|
800
|
+
globalThis.streamRNVideoSDK?.ringingCallLifecycle?.onLeave(this);
|
|
801
|
+
}
|
|
797
802
|
this.state.setParticipants([]);
|
|
798
803
|
this.state.dispose();
|
|
799
804
|
|
|
@@ -983,12 +988,14 @@ export class Call {
|
|
|
983
988
|
members_limit?: number;
|
|
984
989
|
video?: boolean;
|
|
985
990
|
}): Promise<GetCallResponse> => {
|
|
991
|
+
const getLeaveGeneration = this.leaveGeneration;
|
|
986
992
|
await this.setup();
|
|
987
993
|
|
|
988
994
|
const response = await this.streamClient.get<GetCallResponse>(
|
|
989
995
|
this.streamClientBasePath,
|
|
990
996
|
params,
|
|
991
997
|
);
|
|
998
|
+
if (this.leaveGeneration !== getLeaveGeneration) return response;
|
|
992
999
|
|
|
993
1000
|
this.updateFromCallStateResponse(response);
|
|
994
1001
|
|
|
@@ -1135,6 +1142,10 @@ export class Call {
|
|
|
1135
1142
|
/**
|
|
1136
1143
|
* Will start to watch for call related WebSocket events and initiate a call session with the server.
|
|
1137
1144
|
*
|
|
1145
|
+
* One instance is one call flow: discard it after leaving, cancelling, or a
|
|
1146
|
+
* failed join, and create a fresh one for a later flow. Reconnection inside a
|
|
1147
|
+
* live call is handled here and needs no new instance.
|
|
1148
|
+
*
|
|
1138
1149
|
* @returns a promise which resolves once the call join-flow has finished.
|
|
1139
1150
|
*/
|
|
1140
1151
|
join = singleFlight(
|
|
@@ -1154,10 +1165,19 @@ export class Call {
|
|
|
1154
1165
|
} = {}): Promise<void> => {
|
|
1155
1166
|
const callingState = this.state.callingState;
|
|
1156
1167
|
|
|
1168
|
+
// Ahead of the failure boundary below on purpose: a duplicate join on a
|
|
1169
|
+
// live call is refused without tearing that call down.
|
|
1157
1170
|
if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
|
|
1158
1171
|
throw new Error(`Illegal State: call.join() shall be called only once`);
|
|
1159
1172
|
}
|
|
1160
1173
|
|
|
1174
|
+
// snapshot before the first await: a leave() landing at any point from
|
|
1175
|
+
// here on supersedes this join, and the retry loop must bail out instead
|
|
1176
|
+
// of resurrecting a call that leave() already tore down.
|
|
1177
|
+
const joinLeaveGeneration = this.leaveGeneration;
|
|
1178
|
+
const supersededByLeave = () =>
|
|
1179
|
+
this.leaveGeneration !== joinLeaveGeneration;
|
|
1180
|
+
|
|
1161
1181
|
// we need this to be set before the callingx.joinCall() is
|
|
1162
1182
|
// called to avoid registering the test call in the CallKit/Telecom
|
|
1163
1183
|
this.allowOwnTracksLoopback = allowOwnTracksLoopback;
|
|
@@ -1166,35 +1186,67 @@ export class Call {
|
|
|
1166
1186
|
this.ringingSubject.next(true);
|
|
1167
1187
|
}
|
|
1168
1188
|
|
|
1189
|
+
// A ringing call is joined by the SDK rather than by app code, so React
|
|
1190
|
+
// Native prepares it from in here - there is no earlier point at which the
|
|
1191
|
+
// app holds the call. Read after `data.ring` above, which is what makes an
|
|
1192
|
+
// outgoing call ringing in the first place.
|
|
1193
|
+
const ringingLifecycle = this.ringing
|
|
1194
|
+
? globalThis.streamRNVideoSDK?.ringingCallLifecycle
|
|
1195
|
+
: undefined;
|
|
1169
1196
|
const callingX = globalThis.streamRNVideoSDK?.callingX;
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1197
|
+
try {
|
|
1198
|
+
if (ringingLifecycle) {
|
|
1199
|
+
await ringingLifecycle.beforeJoin(this);
|
|
1200
|
+
if (supersededByLeave()) {
|
|
1201
|
+
this.logger.debug('Join superseded by leave; not joining');
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1174
1205
|
|
|
1175
|
-
|
|
1206
|
+
if (callingX) {
|
|
1207
|
+
// for Android/iOS, we need to start the call in the callingx library as soon as possible
|
|
1208
|
+
await callingX.joinCall(
|
|
1209
|
+
this,
|
|
1210
|
+
this.clientStore.calls,
|
|
1211
|
+
supersededByLeave,
|
|
1212
|
+
);
|
|
1213
|
+
if (supersededByLeave()) {
|
|
1214
|
+
this.logger.debug('Join superseded by leave; not setting up');
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1176
1218
|
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
});
|
|
1219
|
+
await this.setup();
|
|
1220
|
+
if (supersededByLeave()) {
|
|
1221
|
+
this.logger.debug('Join superseded by leave; not registering');
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1183
1224
|
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1225
|
+
this.clientEventReporter.registerCall(this.cid, {
|
|
1226
|
+
callType: this.type,
|
|
1227
|
+
callId: this.id,
|
|
1228
|
+
getCallSessionId: () => this.state.session?.id ?? '',
|
|
1229
|
+
getSfuId: () => this.credentials?.server.edge_name ?? '',
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
this.joinResponseTimeout = joinResponseTimeout;
|
|
1233
|
+
this.rpcRequestTimeout = rpcRequestTimeout;
|
|
1234
|
+
// we will count the number of join failures per SFU.
|
|
1235
|
+
// once the number of failures reaches 2, we will piggyback on the `migrating_from`
|
|
1236
|
+
// field to force the coordinator to provide us another SFU
|
|
1237
|
+
const sfuJoinFailures = new Map<string, number>();
|
|
1238
|
+
const joinData: JoinCallData = data;
|
|
1239
|
+
maxJoinRetries = Math.max(maxJoinRetries, 1);
|
|
1193
1240
|
await this.clientEventReporter.withJoinLifecycle(
|
|
1194
1241
|
this.cid,
|
|
1195
1242
|
{ joinReason: 'first-attempt', joinSource },
|
|
1196
1243
|
async () => {
|
|
1197
1244
|
for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
|
|
1245
|
+
// Also covers a leave that lands during the backoff below.
|
|
1246
|
+
if (supersededByLeave()) {
|
|
1247
|
+
this.logger.debug('Join superseded by leave; not attempting');
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1198
1250
|
try {
|
|
1199
1251
|
this.logger.trace(`Joining call (${attempt})`, this.cid);
|
|
1200
1252
|
await this.doJoin(data);
|
|
@@ -1202,6 +1254,10 @@ export class Call {
|
|
|
1202
1254
|
delete joinData.migrating_from_list;
|
|
1203
1255
|
return;
|
|
1204
1256
|
} catch (err) {
|
|
1257
|
+
if (supersededByLeave()) {
|
|
1258
|
+
this.logger.debug('Join superseded by leave; not retrying');
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1205
1261
|
this.logger.warn(`Failed to join call (${attempt})`, this.cid);
|
|
1206
1262
|
if (
|
|
1207
1263
|
(err instanceof ErrorFromResponse && err.unrecoverable) ||
|
|
@@ -1242,6 +1298,9 @@ export class Call {
|
|
|
1242
1298
|
);
|
|
1243
1299
|
} catch (error) {
|
|
1244
1300
|
callingX?.endCall(this, 'error');
|
|
1301
|
+
// Ends the failed ringing flow and releases what its setup installed.
|
|
1302
|
+
// Never rejects, so `error` is what the caller sees.
|
|
1303
|
+
await ringingLifecycle?.onJoinFailed(this);
|
|
1245
1304
|
throw error;
|
|
1246
1305
|
}
|
|
1247
1306
|
},
|
|
@@ -1270,6 +1329,10 @@ export class Call {
|
|
|
1270
1329
|
// globals resolve to the call's factory. Idempotent across
|
|
1271
1330
|
// reconnect/migration attempts.
|
|
1272
1331
|
await this.ensureMediaFactory();
|
|
1332
|
+
if (supersededByLeave()) {
|
|
1333
|
+
this.logger.debug('Join superseded by leave; not wiring media');
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1273
1336
|
|
|
1274
1337
|
const callingX = globalThis.streamRNVideoSDK?.callingX;
|
|
1275
1338
|
if (callingX) {
|
|
@@ -1297,10 +1360,17 @@ export class Call {
|
|
|
1297
1360
|
'CoordinatorJoin',
|
|
1298
1361
|
() => this.doJoinRequest(data),
|
|
1299
1362
|
);
|
|
1363
|
+
if (!joinResponse || supersededByLeave()) {
|
|
1364
|
+
this.logger.debug(
|
|
1365
|
+
'Join superseded by leave; not creating SFU client',
|
|
1366
|
+
);
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1300
1369
|
this.credentials = joinResponse.credentials;
|
|
1301
1370
|
statsOptions = joinResponse.stats_options;
|
|
1302
1371
|
this.lastStatsOptions = statsOptions;
|
|
1303
1372
|
} catch (error) {
|
|
1373
|
+
if (supersededByLeave()) return;
|
|
1304
1374
|
// prevent triggering reconnect flow if the state is OFFLINE
|
|
1305
1375
|
const avoidRestoreState =
|
|
1306
1376
|
this.state.callingState === CallingState.OFFLINE;
|
|
@@ -1355,6 +1425,10 @@ export class Call {
|
|
|
1355
1425
|
getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
|
|
1356
1426
|
getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
|
|
1357
1427
|
]);
|
|
1428
|
+
if (supersededByLeave()) {
|
|
1429
|
+
this.logger.debug('Join superseded by leave; not joining SFU');
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1358
1432
|
const isReconnecting =
|
|
1359
1433
|
this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
|
|
1360
1434
|
const reconnectDetails = isReconnecting
|
|
@@ -1388,6 +1462,12 @@ export class Call {
|
|
|
1388
1462
|
source: ParticipantSource.WEBRTC_UNSPECIFIED,
|
|
1389
1463
|
}),
|
|
1390
1464
|
);
|
|
1465
|
+
if (supersededByLeave()) {
|
|
1466
|
+
this.logger.debug(
|
|
1467
|
+
'Join superseded by leave; not applying SFU response',
|
|
1468
|
+
);
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1391
1471
|
|
|
1392
1472
|
this.currentPublishOptions = publishOptions;
|
|
1393
1473
|
this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
|
|
@@ -1399,6 +1479,10 @@ export class Call {
|
|
|
1399
1479
|
);
|
|
1400
1480
|
}
|
|
1401
1481
|
} catch (error) {
|
|
1482
|
+
if (supersededByLeave()) {
|
|
1483
|
+
this.logger.debug('Join superseded by leave; ignoring SFU failure');
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1402
1486
|
this.logger.warn('Join SFU request failed', error);
|
|
1403
1487
|
sfuClient.close(
|
|
1404
1488
|
StreamSfuClient.JOIN_FAILED,
|
|
@@ -1410,8 +1494,8 @@ export class Call {
|
|
|
1410
1494
|
}
|
|
1411
1495
|
}
|
|
1412
1496
|
|
|
1413
|
-
// If the user left while this join was in flight, bail before re-setting JOINED
|
|
1414
|
-
// peer-connection setup below
|
|
1497
|
+
// If the user left while this join was in flight, bail before re-setting JOINED
|
|
1498
|
+
// or starting peer-connection setup below.
|
|
1415
1499
|
if (supersededByLeave()) {
|
|
1416
1500
|
this.logger.debug('Join superseded by leave; aborting join flow');
|
|
1417
1501
|
return;
|
|
@@ -1441,6 +1525,10 @@ export class Call {
|
|
|
1441
1525
|
unifiedSessionId: this.unifiedSessionId,
|
|
1442
1526
|
});
|
|
1443
1527
|
}
|
|
1528
|
+
if (supersededByLeave()) {
|
|
1529
|
+
this.logger.debug('Join superseded by leave; not completing join flow');
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1444
1532
|
|
|
1445
1533
|
// make sure we only track connection timing if we are not calling this method as part of a reconnection flow
|
|
1446
1534
|
if (!performingRejoin && !performingFastReconnect && !performingMigration) {
|
|
@@ -1626,6 +1714,7 @@ export class Call {
|
|
|
1626
1714
|
closePreviousInstances: boolean;
|
|
1627
1715
|
unifiedSessionId: string;
|
|
1628
1716
|
}) => {
|
|
1717
|
+
const joinLeaveGeneration = this.leaveGeneration;
|
|
1629
1718
|
const {
|
|
1630
1719
|
sfuClient,
|
|
1631
1720
|
connectionConfig,
|
|
@@ -1642,10 +1731,12 @@ export class Call {
|
|
|
1642
1731
|
// Flush the previous reporter's final sample while its peer connections are
|
|
1643
1732
|
// still alive, before we dispose them below. Awaits only the sampling step.
|
|
1644
1733
|
await this.sfuStatsReporter?.flush();
|
|
1734
|
+
if (this.leaveGeneration !== joinLeaveGeneration) return;
|
|
1645
1735
|
this.sfuStatsReporter?.stop();
|
|
1646
1736
|
this.sfuStatsReporter = undefined;
|
|
1647
1737
|
if (closePreviousInstances && this.subscriber) {
|
|
1648
1738
|
await this.subscriber.dispose();
|
|
1739
|
+
if (this.leaveGeneration !== joinLeaveGeneration) return;
|
|
1649
1740
|
this.state.removeAllOrphanedTracks();
|
|
1650
1741
|
}
|
|
1651
1742
|
const basePeerConnectionOptions: BasePeerConnectionOpts = {
|
|
@@ -1692,6 +1783,7 @@ export class Call {
|
|
|
1692
1783
|
if (!isAnonymous) {
|
|
1693
1784
|
if (closePreviousInstances && this.publisher) {
|
|
1694
1785
|
await this.publisher.dispose();
|
|
1786
|
+
if (this.leaveGeneration !== joinLeaveGeneration) return;
|
|
1695
1787
|
}
|
|
1696
1788
|
this.publisher = new Publisher(
|
|
1697
1789
|
basePeerConnectionOptions,
|
|
@@ -1735,9 +1827,19 @@ export class Call {
|
|
|
1735
1827
|
*
|
|
1736
1828
|
* @internal
|
|
1737
1829
|
* @param data the join call data.
|
|
1830
|
+
* @returns The coordinator response, or undefined if leave superseded the request before it was sent.
|
|
1738
1831
|
*/
|
|
1739
|
-
doJoinRequest = async (
|
|
1832
|
+
doJoinRequest = async (
|
|
1833
|
+
data?: JoinCallData,
|
|
1834
|
+
): Promise<JoinCallResponse | undefined> => {
|
|
1835
|
+
const joinLeaveGeneration = this.leaveGeneration;
|
|
1740
1836
|
const location = await this.streamClient.getLocationHint();
|
|
1837
|
+
if (this.leaveGeneration !== joinLeaveGeneration) {
|
|
1838
|
+
this.logger.debug(
|
|
1839
|
+
'Join superseded by leave; not sending coordinator request',
|
|
1840
|
+
);
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1741
1843
|
const e2ee = !!this.e2eeManager;
|
|
1742
1844
|
const request: JoinCallRequest = { ...data, location, e2ee };
|
|
1743
1845
|
const joinResponse = await this.streamClient.post<
|
|
@@ -1745,6 +1847,13 @@ export class Call {
|
|
|
1745
1847
|
JoinCallRequest
|
|
1746
1848
|
>(`${this.streamClientBasePath}/join`, request);
|
|
1747
1849
|
|
|
1850
|
+
if (this.leaveGeneration !== joinLeaveGeneration) {
|
|
1851
|
+
this.logger.debug(
|
|
1852
|
+
'Join superseded by leave; not applying coordinator response',
|
|
1853
|
+
);
|
|
1854
|
+
return joinResponse;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1748
1857
|
this.state.updateFromCallResponse(joinResponse.call);
|
|
1749
1858
|
this.state.setMembers(joinResponse.members);
|
|
1750
1859
|
this.state.setOwnCapabilities(joinResponse.own_capabilities);
|
|
@@ -1759,6 +1868,7 @@ export class Call {
|
|
|
1759
1868
|
if (!isReconnecting && this.ringing && !this.isCreatedByMe) {
|
|
1760
1869
|
// signals other users that I have accepted the incoming call.
|
|
1761
1870
|
await this.accept();
|
|
1871
|
+
if (this.leaveGeneration !== joinLeaveGeneration) return joinResponse;
|
|
1762
1872
|
}
|
|
1763
1873
|
|
|
1764
1874
|
if (this.streamClient._hasConnectionID()) {
|
|
@@ -1863,6 +1973,7 @@ export class Call {
|
|
|
1863
1973
|
callingState === CallingState.JOINING ||
|
|
1864
1974
|
callingState === CallingState.RECONNECTING ||
|
|
1865
1975
|
callingState === CallingState.MIGRATING ||
|
|
1976
|
+
callingState === CallingState.LEFT ||
|
|
1866
1977
|
callingState === CallingState.RECONNECTING_FAILED
|
|
1867
1978
|
)
|
|
1868
1979
|
return;
|
|
@@ -1873,19 +1984,26 @@ export class Call {
|
|
|
1873
1984
|
// `POST /join` per entry) once the call is already healthy again.
|
|
1874
1985
|
if (hasPending(this.reconnectConcurrencyTag)) return;
|
|
1875
1986
|
|
|
1987
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
1988
|
+
const supersededByLeave = () =>
|
|
1989
|
+
this.leaveGeneration !== reconnectLeaveGeneration;
|
|
1876
1990
|
return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
|
|
1991
|
+
if (supersededByLeave()) return;
|
|
1877
1992
|
const reconnectStartTime = Date.now();
|
|
1878
1993
|
this.reconnectStrategy = strategy;
|
|
1879
1994
|
this.reconnectReason = reason;
|
|
1880
1995
|
const sfuRejoinFailures = new Map<string, number>();
|
|
1881
1996
|
|
|
1882
1997
|
const markAsReconnectingFailed = async () => {
|
|
1998
|
+
if (supersededByLeave()) return;
|
|
1883
1999
|
try {
|
|
1884
2000
|
// attempt to fetch the call data from the server, as the call
|
|
1885
2001
|
// state might have changed while we were reconnecting or were offline
|
|
1886
2002
|
await this.get();
|
|
1887
2003
|
} finally {
|
|
1888
|
-
|
|
2004
|
+
if (!supersededByLeave()) {
|
|
2005
|
+
this.state.setCallingState(CallingState.RECONNECTING_FAILED);
|
|
2006
|
+
}
|
|
1889
2007
|
}
|
|
1890
2008
|
};
|
|
1891
2009
|
|
|
@@ -1955,6 +2073,7 @@ export class Call {
|
|
|
1955
2073
|
try {
|
|
1956
2074
|
// wait until the network is available
|
|
1957
2075
|
await this.networkAvailableTask?.promise;
|
|
2076
|
+
if (supersededByLeave()) return;
|
|
1958
2077
|
|
|
1959
2078
|
this.logger.info(
|
|
1960
2079
|
`[Reconnect] Reconnecting with strategy ${
|
|
@@ -2005,6 +2124,7 @@ export class Call {
|
|
|
2005
2124
|
this.consecutiveNegotiationFailures = 0;
|
|
2006
2125
|
break; // do-while loop, reconnection worked, exit the loop
|
|
2007
2126
|
} catch (error) {
|
|
2127
|
+
if (supersededByLeave()) return;
|
|
2008
2128
|
if (attemptedStrategy === WebsocketReconnectStrategy.REJOIN) {
|
|
2009
2129
|
const failedSfu = this.credentials?.server.edge_name;
|
|
2010
2130
|
if (failedSfu) {
|
|
@@ -2048,6 +2168,7 @@ export class Call {
|
|
|
2048
2168
|
|
|
2049
2169
|
// exponential backoff with jitter, capped at 5 s
|
|
2050
2170
|
await sleep(retryInterval(attempt));
|
|
2171
|
+
if (supersededByLeave()) return;
|
|
2051
2172
|
|
|
2052
2173
|
const wasMigrating =
|
|
2053
2174
|
this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
|
|
@@ -2078,6 +2199,7 @@ export class Call {
|
|
|
2078
2199
|
);
|
|
2079
2200
|
}
|
|
2080
2201
|
} while (
|
|
2202
|
+
!supersededByLeave() &&
|
|
2081
2203
|
this.state.callingState !== CallingState.JOINED &&
|
|
2082
2204
|
this.state.callingState !== CallingState.RECONNECTING_FAILED &&
|
|
2083
2205
|
this.state.callingState !== CallingState.LEFT
|
|
@@ -2091,10 +2213,12 @@ export class Call {
|
|
|
2091
2213
|
* @internal
|
|
2092
2214
|
*/
|
|
2093
2215
|
private reconnectFast = async () => {
|
|
2216
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
2094
2217
|
const reconnectStartTime = Date.now();
|
|
2095
2218
|
this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
|
|
2096
2219
|
this.state.setCallingState(CallingState.RECONNECTING);
|
|
2097
2220
|
await this.doJoin(this.joinCallData);
|
|
2221
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration) return;
|
|
2098
2222
|
await this.get(); // fetch the latest call state, as it might have changed
|
|
2099
2223
|
this.sfuStatsReporter?.sendReconnectionTime(
|
|
2100
2224
|
WebsocketReconnectStrategy.FAST,
|
|
@@ -2107,6 +2231,7 @@ export class Call {
|
|
|
2107
2231
|
* @internal
|
|
2108
2232
|
*/
|
|
2109
2233
|
private reconnectRejoin = async () => {
|
|
2234
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
2110
2235
|
const reconnectStartTime = Date.now();
|
|
2111
2236
|
this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
|
|
2112
2237
|
this.state.setCallingState(CallingState.RECONNECTING);
|
|
@@ -2119,7 +2244,9 @@ export class Call {
|
|
|
2119
2244
|
{ joinReason },
|
|
2120
2245
|
() => this.doJoin(this.joinCallData),
|
|
2121
2246
|
);
|
|
2247
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration) return;
|
|
2122
2248
|
await this.restorePublishedTracks();
|
|
2249
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration) return;
|
|
2123
2250
|
this.restoreSubscribedTracks();
|
|
2124
2251
|
this.sfuStatsReporter?.sendReconnectionTime(
|
|
2125
2252
|
WebsocketReconnectStrategy.REJOIN,
|
|
@@ -2132,6 +2259,7 @@ export class Call {
|
|
|
2132
2259
|
* @internal
|
|
2133
2260
|
*/
|
|
2134
2261
|
private reconnectMigrate = async () => {
|
|
2262
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
2135
2263
|
const reconnectStartTime = Date.now();
|
|
2136
2264
|
const currentSfuClient = this.sfuClient;
|
|
2137
2265
|
if (!currentSfuClient) {
|
|
@@ -2149,32 +2277,35 @@ export class Call {
|
|
|
2149
2277
|
const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
|
|
2150
2278
|
|
|
2151
2279
|
try {
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
this.
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2280
|
+
try {
|
|
2281
|
+
const currentSfu = currentSfuClient.edgeName;
|
|
2282
|
+
await this.clientEventReporter.withJoinLifecycle(
|
|
2283
|
+
this.cid,
|
|
2284
|
+
{ joinReason: 'migration' },
|
|
2285
|
+
() =>
|
|
2286
|
+
this.doJoin({
|
|
2287
|
+
...this.joinCallData,
|
|
2288
|
+
migrating_from: currentSfu,
|
|
2289
|
+
migrating_from_list: [currentSfu],
|
|
2290
|
+
}),
|
|
2291
|
+
);
|
|
2292
|
+
} finally {
|
|
2293
|
+
// cleanup the migration_from field after the migration is complete or failed
|
|
2294
|
+
// as we don't want to keep dirty data in the join call data
|
|
2295
|
+
delete this.joinCallData?.migrating_from;
|
|
2296
|
+
delete this.joinCallData?.migrating_from_list;
|
|
2297
|
+
}
|
|
2169
2298
|
|
|
2170
|
-
|
|
2171
|
-
|
|
2299
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration) return;
|
|
2300
|
+
await this.restorePublishedTracks();
|
|
2301
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration) return;
|
|
2302
|
+
this.restoreSubscribedTracks();
|
|
2172
2303
|
|
|
2173
|
-
try {
|
|
2174
2304
|
// Wait for the migration to complete, then close the previous SFU client
|
|
2175
2305
|
// and the peer connection instances. In case of failure, the migration
|
|
2176
2306
|
// task would throw an error and REJOIN would be attempted.
|
|
2177
2307
|
await migrationTask();
|
|
2308
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration) return;
|
|
2178
2309
|
|
|
2179
2310
|
// in MIGRATE, we can consider the call as joined only after
|
|
2180
2311
|
// `participantMigrationComplete` event is received, signaled by
|
|
@@ -2508,10 +2639,11 @@ export class Call {
|
|
|
2508
2639
|
* Must be called before {@link join} so the RTCPeerConnection can be
|
|
2509
2640
|
* configured for E2EE.
|
|
2510
2641
|
*
|
|
2511
|
-
* The
|
|
2512
|
-
*
|
|
2513
|
-
*
|
|
2514
|
-
*
|
|
2642
|
+
* The application owns the manager's lifetime - the SDK never disposes it, and
|
|
2643
|
+
* closing the peer connections does not release it. Discard the manager
|
|
2644
|
+
* together with this call instance once its flow has ended, and create a fresh
|
|
2645
|
+
* pair for a later flow: a disposed manager throws from `encrypt`/`decrypt`
|
|
2646
|
+
* rather than silently publishing nothing.
|
|
2515
2647
|
*
|
|
2516
2648
|
* @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
|
|
2517
2649
|
* built-in AES-GCM scheme, or pass your own implementation.
|