@stream-io/video-client 1.47.0 → 1.49.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 +20 -0
- package/dist/index.browser.es.js +383 -238
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +382 -238
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.es.js +383 -238
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +35 -1
- package/dist/src/StreamSfuClient.d.ts +8 -1
- package/dist/src/devices/DeviceManagerState.d.ts +13 -0
- package/dist/src/devices/MicrophoneManager.d.ts +0 -1
- package/dist/src/helpers/SlidingWindowRateLimiter.d.ts +28 -0
- package/dist/src/rtc/BasePeerConnection.d.ts +11 -2
- package/dist/src/rtc/index.d.ts +1 -0
- package/dist/src/rtc/types.d.ts +33 -1
- package/dist/src/types.d.ts +11 -0
- package/index.ts +0 -1
- package/package.json +1 -1
- package/src/Call.ts +179 -18
- package/src/StreamSfuClient.ts +75 -12
- package/src/__tests__/Call.publishing.test.ts +103 -0
- package/src/__tests__/StreamSfuClient.test.ts +275 -0
- package/src/devices/DeviceManagerState.ts +20 -0
- package/src/devices/MicrophoneManager.ts +9 -5
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +28 -29
- package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
- package/src/devices/devices.ts +2 -1
- package/src/helpers/SlidingWindowRateLimiter.ts +49 -0
- package/src/helpers/__tests__/SlidingWindowRateLimiter.test.ts +43 -0
- package/src/rpc/retryable.ts +0 -1
- package/src/rtc/BasePeerConnection.ts +96 -6
- package/src/rtc/Publisher.ts +2 -1
- package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
- package/src/rtc/__tests__/Publisher.test.ts +210 -0
- package/src/rtc/__tests__/Subscriber.test.ts +56 -0
- package/src/rtc/index.ts +1 -0
- package/src/rtc/types.ts +38 -1
- package/src/types.ts +9 -0
- package/dist/src/helpers/RNSpeechDetector.d.ts +0 -23
- package/src/helpers/RNSpeechDetector.ts +0 -224
- package/src/helpers/__tests__/RNSpeechDetector.test.ts +0 -52
package/dist/index.cjs.js
CHANGED
|
@@ -4026,8 +4026,6 @@ const retryable = async (rpc, signal, maxRetries = Number.POSITIVE_INFINITY) =>
|
|
|
4026
4026
|
do {
|
|
4027
4027
|
if (attempt > 0)
|
|
4028
4028
|
await sleep(retryInterval(attempt));
|
|
4029
|
-
if (signal?.aborted)
|
|
4030
|
-
throw new Error(signal.reason);
|
|
4031
4029
|
try {
|
|
4032
4030
|
result = await rpc({ attempt });
|
|
4033
4031
|
}
|
|
@@ -4425,6 +4423,21 @@ class Dispatcher {
|
|
|
4425
4423
|
}
|
|
4426
4424
|
}
|
|
4427
4425
|
|
|
4426
|
+
/**
|
|
4427
|
+
* NegotiationError is thrown when there is an error during the negotiation process.
|
|
4428
|
+
* It extends the built-in Error class and includes an SfuError object for more details.
|
|
4429
|
+
*/
|
|
4430
|
+
class NegotiationError extends Error {
|
|
4431
|
+
/**
|
|
4432
|
+
* Creates an instance of NegotiationError.
|
|
4433
|
+
*/
|
|
4434
|
+
constructor(error) {
|
|
4435
|
+
super(error.message);
|
|
4436
|
+
this.name = 'NegotiationError';
|
|
4437
|
+
this.error = error;
|
|
4438
|
+
}
|
|
4439
|
+
}
|
|
4440
|
+
|
|
4428
4441
|
/**
|
|
4429
4442
|
* A buffer for ICE Candidates. Used for ICE Trickle:
|
|
4430
4443
|
* - https://bloggeek.me/webrtcglossary/trickle-ice/
|
|
@@ -6242,21 +6255,6 @@ class CallState {
|
|
|
6242
6255
|
}
|
|
6243
6256
|
}
|
|
6244
6257
|
|
|
6245
|
-
/**
|
|
6246
|
-
* NegotiationError is thrown when there is an error during the negotiation process.
|
|
6247
|
-
* It extends the built-in Error class and includes an SfuError object for more details.
|
|
6248
|
-
*/
|
|
6249
|
-
class NegotiationError extends Error {
|
|
6250
|
-
/**
|
|
6251
|
-
* Creates an instance of NegotiationError.
|
|
6252
|
-
*/
|
|
6253
|
-
constructor(error) {
|
|
6254
|
-
super(error.message);
|
|
6255
|
-
this.name = 'NegotiationError';
|
|
6256
|
-
this.error = error;
|
|
6257
|
-
}
|
|
6258
|
-
}
|
|
6259
|
-
|
|
6260
6258
|
/**
|
|
6261
6259
|
* Flatten the stats report into an array of stats objects.
|
|
6262
6260
|
*
|
|
@@ -6304,7 +6302,7 @@ const getSdkVersion = (sdk) => {
|
|
|
6304
6302
|
return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
|
|
6305
6303
|
};
|
|
6306
6304
|
|
|
6307
|
-
const version = "1.
|
|
6305
|
+
const version = "1.49.0";
|
|
6308
6306
|
const [major, minor, patch] = version.split('.');
|
|
6309
6307
|
let sdkInfo = {
|
|
6310
6308
|
type: SdkType.PLAIN_JAVASCRIPT,
|
|
@@ -7326,6 +7324,30 @@ class Tracer {
|
|
|
7326
7324
|
}
|
|
7327
7325
|
}
|
|
7328
7326
|
|
|
7327
|
+
/**
|
|
7328
|
+
* Canonical reasons the SDK uses to trigger a reconnection. Free-form strings
|
|
7329
|
+
* are still accepted at the callback boundary (e.g. when forwarding an SFU
|
|
7330
|
+
* error message), but only the members below influence reconnect-loop
|
|
7331
|
+
* behavior. In particular, `Call.reconnect` programmatically inspects
|
|
7332
|
+
* `ICE_NEVER_CONNECTED` to drive the unsupported-network detector — pass a
|
|
7333
|
+
* canonical member when you want the SDK to react to the reason; pass a
|
|
7334
|
+
* free-form string when the value is purely diagnostic.
|
|
7335
|
+
*/
|
|
7336
|
+
const ReconnectReason = {
|
|
7337
|
+
/** ICE never reached `connected`/`completed`, escalate to REJOIN. */
|
|
7338
|
+
ICE_NEVER_CONNECTED: 'ice_never_connected',
|
|
7339
|
+
/** RTCPeerConnection.connectionState became `failed`. */
|
|
7340
|
+
CONNECTION_FAILED: 'connection_failed',
|
|
7341
|
+
/** `restartIce()` rejected. */
|
|
7342
|
+
RESTART_ICE_FAILED: 'restart_ice_failed',
|
|
7343
|
+
/** SFU `goAway` event, migrate to a new SFU. */
|
|
7344
|
+
GO_AWAY: 'go_away',
|
|
7345
|
+
/** Network came back online after going offline. */
|
|
7346
|
+
NETWORK_BACK_ONLINE: 'network_back_online',
|
|
7347
|
+
/** SFU error event with no descriptive message. */
|
|
7348
|
+
SFU_ERROR: 'sfu_error',
|
|
7349
|
+
};
|
|
7350
|
+
|
|
7329
7351
|
/**
|
|
7330
7352
|
* A base class for the `Publisher` and `Subscriber` classes.
|
|
7331
7353
|
* @internal
|
|
@@ -7334,7 +7356,8 @@ class BasePeerConnection {
|
|
|
7334
7356
|
/**
|
|
7335
7357
|
* Constructs a new `BasePeerConnection` instance.
|
|
7336
7358
|
*/
|
|
7337
|
-
constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, }) {
|
|
7359
|
+
constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, }) {
|
|
7360
|
+
this.iceHasEverConnected = false;
|
|
7338
7361
|
this.isIceRestarting = false;
|
|
7339
7362
|
this.isDisposed = false;
|
|
7340
7363
|
this.trackIdToTrackType = new Map();
|
|
@@ -7357,13 +7380,12 @@ class BasePeerConnection {
|
|
|
7357
7380
|
*/
|
|
7358
7381
|
this.tryRestartIce = () => {
|
|
7359
7382
|
this.restartIce().catch((e) => {
|
|
7360
|
-
|
|
7361
|
-
this.logger.error(reason, e);
|
|
7383
|
+
this.logger.error('restartICE() failed, initiating reconnect', e);
|
|
7362
7384
|
const strategy = e instanceof NegotiationError &&
|
|
7363
7385
|
e.error.code === ErrorCode.PARTICIPANT_SIGNAL_LOST
|
|
7364
7386
|
? WebsocketReconnectStrategy.FAST
|
|
7365
7387
|
: WebsocketReconnectStrategy.REJOIN;
|
|
7366
|
-
this.onReconnectionNeeded?.(strategy,
|
|
7388
|
+
this.onReconnectionNeeded?.(strategy, ReconnectReason.RESTART_ICE_FAILED, this.peerType);
|
|
7367
7389
|
});
|
|
7368
7390
|
};
|
|
7369
7391
|
/**
|
|
@@ -7432,6 +7454,17 @@ class BasePeerConnection {
|
|
|
7432
7454
|
const connectionState = this.pc.connectionState;
|
|
7433
7455
|
return !failedStates.has(iceState) && !failedStates.has(connectionState);
|
|
7434
7456
|
};
|
|
7457
|
+
/**
|
|
7458
|
+
* Returns true only when the peer connection is currently fully established
|
|
7459
|
+
* (ICE `connected`/`completed` AND connection state `connected`).
|
|
7460
|
+
* Transient states like `disconnected`, `checking`, or `new` return false.
|
|
7461
|
+
*/
|
|
7462
|
+
this.isStable = () => {
|
|
7463
|
+
const iceState = this.pc.iceConnectionState;
|
|
7464
|
+
const connectionState = this.pc.connectionState;
|
|
7465
|
+
return ((iceState === 'connected' || iceState === 'completed') &&
|
|
7466
|
+
connectionState === 'connected');
|
|
7467
|
+
};
|
|
7435
7468
|
/**
|
|
7436
7469
|
* Handles the ICECandidate event and
|
|
7437
7470
|
* Initiates an ICE Trickle process with the SFU.
|
|
@@ -7481,7 +7514,7 @@ class BasePeerConnection {
|
|
|
7481
7514
|
}
|
|
7482
7515
|
// we can't recover from a failed connection state (contrary to ICE)
|
|
7483
7516
|
if (state === 'failed') {
|
|
7484
|
-
this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN,
|
|
7517
|
+
this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.CONNECTION_FAILED, this.peerType);
|
|
7485
7518
|
return;
|
|
7486
7519
|
}
|
|
7487
7520
|
this.handleConnectionStateUpdate(state);
|
|
@@ -7503,6 +7536,41 @@ class BasePeerConnection {
|
|
|
7503
7536
|
// do nothing when ICE is restarting
|
|
7504
7537
|
if (this.isIceRestarting)
|
|
7505
7538
|
return;
|
|
7539
|
+
// Pre-connect handling: ICE has never reached `connected`/`completed`.
|
|
7540
|
+
// Restart is futile here (the data plane was never established), but
|
|
7541
|
+
// these two terminal-ish states need different treatment:
|
|
7542
|
+
// - `failed` is terminal, escalate to REJOIN so a new SFU/credentials
|
|
7543
|
+
// /PC configuration gets a chance, and let `Call.reconnect` count
|
|
7544
|
+
// this toward the unsupported-network budget.
|
|
7545
|
+
// - `disconnected` is transient, the browser may yet move back to
|
|
7546
|
+
// `checking`/`connected`. Don't restart, don't escalate; wait it
|
|
7547
|
+
// out. If it ultimately fails, ICE will transition to `failed` and
|
|
7548
|
+
// the branch above will take over.
|
|
7549
|
+
if (!this.iceHasEverConnected) {
|
|
7550
|
+
if (state === 'failed') {
|
|
7551
|
+
this.logger.info('ICE failed before connected, escalating to REJOIN');
|
|
7552
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
7553
|
+
this.preConnectStuckTimeout = undefined;
|
|
7554
|
+
this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED, this.peerType);
|
|
7555
|
+
return;
|
|
7556
|
+
}
|
|
7557
|
+
if (state === 'disconnected') {
|
|
7558
|
+
this.logger.info('ICE disconnected before connected, wait to recover');
|
|
7559
|
+
// Watchdog: if the browser stays in `disconnected` without ever
|
|
7560
|
+
// reaching `connected` or transitioning to `failed`, escalate to
|
|
7561
|
+
// REJOIN ourselves so we don't wait silently forever. Rare but
|
|
7562
|
+
// observed on flaky mobile networks.
|
|
7563
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
7564
|
+
this.preConnectStuckTimeout = setTimeout(() => {
|
|
7565
|
+
if (!this.iceHasEverConnected &&
|
|
7566
|
+
this.pc.iceConnectionState === 'disconnected') {
|
|
7567
|
+
this.logger.info('ICE stuck in pre-connect disconnected, escalating to REJOIN');
|
|
7568
|
+
this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED, this.peerType);
|
|
7569
|
+
}
|
|
7570
|
+
}, this.iceRestartDelay * 2);
|
|
7571
|
+
return;
|
|
7572
|
+
}
|
|
7573
|
+
}
|
|
7506
7574
|
switch (state) {
|
|
7507
7575
|
case 'failed':
|
|
7508
7576
|
// in the `failed` state, we try to restart ICE immediately
|
|
@@ -7522,12 +7590,24 @@ class BasePeerConnection {
|
|
|
7522
7590
|
}, this.iceRestartDelay);
|
|
7523
7591
|
break;
|
|
7524
7592
|
case 'connected':
|
|
7525
|
-
|
|
7593
|
+
case 'completed':
|
|
7594
|
+
// Fire `onIceConnected` exactly once per peer-connection lifetime —
|
|
7595
|
+
// the first time ICE reaches `connected`/`completed` end-to-end.
|
|
7596
|
+
// Used by `Call` to reset the unsupported-network failure counter
|
|
7597
|
+
// only after WebRTC has actually recovered, not merely on SFU join.
|
|
7598
|
+
if (!this.iceHasEverConnected) {
|
|
7599
|
+
this.iceHasEverConnected = true;
|
|
7600
|
+
this.onIceConnected?.(this.peerType);
|
|
7601
|
+
}
|
|
7602
|
+
// clear any scheduled restartICE since the connection is healthy
|
|
7526
7603
|
if (this.iceRestartTimeout) {
|
|
7527
7604
|
this.logger.info('connected connection, canceling restartICE');
|
|
7528
7605
|
clearTimeout(this.iceRestartTimeout);
|
|
7529
7606
|
this.iceRestartTimeout = undefined;
|
|
7530
7607
|
}
|
|
7608
|
+
// clear the pre-connect watchdog if it was armed
|
|
7609
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
7610
|
+
this.preConnectStuckTimeout = undefined;
|
|
7531
7611
|
break;
|
|
7532
7612
|
}
|
|
7533
7613
|
};
|
|
@@ -7560,6 +7640,7 @@ class BasePeerConnection {
|
|
|
7560
7640
|
this.clientPublishOptions = clientPublishOptions;
|
|
7561
7641
|
this.tag = tag;
|
|
7562
7642
|
this.onReconnectionNeeded = onReconnectionNeeded;
|
|
7643
|
+
this.onIceConnected = onIceConnected;
|
|
7563
7644
|
this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
|
|
7564
7645
|
this.pc = this.createPeerConnection(connectionConfig);
|
|
7565
7646
|
this.stats = new StatsTracer(this.pc, peerType, this.trackIdToTrackType);
|
|
@@ -7578,7 +7659,10 @@ class BasePeerConnection {
|
|
|
7578
7659
|
dispose() {
|
|
7579
7660
|
clearTimeout(this.iceRestartTimeout);
|
|
7580
7661
|
this.iceRestartTimeout = undefined;
|
|
7662
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
7663
|
+
this.preConnectStuckTimeout = undefined;
|
|
7581
7664
|
this.onReconnectionNeeded = undefined;
|
|
7665
|
+
this.onIceConnected = undefined;
|
|
7582
7666
|
this.isDisposed = true;
|
|
7583
7667
|
this.detachEventHandlers();
|
|
7584
7668
|
this.pc.close();
|
|
@@ -8232,7 +8316,8 @@ class Publisher extends BasePeerConnection {
|
|
|
8232
8316
|
const trackInfos = [];
|
|
8233
8317
|
for (const publishOption of this.publishOptions) {
|
|
8234
8318
|
const bundle = this.transceiverCache.get(publishOption);
|
|
8235
|
-
|
|
8319
|
+
const track = bundle?.transceiver.sender.track;
|
|
8320
|
+
if (!bundle || !track || track.readyState !== 'live')
|
|
8236
8321
|
continue;
|
|
8237
8322
|
trackInfos.push(this.toTrackInfo(bundle, sdp));
|
|
8238
8323
|
}
|
|
@@ -8577,6 +8662,20 @@ class SfuJoinError extends Error {
|
|
|
8577
8662
|
}
|
|
8578
8663
|
}
|
|
8579
8664
|
|
|
8665
|
+
/**
|
|
8666
|
+
* Creates a fresh `joinResponseTask` with a no-op rejection handler attached
|
|
8667
|
+
* to the underlying promise. The handler marks the rejection path as handled
|
|
8668
|
+
* so a teardown-time reject (e.g., from `close()` during disposal) does not
|
|
8669
|
+
* surface as an `UnhandledPromiseRejection`. Explicit awaiters of
|
|
8670
|
+
* `StreamSfuClient.joinTask` still observe the rejection through their own
|
|
8671
|
+
* `then`/`catch` chain. `.catch()` returns a new promise; the original is
|
|
8672
|
+
* unchanged.
|
|
8673
|
+
*/
|
|
8674
|
+
const makeJoinResponseTask = () => {
|
|
8675
|
+
const task = promiseWithResolvers();
|
|
8676
|
+
task.promise.catch(() => { }); // see the comment above
|
|
8677
|
+
return task;
|
|
8678
|
+
};
|
|
8580
8679
|
/**
|
|
8581
8680
|
* The client used for exchanging information with the SFU.
|
|
8582
8681
|
*/
|
|
@@ -8608,9 +8707,10 @@ class StreamSfuClient {
|
|
|
8608
8707
|
this.subscriptionsConcurrencyTag = Symbol('subscriptionsConcurrencyTag');
|
|
8609
8708
|
/**
|
|
8610
8709
|
* Promise that resolves when the JoinResponse is received.
|
|
8611
|
-
* Rejects after a certain threshold if the response is not received
|
|
8710
|
+
* Rejects after a certain threshold if the response is not received,
|
|
8711
|
+
* or when the SFU client is disposed before a join completes.
|
|
8612
8712
|
*/
|
|
8613
|
-
this.joinResponseTask =
|
|
8713
|
+
this.joinResponseTask = makeJoinResponseTask();
|
|
8614
8714
|
/**
|
|
8615
8715
|
* A controller to abort the current requests.
|
|
8616
8716
|
*/
|
|
@@ -8680,14 +8780,21 @@ class StreamSfuClient {
|
|
|
8680
8780
|
};
|
|
8681
8781
|
this.close = (code = StreamSfuClient.NORMAL_CLOSURE, reason) => {
|
|
8682
8782
|
this.isClosingClean = code !== StreamSfuClient.ERROR_CONNECTION_UNHEALTHY;
|
|
8683
|
-
|
|
8783
|
+
// Close the WebSocket whether it has fully opened (`OPEN`) or is still
|
|
8784
|
+
// mid-handshake (`CONNECTING`). The WebSocket spec aborts the handshake
|
|
8785
|
+
// when `close()` is called on a CONNECTING socket. Without this, an
|
|
8786
|
+
// SFU socket that opens just after teardown would dispatch events into
|
|
8787
|
+
// a Call instance that has already moved on.
|
|
8788
|
+
const ws = this.signalWs;
|
|
8789
|
+
if (ws.readyState === WebSocket.OPEN ||
|
|
8790
|
+
ws.readyState === WebSocket.CONNECTING) {
|
|
8684
8791
|
this.logger.debug(`Closing SFU WS connection: ${code} - ${reason}`);
|
|
8685
|
-
|
|
8686
|
-
|
|
8792
|
+
ws.close(code, `js-client: ${reason}`);
|
|
8793
|
+
ws.removeEventListener('close', this.handleWebSocketClose);
|
|
8687
8794
|
}
|
|
8688
|
-
this.dispose();
|
|
8795
|
+
this.dispose(reason);
|
|
8689
8796
|
};
|
|
8690
|
-
this.dispose = () => {
|
|
8797
|
+
this.dispose = (reason) => {
|
|
8691
8798
|
this.logger.debug('Disposing SFU client');
|
|
8692
8799
|
this.unsubscribeIceTrickle();
|
|
8693
8800
|
this.unsubscribeNetworkChanged();
|
|
@@ -8696,6 +8803,13 @@ class StreamSfuClient {
|
|
|
8696
8803
|
clearTimeout(this.migrateAwayTimeout);
|
|
8697
8804
|
this.abortController.abort();
|
|
8698
8805
|
this.migrationTask?.resolve();
|
|
8806
|
+
// Settle a pending `joinResponseTask` so `leaveAndClose`, `join()`, and
|
|
8807
|
+
// any other awaiters (`await this.joinTask`) don't hang indefinitely
|
|
8808
|
+
// when the SFU client is torn down before the SFU sent a JoinResponse.
|
|
8809
|
+
if (!this.joinResponseTask.isResolved() &&
|
|
8810
|
+
!this.joinResponseTask.isRejected()) {
|
|
8811
|
+
this.joinResponseTask.reject(new Error(`SFU client disposed before join completed${reason ? `: ${reason}` : ''}`));
|
|
8812
|
+
}
|
|
8699
8813
|
this.iceTrickleBuffer.dispose();
|
|
8700
8814
|
};
|
|
8701
8815
|
this.getTrace = () => {
|
|
@@ -8704,8 +8818,24 @@ class StreamSfuClient {
|
|
|
8704
8818
|
this.leaveAndClose = async (reason) => {
|
|
8705
8819
|
try {
|
|
8706
8820
|
this.isLeaving = true;
|
|
8707
|
-
|
|
8708
|
-
|
|
8821
|
+
// Best-effort: give an in-flight join a short grace period to complete
|
|
8822
|
+
// so we can send a graceful `leaveCallRequest`. Bounded so we never hang
|
|
8823
|
+
// here if the SFU is unresponsive. If the task settles either way during
|
|
8824
|
+
// the wait, the re-check below decides whether to notify.
|
|
8825
|
+
if (!this.joinResponseTask.isResolved() &&
|
|
8826
|
+
!this.joinResponseTask.isRejected()) {
|
|
8827
|
+
await Promise.race([
|
|
8828
|
+
// swallow rejection — we re-check `isResolved()` below to decide
|
|
8829
|
+
this.joinResponseTask.promise.catch(() => { }),
|
|
8830
|
+
sleep(StreamSfuClient.LEAVE_NOTIFY_GRACE_MS),
|
|
8831
|
+
]);
|
|
8832
|
+
}
|
|
8833
|
+
if (this.joinResponseTask.isResolved()) {
|
|
8834
|
+
await this.notifyLeave(reason);
|
|
8835
|
+
}
|
|
8836
|
+
else {
|
|
8837
|
+
this.logger.debug('[leaveAndClose] join not completed within grace period, skipping notifyLeave');
|
|
8838
|
+
}
|
|
8709
8839
|
}
|
|
8710
8840
|
catch (err) {
|
|
8711
8841
|
this.logger.debug('Error notifying SFU about leaving call', err);
|
|
@@ -8774,9 +8904,9 @@ class StreamSfuClient {
|
|
|
8774
8904
|
this.joinResponseTask.isRejected()) {
|
|
8775
8905
|
// we need to lock the RPC requests until we receive a JoinResponse.
|
|
8776
8906
|
// that's why we have this primitive lock mechanism.
|
|
8777
|
-
// the client starts with already initialized joinResponseTask,
|
|
8907
|
+
// the client starts with an already initialized joinResponseTask,
|
|
8778
8908
|
// and this code creates a new one for the next join request.
|
|
8779
|
-
this.joinResponseTask =
|
|
8909
|
+
this.joinResponseTask = makeJoinResponseTask();
|
|
8780
8910
|
}
|
|
8781
8911
|
// capture a reference to the current joinResponseTask as it might
|
|
8782
8912
|
// be replaced with a new one in case a second join request is made
|
|
@@ -8949,6 +9079,12 @@ StreamSfuClient.DISPOSE_OLD_SOCKET = 4100;
|
|
|
8949
9079
|
* The close code used when the client fails to join the call (on the SFU).
|
|
8950
9080
|
*/
|
|
8951
9081
|
StreamSfuClient.JOIN_FAILED = 4101;
|
|
9082
|
+
/**
|
|
9083
|
+
* Best-effort grace period in `leaveAndClose` for an in-flight join to
|
|
9084
|
+
* complete before we give up and close without sending `leaveCallRequest`.
|
|
9085
|
+
* Bounded so a stuck join can never hang the leave path.
|
|
9086
|
+
*/
|
|
9087
|
+
StreamSfuClient.LEAVE_NOTIFY_GRACE_MS = 1000;
|
|
8952
9088
|
|
|
8953
9089
|
/**
|
|
8954
9090
|
* Event handler that watched the delivery of `call.accepted`.
|
|
@@ -10344,6 +10480,50 @@ const CallTypes = new CallTypesRegistry([
|
|
|
10344
10480
|
}),
|
|
10345
10481
|
]);
|
|
10346
10482
|
|
|
10483
|
+
/**
|
|
10484
|
+
* A generic sliding-window rate limiter.
|
|
10485
|
+
*
|
|
10486
|
+
* Allows at most `maxAttempts` registrations inside a rolling `windowMs`.
|
|
10487
|
+
* Attempts spaced further apart than `windowMs` are always allowed.
|
|
10488
|
+
*/
|
|
10489
|
+
class SlidingWindowRateLimiter {
|
|
10490
|
+
constructor(maxAttempts, windowMs) {
|
|
10491
|
+
this.timestamps = [];
|
|
10492
|
+
/**
|
|
10493
|
+
* Attempts to register a new event at `now`. Returns `true` if the attempt
|
|
10494
|
+
* fits inside the budget (and records it), or `false` if the budget is
|
|
10495
|
+
* exhausted (in which case no timestamp is recorded).
|
|
10496
|
+
*/
|
|
10497
|
+
this.tryRegister = (now = Date.now()) => {
|
|
10498
|
+
this.prune(now);
|
|
10499
|
+
if (this.timestamps.length >= this.maxAttempts)
|
|
10500
|
+
return false;
|
|
10501
|
+
this.timestamps.push(now);
|
|
10502
|
+
return true;
|
|
10503
|
+
};
|
|
10504
|
+
/**
|
|
10505
|
+
* Clears the attempt history.
|
|
10506
|
+
*/
|
|
10507
|
+
this.reset = () => {
|
|
10508
|
+
this.timestamps = [];
|
|
10509
|
+
};
|
|
10510
|
+
/**
|
|
10511
|
+
* Updates the budget and window size. Existing timestamps are kept; they
|
|
10512
|
+
* will be pruned by the next `tryRegister` call.
|
|
10513
|
+
*/
|
|
10514
|
+
this.setLimits = (maxAttempts, windowMs) => {
|
|
10515
|
+
this.maxAttempts = maxAttempts;
|
|
10516
|
+
this.windowMs = windowMs;
|
|
10517
|
+
};
|
|
10518
|
+
this.prune = (now) => {
|
|
10519
|
+
const cutoff = now - this.windowMs;
|
|
10520
|
+
this.timestamps = this.timestamps.filter((t) => t >= cutoff);
|
|
10521
|
+
};
|
|
10522
|
+
this.maxAttempts = maxAttempts;
|
|
10523
|
+
this.windowMs = windowMs;
|
|
10524
|
+
}
|
|
10525
|
+
}
|
|
10526
|
+
|
|
10347
10527
|
/**
|
|
10348
10528
|
* Deactivates MediaStream (stops and removes tracks) to be later garbage collected
|
|
10349
10529
|
*
|
|
@@ -10725,7 +10905,6 @@ const getScreenShareStream = async (options, tracer) => {
|
|
|
10725
10905
|
const tag = `navigator.mediaDevices.getDisplayMedia.${getDisplayMediaExecId++}.`;
|
|
10726
10906
|
try {
|
|
10727
10907
|
const constraints = {
|
|
10728
|
-
// @ts-expect-error - not present in types yet
|
|
10729
10908
|
systemAudio: 'include',
|
|
10730
10909
|
...options,
|
|
10731
10910
|
video: typeof options?.video === 'boolean'
|
|
@@ -10740,6 +10919,8 @@ const getScreenShareStream = async (options, tracer) => {
|
|
|
10740
10919
|
? options.audio
|
|
10741
10920
|
: {
|
|
10742
10921
|
channelCount: { ideal: 2 },
|
|
10922
|
+
// @ts-expect-error not yet present in the types
|
|
10923
|
+
restrictOwnAudio: true,
|
|
10743
10924
|
echoCancellation: false,
|
|
10744
10925
|
autoGainControl: false,
|
|
10745
10926
|
noiseSuppression: false,
|
|
@@ -11384,6 +11565,7 @@ class DeviceManagerState {
|
|
|
11384
11565
|
this.statusSubject = new rxjs.BehaviorSubject(undefined);
|
|
11385
11566
|
this.optimisticStatusSubject = new rxjs.BehaviorSubject(undefined);
|
|
11386
11567
|
this.mediaStreamSubject = new rxjs.BehaviorSubject(undefined);
|
|
11568
|
+
this.rootMediaStreamSubject = new rxjs.BehaviorSubject(undefined);
|
|
11387
11569
|
this.selectedDeviceSubject = new rxjs.BehaviorSubject(undefined);
|
|
11388
11570
|
this.defaultConstraintsSubject = new rxjs.BehaviorSubject(undefined);
|
|
11389
11571
|
/**
|
|
@@ -11391,6 +11573,12 @@ class DeviceManagerState {
|
|
|
11391
11573
|
*
|
|
11392
11574
|
*/
|
|
11393
11575
|
this.mediaStream$ = this.mediaStreamSubject.asObservable();
|
|
11576
|
+
/**
|
|
11577
|
+
* An Observable that emits the raw device media stream (before any filters are applied),
|
|
11578
|
+
* or `undefined` if the device is currently disabled. When no filters are active, this
|
|
11579
|
+
* emits the same stream as `mediaStream$`.
|
|
11580
|
+
*/
|
|
11581
|
+
this.rootMediaStream$ = this.rootMediaStreamSubject.asObservable();
|
|
11394
11582
|
/**
|
|
11395
11583
|
* An Observable that emits the currently selected device
|
|
11396
11584
|
*/
|
|
@@ -11446,6 +11634,14 @@ class DeviceManagerState {
|
|
|
11446
11634
|
get mediaStream() {
|
|
11447
11635
|
return getCurrentValue(this.mediaStream$);
|
|
11448
11636
|
}
|
|
11637
|
+
/**
|
|
11638
|
+
* The raw device media stream (before any filters are applied), or `undefined`
|
|
11639
|
+
* if the device is currently disabled. When no filters are active, this is the
|
|
11640
|
+
* same as `mediaStream`.
|
|
11641
|
+
*/
|
|
11642
|
+
get rootMediaStream() {
|
|
11643
|
+
return getCurrentValue(this.rootMediaStream$);
|
|
11644
|
+
}
|
|
11449
11645
|
/**
|
|
11450
11646
|
* @internal
|
|
11451
11647
|
* @param status
|
|
@@ -11470,6 +11666,7 @@ class DeviceManagerState {
|
|
|
11470
11666
|
*/
|
|
11471
11667
|
setMediaStream(stream, rootStream) {
|
|
11472
11668
|
setCurrentValue(this.mediaStreamSubject, stream);
|
|
11669
|
+
setCurrentValue(this.rootMediaStreamSubject, rootStream);
|
|
11473
11670
|
if (rootStream) {
|
|
11474
11671
|
this.setDevice(this.getDeviceIdFromStream(rootStream));
|
|
11475
11672
|
}
|
|
@@ -11952,192 +12149,6 @@ const createNoAudioDetector = (audioStream, options) => {
|
|
|
11952
12149
|
return stop;
|
|
11953
12150
|
};
|
|
11954
12151
|
|
|
11955
|
-
class RNSpeechDetector {
|
|
11956
|
-
constructor(externalAudioStream) {
|
|
11957
|
-
this.pc1 = new RTCPeerConnection({});
|
|
11958
|
-
this.pc2 = new RTCPeerConnection({});
|
|
11959
|
-
this.isStopped = false;
|
|
11960
|
-
this.externalAudioStream = externalAudioStream;
|
|
11961
|
-
}
|
|
11962
|
-
/**
|
|
11963
|
-
* Starts the speech detection.
|
|
11964
|
-
*/
|
|
11965
|
-
async start(onSoundDetectedStateChanged) {
|
|
11966
|
-
let detachListeners;
|
|
11967
|
-
let unsubscribe;
|
|
11968
|
-
try {
|
|
11969
|
-
this.isStopped = false;
|
|
11970
|
-
const audioStream = this.externalAudioStream != null
|
|
11971
|
-
? this.externalAudioStream
|
|
11972
|
-
: await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
11973
|
-
this.audioStream = audioStream;
|
|
11974
|
-
const onPc1IceCandidate = (e) => {
|
|
11975
|
-
this.forwardIceCandidate(this.pc2, e.candidate);
|
|
11976
|
-
};
|
|
11977
|
-
const onPc2IceCandidate = (e) => {
|
|
11978
|
-
this.forwardIceCandidate(this.pc1, e.candidate);
|
|
11979
|
-
};
|
|
11980
|
-
const onTrackPc2 = (e) => {
|
|
11981
|
-
e.streams[0].getTracks().forEach((track) => {
|
|
11982
|
-
// In RN, the remote track is automatically added to the audio output device
|
|
11983
|
-
// so we need to mute it to avoid hearing the audio back
|
|
11984
|
-
// @ts-expect-error _setVolume is a private method in react-native-webrtc
|
|
11985
|
-
track._setVolume(0);
|
|
11986
|
-
});
|
|
11987
|
-
};
|
|
11988
|
-
this.pc1.addEventListener('icecandidate', onPc1IceCandidate);
|
|
11989
|
-
this.pc2.addEventListener('icecandidate', onPc2IceCandidate);
|
|
11990
|
-
this.pc2.addEventListener('track', onTrackPc2);
|
|
11991
|
-
detachListeners = () => {
|
|
11992
|
-
this.pc1.removeEventListener('icecandidate', onPc1IceCandidate);
|
|
11993
|
-
this.pc2.removeEventListener('icecandidate', onPc2IceCandidate);
|
|
11994
|
-
this.pc2.removeEventListener('track', onTrackPc2);
|
|
11995
|
-
};
|
|
11996
|
-
audioStream
|
|
11997
|
-
.getTracks()
|
|
11998
|
-
.forEach((track) => this.pc1.addTrack(track, audioStream));
|
|
11999
|
-
const offer = await this.pc1.createOffer({});
|
|
12000
|
-
await this.pc2.setRemoteDescription(offer);
|
|
12001
|
-
await this.pc1.setLocalDescription(offer);
|
|
12002
|
-
const answer = await this.pc2.createAnswer();
|
|
12003
|
-
await this.pc1.setRemoteDescription(answer);
|
|
12004
|
-
await this.pc2.setLocalDescription(answer);
|
|
12005
|
-
unsubscribe = this.onSpeakingDetectedStateChange(onSoundDetectedStateChanged);
|
|
12006
|
-
return () => {
|
|
12007
|
-
detachListeners?.();
|
|
12008
|
-
unsubscribe?.();
|
|
12009
|
-
this.stop();
|
|
12010
|
-
};
|
|
12011
|
-
}
|
|
12012
|
-
catch (error) {
|
|
12013
|
-
detachListeners?.();
|
|
12014
|
-
unsubscribe?.();
|
|
12015
|
-
this.stop();
|
|
12016
|
-
const logger = videoLoggerSystem.getLogger('RNSpeechDetector');
|
|
12017
|
-
logger.error('error handling permissions: ', error);
|
|
12018
|
-
return () => { };
|
|
12019
|
-
}
|
|
12020
|
-
}
|
|
12021
|
-
/**
|
|
12022
|
-
* Stops the speech detection and releases all allocated resources.
|
|
12023
|
-
*/
|
|
12024
|
-
stop() {
|
|
12025
|
-
if (this.isStopped)
|
|
12026
|
-
return;
|
|
12027
|
-
this.isStopped = true;
|
|
12028
|
-
this.pc1.close();
|
|
12029
|
-
this.pc2.close();
|
|
12030
|
-
if (this.externalAudioStream != null) {
|
|
12031
|
-
this.externalAudioStream = undefined;
|
|
12032
|
-
}
|
|
12033
|
-
else {
|
|
12034
|
-
this.cleanupAudioStream();
|
|
12035
|
-
}
|
|
12036
|
-
}
|
|
12037
|
-
/**
|
|
12038
|
-
* Public method that detects the audio levels and returns the status.
|
|
12039
|
-
*/
|
|
12040
|
-
onSpeakingDetectedStateChange(onSoundDetectedStateChanged) {
|
|
12041
|
-
const initialBaselineNoiseLevel = 0.13;
|
|
12042
|
-
let baselineNoiseLevel = initialBaselineNoiseLevel;
|
|
12043
|
-
let speechDetected = false;
|
|
12044
|
-
let speechTimer;
|
|
12045
|
-
let silenceTimer;
|
|
12046
|
-
const audioLevelHistory = []; // Store recent audio levels for smoother detection
|
|
12047
|
-
const historyLength = 10;
|
|
12048
|
-
const silenceThreshold = 1.1;
|
|
12049
|
-
const resetThreshold = 0.9;
|
|
12050
|
-
const speechTimeout = 500; // Speech is set to true after 500ms of audio detection
|
|
12051
|
-
const silenceTimeout = 5000; // Reset baseline after 5 seconds of silence
|
|
12052
|
-
const checkAudioLevel = async () => {
|
|
12053
|
-
try {
|
|
12054
|
-
const stats = await this.pc1.getStats();
|
|
12055
|
-
const report = flatten(stats);
|
|
12056
|
-
// Audio levels are present inside stats of type `media-source` and of kind `audio`
|
|
12057
|
-
const audioMediaSourceStats = report.find((stat) => stat.type === 'media-source' &&
|
|
12058
|
-
stat.kind === 'audio');
|
|
12059
|
-
if (audioMediaSourceStats) {
|
|
12060
|
-
const { audioLevel } = audioMediaSourceStats;
|
|
12061
|
-
if (audioLevel) {
|
|
12062
|
-
// Update audio level history (with max historyLength sized array)
|
|
12063
|
-
audioLevelHistory.push(audioLevel);
|
|
12064
|
-
if (audioLevelHistory.length > historyLength) {
|
|
12065
|
-
audioLevelHistory.shift();
|
|
12066
|
-
}
|
|
12067
|
-
// Calculate average audio level
|
|
12068
|
-
const avgAudioLevel = audioLevelHistory.reduce((a, b) => a + b, 0) /
|
|
12069
|
-
audioLevelHistory.length;
|
|
12070
|
-
// Update baseline (if necessary) based on silence detection
|
|
12071
|
-
if (avgAudioLevel < baselineNoiseLevel * silenceThreshold) {
|
|
12072
|
-
if (!silenceTimer) {
|
|
12073
|
-
silenceTimer = setTimeout(() => {
|
|
12074
|
-
baselineNoiseLevel = Math.min(avgAudioLevel * resetThreshold, initialBaselineNoiseLevel);
|
|
12075
|
-
}, silenceTimeout);
|
|
12076
|
-
}
|
|
12077
|
-
}
|
|
12078
|
-
else {
|
|
12079
|
-
clearTimeout(silenceTimer);
|
|
12080
|
-
silenceTimer = undefined;
|
|
12081
|
-
}
|
|
12082
|
-
// Speech detection with hysteresis
|
|
12083
|
-
if (avgAudioLevel > baselineNoiseLevel * 1.5) {
|
|
12084
|
-
if (!speechDetected) {
|
|
12085
|
-
speechDetected = true;
|
|
12086
|
-
onSoundDetectedStateChanged({
|
|
12087
|
-
isSoundDetected: true,
|
|
12088
|
-
audioLevel,
|
|
12089
|
-
});
|
|
12090
|
-
}
|
|
12091
|
-
clearTimeout(speechTimer);
|
|
12092
|
-
speechTimer = setTimeout(() => {
|
|
12093
|
-
speechDetected = false;
|
|
12094
|
-
onSoundDetectedStateChanged({
|
|
12095
|
-
isSoundDetected: false,
|
|
12096
|
-
audioLevel: 0,
|
|
12097
|
-
});
|
|
12098
|
-
}, speechTimeout);
|
|
12099
|
-
}
|
|
12100
|
-
}
|
|
12101
|
-
}
|
|
12102
|
-
}
|
|
12103
|
-
catch (error) {
|
|
12104
|
-
const logger = videoLoggerSystem.getLogger('RNSpeechDetector');
|
|
12105
|
-
logger.error('error checking audio level from stats', error);
|
|
12106
|
-
}
|
|
12107
|
-
};
|
|
12108
|
-
const intervalId = setInterval(checkAudioLevel, 250);
|
|
12109
|
-
return () => {
|
|
12110
|
-
clearInterval(intervalId);
|
|
12111
|
-
clearTimeout(speechTimer);
|
|
12112
|
-
clearTimeout(silenceTimer);
|
|
12113
|
-
};
|
|
12114
|
-
}
|
|
12115
|
-
cleanupAudioStream() {
|
|
12116
|
-
if (!this.audioStream) {
|
|
12117
|
-
return;
|
|
12118
|
-
}
|
|
12119
|
-
this.audioStream.getTracks().forEach((track) => track.stop());
|
|
12120
|
-
if (
|
|
12121
|
-
// @ts-expect-error release() is present in react-native-webrtc
|
|
12122
|
-
typeof this.audioStream.release === 'function') {
|
|
12123
|
-
// @ts-expect-error called to dispose the stream in RN
|
|
12124
|
-
this.audioStream.release();
|
|
12125
|
-
}
|
|
12126
|
-
}
|
|
12127
|
-
forwardIceCandidate(destination, candidate) {
|
|
12128
|
-
if (this.isStopped ||
|
|
12129
|
-
!candidate ||
|
|
12130
|
-
destination.signalingState === 'closed') {
|
|
12131
|
-
return;
|
|
12132
|
-
}
|
|
12133
|
-
destination.addIceCandidate(candidate).catch(() => {
|
|
12134
|
-
// silently ignore the error
|
|
12135
|
-
const logger = videoLoggerSystem.getLogger('RNSpeechDetector');
|
|
12136
|
-
logger.info('cannot add ice candidate - ignoring');
|
|
12137
|
-
});
|
|
12138
|
-
}
|
|
12139
|
-
}
|
|
12140
|
-
|
|
12141
12152
|
class MicrophoneManager extends AudioDeviceManager {
|
|
12142
12153
|
constructor(call, devicePersistence, disableMode = 'stop-tracks') {
|
|
12143
12154
|
super(call, new MicrophoneManagerState(disableMode, call.tracer), TrackType.AUDIO, devicePersistence);
|
|
@@ -12450,13 +12461,16 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
12450
12461
|
return;
|
|
12451
12462
|
await this.teardownSpeakingWhileMutedDetection();
|
|
12452
12463
|
if (isReactNative()) {
|
|
12453
|
-
|
|
12454
|
-
|
|
12464
|
+
const speechActivity = globalThis.streamRNVideoSDK?.nativeEvents?.speechActivity;
|
|
12465
|
+
if (!speechActivity) {
|
|
12466
|
+
this.logger.warn('Native speech activity not available, make sure the "@stream-io/react-native-webrtc" peer dependency version is satisfied');
|
|
12467
|
+
return;
|
|
12468
|
+
}
|
|
12469
|
+
const unsubscribe = speechActivity.subscribe((event) => {
|
|
12455
12470
|
this.state.setSpeakingWhileMuted(event.isSoundDetected);
|
|
12456
12471
|
});
|
|
12457
12472
|
this.soundDetectorCleanup = async () => {
|
|
12458
12473
|
unsubscribe();
|
|
12459
|
-
this.rnSpeechDetector = undefined;
|
|
12460
12474
|
};
|
|
12461
12475
|
}
|
|
12462
12476
|
else {
|
|
@@ -12904,6 +12918,16 @@ class Call {
|
|
|
12904
12918
|
this.fastReconnectDeadlineSeconds = 0;
|
|
12905
12919
|
this.disconnectionTimeoutSeconds = 0;
|
|
12906
12920
|
this.lastOfflineTimestamp = 0;
|
|
12921
|
+
// (10 attempts per rolling 120 s window).
|
|
12922
|
+
this.rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
|
|
12923
|
+
// "Network doesn't support WebRTC" detector: counts peer-connection
|
|
12924
|
+
// failures where ICE never reached `connected`/`completed`.
|
|
12925
|
+
this.maxIceFailuresWithoutConnect = 2;
|
|
12926
|
+
this.iceFailuresWithoutConnect = 0;
|
|
12927
|
+
// Consecutive-negotiation-failure detector: stops the reconnect loop when
|
|
12928
|
+
// the SFU keeps failing to negotiate SDP for us.
|
|
12929
|
+
this.maxConsecutiveNegotiationFailures = 3;
|
|
12930
|
+
this.consecutiveNegotiationFailures = 0;
|
|
12907
12931
|
// maintain the order of publishing tracks to restore them after a reconnection
|
|
12908
12932
|
// it shouldn't contain duplicates
|
|
12909
12933
|
this.trackPublishOrder = [];
|
|
@@ -13206,6 +13230,19 @@ class Call {
|
|
|
13206
13230
|
this.state.setCallingState(exports.CallingState.LEFT);
|
|
13207
13231
|
this.state.setParticipants([]);
|
|
13208
13232
|
this.state.dispose();
|
|
13233
|
+
// Reset reconnect-related accumulators so a future `call.join()` on the
|
|
13234
|
+
// same instance starts with a fresh budget. The `Call` may be reused
|
|
13235
|
+
// (see `Call.test.ts` "can reuse call instance") so this is required.
|
|
13236
|
+
// Strategy/reason/attempts must also be cleared: when `leave()` is
|
|
13237
|
+
// reached via `giveUpAndLeave()` the success-path reset at the end of
|
|
13238
|
+
// `joinFlow` never runs, leaving stale values that would make the next
|
|
13239
|
+
// fresh `join()` send a stale `ReconnectDetails` to the SFU.
|
|
13240
|
+
this.rejoinRateLimiter.reset();
|
|
13241
|
+
this.iceFailuresWithoutConnect = 0;
|
|
13242
|
+
this.consecutiveNegotiationFailures = 0;
|
|
13243
|
+
this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
|
|
13244
|
+
this.reconnectReason = '';
|
|
13245
|
+
this.reconnectAttempts = 0;
|
|
13209
13246
|
// Call all leave call hooks, e.g. to clean up global event handlers
|
|
13210
13247
|
this.leaveCallHooks.forEach((hook) => hook());
|
|
13211
13248
|
this.initialized = false;
|
|
@@ -13556,9 +13593,20 @@ class Call {
|
|
|
13556
13593
|
// when performing fast reconnect, or when we reuse the same SFU client,
|
|
13557
13594
|
// (ws remained healthy), we just need to restore the ICE connection
|
|
13558
13595
|
if (performingFastReconnect) {
|
|
13559
|
-
//
|
|
13560
|
-
// we
|
|
13561
|
-
|
|
13596
|
+
// The SFU automatically issues an ICE restart on the subscriber,
|
|
13597
|
+
// so we only need to decide about the publisher. If the publisher's
|
|
13598
|
+
// peer connection is still stable (ICE still connected end-to-end),
|
|
13599
|
+
// the signal WebSocket drop was the only problem — the new WS alone
|
|
13600
|
+
// is enough, and restarting ICE would add unnecessary SDP/ICE churn.
|
|
13601
|
+
const publisherIsStable = this.publisher?.isStable() ?? true;
|
|
13602
|
+
const includePublisher = !!this.publisher?.isPublishing() && !publisherIsStable;
|
|
13603
|
+
if (!includePublisher && this.publisher?.isPublishing()) {
|
|
13604
|
+
this.logger.info('[Reconnect] FAST: skipping publisher ICE restart, publisher PC is stable');
|
|
13605
|
+
}
|
|
13606
|
+
await this.restoreICE(sfuClient, {
|
|
13607
|
+
includeSubscriber: false,
|
|
13608
|
+
includePublisher,
|
|
13609
|
+
});
|
|
13562
13610
|
}
|
|
13563
13611
|
else {
|
|
13564
13612
|
const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
|
|
@@ -13601,6 +13649,15 @@ class Call {
|
|
|
13601
13649
|
// reset the reconnect strategy to unspecified after a successful reconnection
|
|
13602
13650
|
this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
|
|
13603
13651
|
this.reconnectReason = '';
|
|
13652
|
+
// A successful SFU join handshake resets the consecutive-negotiation
|
|
13653
|
+
// counter (negotiation just succeeded). It does NOT reset
|
|
13654
|
+
// `iceFailuresWithoutConnect` or the rolling `rejoinRateLimiter`;
|
|
13655
|
+
// those track WebRTC-level health and rejoin frequency, which are not
|
|
13656
|
+
// proven by the SFU handshake alone. ICE-failures-without-connect is
|
|
13657
|
+
// cleared via the `onIceConnected` callback when the peer connection
|
|
13658
|
+
// actually reaches `connected`/`completed` end-to-end. The rejoin
|
|
13659
|
+
// rolling window decays naturally as old timestamps age out.
|
|
13660
|
+
this.consecutiveNegotiationFailures = 0;
|
|
13604
13661
|
this.logger.info(`Joined call ${this.cid}`);
|
|
13605
13662
|
};
|
|
13606
13663
|
/**
|
|
@@ -13718,6 +13775,12 @@ class Call {
|
|
|
13718
13775
|
this.logger.warn(message, err);
|
|
13719
13776
|
});
|
|
13720
13777
|
},
|
|
13778
|
+
onIceConnected: () => {
|
|
13779
|
+
// ICE has reached `connected`/`completed` end-to-end on at least
|
|
13780
|
+
// one peer connection, WebRTC is actually working, so the
|
|
13781
|
+
// "ICE never connected" failure budget can be cleared.
|
|
13782
|
+
this.iceFailuresWithoutConnect = 0;
|
|
13783
|
+
},
|
|
13721
13784
|
};
|
|
13722
13785
|
this.subscriber = new Subscriber(basePeerConnectionOptions);
|
|
13723
13786
|
// anonymous users can't publish anything hence, there is no need
|
|
@@ -13823,7 +13886,9 @@ class Call {
|
|
|
13823
13886
|
* @internal
|
|
13824
13887
|
*
|
|
13825
13888
|
* @param strategy the reconnection strategy to use.
|
|
13826
|
-
* @param reason the reason for the reconnection.
|
|
13889
|
+
* @param reason the reason for the reconnection. Pass a `ReconnectReason.*`
|
|
13890
|
+
* constant when the SDK should react to it (e.g.
|
|
13891
|
+
* `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
|
|
13827
13892
|
*/
|
|
13828
13893
|
this.reconnect = async (strategy, reason) => {
|
|
13829
13894
|
if (this.state.callingState === exports.CallingState.RECONNECTING ||
|
|
@@ -13844,6 +13909,30 @@ class Call {
|
|
|
13844
13909
|
this.state.setCallingState(exports.CallingState.RECONNECTING_FAILED);
|
|
13845
13910
|
}
|
|
13846
13911
|
};
|
|
13912
|
+
const giveUpAndLeave = async (message) => {
|
|
13913
|
+
this.logger.warn(`[Reconnect] Giving up: ${message}. Leaving the call.`);
|
|
13914
|
+
// If we're mid-iteration, the state can be JOINING; `Call.leave` would
|
|
13915
|
+
// then wait for JOINED before proceeding, but no more attempts will run
|
|
13916
|
+
// so JOINED never comes. Transition to RECONNECTING so `leave()` proceeds.
|
|
13917
|
+
if (this.state.callingState === exports.CallingState.JOINING) {
|
|
13918
|
+
this.state.setCallingState(exports.CallingState.RECONNECTING);
|
|
13919
|
+
}
|
|
13920
|
+
try {
|
|
13921
|
+
await this.leave({ message });
|
|
13922
|
+
}
|
|
13923
|
+
catch (err) {
|
|
13924
|
+
this.logger.warn(`[Reconnect] leave() failed after ${message}`, err);
|
|
13925
|
+
}
|
|
13926
|
+
};
|
|
13927
|
+
// Count this entry into reconnect if it was triggered by a peer
|
|
13928
|
+
// connection that never reached `connected`/`completed`.
|
|
13929
|
+
if (reason === ReconnectReason.ICE_NEVER_CONNECTED) {
|
|
13930
|
+
this.iceFailuresWithoutConnect++;
|
|
13931
|
+
if (this.iceFailuresWithoutConnect >= this.maxIceFailuresWithoutConnect) {
|
|
13932
|
+
await giveUpAndLeave('webrtc_unsupported_network');
|
|
13933
|
+
return;
|
|
13934
|
+
}
|
|
13935
|
+
}
|
|
13847
13936
|
let attempt = 0;
|
|
13848
13937
|
do {
|
|
13849
13938
|
const reconnectingTime = Date.now() - reconnectStartTime;
|
|
@@ -13854,6 +13943,16 @@ class Call {
|
|
|
13854
13943
|
await markAsReconnectingFailed();
|
|
13855
13944
|
return;
|
|
13856
13945
|
}
|
|
13946
|
+
// Rejoin rate limit: bound the number of REJOIN (and MIGRATE)
|
|
13947
|
+
// transitions inside a rolling window. FAST is not counted because
|
|
13948
|
+
// it does not issue a new backend `joinCall`.
|
|
13949
|
+
if (this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN ||
|
|
13950
|
+
this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE) {
|
|
13951
|
+
if (!this.rejoinRateLimiter.tryRegister()) {
|
|
13952
|
+
await giveUpAndLeave('rejoin_attempt_limit_exceeded');
|
|
13953
|
+
return;
|
|
13954
|
+
}
|
|
13955
|
+
}
|
|
13857
13956
|
// we don't increment reconnect attempts for the FAST strategy.
|
|
13858
13957
|
if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
|
|
13859
13958
|
this.reconnectAttempts++;
|
|
@@ -13881,6 +13980,8 @@ class Call {
|
|
|
13881
13980
|
ensureExhausted(this.reconnectStrategy, 'Unknown reconnection strategy');
|
|
13882
13981
|
break;
|
|
13883
13982
|
}
|
|
13983
|
+
// reconnection worked — reset the negotiation-failure streak.
|
|
13984
|
+
this.consecutiveNegotiationFailures = 0;
|
|
13884
13985
|
break; // do-while loop, reconnection worked, exit the loop
|
|
13885
13986
|
}
|
|
13886
13987
|
catch (error) {
|
|
@@ -13895,7 +13996,16 @@ class Call {
|
|
|
13895
13996
|
await markAsReconnectingFailed();
|
|
13896
13997
|
return;
|
|
13897
13998
|
}
|
|
13898
|
-
|
|
13999
|
+
if (error instanceof NegotiationError) {
|
|
14000
|
+
this.consecutiveNegotiationFailures++;
|
|
14001
|
+
if (this.consecutiveNegotiationFailures >=
|
|
14002
|
+
this.maxConsecutiveNegotiationFailures) {
|
|
14003
|
+
await giveUpAndLeave('repeated_negotiation_failures');
|
|
14004
|
+
return;
|
|
14005
|
+
}
|
|
14006
|
+
}
|
|
14007
|
+
// exponential backoff with jitter, capped at 5 s
|
|
14008
|
+
await sleep(retryInterval(attempt));
|
|
13899
14009
|
const wasMigrating = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
|
|
13900
14010
|
const mustPerformRejoin = (Date.now() - reconnectStartTime) / 1000 >
|
|
13901
14011
|
this.fastReconnectDeadlineSeconds;
|
|
@@ -14004,7 +14114,7 @@ class Call {
|
|
|
14004
14114
|
this.registerReconnectHandlers = () => {
|
|
14005
14115
|
// handles the legacy "goAway" event
|
|
14006
14116
|
const unregisterGoAway = this.on('goAway', () => {
|
|
14007
|
-
this.reconnect(WebsocketReconnectStrategy.MIGRATE,
|
|
14117
|
+
this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
|
|
14008
14118
|
});
|
|
14009
14119
|
// handles the "error" event, through which the SFU can request a reconnect
|
|
14010
14120
|
const unregisterOnError = this.on('error', (e) => {
|
|
@@ -14023,7 +14133,7 @@ class Call {
|
|
|
14023
14133
|
});
|
|
14024
14134
|
}
|
|
14025
14135
|
else {
|
|
14026
|
-
this.reconnect(strategy, error?.message ||
|
|
14136
|
+
this.reconnect(strategy, error?.message || ReconnectReason.SFU_ERROR).catch((err) => {
|
|
14027
14137
|
this.logger.warn('[Reconnect] Error reconnecting', err);
|
|
14028
14138
|
});
|
|
14029
14139
|
}
|
|
@@ -14047,7 +14157,7 @@ class Call {
|
|
|
14047
14157
|
strategy = WebsocketReconnectStrategy.REJOIN;
|
|
14048
14158
|
}
|
|
14049
14159
|
}
|
|
14050
|
-
this.reconnect(strategy,
|
|
14160
|
+
this.reconnect(strategy, ReconnectReason.NETWORK_BACK_ONLINE).catch((err) => {
|
|
14051
14161
|
this.logger.warn('[Reconnect] Error reconnecting after going online', err);
|
|
14052
14162
|
});
|
|
14053
14163
|
});
|
|
@@ -14190,10 +14300,12 @@ class Call {
|
|
|
14190
14300
|
* @param trackTypes the track types to update the call state with.
|
|
14191
14301
|
*/
|
|
14192
14302
|
this.updateLocalStreamState = async (mediaStream, ...trackTypes) => {
|
|
14193
|
-
|
|
14303
|
+
const sessionId = this.sfuClient?.sessionId;
|
|
14304
|
+
if (!sessionId)
|
|
14194
14305
|
return;
|
|
14195
14306
|
await this.notifyTrackMuteState(!mediaStream, ...trackTypes);
|
|
14196
|
-
|
|
14307
|
+
if (this.sfuClient?.sessionId !== sessionId)
|
|
14308
|
+
return;
|
|
14197
14309
|
for (const trackType of trackTypes) {
|
|
14198
14310
|
const streamStateProp = trackTypeToParticipantStreamKey(trackType);
|
|
14199
14311
|
if (!streamStateProp)
|
|
@@ -14989,6 +15101,39 @@ class Call {
|
|
|
14989
15101
|
this.setDisconnectionTimeout = (timeoutSeconds) => {
|
|
14990
15102
|
this.disconnectionTimeoutSeconds = timeoutSeconds;
|
|
14991
15103
|
};
|
|
15104
|
+
/**
|
|
15105
|
+
* Configures the rolling-window limit for REJOIN/MIGRATE attempts. Once
|
|
15106
|
+
* `maxAttempts` rejoins have been registered inside `windowSeconds`, the
|
|
15107
|
+
* SDK stops retrying and transitions the call to `LEFT` with the
|
|
15108
|
+
* `rejoin_attempt_limit_exceeded` leave message.
|
|
15109
|
+
*
|
|
15110
|
+
* Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
|
|
15111
|
+
* Both arguments are clamped to a minimum of 1.
|
|
15112
|
+
*/
|
|
15113
|
+
this.setRejoinAttemptLimit = (maxAttempts, windowSeconds) => {
|
|
15114
|
+
this.rejoinRateLimiter.setLimits(Math.max(1, maxAttempts), Math.max(1, windowSeconds) * 1000);
|
|
15115
|
+
};
|
|
15116
|
+
/**
|
|
15117
|
+
* Configures how many peer-connection failures where ICE never reached
|
|
15118
|
+
* `connected`/`completed` are tolerated before the SDK concludes that the
|
|
15119
|
+
* current network cannot support WebRTC and transitions the call to
|
|
15120
|
+
* `LEFT` with the `webrtc_unsupported_network` leave message.
|
|
15121
|
+
*
|
|
15122
|
+
* Default: 2. Clamped to a minimum of 1.
|
|
15123
|
+
*/
|
|
15124
|
+
this.setMaxIceFailuresWithoutConnect = (n) => {
|
|
15125
|
+
this.maxIceFailuresWithoutConnect = Math.max(1, n);
|
|
15126
|
+
};
|
|
15127
|
+
/**
|
|
15128
|
+
* Configures how many consecutive SDP `NegotiationError`s are tolerated
|
|
15129
|
+
* before the SDK stops retrying and transitions the call to `LEFT` with
|
|
15130
|
+
* the `repeated_negotiation_failures` leave message.
|
|
15131
|
+
*
|
|
15132
|
+
* Default: 3. Clamped to a minimum of 1.
|
|
15133
|
+
*/
|
|
15134
|
+
this.setMaxConsecutiveNegotiationFailures = (n) => {
|
|
15135
|
+
this.maxConsecutiveNegotiationFailures = Math.max(1, n);
|
|
15136
|
+
};
|
|
14992
15137
|
/**
|
|
14993
15138
|
* Enables the provided client capabilities.
|
|
14994
15139
|
*/
|
|
@@ -16169,7 +16314,7 @@ class StreamClient {
|
|
|
16169
16314
|
this.getUserAgent = () => {
|
|
16170
16315
|
if (!this.cachedUserAgent) {
|
|
16171
16316
|
const { clientAppIdentifier = {} } = this.options;
|
|
16172
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
16317
|
+
const { sdkName = 'js', sdkVersion = "1.49.0", ...extras } = clientAppIdentifier;
|
|
16173
16318
|
this.cachedUserAgent = [
|
|
16174
16319
|
`stream-video-${sdkName}-v${sdkVersion}`,
|
|
16175
16320
|
...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
|
|
@@ -16844,7 +16989,6 @@ exports.MicrophoneManager = MicrophoneManager;
|
|
|
16844
16989
|
exports.MicrophoneManagerState = MicrophoneManagerState;
|
|
16845
16990
|
exports.NoiseCancellationSettingsModeEnum = NoiseCancellationSettingsModeEnum;
|
|
16846
16991
|
exports.OwnCapability = OwnCapability;
|
|
16847
|
-
exports.RNSpeechDetector = RNSpeechDetector;
|
|
16848
16992
|
exports.RTMPBroadcastRequestQualityEnum = RTMPBroadcastRequestQualityEnum;
|
|
16849
16993
|
exports.RTMPSettingsRequestQualityEnum = RTMPSettingsRequestQualityEnum;
|
|
16850
16994
|
exports.RawRecordingSettingsRequestModeEnum = RawRecordingSettingsRequestModeEnum;
|