@stream-io/video-client 1.48.0 → 1.50.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 +25 -0
- package/dist/index.browser.es.js +1497 -677
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1497 -677
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1497 -677
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +77 -4
- package/dist/src/StreamSfuClient.d.ts +8 -1
- package/dist/src/coordinator/connection/client.d.ts +1 -1
- package/dist/src/coordinator/connection/connection.d.ts +31 -25
- package/dist/src/coordinator/connection/types.d.ts +14 -0
- package/dist/src/coordinator/connection/utils.d.ts +1 -0
- package/dist/src/devices/DeviceManager.d.ts +3 -0
- package/dist/src/devices/DeviceManagerState.d.ts +13 -1
- package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
- package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
- package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
- package/dist/src/helpers/DynascaleManager.d.ts +8 -86
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
- package/dist/src/helpers/SlidingWindowRateLimiter.d.ts +28 -0
- package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
- package/dist/src/helpers/ViewportTracker.d.ts +11 -17
- package/dist/src/helpers/browsers.d.ts +13 -0
- package/dist/src/helpers/concurrency.d.ts +6 -4
- package/dist/src/rtc/BasePeerConnection.d.ts +11 -2
- package/dist/src/rtc/Publisher.d.ts +17 -0
- package/dist/src/rtc/Subscriber.d.ts +1 -0
- package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
- package/dist/src/rtc/index.d.ts +1 -0
- package/dist/src/rtc/types.d.ts +33 -1
- package/dist/src/stats/rtc/types.d.ts +1 -1
- package/dist/src/store/rxUtils.d.ts +9 -0
- package/dist/src/types.d.ts +18 -0
- package/package.json +2 -2
- package/src/Call.ts +268 -40
- package/src/StreamSfuClient.ts +75 -12
- package/src/__tests__/Call.lifecycle.test.ts +67 -0
- package/src/__tests__/Call.publishing.test.ts +103 -0
- package/src/__tests__/StreamSfuClient.test.ts +275 -0
- package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
- package/src/coordinator/connection/client.ts +1 -1
- package/src/coordinator/connection/connection.ts +149 -96
- package/src/coordinator/connection/types.ts +15 -0
- package/src/coordinator/connection/utils.ts +15 -0
- package/src/devices/DeviceManager.ts +92 -32
- package/src/devices/DeviceManagerState.ts +20 -1
- package/src/devices/__tests__/DeviceManager.test.ts +283 -0
- package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
- package/src/devices/__tests__/mocks.ts +2 -0
- package/src/devices/devices.ts +2 -1
- package/src/gen/video/sfu/event/events.ts +15 -0
- package/src/gen/video/sfu/models/models.ts +44 -0
- package/src/helpers/AudioBindingsWatchdog.ts +10 -7
- package/src/helpers/BlockedAudioTracker.ts +74 -0
- package/src/helpers/DynascaleManager.ts +46 -337
- package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
- package/src/helpers/SlidingWindowRateLimiter.ts +49 -0
- package/src/helpers/TrackSubscriptionManager.ts +243 -0
- package/src/helpers/ViewportTracker.ts +74 -19
- package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
- package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
- package/src/helpers/__tests__/SlidingWindowRateLimiter.test.ts +43 -0
- package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
- package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
- package/src/helpers/__tests__/browsers.test.ts +85 -1
- package/src/helpers/browsers.ts +24 -0
- package/src/helpers/concurrency.ts +9 -10
- package/src/rpc/retryable.ts +0 -1
- package/src/rtc/BasePeerConnection.ts +96 -6
- package/src/rtc/Publisher.ts +49 -2
- package/src/rtc/Subscriber.ts +42 -14
- package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
- package/src/rtc/__tests__/Publisher.test.ts +332 -10
- package/src/rtc/__tests__/Subscriber.test.ts +202 -1
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
- package/src/rtc/helpers/degradationPreference.ts +22 -0
- package/src/rtc/index.ts +1 -0
- package/src/rtc/types.ts +38 -1
- package/src/stats/rtc/types.ts +1 -0
- package/src/store/__tests__/rxUtils.test.ts +276 -0
- package/src/store/rxUtils.ts +19 -0
- package/src/types.ts +19 -0
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,
|
|
@@ -143,11 +145,16 @@ import {
|
|
|
143
145
|
StatsReporter,
|
|
144
146
|
Tracer,
|
|
145
147
|
} from './stats';
|
|
148
|
+
import { AudioBindingsWatchdog } from './helpers/AudioBindingsWatchdog';
|
|
149
|
+
import { BlockedAudioTracker } from './helpers/BlockedAudioTracker';
|
|
150
|
+
import { TrackSubscriptionManager } from './helpers/TrackSubscriptionManager';
|
|
146
151
|
import { DynascaleManager } from './helpers/DynascaleManager';
|
|
152
|
+
import { ViewportTracker } from './helpers/ViewportTracker';
|
|
147
153
|
import { PermissionsContext } from './permissions';
|
|
148
154
|
import { CallTypes } from './CallType';
|
|
149
155
|
import { StreamClient } from './coordinator/connection/client';
|
|
150
156
|
import { retryInterval, sleep } from './coordinator/connection/utils';
|
|
157
|
+
import { SlidingWindowRateLimiter } from './helpers/SlidingWindowRateLimiter';
|
|
151
158
|
import {
|
|
152
159
|
AllCallEvents,
|
|
153
160
|
CallEventListener,
|
|
@@ -227,7 +234,32 @@ export class Call {
|
|
|
227
234
|
/**
|
|
228
235
|
* The DynascaleManager instance.
|
|
229
236
|
*/
|
|
230
|
-
readonly dynascaleManager: DynascaleManager;
|
|
237
|
+
readonly dynascaleManager: DynascaleManager | undefined;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Tracks viewport visibility for participant video elements.
|
|
241
|
+
* Available only in DOM environments.
|
|
242
|
+
*/
|
|
243
|
+
readonly viewportTracker: ViewportTracker | undefined;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Owns the SFU-side video-subscription state (per-session and global overrides).
|
|
247
|
+
*/
|
|
248
|
+
readonly trackSubscriptionManager: TrackSubscriptionManager;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Warns periodically when a remote participant is publishing audio, but no
|
|
252
|
+
* `<audio>` element has been bound for them.
|
|
253
|
+
*/
|
|
254
|
+
readonly audioBindingsWatchdog: AudioBindingsWatchdog | undefined;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Tracks audio elements blocked by the browser's autoplay policy.
|
|
258
|
+
* Subscribe to `blockedAudioTracker.autoplayBlocked$` to react to the
|
|
259
|
+
* blocked state, and call {@link Call.resumeAudio} inside a user gesture
|
|
260
|
+
* to retry playback.
|
|
261
|
+
*/
|
|
262
|
+
readonly blockedAudioTracker: BlockedAudioTracker;
|
|
231
263
|
|
|
232
264
|
subscriber?: Subscriber;
|
|
233
265
|
publisher?: Publisher;
|
|
@@ -271,6 +303,17 @@ export class Call {
|
|
|
271
303
|
private disconnectionTimeoutSeconds: number = 0;
|
|
272
304
|
private lastOfflineTimestamp: number = 0;
|
|
273
305
|
private networkAvailableTask: PromiseWithResolvers<void> | undefined;
|
|
306
|
+
|
|
307
|
+
// (10 attempts per rolling 120 s window).
|
|
308
|
+
private readonly rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
|
|
309
|
+
// "Network doesn't support WebRTC" detector: counts peer-connection
|
|
310
|
+
// failures where ICE never reached `connected`/`completed`.
|
|
311
|
+
private maxIceFailuresWithoutConnect = 2;
|
|
312
|
+
private iceFailuresWithoutConnect = 0;
|
|
313
|
+
// Consecutive-negotiation-failure detector: stops the reconnect loop when
|
|
314
|
+
// the SFU keeps failing to negotiate SDP for us.
|
|
315
|
+
private maxConsecutiveNegotiationFailures = 3;
|
|
316
|
+
private consecutiveNegotiationFailures = 0;
|
|
274
317
|
// maintain the order of publishing tracks to restore them after a reconnection
|
|
275
318
|
// it shouldn't contain duplicates
|
|
276
319
|
private trackPublishOrder: TrackType[] = [];
|
|
@@ -348,11 +391,26 @@ export class Call {
|
|
|
348
391
|
this.microphone = new MicrophoneManager(this, preferences);
|
|
349
392
|
this.speaker = new SpeakerManager(this, preferences);
|
|
350
393
|
this.screenShare = new ScreenShareManager(this);
|
|
351
|
-
this.
|
|
394
|
+
this.trackSubscriptionManager = new TrackSubscriptionManager(
|
|
352
395
|
this.state,
|
|
353
|
-
this.speaker,
|
|
354
396
|
this.tracer,
|
|
355
397
|
);
|
|
398
|
+
this.blockedAudioTracker = new BlockedAudioTracker(this.tracer);
|
|
399
|
+
|
|
400
|
+
if (typeof document !== 'undefined') {
|
|
401
|
+
this.audioBindingsWatchdog = new AudioBindingsWatchdog(
|
|
402
|
+
this.state,
|
|
403
|
+
this.tracer,
|
|
404
|
+
);
|
|
405
|
+
this.viewportTracker = new ViewportTracker(this.state);
|
|
406
|
+
this.dynascaleManager = new DynascaleManager(
|
|
407
|
+
this.state,
|
|
408
|
+
this.speaker,
|
|
409
|
+
this.tracer,
|
|
410
|
+
this.trackSubscriptionManager,
|
|
411
|
+
this.blockedAudioTracker,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
356
414
|
}
|
|
357
415
|
|
|
358
416
|
/**
|
|
@@ -687,13 +745,29 @@ export class Call {
|
|
|
687
745
|
|
|
688
746
|
await this.sfuClient?.leaveAndClose(leaveReason);
|
|
689
747
|
this.sfuClient = undefined;
|
|
690
|
-
this.
|
|
691
|
-
|
|
748
|
+
this.trackSubscriptionManager.setSfuClient(undefined);
|
|
749
|
+
this.trackSubscriptionManager.dispose();
|
|
750
|
+
this.audioBindingsWatchdog?.dispose();
|
|
751
|
+
await this.dynascaleManager?.dispose();
|
|
692
752
|
|
|
693
753
|
this.state.setCallingState(CallingState.LEFT);
|
|
694
754
|
this.state.setParticipants([]);
|
|
695
755
|
this.state.dispose();
|
|
696
756
|
|
|
757
|
+
// Reset reconnect-related accumulators so a future `call.join()` on the
|
|
758
|
+
// same instance starts with a fresh budget. The `Call` may be reused
|
|
759
|
+
// (see `Call.test.ts` "can reuse call instance") so this is required.
|
|
760
|
+
// Strategy/reason/attempts must also be cleared: when `leave()` is
|
|
761
|
+
// reached via `giveUpAndLeave()` the success-path reset at the end of
|
|
762
|
+
// `joinFlow` never runs, leaving stale values that would make the next
|
|
763
|
+
// fresh `join()` send a stale `ReconnectDetails` to the SFU.
|
|
764
|
+
this.rejoinRateLimiter.reset();
|
|
765
|
+
this.iceFailuresWithoutConnect = 0;
|
|
766
|
+
this.consecutiveNegotiationFailures = 0;
|
|
767
|
+
this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
|
|
768
|
+
this.reconnectReason = '';
|
|
769
|
+
this.reconnectAttempts = 0;
|
|
770
|
+
|
|
697
771
|
// Call all leave call hooks, e.g. to clean up global event handlers
|
|
698
772
|
this.leaveCallHooks.forEach((hook) => hook());
|
|
699
773
|
this.initialized = false;
|
|
@@ -1098,7 +1172,7 @@ export class Call {
|
|
|
1098
1172
|
: previousSfuClient;
|
|
1099
1173
|
this.sfuClient = sfuClient;
|
|
1100
1174
|
this.unifiedSessionId ??= sfuClient.sessionId;
|
|
1101
|
-
this.
|
|
1175
|
+
this.trackSubscriptionManager.setSfuClient(sfuClient);
|
|
1102
1176
|
|
|
1103
1177
|
const clientDetails = await getClientDetails();
|
|
1104
1178
|
// we don't need to send JoinRequest if we are re-using an existing healthy SFU client
|
|
@@ -1169,9 +1243,23 @@ export class Call {
|
|
|
1169
1243
|
// when performing fast reconnect, or when we reuse the same SFU client,
|
|
1170
1244
|
// (ws remained healthy), we just need to restore the ICE connection
|
|
1171
1245
|
if (performingFastReconnect) {
|
|
1172
|
-
//
|
|
1173
|
-
// we
|
|
1174
|
-
|
|
1246
|
+
// The SFU automatically issues an ICE restart on the subscriber,
|
|
1247
|
+
// so we only need to decide about the publisher. If the publisher's
|
|
1248
|
+
// peer connection is still stable (ICE still connected end-to-end),
|
|
1249
|
+
// the signal WebSocket drop was the only problem — the new WS alone
|
|
1250
|
+
// is enough, and restarting ICE would add unnecessary SDP/ICE churn.
|
|
1251
|
+
const publisherIsStable = this.publisher?.isStable() ?? true;
|
|
1252
|
+
const includePublisher =
|
|
1253
|
+
!!this.publisher?.isPublishing() && !publisherIsStable;
|
|
1254
|
+
if (!includePublisher && this.publisher?.isPublishing()) {
|
|
1255
|
+
this.logger.info(
|
|
1256
|
+
'[Reconnect] FAST: skipping publisher ICE restart, publisher PC is stable',
|
|
1257
|
+
);
|
|
1258
|
+
}
|
|
1259
|
+
await this.restoreICE(sfuClient, {
|
|
1260
|
+
includeSubscriber: false,
|
|
1261
|
+
includePublisher,
|
|
1262
|
+
});
|
|
1175
1263
|
} else {
|
|
1176
1264
|
const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
|
|
1177
1265
|
this.initPublisherAndSubscriber({
|
|
@@ -1223,6 +1311,15 @@ export class Call {
|
|
|
1223
1311
|
// reset the reconnect strategy to unspecified after a successful reconnection
|
|
1224
1312
|
this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
|
|
1225
1313
|
this.reconnectReason = '';
|
|
1314
|
+
// A successful SFU join handshake resets the consecutive-negotiation
|
|
1315
|
+
// counter (negotiation just succeeded). It does NOT reset
|
|
1316
|
+
// `iceFailuresWithoutConnect` or the rolling `rejoinRateLimiter`;
|
|
1317
|
+
// those track WebRTC-level health and rejoin frequency, which are not
|
|
1318
|
+
// proven by the SFU handshake alone. ICE-failures-without-connect is
|
|
1319
|
+
// cleared via the `onIceConnected` callback when the peer connection
|
|
1320
|
+
// actually reaches `connected`/`completed` end-to-end. The rejoin
|
|
1321
|
+
// rolling window decays naturally as old timestamps age out.
|
|
1322
|
+
this.consecutiveNegotiationFailures = 0;
|
|
1226
1323
|
|
|
1227
1324
|
this.logger.info(`Joined call ${this.cid}`);
|
|
1228
1325
|
};
|
|
@@ -1242,7 +1339,7 @@ export class Call {
|
|
|
1242
1339
|
return {
|
|
1243
1340
|
strategy,
|
|
1244
1341
|
announcedTracks,
|
|
1245
|
-
subscriptions: this.
|
|
1342
|
+
subscriptions: this.trackSubscriptionManager.subscriptions,
|
|
1246
1343
|
reconnectAttempt: this.reconnectAttempts,
|
|
1247
1344
|
fromSfuId: migratingFromSfuId || '',
|
|
1248
1345
|
previousSessionId: performingRejoin ? previousSessionId || '' : '',
|
|
@@ -1375,6 +1472,12 @@ export class Call {
|
|
|
1375
1472
|
this.logger.warn(message, err);
|
|
1376
1473
|
});
|
|
1377
1474
|
},
|
|
1475
|
+
onIceConnected: () => {
|
|
1476
|
+
// ICE has reached `connected`/`completed` end-to-end on at least
|
|
1477
|
+
// one peer connection, WebRTC is actually working, so the
|
|
1478
|
+
// "ICE never connected" failure budget can be cleared.
|
|
1479
|
+
this.iceFailuresWithoutConnect = 0;
|
|
1480
|
+
},
|
|
1378
1481
|
};
|
|
1379
1482
|
|
|
1380
1483
|
this.subscriber = new Subscriber(basePeerConnectionOptions);
|
|
@@ -1501,11 +1604,13 @@ export class Call {
|
|
|
1501
1604
|
* @internal
|
|
1502
1605
|
*
|
|
1503
1606
|
* @param strategy the reconnection strategy to use.
|
|
1504
|
-
* @param reason the reason for the reconnection.
|
|
1607
|
+
* @param reason the reason for the reconnection. Pass a `ReconnectReason.*`
|
|
1608
|
+
* constant when the SDK should react to it (e.g.
|
|
1609
|
+
* `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
|
|
1505
1610
|
*/
|
|
1506
1611
|
private reconnect = async (
|
|
1507
1612
|
strategy: WebsocketReconnectStrategy,
|
|
1508
|
-
reason:
|
|
1613
|
+
reason: ReconnectReason,
|
|
1509
1614
|
): Promise<void> => {
|
|
1510
1615
|
if (
|
|
1511
1616
|
this.state.callingState === CallingState.RECONNECTING ||
|
|
@@ -1529,6 +1634,35 @@ export class Call {
|
|
|
1529
1634
|
}
|
|
1530
1635
|
};
|
|
1531
1636
|
|
|
1637
|
+
const giveUpAndLeave = async (message: string) => {
|
|
1638
|
+
this.logger.warn(
|
|
1639
|
+
`[Reconnect] Giving up: ${message}. Leaving the call.`,
|
|
1640
|
+
);
|
|
1641
|
+
// If we're mid-iteration, the state can be JOINING; `Call.leave` would
|
|
1642
|
+
// then wait for JOINED before proceeding, but no more attempts will run
|
|
1643
|
+
// so JOINED never comes. Transition to RECONNECTING so `leave()` proceeds.
|
|
1644
|
+
if (this.state.callingState === CallingState.JOINING) {
|
|
1645
|
+
this.state.setCallingState(CallingState.RECONNECTING);
|
|
1646
|
+
}
|
|
1647
|
+
try {
|
|
1648
|
+
await this.leave({ message });
|
|
1649
|
+
} catch (err) {
|
|
1650
|
+
this.logger.warn(`[Reconnect] leave() failed after ${message}`, err);
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
|
|
1654
|
+
// Count this entry into reconnect if it was triggered by a peer
|
|
1655
|
+
// connection that never reached `connected`/`completed`.
|
|
1656
|
+
if (reason === ReconnectReason.ICE_NEVER_CONNECTED) {
|
|
1657
|
+
this.iceFailuresWithoutConnect++;
|
|
1658
|
+
if (
|
|
1659
|
+
this.iceFailuresWithoutConnect >= this.maxIceFailuresWithoutConnect
|
|
1660
|
+
) {
|
|
1661
|
+
await giveUpAndLeave('webrtc_unsupported_network');
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1532
1666
|
let attempt = 0;
|
|
1533
1667
|
do {
|
|
1534
1668
|
const reconnectingTime = Date.now() - reconnectStartTime;
|
|
@@ -1544,6 +1678,19 @@ export class Call {
|
|
|
1544
1678
|
return;
|
|
1545
1679
|
}
|
|
1546
1680
|
|
|
1681
|
+
// Rejoin rate limit: bound the number of REJOIN (and MIGRATE)
|
|
1682
|
+
// transitions inside a rolling window. FAST is not counted because
|
|
1683
|
+
// it does not issue a new backend `joinCall`.
|
|
1684
|
+
if (
|
|
1685
|
+
this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN ||
|
|
1686
|
+
this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE
|
|
1687
|
+
) {
|
|
1688
|
+
if (!this.rejoinRateLimiter.tryRegister()) {
|
|
1689
|
+
await giveUpAndLeave('rejoin_attempt_limit_exceeded');
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1547
1694
|
// we don't increment reconnect attempts for the FAST strategy.
|
|
1548
1695
|
if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
|
|
1549
1696
|
this.reconnectAttempts++;
|
|
@@ -1581,6 +1728,8 @@ export class Call {
|
|
|
1581
1728
|
);
|
|
1582
1729
|
break;
|
|
1583
1730
|
}
|
|
1731
|
+
// reconnection worked — reset the negotiation-failure streak.
|
|
1732
|
+
this.consecutiveNegotiationFailures = 0;
|
|
1584
1733
|
break; // do-while loop, reconnection worked, exit the loop
|
|
1585
1734
|
} catch (error) {
|
|
1586
1735
|
if (this.state.callingState === CallingState.OFFLINE) {
|
|
@@ -1599,8 +1748,19 @@ export class Call {
|
|
|
1599
1748
|
await markAsReconnectingFailed();
|
|
1600
1749
|
return;
|
|
1601
1750
|
}
|
|
1751
|
+
if (error instanceof NegotiationError) {
|
|
1752
|
+
this.consecutiveNegotiationFailures++;
|
|
1753
|
+
if (
|
|
1754
|
+
this.consecutiveNegotiationFailures >=
|
|
1755
|
+
this.maxConsecutiveNegotiationFailures
|
|
1756
|
+
) {
|
|
1757
|
+
await giveUpAndLeave('repeated_negotiation_failures');
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1602
1761
|
|
|
1603
|
-
|
|
1762
|
+
// exponential backoff with jitter, capped at 5 s
|
|
1763
|
+
await sleep(retryInterval(attempt));
|
|
1604
1764
|
|
|
1605
1765
|
const wasMigrating =
|
|
1606
1766
|
this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
|
|
@@ -1741,9 +1901,10 @@ export class Call {
|
|
|
1741
1901
|
private registerReconnectHandlers = () => {
|
|
1742
1902
|
// handles the legacy "goAway" event
|
|
1743
1903
|
const unregisterGoAway = this.on('goAway', () => {
|
|
1744
|
-
this.reconnect(
|
|
1745
|
-
|
|
1746
|
-
|
|
1904
|
+
this.reconnect(
|
|
1905
|
+
WebsocketReconnectStrategy.MIGRATE,
|
|
1906
|
+
ReconnectReason.GO_AWAY,
|
|
1907
|
+
).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
|
|
1747
1908
|
});
|
|
1748
1909
|
|
|
1749
1910
|
// handles the "error" event, through which the SFU can request a reconnect
|
|
@@ -1760,7 +1921,10 @@ export class Call {
|
|
|
1760
1921
|
this.logger.warn(`Can't leave call after disconnect request`, err);
|
|
1761
1922
|
});
|
|
1762
1923
|
} else {
|
|
1763
|
-
this.reconnect(
|
|
1924
|
+
this.reconnect(
|
|
1925
|
+
strategy,
|
|
1926
|
+
error?.message || ReconnectReason.SFU_ERROR,
|
|
1927
|
+
).catch((err) => {
|
|
1764
1928
|
this.logger.warn('[Reconnect] Error reconnecting', err);
|
|
1765
1929
|
});
|
|
1766
1930
|
}
|
|
@@ -1787,12 +1951,14 @@ export class Call {
|
|
|
1787
1951
|
}
|
|
1788
1952
|
}
|
|
1789
1953
|
|
|
1790
|
-
this.reconnect(strategy,
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1954
|
+
this.reconnect(strategy, ReconnectReason.NETWORK_BACK_ONLINE).catch(
|
|
1955
|
+
(err) => {
|
|
1956
|
+
this.logger.warn(
|
|
1957
|
+
'[Reconnect] Error reconnecting after going online',
|
|
1958
|
+
err,
|
|
1959
|
+
);
|
|
1960
|
+
},
|
|
1961
|
+
);
|
|
1796
1962
|
});
|
|
1797
1963
|
this.networkAvailableTask = networkAvailableTask;
|
|
1798
1964
|
this.sfuStatsReporter?.stop();
|
|
@@ -1856,7 +2022,7 @@ export class Call {
|
|
|
1856
2022
|
private restoreSubscribedTracks = () => {
|
|
1857
2023
|
const { remoteParticipants } = this.state;
|
|
1858
2024
|
if (remoteParticipants.length <= 0) return;
|
|
1859
|
-
this.
|
|
2025
|
+
this.trackSubscriptionManager.apply(undefined);
|
|
1860
2026
|
};
|
|
1861
2027
|
|
|
1862
2028
|
/**
|
|
@@ -1958,10 +2124,12 @@ export class Call {
|
|
|
1958
2124
|
mediaStream: MediaStream | undefined,
|
|
1959
2125
|
...trackTypes: TrackType[]
|
|
1960
2126
|
) => {
|
|
1961
|
-
|
|
2127
|
+
const sessionId = this.sfuClient?.sessionId;
|
|
2128
|
+
if (!sessionId) return;
|
|
2129
|
+
|
|
1962
2130
|
await this.notifyTrackMuteState(!mediaStream, ...trackTypes);
|
|
2131
|
+
if (this.sfuClient?.sessionId !== sessionId) return;
|
|
1963
2132
|
|
|
1964
|
-
const { sessionId } = this.sfuClient;
|
|
1965
2133
|
for (const trackType of trackTypes) {
|
|
1966
2134
|
const streamStateProp = trackTypeToParticipantStreamKey(trackType);
|
|
1967
2135
|
if (!streamStateProp) continue;
|
|
@@ -1975,6 +2143,20 @@ export class Call {
|
|
|
1975
2143
|
}
|
|
1976
2144
|
};
|
|
1977
2145
|
|
|
2146
|
+
/**
|
|
2147
|
+
* Re-arms the encoder for a currently published track type. Useful for
|
|
2148
|
+
* working around WebKit's stalled sender bug after an iOS audio session
|
|
2149
|
+
* interruption (Siri, PSTN call).
|
|
2150
|
+
*
|
|
2151
|
+
* @internal
|
|
2152
|
+
*
|
|
2153
|
+
* @param trackType the track type to refresh.
|
|
2154
|
+
*/
|
|
2155
|
+
refreshPublishedTrack = async (trackType: TrackType) => {
|
|
2156
|
+
if (!this.publisher) return;
|
|
2157
|
+
await this.publisher.refreshTrack(trackType);
|
|
2158
|
+
};
|
|
2159
|
+
|
|
1978
2160
|
/**
|
|
1979
2161
|
* Updates the preferred publishing options
|
|
1980
2162
|
*
|
|
@@ -2888,7 +3070,7 @@ export class Call {
|
|
|
2888
3070
|
sessionId: string,
|
|
2889
3071
|
trackType: VideoTrackType,
|
|
2890
3072
|
) => {
|
|
2891
|
-
return this.
|
|
3073
|
+
return this.viewportTracker?.trackElementVisibility(
|
|
2892
3074
|
element,
|
|
2893
3075
|
sessionId,
|
|
2894
3076
|
trackType,
|
|
@@ -2901,7 +3083,7 @@ export class Call {
|
|
|
2901
3083
|
* @param element the viewport element.
|
|
2902
3084
|
*/
|
|
2903
3085
|
setViewport = <T extends HTMLElement>(element: T) => {
|
|
2904
|
-
return this.
|
|
3086
|
+
return this.viewportTracker?.setViewport(element);
|
|
2905
3087
|
};
|
|
2906
3088
|
|
|
2907
3089
|
/**
|
|
@@ -2924,7 +3106,7 @@ export class Call {
|
|
|
2924
3106
|
sessionId: string,
|
|
2925
3107
|
trackType: VideoTrackType,
|
|
2926
3108
|
) => {
|
|
2927
|
-
const unbind = this.dynascaleManager
|
|
3109
|
+
const unbind = this.dynascaleManager?.bindVideoElement(
|
|
2928
3110
|
videoElement,
|
|
2929
3111
|
sessionId,
|
|
2930
3112
|
trackType,
|
|
@@ -2953,26 +3135,33 @@ export class Call {
|
|
|
2953
3135
|
sessionId: string,
|
|
2954
3136
|
trackType: AudioTrackType = 'audioTrack',
|
|
2955
3137
|
) => {
|
|
2956
|
-
const unbind = this.dynascaleManager
|
|
3138
|
+
const unbind = this.dynascaleManager?.bindAudioElement(
|
|
2957
3139
|
audioElement,
|
|
2958
3140
|
sessionId,
|
|
2959
3141
|
trackType,
|
|
2960
3142
|
);
|
|
2961
3143
|
|
|
2962
3144
|
if (!unbind) return;
|
|
2963
|
-
this.
|
|
2964
|
-
|
|
2965
|
-
this.leaveCallHooks.delete(unbind);
|
|
3145
|
+
this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
|
|
3146
|
+
const cleanup = () => {
|
|
2966
3147
|
unbind();
|
|
3148
|
+
this.audioBindingsWatchdog?.unregister(sessionId, trackType);
|
|
3149
|
+
};
|
|
3150
|
+
this.leaveCallHooks.add(cleanup);
|
|
3151
|
+
return () => {
|
|
3152
|
+
this.leaveCallHooks.delete(cleanup);
|
|
3153
|
+
cleanup();
|
|
2967
3154
|
};
|
|
2968
3155
|
};
|
|
2969
3156
|
|
|
2970
3157
|
/**
|
|
2971
3158
|
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
3159
|
+
* Must be called from within a user gesture (e.g., click handler).
|
|
3160
|
+
*
|
|
3161
|
+
* Subscribe to `call.blockedAudioTracker.autoplayBlocked$` to know when a
|
|
3162
|
+
* gesture is required.
|
|
2972
3163
|
*/
|
|
2973
|
-
resumeAudio = () =>
|
|
2974
|
-
return this.dynascaleManager.resumeAudio();
|
|
2975
|
-
};
|
|
3164
|
+
resumeAudio = () => this.blockedAudioTracker.resumeAudio();
|
|
2976
3165
|
|
|
2977
3166
|
/**
|
|
2978
3167
|
* Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
|
|
@@ -3026,7 +3215,7 @@ export class Call {
|
|
|
3026
3215
|
resolution: VideoDimension | undefined,
|
|
3027
3216
|
sessionIds?: string[],
|
|
3028
3217
|
) => {
|
|
3029
|
-
this.
|
|
3218
|
+
this.trackSubscriptionManager.setOverrides(
|
|
3030
3219
|
resolution
|
|
3031
3220
|
? {
|
|
3032
3221
|
enabled: true,
|
|
@@ -3035,7 +3224,7 @@ export class Call {
|
|
|
3035
3224
|
: undefined,
|
|
3036
3225
|
sessionIds,
|
|
3037
3226
|
);
|
|
3038
|
-
this.
|
|
3227
|
+
this.trackSubscriptionManager.apply();
|
|
3039
3228
|
};
|
|
3040
3229
|
|
|
3041
3230
|
/**
|
|
@@ -3043,10 +3232,10 @@ export class Call {
|
|
|
3043
3232
|
* and removes any preference for preferred resolution.
|
|
3044
3233
|
*/
|
|
3045
3234
|
setIncomingVideoEnabled = (enabled: boolean) => {
|
|
3046
|
-
this.
|
|
3235
|
+
this.trackSubscriptionManager.setOverrides(
|
|
3047
3236
|
enabled ? undefined : { enabled: false },
|
|
3048
3237
|
);
|
|
3049
|
-
this.
|
|
3238
|
+
this.trackSubscriptionManager.apply();
|
|
3050
3239
|
};
|
|
3051
3240
|
|
|
3052
3241
|
/**
|
|
@@ -3058,6 +3247,45 @@ export class Call {
|
|
|
3058
3247
|
this.disconnectionTimeoutSeconds = timeoutSeconds;
|
|
3059
3248
|
};
|
|
3060
3249
|
|
|
3250
|
+
/**
|
|
3251
|
+
* Configures the rolling-window limit for REJOIN/MIGRATE attempts. Once
|
|
3252
|
+
* `maxAttempts` rejoins have been registered inside `windowSeconds`, the
|
|
3253
|
+
* SDK stops retrying and transitions the call to `LEFT` with the
|
|
3254
|
+
* `rejoin_attempt_limit_exceeded` leave message.
|
|
3255
|
+
*
|
|
3256
|
+
* Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
|
|
3257
|
+
* Both arguments are clamped to a minimum of 1.
|
|
3258
|
+
*/
|
|
3259
|
+
setRejoinAttemptLimit = (maxAttempts: number, windowSeconds: number) => {
|
|
3260
|
+
this.rejoinRateLimiter.setLimits(
|
|
3261
|
+
Math.max(1, maxAttempts),
|
|
3262
|
+
Math.max(1, windowSeconds) * 1000,
|
|
3263
|
+
);
|
|
3264
|
+
};
|
|
3265
|
+
|
|
3266
|
+
/**
|
|
3267
|
+
* Configures how many peer-connection failures where ICE never reached
|
|
3268
|
+
* `connected`/`completed` are tolerated before the SDK concludes that the
|
|
3269
|
+
* current network cannot support WebRTC and transitions the call to
|
|
3270
|
+
* `LEFT` with the `webrtc_unsupported_network` leave message.
|
|
3271
|
+
*
|
|
3272
|
+
* Default: 2. Clamped to a minimum of 1.
|
|
3273
|
+
*/
|
|
3274
|
+
setMaxIceFailuresWithoutConnect = (n: number) => {
|
|
3275
|
+
this.maxIceFailuresWithoutConnect = Math.max(1, n);
|
|
3276
|
+
};
|
|
3277
|
+
|
|
3278
|
+
/**
|
|
3279
|
+
* Configures how many consecutive SDP `NegotiationError`s are tolerated
|
|
3280
|
+
* before the SDK stops retrying and transitions the call to `LEFT` with
|
|
3281
|
+
* the `repeated_negotiation_failures` leave message.
|
|
3282
|
+
*
|
|
3283
|
+
* Default: 3. Clamped to a minimum of 1.
|
|
3284
|
+
*/
|
|
3285
|
+
setMaxConsecutiveNegotiationFailures = (n: number) => {
|
|
3286
|
+
this.maxConsecutiveNegotiationFailures = Math.max(1, n);
|
|
3287
|
+
};
|
|
3288
|
+
|
|
3061
3289
|
/**
|
|
3062
3290
|
* Enables the provided client capabilities.
|
|
3063
3291
|
*/
|
package/src/StreamSfuClient.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
} from './gen/video/sfu/signal_rpc/signal';
|
|
29
29
|
import { ICETrickle } from './gen/video/sfu/models/models';
|
|
30
30
|
import { StreamClient } from './coordinator/connection/client';
|
|
31
|
-
import { generateUUIDv4 } from './coordinator/connection/utils';
|
|
31
|
+
import { generateUUIDv4, sleep } from './coordinator/connection/utils';
|
|
32
32
|
import { Credentials } from './gen/coordinator';
|
|
33
33
|
import { ScopedLogger, videoLoggerSystem } from './logger';
|
|
34
34
|
import {
|
|
@@ -104,6 +104,21 @@ type SfuWebSocketParams = {
|
|
|
104
104
|
cid: string;
|
|
105
105
|
};
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Creates a fresh `joinResponseTask` with a no-op rejection handler attached
|
|
109
|
+
* to the underlying promise. The handler marks the rejection path as handled
|
|
110
|
+
* so a teardown-time reject (e.g., from `close()` during disposal) does not
|
|
111
|
+
* surface as an `UnhandledPromiseRejection`. Explicit awaiters of
|
|
112
|
+
* `StreamSfuClient.joinTask` still observe the rejection through their own
|
|
113
|
+
* `then`/`catch` chain. `.catch()` returns a new promise; the original is
|
|
114
|
+
* unchanged.
|
|
115
|
+
*/
|
|
116
|
+
const makeJoinResponseTask = (): PromiseWithResolvers<JoinResponse> => {
|
|
117
|
+
const task = promiseWithResolvers<JoinResponse>();
|
|
118
|
+
task.promise.catch(() => {}); // see the comment above
|
|
119
|
+
return task;
|
|
120
|
+
};
|
|
121
|
+
|
|
107
122
|
/**
|
|
108
123
|
* The client used for exchanging information with the SFU.
|
|
109
124
|
*/
|
|
@@ -171,9 +186,10 @@ export class StreamSfuClient {
|
|
|
171
186
|
private networkAvailableTask: PromiseWithResolvers<void> | undefined;
|
|
172
187
|
/**
|
|
173
188
|
* Promise that resolves when the JoinResponse is received.
|
|
174
|
-
* Rejects after a certain threshold if the response is not received
|
|
189
|
+
* Rejects after a certain threshold if the response is not received,
|
|
190
|
+
* or when the SFU client is disposed before a join completes.
|
|
175
191
|
*/
|
|
176
|
-
private joinResponseTask =
|
|
192
|
+
private joinResponseTask = makeJoinResponseTask();
|
|
177
193
|
|
|
178
194
|
/**
|
|
179
195
|
* Promise that resolves when the migration is complete.
|
|
@@ -207,6 +223,12 @@ export class StreamSfuClient {
|
|
|
207
223
|
* The close code used when the client fails to join the call (on the SFU).
|
|
208
224
|
*/
|
|
209
225
|
static JOIN_FAILED = 4101;
|
|
226
|
+
/**
|
|
227
|
+
* Best-effort grace period in `leaveAndClose` for an in-flight join to
|
|
228
|
+
* complete before we give up and close without sending `leaveCallRequest`.
|
|
229
|
+
* Bounded so a stuck join can never hang the leave path.
|
|
230
|
+
*/
|
|
231
|
+
static LEAVE_NOTIFY_GRACE_MS = 1000;
|
|
210
232
|
|
|
211
233
|
/**
|
|
212
234
|
* Constructs a new SFU client.
|
|
@@ -358,15 +380,24 @@ export class StreamSfuClient {
|
|
|
358
380
|
|
|
359
381
|
close = (code: number = StreamSfuClient.NORMAL_CLOSURE, reason?: string) => {
|
|
360
382
|
this.isClosingClean = code !== StreamSfuClient.ERROR_CONNECTION_UNHEALTHY;
|
|
361
|
-
|
|
383
|
+
// Close the WebSocket whether it has fully opened (`OPEN`) or is still
|
|
384
|
+
// mid-handshake (`CONNECTING`). The WebSocket spec aborts the handshake
|
|
385
|
+
// when `close()` is called on a CONNECTING socket. Without this, an
|
|
386
|
+
// SFU socket that opens just after teardown would dispatch events into
|
|
387
|
+
// a Call instance that has already moved on.
|
|
388
|
+
const ws = this.signalWs;
|
|
389
|
+
if (
|
|
390
|
+
ws.readyState === WebSocket.OPEN ||
|
|
391
|
+
ws.readyState === WebSocket.CONNECTING
|
|
392
|
+
) {
|
|
362
393
|
this.logger.debug(`Closing SFU WS connection: ${code} - ${reason}`);
|
|
363
|
-
|
|
364
|
-
|
|
394
|
+
ws.close(code, `js-client: ${reason}`);
|
|
395
|
+
ws.removeEventListener('close', this.handleWebSocketClose);
|
|
365
396
|
}
|
|
366
|
-
this.dispose();
|
|
397
|
+
this.dispose(reason);
|
|
367
398
|
};
|
|
368
399
|
|
|
369
|
-
private dispose = () => {
|
|
400
|
+
private dispose = (reason?: string) => {
|
|
370
401
|
this.logger.debug('Disposing SFU client');
|
|
371
402
|
this.unsubscribeIceTrickle();
|
|
372
403
|
this.unsubscribeNetworkChanged();
|
|
@@ -375,6 +406,19 @@ export class StreamSfuClient {
|
|
|
375
406
|
clearTimeout(this.migrateAwayTimeout);
|
|
376
407
|
this.abortController.abort();
|
|
377
408
|
this.migrationTask?.resolve();
|
|
409
|
+
// Settle a pending `joinResponseTask` so `leaveAndClose`, `join()`, and
|
|
410
|
+
// any other awaiters (`await this.joinTask`) don't hang indefinitely
|
|
411
|
+
// when the SFU client is torn down before the SFU sent a JoinResponse.
|
|
412
|
+
if (
|
|
413
|
+
!this.joinResponseTask.isResolved() &&
|
|
414
|
+
!this.joinResponseTask.isRejected()
|
|
415
|
+
) {
|
|
416
|
+
this.joinResponseTask.reject(
|
|
417
|
+
new Error(
|
|
418
|
+
`SFU client disposed before join completed${reason ? `: ${reason}` : ''}`,
|
|
419
|
+
),
|
|
420
|
+
);
|
|
421
|
+
}
|
|
378
422
|
this.iceTrickleBuffer.dispose();
|
|
379
423
|
};
|
|
380
424
|
|
|
@@ -385,8 +429,27 @@ export class StreamSfuClient {
|
|
|
385
429
|
leaveAndClose = async (reason: string) => {
|
|
386
430
|
try {
|
|
387
431
|
this.isLeaving = true;
|
|
388
|
-
|
|
389
|
-
|
|
432
|
+
// Best-effort: give an in-flight join a short grace period to complete
|
|
433
|
+
// so we can send a graceful `leaveCallRequest`. Bounded so we never hang
|
|
434
|
+
// here if the SFU is unresponsive. If the task settles either way during
|
|
435
|
+
// the wait, the re-check below decides whether to notify.
|
|
436
|
+
if (
|
|
437
|
+
!this.joinResponseTask.isResolved() &&
|
|
438
|
+
!this.joinResponseTask.isRejected()
|
|
439
|
+
) {
|
|
440
|
+
await Promise.race([
|
|
441
|
+
// swallow rejection — we re-check `isResolved()` below to decide
|
|
442
|
+
this.joinResponseTask.promise.catch(() => {}),
|
|
443
|
+
sleep(StreamSfuClient.LEAVE_NOTIFY_GRACE_MS),
|
|
444
|
+
]);
|
|
445
|
+
}
|
|
446
|
+
if (this.joinResponseTask.isResolved()) {
|
|
447
|
+
await this.notifyLeave(reason);
|
|
448
|
+
} else {
|
|
449
|
+
this.logger.debug(
|
|
450
|
+
'[leaveAndClose] join not completed within grace period, skipping notifyLeave',
|
|
451
|
+
);
|
|
452
|
+
}
|
|
390
453
|
} catch (err) {
|
|
391
454
|
this.logger.debug('Error notifying SFU about leaving call', err);
|
|
392
455
|
}
|
|
@@ -535,9 +598,9 @@ export class StreamSfuClient {
|
|
|
535
598
|
) {
|
|
536
599
|
// we need to lock the RPC requests until we receive a JoinResponse.
|
|
537
600
|
// that's why we have this primitive lock mechanism.
|
|
538
|
-
// the client starts with already initialized joinResponseTask,
|
|
601
|
+
// the client starts with an already initialized joinResponseTask,
|
|
539
602
|
// and this code creates a new one for the next join request.
|
|
540
|
-
this.joinResponseTask =
|
|
603
|
+
this.joinResponseTask = makeJoinResponseTask();
|
|
541
604
|
}
|
|
542
605
|
|
|
543
606
|
// capture a reference to the current joinResponseTask as it might
|