@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/src/Call.d.ts
CHANGED
|
@@ -92,6 +92,11 @@ export declare class Call {
|
|
|
92
92
|
private disconnectionTimeoutSeconds;
|
|
93
93
|
private lastOfflineTimestamp;
|
|
94
94
|
private networkAvailableTask;
|
|
95
|
+
private readonly rejoinRateLimiter;
|
|
96
|
+
private maxIceFailuresWithoutConnect;
|
|
97
|
+
private iceFailuresWithoutConnect;
|
|
98
|
+
private maxConsecutiveNegotiationFailures;
|
|
99
|
+
private consecutiveNegotiationFailures;
|
|
95
100
|
private trackPublishOrder;
|
|
96
101
|
private joinResponseTimeout?;
|
|
97
102
|
private rpcRequestTimeout?;
|
|
@@ -298,7 +303,9 @@ export declare class Call {
|
|
|
298
303
|
* @internal
|
|
299
304
|
*
|
|
300
305
|
* @param strategy the reconnection strategy to use.
|
|
301
|
-
* @param reason the reason for the reconnection.
|
|
306
|
+
* @param reason the reason for the reconnection. Pass a `ReconnectReason.*`
|
|
307
|
+
* constant when the SDK should react to it (e.g.
|
|
308
|
+
* `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
|
|
302
309
|
*/
|
|
303
310
|
private reconnect;
|
|
304
311
|
/**
|
|
@@ -839,6 +846,33 @@ export declare class Call {
|
|
|
839
846
|
* @param timeoutSeconds Timeout in seconds, or 0 to keep reconnecting indefinetely
|
|
840
847
|
*/
|
|
841
848
|
setDisconnectionTimeout: (timeoutSeconds: number) => void;
|
|
849
|
+
/**
|
|
850
|
+
* Configures the rolling-window limit for REJOIN/MIGRATE attempts. Once
|
|
851
|
+
* `maxAttempts` rejoins have been registered inside `windowSeconds`, the
|
|
852
|
+
* SDK stops retrying and transitions the call to `LEFT` with the
|
|
853
|
+
* `rejoin_attempt_limit_exceeded` leave message.
|
|
854
|
+
*
|
|
855
|
+
* Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
|
|
856
|
+
* Both arguments are clamped to a minimum of 1.
|
|
857
|
+
*/
|
|
858
|
+
setRejoinAttemptLimit: (maxAttempts: number, windowSeconds: number) => void;
|
|
859
|
+
/**
|
|
860
|
+
* Configures how many peer-connection failures where ICE never reached
|
|
861
|
+
* `connected`/`completed` are tolerated before the SDK concludes that the
|
|
862
|
+
* current network cannot support WebRTC and transitions the call to
|
|
863
|
+
* `LEFT` with the `webrtc_unsupported_network` leave message.
|
|
864
|
+
*
|
|
865
|
+
* Default: 2. Clamped to a minimum of 1.
|
|
866
|
+
*/
|
|
867
|
+
setMaxIceFailuresWithoutConnect: (n: number) => void;
|
|
868
|
+
/**
|
|
869
|
+
* Configures how many consecutive SDP `NegotiationError`s are tolerated
|
|
870
|
+
* before the SDK stops retrying and transitions the call to `LEFT` with
|
|
871
|
+
* the `repeated_negotiation_failures` leave message.
|
|
872
|
+
*
|
|
873
|
+
* Default: 3. Clamped to a minimum of 1.
|
|
874
|
+
*/
|
|
875
|
+
setMaxConsecutiveNegotiationFailures: (n: number) => void;
|
|
842
876
|
/**
|
|
843
877
|
* Enables the provided client capabilities.
|
|
844
878
|
*/
|
|
@@ -107,7 +107,8 @@ export declare class StreamSfuClient {
|
|
|
107
107
|
private networkAvailableTask;
|
|
108
108
|
/**
|
|
109
109
|
* Promise that resolves when the JoinResponse is received.
|
|
110
|
-
* Rejects after a certain threshold if the response is not received
|
|
110
|
+
* Rejects after a certain threshold if the response is not received,
|
|
111
|
+
* or when the SFU client is disposed before a join completes.
|
|
111
112
|
*/
|
|
112
113
|
private joinResponseTask;
|
|
113
114
|
/**
|
|
@@ -140,6 +141,12 @@ export declare class StreamSfuClient {
|
|
|
140
141
|
* The close code used when the client fails to join the call (on the SFU).
|
|
141
142
|
*/
|
|
142
143
|
static JOIN_FAILED: number;
|
|
144
|
+
/**
|
|
145
|
+
* Best-effort grace period in `leaveAndClose` for an in-flight join to
|
|
146
|
+
* complete before we give up and close without sending `leaveCallRequest`.
|
|
147
|
+
* Bounded so a stuck join can never hang the leave path.
|
|
148
|
+
*/
|
|
149
|
+
static LEAVE_NOTIFY_GRACE_MS: number;
|
|
143
150
|
/**
|
|
144
151
|
* Constructs a new SFU client.
|
|
145
152
|
*/
|
|
@@ -6,6 +6,7 @@ export declare abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
6
6
|
protected statusSubject: BehaviorSubject<InputDeviceStatus>;
|
|
7
7
|
protected optimisticStatusSubject: BehaviorSubject<InputDeviceStatus>;
|
|
8
8
|
protected mediaStreamSubject: BehaviorSubject<MediaStream | undefined>;
|
|
9
|
+
protected rootMediaStreamSubject: BehaviorSubject<MediaStream | undefined>;
|
|
9
10
|
protected selectedDeviceSubject: BehaviorSubject<string | undefined>;
|
|
10
11
|
protected defaultConstraintsSubject: BehaviorSubject<C | undefined>;
|
|
11
12
|
/**
|
|
@@ -17,6 +18,12 @@ export declare abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
17
18
|
*
|
|
18
19
|
*/
|
|
19
20
|
mediaStream$: Observable<MediaStream | undefined>;
|
|
21
|
+
/**
|
|
22
|
+
* An Observable that emits the raw device media stream (before any filters are applied),
|
|
23
|
+
* or `undefined` if the device is currently disabled. When no filters are active, this
|
|
24
|
+
* emits the same stream as `mediaStream$`.
|
|
25
|
+
*/
|
|
26
|
+
rootMediaStream$: Observable<MediaStream | undefined>;
|
|
20
27
|
/**
|
|
21
28
|
* An Observable that emits the currently selected device
|
|
22
29
|
*/
|
|
@@ -73,6 +80,12 @@ export declare abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
73
80
|
* The current media stream, or `undefined` if the device is currently disabled.
|
|
74
81
|
*/
|
|
75
82
|
get mediaStream(): MediaStream | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* The raw device media stream (before any filters are applied), or `undefined`
|
|
85
|
+
* if the device is currently disabled. When no filters are active, this is the
|
|
86
|
+
* same as `mediaStream`.
|
|
87
|
+
*/
|
|
88
|
+
get rootMediaStream(): MediaStream | undefined;
|
|
76
89
|
/**
|
|
77
90
|
* @internal
|
|
78
91
|
* @param status
|
|
@@ -13,7 +13,6 @@ export declare class MicrophoneManager extends AudioDeviceManager<MicrophoneMana
|
|
|
13
13
|
private soundDetectorCleanup?;
|
|
14
14
|
private soundDetectorDeviceId?;
|
|
15
15
|
private noAudioDetectorCleanup?;
|
|
16
|
-
private rnSpeechDetector;
|
|
17
16
|
private noiseCancellation;
|
|
18
17
|
private noiseCancellationChangeUnsubscribe;
|
|
19
18
|
private noiseCancellationRegistration?;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A generic sliding-window rate limiter.
|
|
3
|
+
*
|
|
4
|
+
* Allows at most `maxAttempts` registrations inside a rolling `windowMs`.
|
|
5
|
+
* Attempts spaced further apart than `windowMs` are always allowed.
|
|
6
|
+
*/
|
|
7
|
+
export declare class SlidingWindowRateLimiter {
|
|
8
|
+
private maxAttempts;
|
|
9
|
+
private windowMs;
|
|
10
|
+
private timestamps;
|
|
11
|
+
constructor(maxAttempts: number, windowMs: number);
|
|
12
|
+
/**
|
|
13
|
+
* Attempts to register a new event at `now`. Returns `true` if the attempt
|
|
14
|
+
* fits inside the budget (and records it), or `false` if the budget is
|
|
15
|
+
* exhausted (in which case no timestamp is recorded).
|
|
16
|
+
*/
|
|
17
|
+
tryRegister: (now?: number) => boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Clears the attempt history.
|
|
20
|
+
*/
|
|
21
|
+
reset: () => void;
|
|
22
|
+
/**
|
|
23
|
+
* Updates the budget and window size. Existing timestamps are kept; they
|
|
24
|
+
* will be pruned by the next `tryRegister` call.
|
|
25
|
+
*/
|
|
26
|
+
setLimits: (maxAttempts: number, windowMs: number) => void;
|
|
27
|
+
private prune;
|
|
28
|
+
}
|
|
@@ -5,7 +5,7 @@ import { PeerType, TrackType } from '../gen/video/sfu/models/models';
|
|
|
5
5
|
import { StreamSfuClient } from '../StreamSfuClient';
|
|
6
6
|
import { AllSfuEvents, Dispatcher } from './Dispatcher';
|
|
7
7
|
import { StatsTracer, Tracer } from '../stats';
|
|
8
|
-
import
|
|
8
|
+
import { BasePeerConnectionOpts } from './types';
|
|
9
9
|
import type { ClientPublishOptions } from '../types';
|
|
10
10
|
/**
|
|
11
11
|
* A base class for the `Publisher` and `Subscriber` classes.
|
|
@@ -21,8 +21,11 @@ export declare abstract class BasePeerConnection {
|
|
|
21
21
|
protected tag: string;
|
|
22
22
|
protected sfuClient: StreamSfuClient;
|
|
23
23
|
private onReconnectionNeeded?;
|
|
24
|
+
private onIceConnected?;
|
|
24
25
|
private readonly iceRestartDelay;
|
|
26
|
+
private iceHasEverConnected;
|
|
25
27
|
private iceRestartTimeout?;
|
|
28
|
+
private preConnectStuckTimeout?;
|
|
26
29
|
protected isIceRestarting: boolean;
|
|
27
30
|
private isDisposed;
|
|
28
31
|
protected trackIdToTrackType: Map<string, TrackType>;
|
|
@@ -34,7 +37,7 @@ export declare abstract class BasePeerConnection {
|
|
|
34
37
|
/**
|
|
35
38
|
* Constructs a new `BasePeerConnection` instance.
|
|
36
39
|
*/
|
|
37
|
-
protected constructor(peerType: PeerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, tag, enableTracing, clientPublishOptions, iceRestartDelay, }: BasePeerConnectionOpts);
|
|
40
|
+
protected constructor(peerType: PeerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, tag, enableTracing, clientPublishOptions, iceRestartDelay, }: BasePeerConnectionOpts);
|
|
38
41
|
private createPeerConnection;
|
|
39
42
|
/**
|
|
40
43
|
* Disposes the `RTCPeerConnection` instance.
|
|
@@ -85,6 +88,12 @@ export declare abstract class BasePeerConnection {
|
|
|
85
88
|
* it returns `false`, otherwise it returns `true`.
|
|
86
89
|
*/
|
|
87
90
|
isHealthy: () => boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Returns true only when the peer connection is currently fully established
|
|
93
|
+
* (ICE `connected`/`completed` AND connection state `connected`).
|
|
94
|
+
* Transient states like `disconnected`, `checking`, or `new` return false.
|
|
95
|
+
*/
|
|
96
|
+
isStable: () => boolean;
|
|
88
97
|
/**
|
|
89
98
|
* Handles the ICECandidate event and
|
|
90
99
|
* Initiates an ICE Trickle process with the SFU.
|
package/dist/src/rtc/index.d.ts
CHANGED
package/dist/src/rtc/types.d.ts
CHANGED
|
@@ -4,13 +4,45 @@ import { CallState } from '../store';
|
|
|
4
4
|
import { Dispatcher } from './Dispatcher';
|
|
5
5
|
import type { OptimalVideoLayer } from './layers';
|
|
6
6
|
import type { ClientPublishOptions } from '../types';
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Canonical reasons the SDK uses to trigger a reconnection. Free-form strings
|
|
9
|
+
* are still accepted at the callback boundary (e.g. when forwarding an SFU
|
|
10
|
+
* error message), but only the members below influence reconnect-loop
|
|
11
|
+
* behavior. In particular, `Call.reconnect` programmatically inspects
|
|
12
|
+
* `ICE_NEVER_CONNECTED` to drive the unsupported-network detector — pass a
|
|
13
|
+
* canonical member when you want the SDK to react to the reason; pass a
|
|
14
|
+
* free-form string when the value is purely diagnostic.
|
|
15
|
+
*/
|
|
16
|
+
export declare const ReconnectReason: {
|
|
17
|
+
/** ICE never reached `connected`/`completed`, escalate to REJOIN. */
|
|
18
|
+
readonly ICE_NEVER_CONNECTED: "ice_never_connected";
|
|
19
|
+
/** RTCPeerConnection.connectionState became `failed`. */
|
|
20
|
+
readonly CONNECTION_FAILED: "connection_failed";
|
|
21
|
+
/** `restartIce()` rejected. */
|
|
22
|
+
readonly RESTART_ICE_FAILED: "restart_ice_failed";
|
|
23
|
+
/** SFU `goAway` event, migrate to a new SFU. */
|
|
24
|
+
readonly GO_AWAY: "go_away";
|
|
25
|
+
/** Network came back online after going offline. */
|
|
26
|
+
readonly NETWORK_BACK_ONLINE: "network_back_online";
|
|
27
|
+
/** SFU error event with no descriptive message. */
|
|
28
|
+
readonly SFU_ERROR: "sfu_error";
|
|
29
|
+
};
|
|
30
|
+
export type ReconnectReason = (typeof ReconnectReason)[keyof typeof ReconnectReason] | (string & {});
|
|
31
|
+
export type OnReconnectionNeeded = (kind: WebsocketReconnectStrategy, reason: ReconnectReason, peerType: PeerType) => void;
|
|
32
|
+
/**
|
|
33
|
+
* Fires the first time a peer connection's ICE transport reaches
|
|
34
|
+
* `connected` or `completed` during its lifetime. Used by `Call` to reset
|
|
35
|
+
* the "ICE never connected" failure counter only when WebRTC has actually
|
|
36
|
+
* recovered, not merely when the SFU join handshake succeeded.
|
|
37
|
+
*/
|
|
38
|
+
export type OnIceConnected = (peerType: PeerType) => void;
|
|
8
39
|
export type BasePeerConnectionOpts = {
|
|
9
40
|
sfuClient: StreamSfuClient;
|
|
10
41
|
state: CallState;
|
|
11
42
|
connectionConfig?: RTCConfiguration;
|
|
12
43
|
dispatcher: Dispatcher;
|
|
13
44
|
onReconnectionNeeded?: OnReconnectionNeeded;
|
|
45
|
+
onIceConnected?: OnIceConnected;
|
|
14
46
|
tag: string;
|
|
15
47
|
enableTracing: boolean;
|
|
16
48
|
iceRestartDelay?: number;
|
package/dist/src/types.d.ts
CHANGED
|
@@ -371,6 +371,17 @@ export type StreamRNVideoSDKGlobals = {
|
|
|
371
371
|
*/
|
|
372
372
|
check(permission: 'microphone' | 'camera'): Promise<boolean>;
|
|
373
373
|
};
|
|
374
|
+
nativeEvents: {
|
|
375
|
+
speechActivity: {
|
|
376
|
+
/**
|
|
377
|
+
* Subscribes to native speech activity events.
|
|
378
|
+
* Returns an unsubscribe function.
|
|
379
|
+
*/
|
|
380
|
+
subscribe(cb: (state: {
|
|
381
|
+
isSoundDetected: boolean;
|
|
382
|
+
}) => void): () => void;
|
|
383
|
+
};
|
|
384
|
+
};
|
|
374
385
|
};
|
|
375
386
|
declare global {
|
|
376
387
|
var streamRNVideoSDK: StreamRNVideoSDKGlobals | undefined;
|
package/index.ts
CHANGED
|
@@ -23,7 +23,6 @@ export * from './src/helpers/DynascaleManager';
|
|
|
23
23
|
export * from './src/helpers/ViewportTracker';
|
|
24
24
|
export * from './src/helpers/sound-detector';
|
|
25
25
|
export * from './src/helpers/participantUtils';
|
|
26
|
-
export * from './src/helpers/RNSpeechDetector';
|
|
27
26
|
export * as Browsers from './src/helpers/browsers';
|
|
28
27
|
|
|
29
28
|
export * from './src/logger';
|
package/package.json
CHANGED
package/src/Call.ts
CHANGED
|
@@ -7,7 +7,9 @@ import {
|
|
|
7
7
|
isAudioTrackType,
|
|
8
8
|
isSfuEvent,
|
|
9
9
|
muteTypeToTrackType,
|
|
10
|
+
NegotiationError,
|
|
10
11
|
Publisher,
|
|
12
|
+
ReconnectReason,
|
|
11
13
|
Subscriber,
|
|
12
14
|
toRtcConfiguration,
|
|
13
15
|
TrackPublishOptions,
|
|
@@ -148,6 +150,7 @@ import { PermissionsContext } from './permissions';
|
|
|
148
150
|
import { CallTypes } from './CallType';
|
|
149
151
|
import { StreamClient } from './coordinator/connection/client';
|
|
150
152
|
import { retryInterval, sleep } from './coordinator/connection/utils';
|
|
153
|
+
import { SlidingWindowRateLimiter } from './helpers/SlidingWindowRateLimiter';
|
|
151
154
|
import {
|
|
152
155
|
AllCallEvents,
|
|
153
156
|
CallEventListener,
|
|
@@ -271,6 +274,17 @@ export class Call {
|
|
|
271
274
|
private disconnectionTimeoutSeconds: number = 0;
|
|
272
275
|
private lastOfflineTimestamp: number = 0;
|
|
273
276
|
private networkAvailableTask: PromiseWithResolvers<void> | undefined;
|
|
277
|
+
|
|
278
|
+
// (10 attempts per rolling 120 s window).
|
|
279
|
+
private readonly rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
|
|
280
|
+
// "Network doesn't support WebRTC" detector: counts peer-connection
|
|
281
|
+
// failures where ICE never reached `connected`/`completed`.
|
|
282
|
+
private maxIceFailuresWithoutConnect = 2;
|
|
283
|
+
private iceFailuresWithoutConnect = 0;
|
|
284
|
+
// Consecutive-negotiation-failure detector: stops the reconnect loop when
|
|
285
|
+
// the SFU keeps failing to negotiate SDP for us.
|
|
286
|
+
private maxConsecutiveNegotiationFailures = 3;
|
|
287
|
+
private consecutiveNegotiationFailures = 0;
|
|
274
288
|
// maintain the order of publishing tracks to restore them after a reconnection
|
|
275
289
|
// it shouldn't contain duplicates
|
|
276
290
|
private trackPublishOrder: TrackType[] = [];
|
|
@@ -694,6 +708,20 @@ export class Call {
|
|
|
694
708
|
this.state.setParticipants([]);
|
|
695
709
|
this.state.dispose();
|
|
696
710
|
|
|
711
|
+
// Reset reconnect-related accumulators so a future `call.join()` on the
|
|
712
|
+
// same instance starts with a fresh budget. The `Call` may be reused
|
|
713
|
+
// (see `Call.test.ts` "can reuse call instance") so this is required.
|
|
714
|
+
// Strategy/reason/attempts must also be cleared: when `leave()` is
|
|
715
|
+
// reached via `giveUpAndLeave()` the success-path reset at the end of
|
|
716
|
+
// `joinFlow` never runs, leaving stale values that would make the next
|
|
717
|
+
// fresh `join()` send a stale `ReconnectDetails` to the SFU.
|
|
718
|
+
this.rejoinRateLimiter.reset();
|
|
719
|
+
this.iceFailuresWithoutConnect = 0;
|
|
720
|
+
this.consecutiveNegotiationFailures = 0;
|
|
721
|
+
this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
|
|
722
|
+
this.reconnectReason = '';
|
|
723
|
+
this.reconnectAttempts = 0;
|
|
724
|
+
|
|
697
725
|
// Call all leave call hooks, e.g. to clean up global event handlers
|
|
698
726
|
this.leaveCallHooks.forEach((hook) => hook());
|
|
699
727
|
this.initialized = false;
|
|
@@ -1169,9 +1197,23 @@ export class Call {
|
|
|
1169
1197
|
// when performing fast reconnect, or when we reuse the same SFU client,
|
|
1170
1198
|
// (ws remained healthy), we just need to restore the ICE connection
|
|
1171
1199
|
if (performingFastReconnect) {
|
|
1172
|
-
//
|
|
1173
|
-
// we
|
|
1174
|
-
|
|
1200
|
+
// The SFU automatically issues an ICE restart on the subscriber,
|
|
1201
|
+
// so we only need to decide about the publisher. If the publisher's
|
|
1202
|
+
// peer connection is still stable (ICE still connected end-to-end),
|
|
1203
|
+
// the signal WebSocket drop was the only problem — the new WS alone
|
|
1204
|
+
// is enough, and restarting ICE would add unnecessary SDP/ICE churn.
|
|
1205
|
+
const publisherIsStable = this.publisher?.isStable() ?? true;
|
|
1206
|
+
const includePublisher =
|
|
1207
|
+
!!this.publisher?.isPublishing() && !publisherIsStable;
|
|
1208
|
+
if (!includePublisher && this.publisher?.isPublishing()) {
|
|
1209
|
+
this.logger.info(
|
|
1210
|
+
'[Reconnect] FAST: skipping publisher ICE restart, publisher PC is stable',
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
await this.restoreICE(sfuClient, {
|
|
1214
|
+
includeSubscriber: false,
|
|
1215
|
+
includePublisher,
|
|
1216
|
+
});
|
|
1175
1217
|
} else {
|
|
1176
1218
|
const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
|
|
1177
1219
|
this.initPublisherAndSubscriber({
|
|
@@ -1223,6 +1265,15 @@ export class Call {
|
|
|
1223
1265
|
// reset the reconnect strategy to unspecified after a successful reconnection
|
|
1224
1266
|
this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
|
|
1225
1267
|
this.reconnectReason = '';
|
|
1268
|
+
// A successful SFU join handshake resets the consecutive-negotiation
|
|
1269
|
+
// counter (negotiation just succeeded). It does NOT reset
|
|
1270
|
+
// `iceFailuresWithoutConnect` or the rolling `rejoinRateLimiter`;
|
|
1271
|
+
// those track WebRTC-level health and rejoin frequency, which are not
|
|
1272
|
+
// proven by the SFU handshake alone. ICE-failures-without-connect is
|
|
1273
|
+
// cleared via the `onIceConnected` callback when the peer connection
|
|
1274
|
+
// actually reaches `connected`/`completed` end-to-end. The rejoin
|
|
1275
|
+
// rolling window decays naturally as old timestamps age out.
|
|
1276
|
+
this.consecutiveNegotiationFailures = 0;
|
|
1226
1277
|
|
|
1227
1278
|
this.logger.info(`Joined call ${this.cid}`);
|
|
1228
1279
|
};
|
|
@@ -1375,6 +1426,12 @@ export class Call {
|
|
|
1375
1426
|
this.logger.warn(message, err);
|
|
1376
1427
|
});
|
|
1377
1428
|
},
|
|
1429
|
+
onIceConnected: () => {
|
|
1430
|
+
// ICE has reached `connected`/`completed` end-to-end on at least
|
|
1431
|
+
// one peer connection, WebRTC is actually working, so the
|
|
1432
|
+
// "ICE never connected" failure budget can be cleared.
|
|
1433
|
+
this.iceFailuresWithoutConnect = 0;
|
|
1434
|
+
},
|
|
1378
1435
|
};
|
|
1379
1436
|
|
|
1380
1437
|
this.subscriber = new Subscriber(basePeerConnectionOptions);
|
|
@@ -1501,11 +1558,13 @@ export class Call {
|
|
|
1501
1558
|
* @internal
|
|
1502
1559
|
*
|
|
1503
1560
|
* @param strategy the reconnection strategy to use.
|
|
1504
|
-
* @param reason the reason for the reconnection.
|
|
1561
|
+
* @param reason the reason for the reconnection. Pass a `ReconnectReason.*`
|
|
1562
|
+
* constant when the SDK should react to it (e.g.
|
|
1563
|
+
* `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
|
|
1505
1564
|
*/
|
|
1506
1565
|
private reconnect = async (
|
|
1507
1566
|
strategy: WebsocketReconnectStrategy,
|
|
1508
|
-
reason:
|
|
1567
|
+
reason: ReconnectReason,
|
|
1509
1568
|
): Promise<void> => {
|
|
1510
1569
|
if (
|
|
1511
1570
|
this.state.callingState === CallingState.RECONNECTING ||
|
|
@@ -1529,6 +1588,35 @@ export class Call {
|
|
|
1529
1588
|
}
|
|
1530
1589
|
};
|
|
1531
1590
|
|
|
1591
|
+
const giveUpAndLeave = async (message: string) => {
|
|
1592
|
+
this.logger.warn(
|
|
1593
|
+
`[Reconnect] Giving up: ${message}. Leaving the call.`,
|
|
1594
|
+
);
|
|
1595
|
+
// If we're mid-iteration, the state can be JOINING; `Call.leave` would
|
|
1596
|
+
// then wait for JOINED before proceeding, but no more attempts will run
|
|
1597
|
+
// so JOINED never comes. Transition to RECONNECTING so `leave()` proceeds.
|
|
1598
|
+
if (this.state.callingState === CallingState.JOINING) {
|
|
1599
|
+
this.state.setCallingState(CallingState.RECONNECTING);
|
|
1600
|
+
}
|
|
1601
|
+
try {
|
|
1602
|
+
await this.leave({ message });
|
|
1603
|
+
} catch (err) {
|
|
1604
|
+
this.logger.warn(`[Reconnect] leave() failed after ${message}`, err);
|
|
1605
|
+
}
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1608
|
+
// Count this entry into reconnect if it was triggered by a peer
|
|
1609
|
+
// connection that never reached `connected`/`completed`.
|
|
1610
|
+
if (reason === ReconnectReason.ICE_NEVER_CONNECTED) {
|
|
1611
|
+
this.iceFailuresWithoutConnect++;
|
|
1612
|
+
if (
|
|
1613
|
+
this.iceFailuresWithoutConnect >= this.maxIceFailuresWithoutConnect
|
|
1614
|
+
) {
|
|
1615
|
+
await giveUpAndLeave('webrtc_unsupported_network');
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1532
1620
|
let attempt = 0;
|
|
1533
1621
|
do {
|
|
1534
1622
|
const reconnectingTime = Date.now() - reconnectStartTime;
|
|
@@ -1544,6 +1632,19 @@ export class Call {
|
|
|
1544
1632
|
return;
|
|
1545
1633
|
}
|
|
1546
1634
|
|
|
1635
|
+
// Rejoin rate limit: bound the number of REJOIN (and MIGRATE)
|
|
1636
|
+
// transitions inside a rolling window. FAST is not counted because
|
|
1637
|
+
// it does not issue a new backend `joinCall`.
|
|
1638
|
+
if (
|
|
1639
|
+
this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN ||
|
|
1640
|
+
this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE
|
|
1641
|
+
) {
|
|
1642
|
+
if (!this.rejoinRateLimiter.tryRegister()) {
|
|
1643
|
+
await giveUpAndLeave('rejoin_attempt_limit_exceeded');
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1547
1648
|
// we don't increment reconnect attempts for the FAST strategy.
|
|
1548
1649
|
if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
|
|
1549
1650
|
this.reconnectAttempts++;
|
|
@@ -1581,6 +1682,8 @@ export class Call {
|
|
|
1581
1682
|
);
|
|
1582
1683
|
break;
|
|
1583
1684
|
}
|
|
1685
|
+
// reconnection worked — reset the negotiation-failure streak.
|
|
1686
|
+
this.consecutiveNegotiationFailures = 0;
|
|
1584
1687
|
break; // do-while loop, reconnection worked, exit the loop
|
|
1585
1688
|
} catch (error) {
|
|
1586
1689
|
if (this.state.callingState === CallingState.OFFLINE) {
|
|
@@ -1599,8 +1702,19 @@ export class Call {
|
|
|
1599
1702
|
await markAsReconnectingFailed();
|
|
1600
1703
|
return;
|
|
1601
1704
|
}
|
|
1705
|
+
if (error instanceof NegotiationError) {
|
|
1706
|
+
this.consecutiveNegotiationFailures++;
|
|
1707
|
+
if (
|
|
1708
|
+
this.consecutiveNegotiationFailures >=
|
|
1709
|
+
this.maxConsecutiveNegotiationFailures
|
|
1710
|
+
) {
|
|
1711
|
+
await giveUpAndLeave('repeated_negotiation_failures');
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1602
1715
|
|
|
1603
|
-
|
|
1716
|
+
// exponential backoff with jitter, capped at 5 s
|
|
1717
|
+
await sleep(retryInterval(attempt));
|
|
1604
1718
|
|
|
1605
1719
|
const wasMigrating =
|
|
1606
1720
|
this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
|
|
@@ -1741,9 +1855,10 @@ export class Call {
|
|
|
1741
1855
|
private registerReconnectHandlers = () => {
|
|
1742
1856
|
// handles the legacy "goAway" event
|
|
1743
1857
|
const unregisterGoAway = this.on('goAway', () => {
|
|
1744
|
-
this.reconnect(
|
|
1745
|
-
|
|
1746
|
-
|
|
1858
|
+
this.reconnect(
|
|
1859
|
+
WebsocketReconnectStrategy.MIGRATE,
|
|
1860
|
+
ReconnectReason.GO_AWAY,
|
|
1861
|
+
).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
|
|
1747
1862
|
});
|
|
1748
1863
|
|
|
1749
1864
|
// handles the "error" event, through which the SFU can request a reconnect
|
|
@@ -1760,7 +1875,10 @@ export class Call {
|
|
|
1760
1875
|
this.logger.warn(`Can't leave call after disconnect request`, err);
|
|
1761
1876
|
});
|
|
1762
1877
|
} else {
|
|
1763
|
-
this.reconnect(
|
|
1878
|
+
this.reconnect(
|
|
1879
|
+
strategy,
|
|
1880
|
+
error?.message || ReconnectReason.SFU_ERROR,
|
|
1881
|
+
).catch((err) => {
|
|
1764
1882
|
this.logger.warn('[Reconnect] Error reconnecting', err);
|
|
1765
1883
|
});
|
|
1766
1884
|
}
|
|
@@ -1787,12 +1905,14 @@ export class Call {
|
|
|
1787
1905
|
}
|
|
1788
1906
|
}
|
|
1789
1907
|
|
|
1790
|
-
this.reconnect(strategy,
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1908
|
+
this.reconnect(strategy, ReconnectReason.NETWORK_BACK_ONLINE).catch(
|
|
1909
|
+
(err) => {
|
|
1910
|
+
this.logger.warn(
|
|
1911
|
+
'[Reconnect] Error reconnecting after going online',
|
|
1912
|
+
err,
|
|
1913
|
+
);
|
|
1914
|
+
},
|
|
1915
|
+
);
|
|
1796
1916
|
});
|
|
1797
1917
|
this.networkAvailableTask = networkAvailableTask;
|
|
1798
1918
|
this.sfuStatsReporter?.stop();
|
|
@@ -1958,10 +2078,12 @@ export class Call {
|
|
|
1958
2078
|
mediaStream: MediaStream | undefined,
|
|
1959
2079
|
...trackTypes: TrackType[]
|
|
1960
2080
|
) => {
|
|
1961
|
-
|
|
2081
|
+
const sessionId = this.sfuClient?.sessionId;
|
|
2082
|
+
if (!sessionId) return;
|
|
2083
|
+
|
|
1962
2084
|
await this.notifyTrackMuteState(!mediaStream, ...trackTypes);
|
|
2085
|
+
if (this.sfuClient?.sessionId !== sessionId) return;
|
|
1963
2086
|
|
|
1964
|
-
const { sessionId } = this.sfuClient;
|
|
1965
2087
|
for (const trackType of trackTypes) {
|
|
1966
2088
|
const streamStateProp = trackTypeToParticipantStreamKey(trackType);
|
|
1967
2089
|
if (!streamStateProp) continue;
|
|
@@ -3058,6 +3180,45 @@ export class Call {
|
|
|
3058
3180
|
this.disconnectionTimeoutSeconds = timeoutSeconds;
|
|
3059
3181
|
};
|
|
3060
3182
|
|
|
3183
|
+
/**
|
|
3184
|
+
* Configures the rolling-window limit for REJOIN/MIGRATE attempts. Once
|
|
3185
|
+
* `maxAttempts` rejoins have been registered inside `windowSeconds`, the
|
|
3186
|
+
* SDK stops retrying and transitions the call to `LEFT` with the
|
|
3187
|
+
* `rejoin_attempt_limit_exceeded` leave message.
|
|
3188
|
+
*
|
|
3189
|
+
* Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
|
|
3190
|
+
* Both arguments are clamped to a minimum of 1.
|
|
3191
|
+
*/
|
|
3192
|
+
setRejoinAttemptLimit = (maxAttempts: number, windowSeconds: number) => {
|
|
3193
|
+
this.rejoinRateLimiter.setLimits(
|
|
3194
|
+
Math.max(1, maxAttempts),
|
|
3195
|
+
Math.max(1, windowSeconds) * 1000,
|
|
3196
|
+
);
|
|
3197
|
+
};
|
|
3198
|
+
|
|
3199
|
+
/**
|
|
3200
|
+
* Configures how many peer-connection failures where ICE never reached
|
|
3201
|
+
* `connected`/`completed` are tolerated before the SDK concludes that the
|
|
3202
|
+
* current network cannot support WebRTC and transitions the call to
|
|
3203
|
+
* `LEFT` with the `webrtc_unsupported_network` leave message.
|
|
3204
|
+
*
|
|
3205
|
+
* Default: 2. Clamped to a minimum of 1.
|
|
3206
|
+
*/
|
|
3207
|
+
setMaxIceFailuresWithoutConnect = (n: number) => {
|
|
3208
|
+
this.maxIceFailuresWithoutConnect = Math.max(1, n);
|
|
3209
|
+
};
|
|
3210
|
+
|
|
3211
|
+
/**
|
|
3212
|
+
* Configures how many consecutive SDP `NegotiationError`s are tolerated
|
|
3213
|
+
* before the SDK stops retrying and transitions the call to `LEFT` with
|
|
3214
|
+
* the `repeated_negotiation_failures` leave message.
|
|
3215
|
+
*
|
|
3216
|
+
* Default: 3. Clamped to a minimum of 1.
|
|
3217
|
+
*/
|
|
3218
|
+
setMaxConsecutiveNegotiationFailures = (n: number) => {
|
|
3219
|
+
this.maxConsecutiveNegotiationFailures = Math.max(1, n);
|
|
3220
|
+
};
|
|
3221
|
+
|
|
3061
3222
|
/**
|
|
3062
3223
|
* Enables the provided client capabilities.
|
|
3063
3224
|
*/
|