@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.es.js
CHANGED
|
@@ -4007,8 +4007,6 @@ const retryable = async (rpc, signal, maxRetries = Number.POSITIVE_INFINITY) =>
|
|
|
4007
4007
|
do {
|
|
4008
4008
|
if (attempt > 0)
|
|
4009
4009
|
await sleep(retryInterval(attempt));
|
|
4010
|
-
if (signal?.aborted)
|
|
4011
|
-
throw new Error(signal.reason);
|
|
4012
4010
|
try {
|
|
4013
4011
|
result = await rpc({ attempt });
|
|
4014
4012
|
}
|
|
@@ -4406,6 +4404,21 @@ class Dispatcher {
|
|
|
4406
4404
|
}
|
|
4407
4405
|
}
|
|
4408
4406
|
|
|
4407
|
+
/**
|
|
4408
|
+
* NegotiationError is thrown when there is an error during the negotiation process.
|
|
4409
|
+
* It extends the built-in Error class and includes an SfuError object for more details.
|
|
4410
|
+
*/
|
|
4411
|
+
class NegotiationError extends Error {
|
|
4412
|
+
/**
|
|
4413
|
+
* Creates an instance of NegotiationError.
|
|
4414
|
+
*/
|
|
4415
|
+
constructor(error) {
|
|
4416
|
+
super(error.message);
|
|
4417
|
+
this.name = 'NegotiationError';
|
|
4418
|
+
this.error = error;
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
|
|
4409
4422
|
/**
|
|
4410
4423
|
* A buffer for ICE Candidates. Used for ICE Trickle:
|
|
4411
4424
|
* - https://bloggeek.me/webrtcglossary/trickle-ice/
|
|
@@ -6223,21 +6236,6 @@ class CallState {
|
|
|
6223
6236
|
}
|
|
6224
6237
|
}
|
|
6225
6238
|
|
|
6226
|
-
/**
|
|
6227
|
-
* NegotiationError is thrown when there is an error during the negotiation process.
|
|
6228
|
-
* It extends the built-in Error class and includes an SfuError object for more details.
|
|
6229
|
-
*/
|
|
6230
|
-
class NegotiationError extends Error {
|
|
6231
|
-
/**
|
|
6232
|
-
* Creates an instance of NegotiationError.
|
|
6233
|
-
*/
|
|
6234
|
-
constructor(error) {
|
|
6235
|
-
super(error.message);
|
|
6236
|
-
this.name = 'NegotiationError';
|
|
6237
|
-
this.error = error;
|
|
6238
|
-
}
|
|
6239
|
-
}
|
|
6240
|
-
|
|
6241
6239
|
/**
|
|
6242
6240
|
* Flatten the stats report into an array of stats objects.
|
|
6243
6241
|
*
|
|
@@ -6285,7 +6283,7 @@ const getSdkVersion = (sdk) => {
|
|
|
6285
6283
|
return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
|
|
6286
6284
|
};
|
|
6287
6285
|
|
|
6288
|
-
const version = "1.
|
|
6286
|
+
const version = "1.49.0";
|
|
6289
6287
|
const [major, minor, patch] = version.split('.');
|
|
6290
6288
|
let sdkInfo = {
|
|
6291
6289
|
type: SdkType.PLAIN_JAVASCRIPT,
|
|
@@ -7307,6 +7305,30 @@ class Tracer {
|
|
|
7307
7305
|
}
|
|
7308
7306
|
}
|
|
7309
7307
|
|
|
7308
|
+
/**
|
|
7309
|
+
* Canonical reasons the SDK uses to trigger a reconnection. Free-form strings
|
|
7310
|
+
* are still accepted at the callback boundary (e.g. when forwarding an SFU
|
|
7311
|
+
* error message), but only the members below influence reconnect-loop
|
|
7312
|
+
* behavior. In particular, `Call.reconnect` programmatically inspects
|
|
7313
|
+
* `ICE_NEVER_CONNECTED` to drive the unsupported-network detector — pass a
|
|
7314
|
+
* canonical member when you want the SDK to react to the reason; pass a
|
|
7315
|
+
* free-form string when the value is purely diagnostic.
|
|
7316
|
+
*/
|
|
7317
|
+
const ReconnectReason = {
|
|
7318
|
+
/** ICE never reached `connected`/`completed`, escalate to REJOIN. */
|
|
7319
|
+
ICE_NEVER_CONNECTED: 'ice_never_connected',
|
|
7320
|
+
/** RTCPeerConnection.connectionState became `failed`. */
|
|
7321
|
+
CONNECTION_FAILED: 'connection_failed',
|
|
7322
|
+
/** `restartIce()` rejected. */
|
|
7323
|
+
RESTART_ICE_FAILED: 'restart_ice_failed',
|
|
7324
|
+
/** SFU `goAway` event, migrate to a new SFU. */
|
|
7325
|
+
GO_AWAY: 'go_away',
|
|
7326
|
+
/** Network came back online after going offline. */
|
|
7327
|
+
NETWORK_BACK_ONLINE: 'network_back_online',
|
|
7328
|
+
/** SFU error event with no descriptive message. */
|
|
7329
|
+
SFU_ERROR: 'sfu_error',
|
|
7330
|
+
};
|
|
7331
|
+
|
|
7310
7332
|
/**
|
|
7311
7333
|
* A base class for the `Publisher` and `Subscriber` classes.
|
|
7312
7334
|
* @internal
|
|
@@ -7315,7 +7337,8 @@ class BasePeerConnection {
|
|
|
7315
7337
|
/**
|
|
7316
7338
|
* Constructs a new `BasePeerConnection` instance.
|
|
7317
7339
|
*/
|
|
7318
|
-
constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, }) {
|
|
7340
|
+
constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, }) {
|
|
7341
|
+
this.iceHasEverConnected = false;
|
|
7319
7342
|
this.isIceRestarting = false;
|
|
7320
7343
|
this.isDisposed = false;
|
|
7321
7344
|
this.trackIdToTrackType = new Map();
|
|
@@ -7338,13 +7361,12 @@ class BasePeerConnection {
|
|
|
7338
7361
|
*/
|
|
7339
7362
|
this.tryRestartIce = () => {
|
|
7340
7363
|
this.restartIce().catch((e) => {
|
|
7341
|
-
|
|
7342
|
-
this.logger.error(reason, e);
|
|
7364
|
+
this.logger.error('restartICE() failed, initiating reconnect', e);
|
|
7343
7365
|
const strategy = e instanceof NegotiationError &&
|
|
7344
7366
|
e.error.code === ErrorCode.PARTICIPANT_SIGNAL_LOST
|
|
7345
7367
|
? WebsocketReconnectStrategy.FAST
|
|
7346
7368
|
: WebsocketReconnectStrategy.REJOIN;
|
|
7347
|
-
this.onReconnectionNeeded?.(strategy,
|
|
7369
|
+
this.onReconnectionNeeded?.(strategy, ReconnectReason.RESTART_ICE_FAILED, this.peerType);
|
|
7348
7370
|
});
|
|
7349
7371
|
};
|
|
7350
7372
|
/**
|
|
@@ -7413,6 +7435,17 @@ class BasePeerConnection {
|
|
|
7413
7435
|
const connectionState = this.pc.connectionState;
|
|
7414
7436
|
return !failedStates.has(iceState) && !failedStates.has(connectionState);
|
|
7415
7437
|
};
|
|
7438
|
+
/**
|
|
7439
|
+
* Returns true only when the peer connection is currently fully established
|
|
7440
|
+
* (ICE `connected`/`completed` AND connection state `connected`).
|
|
7441
|
+
* Transient states like `disconnected`, `checking`, or `new` return false.
|
|
7442
|
+
*/
|
|
7443
|
+
this.isStable = () => {
|
|
7444
|
+
const iceState = this.pc.iceConnectionState;
|
|
7445
|
+
const connectionState = this.pc.connectionState;
|
|
7446
|
+
return ((iceState === 'connected' || iceState === 'completed') &&
|
|
7447
|
+
connectionState === 'connected');
|
|
7448
|
+
};
|
|
7416
7449
|
/**
|
|
7417
7450
|
* Handles the ICECandidate event and
|
|
7418
7451
|
* Initiates an ICE Trickle process with the SFU.
|
|
@@ -7462,7 +7495,7 @@ class BasePeerConnection {
|
|
|
7462
7495
|
}
|
|
7463
7496
|
// we can't recover from a failed connection state (contrary to ICE)
|
|
7464
7497
|
if (state === 'failed') {
|
|
7465
|
-
this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN,
|
|
7498
|
+
this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.CONNECTION_FAILED, this.peerType);
|
|
7466
7499
|
return;
|
|
7467
7500
|
}
|
|
7468
7501
|
this.handleConnectionStateUpdate(state);
|
|
@@ -7484,6 +7517,41 @@ class BasePeerConnection {
|
|
|
7484
7517
|
// do nothing when ICE is restarting
|
|
7485
7518
|
if (this.isIceRestarting)
|
|
7486
7519
|
return;
|
|
7520
|
+
// Pre-connect handling: ICE has never reached `connected`/`completed`.
|
|
7521
|
+
// Restart is futile here (the data plane was never established), but
|
|
7522
|
+
// these two terminal-ish states need different treatment:
|
|
7523
|
+
// - `failed` is terminal, escalate to REJOIN so a new SFU/credentials
|
|
7524
|
+
// /PC configuration gets a chance, and let `Call.reconnect` count
|
|
7525
|
+
// this toward the unsupported-network budget.
|
|
7526
|
+
// - `disconnected` is transient, the browser may yet move back to
|
|
7527
|
+
// `checking`/`connected`. Don't restart, don't escalate; wait it
|
|
7528
|
+
// out. If it ultimately fails, ICE will transition to `failed` and
|
|
7529
|
+
// the branch above will take over.
|
|
7530
|
+
if (!this.iceHasEverConnected) {
|
|
7531
|
+
if (state === 'failed') {
|
|
7532
|
+
this.logger.info('ICE failed before connected, escalating to REJOIN');
|
|
7533
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
7534
|
+
this.preConnectStuckTimeout = undefined;
|
|
7535
|
+
this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED, this.peerType);
|
|
7536
|
+
return;
|
|
7537
|
+
}
|
|
7538
|
+
if (state === 'disconnected') {
|
|
7539
|
+
this.logger.info('ICE disconnected before connected, wait to recover');
|
|
7540
|
+
// Watchdog: if the browser stays in `disconnected` without ever
|
|
7541
|
+
// reaching `connected` or transitioning to `failed`, escalate to
|
|
7542
|
+
// REJOIN ourselves so we don't wait silently forever. Rare but
|
|
7543
|
+
// observed on flaky mobile networks.
|
|
7544
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
7545
|
+
this.preConnectStuckTimeout = setTimeout(() => {
|
|
7546
|
+
if (!this.iceHasEverConnected &&
|
|
7547
|
+
this.pc.iceConnectionState === 'disconnected') {
|
|
7548
|
+
this.logger.info('ICE stuck in pre-connect disconnected, escalating to REJOIN');
|
|
7549
|
+
this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED, this.peerType);
|
|
7550
|
+
}
|
|
7551
|
+
}, this.iceRestartDelay * 2);
|
|
7552
|
+
return;
|
|
7553
|
+
}
|
|
7554
|
+
}
|
|
7487
7555
|
switch (state) {
|
|
7488
7556
|
case 'failed':
|
|
7489
7557
|
// in the `failed` state, we try to restart ICE immediately
|
|
@@ -7503,12 +7571,24 @@ class BasePeerConnection {
|
|
|
7503
7571
|
}, this.iceRestartDelay);
|
|
7504
7572
|
break;
|
|
7505
7573
|
case 'connected':
|
|
7506
|
-
|
|
7574
|
+
case 'completed':
|
|
7575
|
+
// Fire `onIceConnected` exactly once per peer-connection lifetime —
|
|
7576
|
+
// the first time ICE reaches `connected`/`completed` end-to-end.
|
|
7577
|
+
// Used by `Call` to reset the unsupported-network failure counter
|
|
7578
|
+
// only after WebRTC has actually recovered, not merely on SFU join.
|
|
7579
|
+
if (!this.iceHasEverConnected) {
|
|
7580
|
+
this.iceHasEverConnected = true;
|
|
7581
|
+
this.onIceConnected?.(this.peerType);
|
|
7582
|
+
}
|
|
7583
|
+
// clear any scheduled restartICE since the connection is healthy
|
|
7507
7584
|
if (this.iceRestartTimeout) {
|
|
7508
7585
|
this.logger.info('connected connection, canceling restartICE');
|
|
7509
7586
|
clearTimeout(this.iceRestartTimeout);
|
|
7510
7587
|
this.iceRestartTimeout = undefined;
|
|
7511
7588
|
}
|
|
7589
|
+
// clear the pre-connect watchdog if it was armed
|
|
7590
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
7591
|
+
this.preConnectStuckTimeout = undefined;
|
|
7512
7592
|
break;
|
|
7513
7593
|
}
|
|
7514
7594
|
};
|
|
@@ -7541,6 +7621,7 @@ class BasePeerConnection {
|
|
|
7541
7621
|
this.clientPublishOptions = clientPublishOptions;
|
|
7542
7622
|
this.tag = tag;
|
|
7543
7623
|
this.onReconnectionNeeded = onReconnectionNeeded;
|
|
7624
|
+
this.onIceConnected = onIceConnected;
|
|
7544
7625
|
this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
|
|
7545
7626
|
this.pc = this.createPeerConnection(connectionConfig);
|
|
7546
7627
|
this.stats = new StatsTracer(this.pc, peerType, this.trackIdToTrackType);
|
|
@@ -7559,7 +7640,10 @@ class BasePeerConnection {
|
|
|
7559
7640
|
dispose() {
|
|
7560
7641
|
clearTimeout(this.iceRestartTimeout);
|
|
7561
7642
|
this.iceRestartTimeout = undefined;
|
|
7643
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
7644
|
+
this.preConnectStuckTimeout = undefined;
|
|
7562
7645
|
this.onReconnectionNeeded = undefined;
|
|
7646
|
+
this.onIceConnected = undefined;
|
|
7563
7647
|
this.isDisposed = true;
|
|
7564
7648
|
this.detachEventHandlers();
|
|
7565
7649
|
this.pc.close();
|
|
@@ -8213,7 +8297,8 @@ class Publisher extends BasePeerConnection {
|
|
|
8213
8297
|
const trackInfos = [];
|
|
8214
8298
|
for (const publishOption of this.publishOptions) {
|
|
8215
8299
|
const bundle = this.transceiverCache.get(publishOption);
|
|
8216
|
-
|
|
8300
|
+
const track = bundle?.transceiver.sender.track;
|
|
8301
|
+
if (!bundle || !track || track.readyState !== 'live')
|
|
8217
8302
|
continue;
|
|
8218
8303
|
trackInfos.push(this.toTrackInfo(bundle, sdp));
|
|
8219
8304
|
}
|
|
@@ -8558,6 +8643,20 @@ class SfuJoinError extends Error {
|
|
|
8558
8643
|
}
|
|
8559
8644
|
}
|
|
8560
8645
|
|
|
8646
|
+
/**
|
|
8647
|
+
* Creates a fresh `joinResponseTask` with a no-op rejection handler attached
|
|
8648
|
+
* to the underlying promise. The handler marks the rejection path as handled
|
|
8649
|
+
* so a teardown-time reject (e.g., from `close()` during disposal) does not
|
|
8650
|
+
* surface as an `UnhandledPromiseRejection`. Explicit awaiters of
|
|
8651
|
+
* `StreamSfuClient.joinTask` still observe the rejection through their own
|
|
8652
|
+
* `then`/`catch` chain. `.catch()` returns a new promise; the original is
|
|
8653
|
+
* unchanged.
|
|
8654
|
+
*/
|
|
8655
|
+
const makeJoinResponseTask = () => {
|
|
8656
|
+
const task = promiseWithResolvers();
|
|
8657
|
+
task.promise.catch(() => { }); // see the comment above
|
|
8658
|
+
return task;
|
|
8659
|
+
};
|
|
8561
8660
|
/**
|
|
8562
8661
|
* The client used for exchanging information with the SFU.
|
|
8563
8662
|
*/
|
|
@@ -8589,9 +8688,10 @@ class StreamSfuClient {
|
|
|
8589
8688
|
this.subscriptionsConcurrencyTag = Symbol('subscriptionsConcurrencyTag');
|
|
8590
8689
|
/**
|
|
8591
8690
|
* Promise that resolves when the JoinResponse is received.
|
|
8592
|
-
* Rejects after a certain threshold if the response is not received
|
|
8691
|
+
* Rejects after a certain threshold if the response is not received,
|
|
8692
|
+
* or when the SFU client is disposed before a join completes.
|
|
8593
8693
|
*/
|
|
8594
|
-
this.joinResponseTask =
|
|
8694
|
+
this.joinResponseTask = makeJoinResponseTask();
|
|
8595
8695
|
/**
|
|
8596
8696
|
* A controller to abort the current requests.
|
|
8597
8697
|
*/
|
|
@@ -8661,14 +8761,21 @@ class StreamSfuClient {
|
|
|
8661
8761
|
};
|
|
8662
8762
|
this.close = (code = StreamSfuClient.NORMAL_CLOSURE, reason) => {
|
|
8663
8763
|
this.isClosingClean = code !== StreamSfuClient.ERROR_CONNECTION_UNHEALTHY;
|
|
8664
|
-
|
|
8764
|
+
// Close the WebSocket whether it has fully opened (`OPEN`) or is still
|
|
8765
|
+
// mid-handshake (`CONNECTING`). The WebSocket spec aborts the handshake
|
|
8766
|
+
// when `close()` is called on a CONNECTING socket. Without this, an
|
|
8767
|
+
// SFU socket that opens just after teardown would dispatch events into
|
|
8768
|
+
// a Call instance that has already moved on.
|
|
8769
|
+
const ws = this.signalWs;
|
|
8770
|
+
if (ws.readyState === WebSocket.OPEN ||
|
|
8771
|
+
ws.readyState === WebSocket.CONNECTING) {
|
|
8665
8772
|
this.logger.debug(`Closing SFU WS connection: ${code} - ${reason}`);
|
|
8666
|
-
|
|
8667
|
-
|
|
8773
|
+
ws.close(code, `js-client: ${reason}`);
|
|
8774
|
+
ws.removeEventListener('close', this.handleWebSocketClose);
|
|
8668
8775
|
}
|
|
8669
|
-
this.dispose();
|
|
8776
|
+
this.dispose(reason);
|
|
8670
8777
|
};
|
|
8671
|
-
this.dispose = () => {
|
|
8778
|
+
this.dispose = (reason) => {
|
|
8672
8779
|
this.logger.debug('Disposing SFU client');
|
|
8673
8780
|
this.unsubscribeIceTrickle();
|
|
8674
8781
|
this.unsubscribeNetworkChanged();
|
|
@@ -8677,6 +8784,13 @@ class StreamSfuClient {
|
|
|
8677
8784
|
clearTimeout(this.migrateAwayTimeout);
|
|
8678
8785
|
this.abortController.abort();
|
|
8679
8786
|
this.migrationTask?.resolve();
|
|
8787
|
+
// Settle a pending `joinResponseTask` so `leaveAndClose`, `join()`, and
|
|
8788
|
+
// any other awaiters (`await this.joinTask`) don't hang indefinitely
|
|
8789
|
+
// when the SFU client is torn down before the SFU sent a JoinResponse.
|
|
8790
|
+
if (!this.joinResponseTask.isResolved() &&
|
|
8791
|
+
!this.joinResponseTask.isRejected()) {
|
|
8792
|
+
this.joinResponseTask.reject(new Error(`SFU client disposed before join completed${reason ? `: ${reason}` : ''}`));
|
|
8793
|
+
}
|
|
8680
8794
|
this.iceTrickleBuffer.dispose();
|
|
8681
8795
|
};
|
|
8682
8796
|
this.getTrace = () => {
|
|
@@ -8685,8 +8799,24 @@ class StreamSfuClient {
|
|
|
8685
8799
|
this.leaveAndClose = async (reason) => {
|
|
8686
8800
|
try {
|
|
8687
8801
|
this.isLeaving = true;
|
|
8688
|
-
|
|
8689
|
-
|
|
8802
|
+
// Best-effort: give an in-flight join a short grace period to complete
|
|
8803
|
+
// so we can send a graceful `leaveCallRequest`. Bounded so we never hang
|
|
8804
|
+
// here if the SFU is unresponsive. If the task settles either way during
|
|
8805
|
+
// the wait, the re-check below decides whether to notify.
|
|
8806
|
+
if (!this.joinResponseTask.isResolved() &&
|
|
8807
|
+
!this.joinResponseTask.isRejected()) {
|
|
8808
|
+
await Promise.race([
|
|
8809
|
+
// swallow rejection — we re-check `isResolved()` below to decide
|
|
8810
|
+
this.joinResponseTask.promise.catch(() => { }),
|
|
8811
|
+
sleep(StreamSfuClient.LEAVE_NOTIFY_GRACE_MS),
|
|
8812
|
+
]);
|
|
8813
|
+
}
|
|
8814
|
+
if (this.joinResponseTask.isResolved()) {
|
|
8815
|
+
await this.notifyLeave(reason);
|
|
8816
|
+
}
|
|
8817
|
+
else {
|
|
8818
|
+
this.logger.debug('[leaveAndClose] join not completed within grace period, skipping notifyLeave');
|
|
8819
|
+
}
|
|
8690
8820
|
}
|
|
8691
8821
|
catch (err) {
|
|
8692
8822
|
this.logger.debug('Error notifying SFU about leaving call', err);
|
|
@@ -8755,9 +8885,9 @@ class StreamSfuClient {
|
|
|
8755
8885
|
this.joinResponseTask.isRejected()) {
|
|
8756
8886
|
// we need to lock the RPC requests until we receive a JoinResponse.
|
|
8757
8887
|
// that's why we have this primitive lock mechanism.
|
|
8758
|
-
// the client starts with already initialized joinResponseTask,
|
|
8888
|
+
// the client starts with an already initialized joinResponseTask,
|
|
8759
8889
|
// and this code creates a new one for the next join request.
|
|
8760
|
-
this.joinResponseTask =
|
|
8890
|
+
this.joinResponseTask = makeJoinResponseTask();
|
|
8761
8891
|
}
|
|
8762
8892
|
// capture a reference to the current joinResponseTask as it might
|
|
8763
8893
|
// be replaced with a new one in case a second join request is made
|
|
@@ -8930,6 +9060,12 @@ StreamSfuClient.DISPOSE_OLD_SOCKET = 4100;
|
|
|
8930
9060
|
* The close code used when the client fails to join the call (on the SFU).
|
|
8931
9061
|
*/
|
|
8932
9062
|
StreamSfuClient.JOIN_FAILED = 4101;
|
|
9063
|
+
/**
|
|
9064
|
+
* Best-effort grace period in `leaveAndClose` for an in-flight join to
|
|
9065
|
+
* complete before we give up and close without sending `leaveCallRequest`.
|
|
9066
|
+
* Bounded so a stuck join can never hang the leave path.
|
|
9067
|
+
*/
|
|
9068
|
+
StreamSfuClient.LEAVE_NOTIFY_GRACE_MS = 1000;
|
|
8933
9069
|
|
|
8934
9070
|
/**
|
|
8935
9071
|
* Event handler that watched the delivery of `call.accepted`.
|
|
@@ -10325,6 +10461,50 @@ const CallTypes = new CallTypesRegistry([
|
|
|
10325
10461
|
}),
|
|
10326
10462
|
]);
|
|
10327
10463
|
|
|
10464
|
+
/**
|
|
10465
|
+
* A generic sliding-window rate limiter.
|
|
10466
|
+
*
|
|
10467
|
+
* Allows at most `maxAttempts` registrations inside a rolling `windowMs`.
|
|
10468
|
+
* Attempts spaced further apart than `windowMs` are always allowed.
|
|
10469
|
+
*/
|
|
10470
|
+
class SlidingWindowRateLimiter {
|
|
10471
|
+
constructor(maxAttempts, windowMs) {
|
|
10472
|
+
this.timestamps = [];
|
|
10473
|
+
/**
|
|
10474
|
+
* Attempts to register a new event at `now`. Returns `true` if the attempt
|
|
10475
|
+
* fits inside the budget (and records it), or `false` if the budget is
|
|
10476
|
+
* exhausted (in which case no timestamp is recorded).
|
|
10477
|
+
*/
|
|
10478
|
+
this.tryRegister = (now = Date.now()) => {
|
|
10479
|
+
this.prune(now);
|
|
10480
|
+
if (this.timestamps.length >= this.maxAttempts)
|
|
10481
|
+
return false;
|
|
10482
|
+
this.timestamps.push(now);
|
|
10483
|
+
return true;
|
|
10484
|
+
};
|
|
10485
|
+
/**
|
|
10486
|
+
* Clears the attempt history.
|
|
10487
|
+
*/
|
|
10488
|
+
this.reset = () => {
|
|
10489
|
+
this.timestamps = [];
|
|
10490
|
+
};
|
|
10491
|
+
/**
|
|
10492
|
+
* Updates the budget and window size. Existing timestamps are kept; they
|
|
10493
|
+
* will be pruned by the next `tryRegister` call.
|
|
10494
|
+
*/
|
|
10495
|
+
this.setLimits = (maxAttempts, windowMs) => {
|
|
10496
|
+
this.maxAttempts = maxAttempts;
|
|
10497
|
+
this.windowMs = windowMs;
|
|
10498
|
+
};
|
|
10499
|
+
this.prune = (now) => {
|
|
10500
|
+
const cutoff = now - this.windowMs;
|
|
10501
|
+
this.timestamps = this.timestamps.filter((t) => t >= cutoff);
|
|
10502
|
+
};
|
|
10503
|
+
this.maxAttempts = maxAttempts;
|
|
10504
|
+
this.windowMs = windowMs;
|
|
10505
|
+
}
|
|
10506
|
+
}
|
|
10507
|
+
|
|
10328
10508
|
/**
|
|
10329
10509
|
* Deactivates MediaStream (stops and removes tracks) to be later garbage collected
|
|
10330
10510
|
*
|
|
@@ -10706,7 +10886,6 @@ const getScreenShareStream = async (options, tracer) => {
|
|
|
10706
10886
|
const tag = `navigator.mediaDevices.getDisplayMedia.${getDisplayMediaExecId++}.`;
|
|
10707
10887
|
try {
|
|
10708
10888
|
const constraints = {
|
|
10709
|
-
// @ts-expect-error - not present in types yet
|
|
10710
10889
|
systemAudio: 'include',
|
|
10711
10890
|
...options,
|
|
10712
10891
|
video: typeof options?.video === 'boolean'
|
|
@@ -10721,6 +10900,8 @@ const getScreenShareStream = async (options, tracer) => {
|
|
|
10721
10900
|
? options.audio
|
|
10722
10901
|
: {
|
|
10723
10902
|
channelCount: { ideal: 2 },
|
|
10903
|
+
// @ts-expect-error not yet present in the types
|
|
10904
|
+
restrictOwnAudio: true,
|
|
10724
10905
|
echoCancellation: false,
|
|
10725
10906
|
autoGainControl: false,
|
|
10726
10907
|
noiseSuppression: false,
|
|
@@ -11365,6 +11546,7 @@ class DeviceManagerState {
|
|
|
11365
11546
|
this.statusSubject = new BehaviorSubject(undefined);
|
|
11366
11547
|
this.optimisticStatusSubject = new BehaviorSubject(undefined);
|
|
11367
11548
|
this.mediaStreamSubject = new BehaviorSubject(undefined);
|
|
11549
|
+
this.rootMediaStreamSubject = new BehaviorSubject(undefined);
|
|
11368
11550
|
this.selectedDeviceSubject = new BehaviorSubject(undefined);
|
|
11369
11551
|
this.defaultConstraintsSubject = new BehaviorSubject(undefined);
|
|
11370
11552
|
/**
|
|
@@ -11372,6 +11554,12 @@ class DeviceManagerState {
|
|
|
11372
11554
|
*
|
|
11373
11555
|
*/
|
|
11374
11556
|
this.mediaStream$ = this.mediaStreamSubject.asObservable();
|
|
11557
|
+
/**
|
|
11558
|
+
* An Observable that emits the raw device media stream (before any filters are applied),
|
|
11559
|
+
* or `undefined` if the device is currently disabled. When no filters are active, this
|
|
11560
|
+
* emits the same stream as `mediaStream$`.
|
|
11561
|
+
*/
|
|
11562
|
+
this.rootMediaStream$ = this.rootMediaStreamSubject.asObservable();
|
|
11375
11563
|
/**
|
|
11376
11564
|
* An Observable that emits the currently selected device
|
|
11377
11565
|
*/
|
|
@@ -11427,6 +11615,14 @@ class DeviceManagerState {
|
|
|
11427
11615
|
get mediaStream() {
|
|
11428
11616
|
return getCurrentValue(this.mediaStream$);
|
|
11429
11617
|
}
|
|
11618
|
+
/**
|
|
11619
|
+
* The raw device media stream (before any filters are applied), or `undefined`
|
|
11620
|
+
* if the device is currently disabled. When no filters are active, this is the
|
|
11621
|
+
* same as `mediaStream`.
|
|
11622
|
+
*/
|
|
11623
|
+
get rootMediaStream() {
|
|
11624
|
+
return getCurrentValue(this.rootMediaStream$);
|
|
11625
|
+
}
|
|
11430
11626
|
/**
|
|
11431
11627
|
* @internal
|
|
11432
11628
|
* @param status
|
|
@@ -11451,6 +11647,7 @@ class DeviceManagerState {
|
|
|
11451
11647
|
*/
|
|
11452
11648
|
setMediaStream(stream, rootStream) {
|
|
11453
11649
|
setCurrentValue(this.mediaStreamSubject, stream);
|
|
11650
|
+
setCurrentValue(this.rootMediaStreamSubject, rootStream);
|
|
11454
11651
|
if (rootStream) {
|
|
11455
11652
|
this.setDevice(this.getDeviceIdFromStream(rootStream));
|
|
11456
11653
|
}
|
|
@@ -11933,192 +12130,6 @@ const createNoAudioDetector = (audioStream, options) => {
|
|
|
11933
12130
|
return stop;
|
|
11934
12131
|
};
|
|
11935
12132
|
|
|
11936
|
-
class RNSpeechDetector {
|
|
11937
|
-
constructor(externalAudioStream) {
|
|
11938
|
-
this.pc1 = new RTCPeerConnection({});
|
|
11939
|
-
this.pc2 = new RTCPeerConnection({});
|
|
11940
|
-
this.isStopped = false;
|
|
11941
|
-
this.externalAudioStream = externalAudioStream;
|
|
11942
|
-
}
|
|
11943
|
-
/**
|
|
11944
|
-
* Starts the speech detection.
|
|
11945
|
-
*/
|
|
11946
|
-
async start(onSoundDetectedStateChanged) {
|
|
11947
|
-
let detachListeners;
|
|
11948
|
-
let unsubscribe;
|
|
11949
|
-
try {
|
|
11950
|
-
this.isStopped = false;
|
|
11951
|
-
const audioStream = this.externalAudioStream != null
|
|
11952
|
-
? this.externalAudioStream
|
|
11953
|
-
: await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
11954
|
-
this.audioStream = audioStream;
|
|
11955
|
-
const onPc1IceCandidate = (e) => {
|
|
11956
|
-
this.forwardIceCandidate(this.pc2, e.candidate);
|
|
11957
|
-
};
|
|
11958
|
-
const onPc2IceCandidate = (e) => {
|
|
11959
|
-
this.forwardIceCandidate(this.pc1, e.candidate);
|
|
11960
|
-
};
|
|
11961
|
-
const onTrackPc2 = (e) => {
|
|
11962
|
-
e.streams[0].getTracks().forEach((track) => {
|
|
11963
|
-
// In RN, the remote track is automatically added to the audio output device
|
|
11964
|
-
// so we need to mute it to avoid hearing the audio back
|
|
11965
|
-
// @ts-expect-error _setVolume is a private method in react-native-webrtc
|
|
11966
|
-
track._setVolume(0);
|
|
11967
|
-
});
|
|
11968
|
-
};
|
|
11969
|
-
this.pc1.addEventListener('icecandidate', onPc1IceCandidate);
|
|
11970
|
-
this.pc2.addEventListener('icecandidate', onPc2IceCandidate);
|
|
11971
|
-
this.pc2.addEventListener('track', onTrackPc2);
|
|
11972
|
-
detachListeners = () => {
|
|
11973
|
-
this.pc1.removeEventListener('icecandidate', onPc1IceCandidate);
|
|
11974
|
-
this.pc2.removeEventListener('icecandidate', onPc2IceCandidate);
|
|
11975
|
-
this.pc2.removeEventListener('track', onTrackPc2);
|
|
11976
|
-
};
|
|
11977
|
-
audioStream
|
|
11978
|
-
.getTracks()
|
|
11979
|
-
.forEach((track) => this.pc1.addTrack(track, audioStream));
|
|
11980
|
-
const offer = await this.pc1.createOffer({});
|
|
11981
|
-
await this.pc2.setRemoteDescription(offer);
|
|
11982
|
-
await this.pc1.setLocalDescription(offer);
|
|
11983
|
-
const answer = await this.pc2.createAnswer();
|
|
11984
|
-
await this.pc1.setRemoteDescription(answer);
|
|
11985
|
-
await this.pc2.setLocalDescription(answer);
|
|
11986
|
-
unsubscribe = this.onSpeakingDetectedStateChange(onSoundDetectedStateChanged);
|
|
11987
|
-
return () => {
|
|
11988
|
-
detachListeners?.();
|
|
11989
|
-
unsubscribe?.();
|
|
11990
|
-
this.stop();
|
|
11991
|
-
};
|
|
11992
|
-
}
|
|
11993
|
-
catch (error) {
|
|
11994
|
-
detachListeners?.();
|
|
11995
|
-
unsubscribe?.();
|
|
11996
|
-
this.stop();
|
|
11997
|
-
const logger = videoLoggerSystem.getLogger('RNSpeechDetector');
|
|
11998
|
-
logger.error('error handling permissions: ', error);
|
|
11999
|
-
return () => { };
|
|
12000
|
-
}
|
|
12001
|
-
}
|
|
12002
|
-
/**
|
|
12003
|
-
* Stops the speech detection and releases all allocated resources.
|
|
12004
|
-
*/
|
|
12005
|
-
stop() {
|
|
12006
|
-
if (this.isStopped)
|
|
12007
|
-
return;
|
|
12008
|
-
this.isStopped = true;
|
|
12009
|
-
this.pc1.close();
|
|
12010
|
-
this.pc2.close();
|
|
12011
|
-
if (this.externalAudioStream != null) {
|
|
12012
|
-
this.externalAudioStream = undefined;
|
|
12013
|
-
}
|
|
12014
|
-
else {
|
|
12015
|
-
this.cleanupAudioStream();
|
|
12016
|
-
}
|
|
12017
|
-
}
|
|
12018
|
-
/**
|
|
12019
|
-
* Public method that detects the audio levels and returns the status.
|
|
12020
|
-
*/
|
|
12021
|
-
onSpeakingDetectedStateChange(onSoundDetectedStateChanged) {
|
|
12022
|
-
const initialBaselineNoiseLevel = 0.13;
|
|
12023
|
-
let baselineNoiseLevel = initialBaselineNoiseLevel;
|
|
12024
|
-
let speechDetected = false;
|
|
12025
|
-
let speechTimer;
|
|
12026
|
-
let silenceTimer;
|
|
12027
|
-
const audioLevelHistory = []; // Store recent audio levels for smoother detection
|
|
12028
|
-
const historyLength = 10;
|
|
12029
|
-
const silenceThreshold = 1.1;
|
|
12030
|
-
const resetThreshold = 0.9;
|
|
12031
|
-
const speechTimeout = 500; // Speech is set to true after 500ms of audio detection
|
|
12032
|
-
const silenceTimeout = 5000; // Reset baseline after 5 seconds of silence
|
|
12033
|
-
const checkAudioLevel = async () => {
|
|
12034
|
-
try {
|
|
12035
|
-
const stats = await this.pc1.getStats();
|
|
12036
|
-
const report = flatten(stats);
|
|
12037
|
-
// Audio levels are present inside stats of type `media-source` and of kind `audio`
|
|
12038
|
-
const audioMediaSourceStats = report.find((stat) => stat.type === 'media-source' &&
|
|
12039
|
-
stat.kind === 'audio');
|
|
12040
|
-
if (audioMediaSourceStats) {
|
|
12041
|
-
const { audioLevel } = audioMediaSourceStats;
|
|
12042
|
-
if (audioLevel) {
|
|
12043
|
-
// Update audio level history (with max historyLength sized array)
|
|
12044
|
-
audioLevelHistory.push(audioLevel);
|
|
12045
|
-
if (audioLevelHistory.length > historyLength) {
|
|
12046
|
-
audioLevelHistory.shift();
|
|
12047
|
-
}
|
|
12048
|
-
// Calculate average audio level
|
|
12049
|
-
const avgAudioLevel = audioLevelHistory.reduce((a, b) => a + b, 0) /
|
|
12050
|
-
audioLevelHistory.length;
|
|
12051
|
-
// Update baseline (if necessary) based on silence detection
|
|
12052
|
-
if (avgAudioLevel < baselineNoiseLevel * silenceThreshold) {
|
|
12053
|
-
if (!silenceTimer) {
|
|
12054
|
-
silenceTimer = setTimeout(() => {
|
|
12055
|
-
baselineNoiseLevel = Math.min(avgAudioLevel * resetThreshold, initialBaselineNoiseLevel);
|
|
12056
|
-
}, silenceTimeout);
|
|
12057
|
-
}
|
|
12058
|
-
}
|
|
12059
|
-
else {
|
|
12060
|
-
clearTimeout(silenceTimer);
|
|
12061
|
-
silenceTimer = undefined;
|
|
12062
|
-
}
|
|
12063
|
-
// Speech detection with hysteresis
|
|
12064
|
-
if (avgAudioLevel > baselineNoiseLevel * 1.5) {
|
|
12065
|
-
if (!speechDetected) {
|
|
12066
|
-
speechDetected = true;
|
|
12067
|
-
onSoundDetectedStateChanged({
|
|
12068
|
-
isSoundDetected: true,
|
|
12069
|
-
audioLevel,
|
|
12070
|
-
});
|
|
12071
|
-
}
|
|
12072
|
-
clearTimeout(speechTimer);
|
|
12073
|
-
speechTimer = setTimeout(() => {
|
|
12074
|
-
speechDetected = false;
|
|
12075
|
-
onSoundDetectedStateChanged({
|
|
12076
|
-
isSoundDetected: false,
|
|
12077
|
-
audioLevel: 0,
|
|
12078
|
-
});
|
|
12079
|
-
}, speechTimeout);
|
|
12080
|
-
}
|
|
12081
|
-
}
|
|
12082
|
-
}
|
|
12083
|
-
}
|
|
12084
|
-
catch (error) {
|
|
12085
|
-
const logger = videoLoggerSystem.getLogger('RNSpeechDetector');
|
|
12086
|
-
logger.error('error checking audio level from stats', error);
|
|
12087
|
-
}
|
|
12088
|
-
};
|
|
12089
|
-
const intervalId = setInterval(checkAudioLevel, 250);
|
|
12090
|
-
return () => {
|
|
12091
|
-
clearInterval(intervalId);
|
|
12092
|
-
clearTimeout(speechTimer);
|
|
12093
|
-
clearTimeout(silenceTimer);
|
|
12094
|
-
};
|
|
12095
|
-
}
|
|
12096
|
-
cleanupAudioStream() {
|
|
12097
|
-
if (!this.audioStream) {
|
|
12098
|
-
return;
|
|
12099
|
-
}
|
|
12100
|
-
this.audioStream.getTracks().forEach((track) => track.stop());
|
|
12101
|
-
if (
|
|
12102
|
-
// @ts-expect-error release() is present in react-native-webrtc
|
|
12103
|
-
typeof this.audioStream.release === 'function') {
|
|
12104
|
-
// @ts-expect-error called to dispose the stream in RN
|
|
12105
|
-
this.audioStream.release();
|
|
12106
|
-
}
|
|
12107
|
-
}
|
|
12108
|
-
forwardIceCandidate(destination, candidate) {
|
|
12109
|
-
if (this.isStopped ||
|
|
12110
|
-
!candidate ||
|
|
12111
|
-
destination.signalingState === 'closed') {
|
|
12112
|
-
return;
|
|
12113
|
-
}
|
|
12114
|
-
destination.addIceCandidate(candidate).catch(() => {
|
|
12115
|
-
// silently ignore the error
|
|
12116
|
-
const logger = videoLoggerSystem.getLogger('RNSpeechDetector');
|
|
12117
|
-
logger.info('cannot add ice candidate - ignoring');
|
|
12118
|
-
});
|
|
12119
|
-
}
|
|
12120
|
-
}
|
|
12121
|
-
|
|
12122
12133
|
class MicrophoneManager extends AudioDeviceManager {
|
|
12123
12134
|
constructor(call, devicePersistence, disableMode = 'stop-tracks') {
|
|
12124
12135
|
super(call, new MicrophoneManagerState(disableMode, call.tracer), TrackType.AUDIO, devicePersistence);
|
|
@@ -12431,13 +12442,16 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
12431
12442
|
return;
|
|
12432
12443
|
await this.teardownSpeakingWhileMutedDetection();
|
|
12433
12444
|
if (isReactNative()) {
|
|
12434
|
-
|
|
12435
|
-
|
|
12445
|
+
const speechActivity = globalThis.streamRNVideoSDK?.nativeEvents?.speechActivity;
|
|
12446
|
+
if (!speechActivity) {
|
|
12447
|
+
this.logger.warn('Native speech activity not available, make sure the "@stream-io/react-native-webrtc" peer dependency version is satisfied');
|
|
12448
|
+
return;
|
|
12449
|
+
}
|
|
12450
|
+
const unsubscribe = speechActivity.subscribe((event) => {
|
|
12436
12451
|
this.state.setSpeakingWhileMuted(event.isSoundDetected);
|
|
12437
12452
|
});
|
|
12438
12453
|
this.soundDetectorCleanup = async () => {
|
|
12439
12454
|
unsubscribe();
|
|
12440
|
-
this.rnSpeechDetector = undefined;
|
|
12441
12455
|
};
|
|
12442
12456
|
}
|
|
12443
12457
|
else {
|
|
@@ -12885,6 +12899,16 @@ class Call {
|
|
|
12885
12899
|
this.fastReconnectDeadlineSeconds = 0;
|
|
12886
12900
|
this.disconnectionTimeoutSeconds = 0;
|
|
12887
12901
|
this.lastOfflineTimestamp = 0;
|
|
12902
|
+
// (10 attempts per rolling 120 s window).
|
|
12903
|
+
this.rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
|
|
12904
|
+
// "Network doesn't support WebRTC" detector: counts peer-connection
|
|
12905
|
+
// failures where ICE never reached `connected`/`completed`.
|
|
12906
|
+
this.maxIceFailuresWithoutConnect = 2;
|
|
12907
|
+
this.iceFailuresWithoutConnect = 0;
|
|
12908
|
+
// Consecutive-negotiation-failure detector: stops the reconnect loop when
|
|
12909
|
+
// the SFU keeps failing to negotiate SDP for us.
|
|
12910
|
+
this.maxConsecutiveNegotiationFailures = 3;
|
|
12911
|
+
this.consecutiveNegotiationFailures = 0;
|
|
12888
12912
|
// maintain the order of publishing tracks to restore them after a reconnection
|
|
12889
12913
|
// it shouldn't contain duplicates
|
|
12890
12914
|
this.trackPublishOrder = [];
|
|
@@ -13187,6 +13211,19 @@ class Call {
|
|
|
13187
13211
|
this.state.setCallingState(CallingState.LEFT);
|
|
13188
13212
|
this.state.setParticipants([]);
|
|
13189
13213
|
this.state.dispose();
|
|
13214
|
+
// Reset reconnect-related accumulators so a future `call.join()` on the
|
|
13215
|
+
// same instance starts with a fresh budget. The `Call` may be reused
|
|
13216
|
+
// (see `Call.test.ts` "can reuse call instance") so this is required.
|
|
13217
|
+
// Strategy/reason/attempts must also be cleared: when `leave()` is
|
|
13218
|
+
// reached via `giveUpAndLeave()` the success-path reset at the end of
|
|
13219
|
+
// `joinFlow` never runs, leaving stale values that would make the next
|
|
13220
|
+
// fresh `join()` send a stale `ReconnectDetails` to the SFU.
|
|
13221
|
+
this.rejoinRateLimiter.reset();
|
|
13222
|
+
this.iceFailuresWithoutConnect = 0;
|
|
13223
|
+
this.consecutiveNegotiationFailures = 0;
|
|
13224
|
+
this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
|
|
13225
|
+
this.reconnectReason = '';
|
|
13226
|
+
this.reconnectAttempts = 0;
|
|
13190
13227
|
// Call all leave call hooks, e.g. to clean up global event handlers
|
|
13191
13228
|
this.leaveCallHooks.forEach((hook) => hook());
|
|
13192
13229
|
this.initialized = false;
|
|
@@ -13537,9 +13574,20 @@ class Call {
|
|
|
13537
13574
|
// when performing fast reconnect, or when we reuse the same SFU client,
|
|
13538
13575
|
// (ws remained healthy), we just need to restore the ICE connection
|
|
13539
13576
|
if (performingFastReconnect) {
|
|
13540
|
-
//
|
|
13541
|
-
// we
|
|
13542
|
-
|
|
13577
|
+
// The SFU automatically issues an ICE restart on the subscriber,
|
|
13578
|
+
// so we only need to decide about the publisher. If the publisher's
|
|
13579
|
+
// peer connection is still stable (ICE still connected end-to-end),
|
|
13580
|
+
// the signal WebSocket drop was the only problem — the new WS alone
|
|
13581
|
+
// is enough, and restarting ICE would add unnecessary SDP/ICE churn.
|
|
13582
|
+
const publisherIsStable = this.publisher?.isStable() ?? true;
|
|
13583
|
+
const includePublisher = !!this.publisher?.isPublishing() && !publisherIsStable;
|
|
13584
|
+
if (!includePublisher && this.publisher?.isPublishing()) {
|
|
13585
|
+
this.logger.info('[Reconnect] FAST: skipping publisher ICE restart, publisher PC is stable');
|
|
13586
|
+
}
|
|
13587
|
+
await this.restoreICE(sfuClient, {
|
|
13588
|
+
includeSubscriber: false,
|
|
13589
|
+
includePublisher,
|
|
13590
|
+
});
|
|
13543
13591
|
}
|
|
13544
13592
|
else {
|
|
13545
13593
|
const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
|
|
@@ -13582,6 +13630,15 @@ class Call {
|
|
|
13582
13630
|
// reset the reconnect strategy to unspecified after a successful reconnection
|
|
13583
13631
|
this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
|
|
13584
13632
|
this.reconnectReason = '';
|
|
13633
|
+
// A successful SFU join handshake resets the consecutive-negotiation
|
|
13634
|
+
// counter (negotiation just succeeded). It does NOT reset
|
|
13635
|
+
// `iceFailuresWithoutConnect` or the rolling `rejoinRateLimiter`;
|
|
13636
|
+
// those track WebRTC-level health and rejoin frequency, which are not
|
|
13637
|
+
// proven by the SFU handshake alone. ICE-failures-without-connect is
|
|
13638
|
+
// cleared via the `onIceConnected` callback when the peer connection
|
|
13639
|
+
// actually reaches `connected`/`completed` end-to-end. The rejoin
|
|
13640
|
+
// rolling window decays naturally as old timestamps age out.
|
|
13641
|
+
this.consecutiveNegotiationFailures = 0;
|
|
13585
13642
|
this.logger.info(`Joined call ${this.cid}`);
|
|
13586
13643
|
};
|
|
13587
13644
|
/**
|
|
@@ -13699,6 +13756,12 @@ class Call {
|
|
|
13699
13756
|
this.logger.warn(message, err);
|
|
13700
13757
|
});
|
|
13701
13758
|
},
|
|
13759
|
+
onIceConnected: () => {
|
|
13760
|
+
// ICE has reached `connected`/`completed` end-to-end on at least
|
|
13761
|
+
// one peer connection, WebRTC is actually working, so the
|
|
13762
|
+
// "ICE never connected" failure budget can be cleared.
|
|
13763
|
+
this.iceFailuresWithoutConnect = 0;
|
|
13764
|
+
},
|
|
13702
13765
|
};
|
|
13703
13766
|
this.subscriber = new Subscriber(basePeerConnectionOptions);
|
|
13704
13767
|
// anonymous users can't publish anything hence, there is no need
|
|
@@ -13804,7 +13867,9 @@ class Call {
|
|
|
13804
13867
|
* @internal
|
|
13805
13868
|
*
|
|
13806
13869
|
* @param strategy the reconnection strategy to use.
|
|
13807
|
-
* @param reason the reason for the reconnection.
|
|
13870
|
+
* @param reason the reason for the reconnection. Pass a `ReconnectReason.*`
|
|
13871
|
+
* constant when the SDK should react to it (e.g.
|
|
13872
|
+
* `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
|
|
13808
13873
|
*/
|
|
13809
13874
|
this.reconnect = async (strategy, reason) => {
|
|
13810
13875
|
if (this.state.callingState === CallingState.RECONNECTING ||
|
|
@@ -13825,6 +13890,30 @@ class Call {
|
|
|
13825
13890
|
this.state.setCallingState(CallingState.RECONNECTING_FAILED);
|
|
13826
13891
|
}
|
|
13827
13892
|
};
|
|
13893
|
+
const giveUpAndLeave = async (message) => {
|
|
13894
|
+
this.logger.warn(`[Reconnect] Giving up: ${message}. Leaving the call.`);
|
|
13895
|
+
// If we're mid-iteration, the state can be JOINING; `Call.leave` would
|
|
13896
|
+
// then wait for JOINED before proceeding, but no more attempts will run
|
|
13897
|
+
// so JOINED never comes. Transition to RECONNECTING so `leave()` proceeds.
|
|
13898
|
+
if (this.state.callingState === CallingState.JOINING) {
|
|
13899
|
+
this.state.setCallingState(CallingState.RECONNECTING);
|
|
13900
|
+
}
|
|
13901
|
+
try {
|
|
13902
|
+
await this.leave({ message });
|
|
13903
|
+
}
|
|
13904
|
+
catch (err) {
|
|
13905
|
+
this.logger.warn(`[Reconnect] leave() failed after ${message}`, err);
|
|
13906
|
+
}
|
|
13907
|
+
};
|
|
13908
|
+
// Count this entry into reconnect if it was triggered by a peer
|
|
13909
|
+
// connection that never reached `connected`/`completed`.
|
|
13910
|
+
if (reason === ReconnectReason.ICE_NEVER_CONNECTED) {
|
|
13911
|
+
this.iceFailuresWithoutConnect++;
|
|
13912
|
+
if (this.iceFailuresWithoutConnect >= this.maxIceFailuresWithoutConnect) {
|
|
13913
|
+
await giveUpAndLeave('webrtc_unsupported_network');
|
|
13914
|
+
return;
|
|
13915
|
+
}
|
|
13916
|
+
}
|
|
13828
13917
|
let attempt = 0;
|
|
13829
13918
|
do {
|
|
13830
13919
|
const reconnectingTime = Date.now() - reconnectStartTime;
|
|
@@ -13835,6 +13924,16 @@ class Call {
|
|
|
13835
13924
|
await markAsReconnectingFailed();
|
|
13836
13925
|
return;
|
|
13837
13926
|
}
|
|
13927
|
+
// Rejoin rate limit: bound the number of REJOIN (and MIGRATE)
|
|
13928
|
+
// transitions inside a rolling window. FAST is not counted because
|
|
13929
|
+
// it does not issue a new backend `joinCall`.
|
|
13930
|
+
if (this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN ||
|
|
13931
|
+
this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE) {
|
|
13932
|
+
if (!this.rejoinRateLimiter.tryRegister()) {
|
|
13933
|
+
await giveUpAndLeave('rejoin_attempt_limit_exceeded');
|
|
13934
|
+
return;
|
|
13935
|
+
}
|
|
13936
|
+
}
|
|
13838
13937
|
// we don't increment reconnect attempts for the FAST strategy.
|
|
13839
13938
|
if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
|
|
13840
13939
|
this.reconnectAttempts++;
|
|
@@ -13862,6 +13961,8 @@ class Call {
|
|
|
13862
13961
|
ensureExhausted(this.reconnectStrategy, 'Unknown reconnection strategy');
|
|
13863
13962
|
break;
|
|
13864
13963
|
}
|
|
13964
|
+
// reconnection worked — reset the negotiation-failure streak.
|
|
13965
|
+
this.consecutiveNegotiationFailures = 0;
|
|
13865
13966
|
break; // do-while loop, reconnection worked, exit the loop
|
|
13866
13967
|
}
|
|
13867
13968
|
catch (error) {
|
|
@@ -13876,7 +13977,16 @@ class Call {
|
|
|
13876
13977
|
await markAsReconnectingFailed();
|
|
13877
13978
|
return;
|
|
13878
13979
|
}
|
|
13879
|
-
|
|
13980
|
+
if (error instanceof NegotiationError) {
|
|
13981
|
+
this.consecutiveNegotiationFailures++;
|
|
13982
|
+
if (this.consecutiveNegotiationFailures >=
|
|
13983
|
+
this.maxConsecutiveNegotiationFailures) {
|
|
13984
|
+
await giveUpAndLeave('repeated_negotiation_failures');
|
|
13985
|
+
return;
|
|
13986
|
+
}
|
|
13987
|
+
}
|
|
13988
|
+
// exponential backoff with jitter, capped at 5 s
|
|
13989
|
+
await sleep(retryInterval(attempt));
|
|
13880
13990
|
const wasMigrating = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
|
|
13881
13991
|
const mustPerformRejoin = (Date.now() - reconnectStartTime) / 1000 >
|
|
13882
13992
|
this.fastReconnectDeadlineSeconds;
|
|
@@ -13985,7 +14095,7 @@ class Call {
|
|
|
13985
14095
|
this.registerReconnectHandlers = () => {
|
|
13986
14096
|
// handles the legacy "goAway" event
|
|
13987
14097
|
const unregisterGoAway = this.on('goAway', () => {
|
|
13988
|
-
this.reconnect(WebsocketReconnectStrategy.MIGRATE,
|
|
14098
|
+
this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
|
|
13989
14099
|
});
|
|
13990
14100
|
// handles the "error" event, through which the SFU can request a reconnect
|
|
13991
14101
|
const unregisterOnError = this.on('error', (e) => {
|
|
@@ -14004,7 +14114,7 @@ class Call {
|
|
|
14004
14114
|
});
|
|
14005
14115
|
}
|
|
14006
14116
|
else {
|
|
14007
|
-
this.reconnect(strategy, error?.message ||
|
|
14117
|
+
this.reconnect(strategy, error?.message || ReconnectReason.SFU_ERROR).catch((err) => {
|
|
14008
14118
|
this.logger.warn('[Reconnect] Error reconnecting', err);
|
|
14009
14119
|
});
|
|
14010
14120
|
}
|
|
@@ -14028,7 +14138,7 @@ class Call {
|
|
|
14028
14138
|
strategy = WebsocketReconnectStrategy.REJOIN;
|
|
14029
14139
|
}
|
|
14030
14140
|
}
|
|
14031
|
-
this.reconnect(strategy,
|
|
14141
|
+
this.reconnect(strategy, ReconnectReason.NETWORK_BACK_ONLINE).catch((err) => {
|
|
14032
14142
|
this.logger.warn('[Reconnect] Error reconnecting after going online', err);
|
|
14033
14143
|
});
|
|
14034
14144
|
});
|
|
@@ -14171,10 +14281,12 @@ class Call {
|
|
|
14171
14281
|
* @param trackTypes the track types to update the call state with.
|
|
14172
14282
|
*/
|
|
14173
14283
|
this.updateLocalStreamState = async (mediaStream, ...trackTypes) => {
|
|
14174
|
-
|
|
14284
|
+
const sessionId = this.sfuClient?.sessionId;
|
|
14285
|
+
if (!sessionId)
|
|
14175
14286
|
return;
|
|
14176
14287
|
await this.notifyTrackMuteState(!mediaStream, ...trackTypes);
|
|
14177
|
-
|
|
14288
|
+
if (this.sfuClient?.sessionId !== sessionId)
|
|
14289
|
+
return;
|
|
14178
14290
|
for (const trackType of trackTypes) {
|
|
14179
14291
|
const streamStateProp = trackTypeToParticipantStreamKey(trackType);
|
|
14180
14292
|
if (!streamStateProp)
|
|
@@ -14970,6 +15082,39 @@ class Call {
|
|
|
14970
15082
|
this.setDisconnectionTimeout = (timeoutSeconds) => {
|
|
14971
15083
|
this.disconnectionTimeoutSeconds = timeoutSeconds;
|
|
14972
15084
|
};
|
|
15085
|
+
/**
|
|
15086
|
+
* Configures the rolling-window limit for REJOIN/MIGRATE attempts. Once
|
|
15087
|
+
* `maxAttempts` rejoins have been registered inside `windowSeconds`, the
|
|
15088
|
+
* SDK stops retrying and transitions the call to `LEFT` with the
|
|
15089
|
+
* `rejoin_attempt_limit_exceeded` leave message.
|
|
15090
|
+
*
|
|
15091
|
+
* Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
|
|
15092
|
+
* Both arguments are clamped to a minimum of 1.
|
|
15093
|
+
*/
|
|
15094
|
+
this.setRejoinAttemptLimit = (maxAttempts, windowSeconds) => {
|
|
15095
|
+
this.rejoinRateLimiter.setLimits(Math.max(1, maxAttempts), Math.max(1, windowSeconds) * 1000);
|
|
15096
|
+
};
|
|
15097
|
+
/**
|
|
15098
|
+
* Configures how many peer-connection failures where ICE never reached
|
|
15099
|
+
* `connected`/`completed` are tolerated before the SDK concludes that the
|
|
15100
|
+
* current network cannot support WebRTC and transitions the call to
|
|
15101
|
+
* `LEFT` with the `webrtc_unsupported_network` leave message.
|
|
15102
|
+
*
|
|
15103
|
+
* Default: 2. Clamped to a minimum of 1.
|
|
15104
|
+
*/
|
|
15105
|
+
this.setMaxIceFailuresWithoutConnect = (n) => {
|
|
15106
|
+
this.maxIceFailuresWithoutConnect = Math.max(1, n);
|
|
15107
|
+
};
|
|
15108
|
+
/**
|
|
15109
|
+
* Configures how many consecutive SDP `NegotiationError`s are tolerated
|
|
15110
|
+
* before the SDK stops retrying and transitions the call to `LEFT` with
|
|
15111
|
+
* the `repeated_negotiation_failures` leave message.
|
|
15112
|
+
*
|
|
15113
|
+
* Default: 3. Clamped to a minimum of 1.
|
|
15114
|
+
*/
|
|
15115
|
+
this.setMaxConsecutiveNegotiationFailures = (n) => {
|
|
15116
|
+
this.maxConsecutiveNegotiationFailures = Math.max(1, n);
|
|
15117
|
+
};
|
|
14973
15118
|
/**
|
|
14974
15119
|
* Enables the provided client capabilities.
|
|
14975
15120
|
*/
|
|
@@ -16150,7 +16295,7 @@ class StreamClient {
|
|
|
16150
16295
|
this.getUserAgent = () => {
|
|
16151
16296
|
if (!this.cachedUserAgent) {
|
|
16152
16297
|
const { clientAppIdentifier = {} } = this.options;
|
|
16153
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
16298
|
+
const { sdkName = 'js', sdkVersion = "1.49.0", ...extras } = clientAppIdentifier;
|
|
16154
16299
|
this.cachedUserAgent = [
|
|
16155
16300
|
`stream-video-${sdkName}-v${sdkVersion}`,
|
|
16156
16301
|
...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
|
|
@@ -16786,5 +16931,5 @@ const humanize = (n) => {
|
|
|
16786
16931
|
return String(n);
|
|
16787
16932
|
};
|
|
16788
16933
|
|
|
16789
|
-
export { AudioSettingsRequestDefaultDeviceEnum, AudioSettingsResponseDefaultDeviceEnum, browsers as Browsers, Call, CallRecordingFailedEventRecordingTypeEnum, CallRecordingReadyEventRecordingTypeEnum, CallRecordingStartedEventRecordingTypeEnum, CallRecordingStoppedEventRecordingTypeEnum, CallState, CallType, CallTypes, CallingState, CameraManager, CameraManagerState, CreateDeviceRequestPushProviderEnum, DebounceType, DeviceManager, DeviceManagerState, DynascaleManager, ErrorFromResponse, FrameRecordingSettingsRequestModeEnum, FrameRecordingSettingsRequestQualityEnum, FrameRecordingSettingsResponseModeEnum, IndividualRecordingSettingsRequestModeEnum, IndividualRecordingSettingsResponseModeEnum, IngressAudioEncodingOptionsRequestChannelsEnum, IngressSourceRequestFpsEnum, IngressVideoLayerRequestCodecEnum, LayoutSettingsRequestNameEnum, MicrophoneManager, MicrophoneManagerState, NoiseCancellationSettingsModeEnum, OwnCapability,
|
|
16934
|
+
export { AudioSettingsRequestDefaultDeviceEnum, AudioSettingsResponseDefaultDeviceEnum, browsers as Browsers, Call, CallRecordingFailedEventRecordingTypeEnum, CallRecordingReadyEventRecordingTypeEnum, CallRecordingStartedEventRecordingTypeEnum, CallRecordingStoppedEventRecordingTypeEnum, CallState, CallType, CallTypes, CallingState, CameraManager, CameraManagerState, CreateDeviceRequestPushProviderEnum, DebounceType, DeviceManager, DeviceManagerState, DynascaleManager, ErrorFromResponse, FrameRecordingSettingsRequestModeEnum, FrameRecordingSettingsRequestQualityEnum, FrameRecordingSettingsResponseModeEnum, IndividualRecordingSettingsRequestModeEnum, IndividualRecordingSettingsResponseModeEnum, IngressAudioEncodingOptionsRequestChannelsEnum, IngressSourceRequestFpsEnum, IngressVideoLayerRequestCodecEnum, LayoutSettingsRequestNameEnum, MicrophoneManager, MicrophoneManagerState, NoiseCancellationSettingsModeEnum, OwnCapability, RTMPBroadcastRequestQualityEnum, RTMPSettingsRequestQualityEnum, RawRecordingSettingsRequestModeEnum, RawRecordingSettingsResponseModeEnum, RecordSettingsRequestModeEnum, RecordSettingsRequestQualityEnum, rxUtils as RxUtils, ScreenShareManager, ScreenShareState, events as SfuEvents, SfuJoinError, models as SfuModels, SpeakerManager, SpeakerState, StartClosedCaptionsRequestLanguageEnum, StartTranscriptionRequestLanguageEnum, StreamSfuClient, StreamVideoClient, StreamVideoReadOnlyStateStore, StreamVideoWriteableStateStore, TranscriptionSettingsRequestClosedCaptionModeEnum, TranscriptionSettingsRequestLanguageEnum, TranscriptionSettingsRequestModeEnum, TranscriptionSettingsResponseClosedCaptionModeEnum, TranscriptionSettingsResponseLanguageEnum, TranscriptionSettingsResponseModeEnum, VideoSettingsRequestCameraFacingEnum, VideoSettingsResponseCameraFacingEnum, ViewportTracker, VisibilityState, checkIfAudioOutputChangeSupported, combineComparators, conditional, createSoundDetector, defaultSortPreset, descending, deviceIds$, disposeOfMediaStream, dominantSpeaker, getAudioBrowserPermission, getAudioDevices, getAudioOutputDevices, getAudioStream, getClientDetails, getDeviceState, getScreenShareStream, getSdkInfo, getVideoBrowserPermission, getVideoDevices, getVideoStream, getWebRTCInfo, hasAudio$1 as hasAudio, hasPausedTrack, hasScreenShare, hasScreenShareAudio, hasVideo, humanize, isPinned, livestreamOrAudioRoomSortPreset, logToConsole, name, noopComparator, paginatedLayoutSortPreset, pinned, publishingAudio, publishingVideo, reactionType, resolveDeviceId, role, screenSharing, setDeviceInfo, setOSInfo, setPowerState, setSdkInfo, setThermalState, setWebRTCInfo, speakerLayoutSortPreset, speaking, videoLoggerSystem, withParticipantSource };
|
|
16790
16935
|
//# sourceMappingURL=index.es.js.map
|