@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
|
@@ -11,8 +11,10 @@ import {
|
|
|
11
11
|
ErrorCode,
|
|
12
12
|
PeerType,
|
|
13
13
|
TrackType,
|
|
14
|
+
WebsocketReconnectStrategy,
|
|
14
15
|
} from '../../gen/video/sfu/models/models';
|
|
15
16
|
import { NegotiationError } from '../NegotiationError';
|
|
17
|
+
import { ReconnectReason } from '../types';
|
|
16
18
|
import { IceTrickleBuffer } from '../IceTrickleBuffer';
|
|
17
19
|
import { StreamClient } from '../../coordinator/connection/client';
|
|
18
20
|
|
|
@@ -26,10 +28,11 @@ vi.mock('../../StreamSfuClient', () => {
|
|
|
26
28
|
describe('Subscriber', () => {
|
|
27
29
|
let sfuClient: StreamSfuClient;
|
|
28
30
|
let subscriber: Subscriber;
|
|
29
|
-
|
|
31
|
+
let state: CallState;
|
|
30
32
|
let dispatcher: Dispatcher;
|
|
31
33
|
|
|
32
34
|
beforeEach(() => {
|
|
35
|
+
state = new CallState();
|
|
33
36
|
dispatcher = new Dispatcher();
|
|
34
37
|
sfuClient = new StreamSfuClient({
|
|
35
38
|
dispatcher,
|
|
@@ -62,6 +65,7 @@ describe('Subscriber', () => {
|
|
|
62
65
|
});
|
|
63
66
|
|
|
64
67
|
afterEach(() => {
|
|
68
|
+
vi.useRealTimers();
|
|
65
69
|
vi.clearAllMocks();
|
|
66
70
|
vi.resetModules();
|
|
67
71
|
subscriber.dispose();
|
|
@@ -97,7 +101,14 @@ describe('Subscriber', () => {
|
|
|
97
101
|
});
|
|
98
102
|
});
|
|
99
103
|
|
|
104
|
+
const simulatePriorIceConnected = () => {
|
|
105
|
+
// @ts-expect-error - private field
|
|
106
|
+
subscriber['pc'].iceConnectionState = 'connected';
|
|
107
|
+
subscriber['onIceConnectionStateChange']();
|
|
108
|
+
};
|
|
109
|
+
|
|
100
110
|
it(`should perform ICE restart when connection state changes to 'failed'`, () => {
|
|
111
|
+
simulatePriorIceConnected();
|
|
101
112
|
vi.spyOn(subscriber, 'restartIce').mockResolvedValue();
|
|
102
113
|
// @ts-expect-error - private field
|
|
103
114
|
subscriber['pc'].iceConnectionState = 'failed';
|
|
@@ -106,6 +117,7 @@ describe('Subscriber', () => {
|
|
|
106
117
|
});
|
|
107
118
|
|
|
108
119
|
it(`should perform ICE restart when connection state changes to 'disconnected'`, () => {
|
|
120
|
+
simulatePriorIceConnected();
|
|
109
121
|
vi.spyOn(subscriber, 'restartIce').mockResolvedValue();
|
|
110
122
|
vi.useFakeTimers();
|
|
111
123
|
// @ts-expect-error - private field
|
|
@@ -115,6 +127,51 @@ describe('Subscriber', () => {
|
|
|
115
127
|
expect(subscriber.restartIce).toHaveBeenCalled();
|
|
116
128
|
});
|
|
117
129
|
|
|
130
|
+
it(`does NOT perform ICE restart when ICE never connected and state goes to 'failed' — emits REJOIN with 'ice_never_connected'`, () => {
|
|
131
|
+
vi.spyOn(subscriber, 'restartIce').mockResolvedValue();
|
|
132
|
+
subscriber['onReconnectionNeeded'] = vi.fn();
|
|
133
|
+
// @ts-expect-error - private field
|
|
134
|
+
subscriber['pc'].iceConnectionState = 'failed';
|
|
135
|
+
subscriber['onIceConnectionStateChange']();
|
|
136
|
+
expect(subscriber.restartIce).not.toHaveBeenCalled();
|
|
137
|
+
expect(subscriber['onReconnectionNeeded']).toHaveBeenCalledWith(
|
|
138
|
+
WebsocketReconnectStrategy.REJOIN,
|
|
139
|
+
ReconnectReason.ICE_NEVER_CONNECTED,
|
|
140
|
+
PeerType.SUBSCRIBER,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it(`isStable() returns true only when ICE is connected/completed and connectionState is connected`, () => {
|
|
145
|
+
// @ts-expect-error - private field
|
|
146
|
+
subscriber['pc'].iceConnectionState = 'connected';
|
|
147
|
+
// @ts-expect-error - private field
|
|
148
|
+
subscriber['pc'].connectionState = 'connected';
|
|
149
|
+
expect(subscriber.isStable()).toBe(true);
|
|
150
|
+
|
|
151
|
+
// @ts-expect-error - private field
|
|
152
|
+
subscriber['pc'].iceConnectionState = 'completed';
|
|
153
|
+
expect(subscriber.isStable()).toBe(true);
|
|
154
|
+
|
|
155
|
+
// @ts-expect-error - private field
|
|
156
|
+
subscriber['pc'].iceConnectionState = 'disconnected';
|
|
157
|
+
expect(subscriber.isStable()).toBe(false);
|
|
158
|
+
|
|
159
|
+
// @ts-expect-error - private field
|
|
160
|
+
subscriber['pc'].iceConnectionState = 'new';
|
|
161
|
+
expect(subscriber.isStable()).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it(`iceHasEverConnected tracks lifetime connectivity`, () => {
|
|
165
|
+
expect(subscriber['iceHasEverConnected']).toBe(false);
|
|
166
|
+
simulatePriorIceConnected();
|
|
167
|
+
expect(subscriber['iceHasEverConnected']).toBe(true);
|
|
168
|
+
// going disconnected does not reset the flag
|
|
169
|
+
// @ts-expect-error - private field
|
|
170
|
+
subscriber['pc'].iceConnectionState = 'disconnected';
|
|
171
|
+
subscriber['onIceConnectionStateChange']();
|
|
172
|
+
expect(subscriber['iceHasEverConnected']).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
118
175
|
it(`should throw NegotiationError when SFU returns an error`, async () => {
|
|
119
176
|
sfuClient.iceRestart = vi.fn().mockResolvedValue({
|
|
120
177
|
response: {
|
|
@@ -216,6 +273,150 @@ describe('Subscriber', () => {
|
|
|
216
273
|
});
|
|
217
274
|
});
|
|
218
275
|
|
|
276
|
+
describe('interruptedTracks', () => {
|
|
277
|
+
const setup = ({ muted = false }: { muted?: boolean } = {}) => {
|
|
278
|
+
const mediaStream = new MediaStream();
|
|
279
|
+
const track = new MediaStreamTrack();
|
|
280
|
+
// @ts-expect-error - mock
|
|
281
|
+
mediaStream.id = 'lookup:TRACK_TYPE_AUDIO';
|
|
282
|
+
// @ts-expect-error - mock
|
|
283
|
+
track.kind = 'audio';
|
|
284
|
+
Object.defineProperty(track, 'muted', {
|
|
285
|
+
configurable: true,
|
|
286
|
+
get: () => muted,
|
|
287
|
+
});
|
|
288
|
+
// @ts-expect-error - incomplete mock
|
|
289
|
+
state.updateOrAddParticipant('session-id', {
|
|
290
|
+
sessionId: 'session-id',
|
|
291
|
+
trackLookupPrefix: 'lookup',
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
295
|
+
// @ts-expect-error - incomplete mock
|
|
296
|
+
onTrack({ streams: [mediaStream], track });
|
|
297
|
+
|
|
298
|
+
const calls = (track.addEventListener as ReturnType<typeof vi.fn>).mock
|
|
299
|
+
.calls;
|
|
300
|
+
const handlers: Record<string, () => void> = {};
|
|
301
|
+
for (const [event, handler] of calls) {
|
|
302
|
+
handlers[event] = handler as () => void;
|
|
303
|
+
}
|
|
304
|
+
return { track, handlers };
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const interruptedFor = (sessionId: string) =>
|
|
308
|
+
state.participants.find((p) => p.sessionId === sessionId)
|
|
309
|
+
?.interruptedTracks ?? [];
|
|
310
|
+
|
|
311
|
+
it('adds the track type when the mute handler fires', () => {
|
|
312
|
+
const { handlers } = setup();
|
|
313
|
+
expect(interruptedFor('session-id')).toEqual([]);
|
|
314
|
+
|
|
315
|
+
handlers['mute']();
|
|
316
|
+
|
|
317
|
+
expect(interruptedFor('session-id')).toEqual([TrackType.AUDIO]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('removes the track type when the unmute handler fires', () => {
|
|
321
|
+
const { handlers } = setup();
|
|
322
|
+
handlers['mute']();
|
|
323
|
+
expect(interruptedFor('session-id')).toEqual([TrackType.AUDIO]);
|
|
324
|
+
|
|
325
|
+
handlers['unmute']();
|
|
326
|
+
|
|
327
|
+
expect(interruptedFor('session-id')).toEqual([]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('seeds the track type when the track arrives already muted', () => {
|
|
331
|
+
setup({ muted: true });
|
|
332
|
+
|
|
333
|
+
expect(interruptedFor('session-id')).toEqual([TrackType.AUDIO]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('clears the track type when the track ends', () => {
|
|
337
|
+
const { handlers } = setup();
|
|
338
|
+
handlers['mute']();
|
|
339
|
+
expect(interruptedFor('session-id')).toEqual([TrackType.AUDIO]);
|
|
340
|
+
|
|
341
|
+
handlers['ended']();
|
|
342
|
+
|
|
343
|
+
expect(interruptedFor('session-id')).toEqual([]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('ignores non-audio remote tracks to avoid Dynascale false positives', () => {
|
|
347
|
+
// Remote video track.muted is dominated by viewport-driven
|
|
348
|
+
// SFU unsubscriptions, so we deliberately only track audio
|
|
349
|
+
// interruption on remote participants.
|
|
350
|
+
const mediaStream = new MediaStream();
|
|
351
|
+
const track = new MediaStreamTrack();
|
|
352
|
+
// @ts-expect-error - mock
|
|
353
|
+
mediaStream.id = 'video-lookup:TRACK_TYPE_VIDEO';
|
|
354
|
+
// @ts-expect-error - mock
|
|
355
|
+
track.kind = 'video';
|
|
356
|
+
Object.defineProperty(track, 'muted', {
|
|
357
|
+
configurable: true,
|
|
358
|
+
get: () => true,
|
|
359
|
+
});
|
|
360
|
+
// @ts-expect-error - incomplete mock
|
|
361
|
+
state.updateOrAddParticipant('video-session', {
|
|
362
|
+
sessionId: 'video-session',
|
|
363
|
+
trackLookupPrefix: 'video-lookup',
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
367
|
+
// @ts-expect-error - incomplete mock
|
|
368
|
+
onTrack({ streams: [mediaStream], track });
|
|
369
|
+
|
|
370
|
+
// Seeded muted track is ignored.
|
|
371
|
+
expect(interruptedFor('video-session')).toEqual([]);
|
|
372
|
+
|
|
373
|
+
// Subsequent mute / unmute events are ignored too.
|
|
374
|
+
const calls = (track.addEventListener as ReturnType<typeof vi.fn>).mock
|
|
375
|
+
.calls;
|
|
376
|
+
const handlers: Record<string, () => void> = {};
|
|
377
|
+
for (const [event, handler] of calls) {
|
|
378
|
+
handlers[event] = handler as () => void;
|
|
379
|
+
}
|
|
380
|
+
handlers['mute']();
|
|
381
|
+
handlers['unmute']();
|
|
382
|
+
expect(interruptedFor('video-session')).toEqual([]);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('does not mutate state for orphaned tracks until associated', () => {
|
|
386
|
+
const mediaStream = new MediaStream();
|
|
387
|
+
const track = new MediaStreamTrack();
|
|
388
|
+
// @ts-expect-error - mock
|
|
389
|
+
mediaStream.id = 'orphan:TRACK_TYPE_AUDIO';
|
|
390
|
+
// @ts-expect-error - mock
|
|
391
|
+
track.kind = 'audio';
|
|
392
|
+
|
|
393
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
394
|
+
// @ts-expect-error - incomplete mock
|
|
395
|
+
onTrack({ streams: [mediaStream], track });
|
|
396
|
+
|
|
397
|
+
const calls = (track.addEventListener as ReturnType<typeof vi.fn>).mock
|
|
398
|
+
.calls;
|
|
399
|
+
const handlers: Record<string, () => void> = {};
|
|
400
|
+
for (const [event, handler] of calls) {
|
|
401
|
+
handlers[event] = handler as () => void;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Orphan: handler fires before the participant exists.
|
|
405
|
+
handlers['mute']();
|
|
406
|
+
expect(state.participants).toEqual([]);
|
|
407
|
+
|
|
408
|
+
// Once the participant is registered, the next event lands.
|
|
409
|
+
// @ts-expect-error - incomplete mock
|
|
410
|
+
state.updateOrAddParticipant('orphan-session', {
|
|
411
|
+
sessionId: 'orphan-session',
|
|
412
|
+
trackLookupPrefix: 'orphan',
|
|
413
|
+
});
|
|
414
|
+
handlers['mute']();
|
|
415
|
+
|
|
416
|
+
expect(interruptedFor('orphan-session')).toEqual([TrackType.AUDIO]);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
219
420
|
describe('Negotiation', () => {
|
|
220
421
|
it('negotiates with the SFU', async () => {
|
|
221
422
|
sfuClient.sendAnswer = vi.fn();
|
|
@@ -16,8 +16,8 @@ const RTCPeerConnectionMock = vi.fn((): Partial<RTCPeerConnection> => {
|
|
|
16
16
|
close: vi.fn(),
|
|
17
17
|
connectionState: 'connected',
|
|
18
18
|
signalingState: 'stable',
|
|
19
|
-
getReceivers: vi.fn(),
|
|
20
|
-
getSenders: vi.fn(),
|
|
19
|
+
getReceivers: vi.fn().mockReturnValue([]),
|
|
20
|
+
getSenders: vi.fn().mockReturnValue([]),
|
|
21
21
|
removeTrack: vi.fn(),
|
|
22
22
|
};
|
|
23
23
|
});
|
|
@@ -109,6 +109,16 @@ const AudioContextMock = vi.fn((): Partial<AudioContext> => {
|
|
|
109
109
|
gain: { value: 1 },
|
|
110
110
|
} as unknown as GainNode;
|
|
111
111
|
}),
|
|
112
|
+
// Silent keep-alive node used by DynascaleManager's probe AudioContext.
|
|
113
|
+
createConstantSource: vi.fn(() => {
|
|
114
|
+
return {
|
|
115
|
+
offset: { value: 0 },
|
|
116
|
+
connect: vi.fn((v) => v),
|
|
117
|
+
disconnect: vi.fn(),
|
|
118
|
+
start: vi.fn(),
|
|
119
|
+
stop: vi.fn(),
|
|
120
|
+
} as unknown as ConstantSourceNode;
|
|
121
|
+
}),
|
|
112
122
|
close: vi.fn(async function () {
|
|
113
123
|
this.state = 'closed';
|
|
114
124
|
}),
|
|
@@ -119,6 +129,7 @@ const AudioContextMock = vi.fn((): Partial<AudioContext> => {
|
|
|
119
129
|
this.sinkId = sinkId;
|
|
120
130
|
}),
|
|
121
131
|
addEventListener: vi.fn(),
|
|
132
|
+
removeEventListener: vi.fn(),
|
|
122
133
|
};
|
|
123
134
|
});
|
|
124
135
|
vi.stubGlobal('AudioContext', AudioContextMock);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DegradationPreference } from '../../../gen/video/sfu/models/models';
|
|
3
|
+
import { toRTCDegradationPreference } from '../degradationPreference';
|
|
4
|
+
|
|
5
|
+
describe('toRTCDegradationPreference', () => {
|
|
6
|
+
it.each([
|
|
7
|
+
[DegradationPreference.BALANCED, 'balanced'],
|
|
8
|
+
[DegradationPreference.MAINTAIN_FRAMERATE, 'maintain-framerate'],
|
|
9
|
+
[DegradationPreference.MAINTAIN_RESOLUTION, 'maintain-resolution'],
|
|
10
|
+
[
|
|
11
|
+
DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION,
|
|
12
|
+
'maintain-framerate-and-resolution',
|
|
13
|
+
],
|
|
14
|
+
])('maps %s to "%s"', (preference, expected) => {
|
|
15
|
+
expect(toRTCDegradationPreference(preference)).toBe(expected);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns undefined for UNSPECIFIED', () => {
|
|
19
|
+
expect(
|
|
20
|
+
toRTCDegradationPreference(DegradationPreference.UNSPECIFIED),
|
|
21
|
+
).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { DegradationPreference } from '../../gen/video/sfu/models/models';
|
|
2
|
+
import { ensureExhausted } from '../../helpers/ensureExhausted';
|
|
3
|
+
|
|
4
|
+
export const toRTCDegradationPreference = (
|
|
5
|
+
preference: DegradationPreference,
|
|
6
|
+
): RTCDegradationPreference | undefined => {
|
|
7
|
+
switch (preference) {
|
|
8
|
+
case DegradationPreference.BALANCED:
|
|
9
|
+
return 'balanced';
|
|
10
|
+
case DegradationPreference.MAINTAIN_FRAMERATE:
|
|
11
|
+
return 'maintain-framerate';
|
|
12
|
+
case DegradationPreference.MAINTAIN_RESOLUTION:
|
|
13
|
+
return 'maintain-resolution';
|
|
14
|
+
case DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION:
|
|
15
|
+
// @ts-expect-error not in the typedefs yet
|
|
16
|
+
return 'maintain-framerate-and-resolution';
|
|
17
|
+
case DegradationPreference.UNSPECIFIED:
|
|
18
|
+
return undefined;
|
|
19
|
+
default:
|
|
20
|
+
ensureExhausted(preference, 'Unknown degradation preference');
|
|
21
|
+
}
|
|
22
|
+
};
|
package/src/rtc/index.ts
CHANGED
package/src/rtc/types.ts
CHANGED
|
@@ -10,18 +10,55 @@ import { Dispatcher } from './Dispatcher';
|
|
|
10
10
|
import type { OptimalVideoLayer } from './layers';
|
|
11
11
|
import type { ClientPublishOptions } from '../types';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Canonical reasons the SDK uses to trigger a reconnection. Free-form strings
|
|
15
|
+
* are still accepted at the callback boundary (e.g. when forwarding an SFU
|
|
16
|
+
* error message), but only the members below influence reconnect-loop
|
|
17
|
+
* behavior. In particular, `Call.reconnect` programmatically inspects
|
|
18
|
+
* `ICE_NEVER_CONNECTED` to drive the unsupported-network detector — pass a
|
|
19
|
+
* canonical member when you want the SDK to react to the reason; pass a
|
|
20
|
+
* free-form string when the value is purely diagnostic.
|
|
21
|
+
*/
|
|
22
|
+
export const ReconnectReason = {
|
|
23
|
+
/** ICE never reached `connected`/`completed`, escalate to REJOIN. */
|
|
24
|
+
ICE_NEVER_CONNECTED: 'ice_never_connected',
|
|
25
|
+
/** RTCPeerConnection.connectionState became `failed`. */
|
|
26
|
+
CONNECTION_FAILED: 'connection_failed',
|
|
27
|
+
/** `restartIce()` rejected. */
|
|
28
|
+
RESTART_ICE_FAILED: 'restart_ice_failed',
|
|
29
|
+
/** SFU `goAway` event, migrate to a new SFU. */
|
|
30
|
+
GO_AWAY: 'go_away',
|
|
31
|
+
/** Network came back online after going offline. */
|
|
32
|
+
NETWORK_BACK_ONLINE: 'network_back_online',
|
|
33
|
+
/** SFU error event with no descriptive message. */
|
|
34
|
+
SFU_ERROR: 'sfu_error',
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
export type ReconnectReason =
|
|
38
|
+
| (typeof ReconnectReason)[keyof typeof ReconnectReason]
|
|
39
|
+
| (string & {});
|
|
40
|
+
|
|
13
41
|
export type OnReconnectionNeeded = (
|
|
14
42
|
kind: WebsocketReconnectStrategy,
|
|
15
|
-
reason:
|
|
43
|
+
reason: ReconnectReason,
|
|
16
44
|
peerType: PeerType,
|
|
17
45
|
) => void;
|
|
18
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Fires the first time a peer connection's ICE transport reaches
|
|
49
|
+
* `connected` or `completed` during its lifetime. Used by `Call` to reset
|
|
50
|
+
* the "ICE never connected" failure counter only when WebRTC has actually
|
|
51
|
+
* recovered, not merely when the SFU join handshake succeeded.
|
|
52
|
+
*/
|
|
53
|
+
export type OnIceConnected = (peerType: PeerType) => void;
|
|
54
|
+
|
|
19
55
|
export type BasePeerConnectionOpts = {
|
|
20
56
|
sfuClient: StreamSfuClient;
|
|
21
57
|
state: CallState;
|
|
22
58
|
connectionConfig?: RTCConfiguration;
|
|
23
59
|
dispatcher: Dispatcher;
|
|
24
60
|
onReconnectionNeeded?: OnReconnectionNeeded;
|
|
61
|
+
onIceConnected?: OnIceConnected;
|
|
25
62
|
tag: string;
|
|
26
63
|
enableTracing: boolean;
|
|
27
64
|
iceRestartDelay?: number;
|
package/src/stats/rtc/types.ts
CHANGED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { BehaviorSubject, Subject, throwError } from 'rxjs';
|
|
3
|
+
import { promiseWithResolvers } from '../../helpers/promise';
|
|
4
|
+
import {
|
|
5
|
+
createSafeAsyncSubscription,
|
|
6
|
+
createSubscription,
|
|
7
|
+
getCurrentValue,
|
|
8
|
+
setCurrentValue,
|
|
9
|
+
setCurrentValueAsync,
|
|
10
|
+
updateValue,
|
|
11
|
+
} from '../rxUtils';
|
|
12
|
+
|
|
13
|
+
describe('getCurrentValue', () => {
|
|
14
|
+
it('returns the current value of a BehaviorSubject', () => {
|
|
15
|
+
const subject = new BehaviorSubject(42);
|
|
16
|
+
expect(getCurrentValue(subject)).toBe(42);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('reflects subsequent emissions', () => {
|
|
20
|
+
const subject = new BehaviorSubject('a');
|
|
21
|
+
subject.next('b');
|
|
22
|
+
expect(getCurrentValue(subject)).toBe('b');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('rethrows errors emitted by the observable', () => {
|
|
26
|
+
const err = new Error('observable failed');
|
|
27
|
+
expect(() => getCurrentValue(throwError(() => err))).toThrow(err);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('setCurrentValue', () => {
|
|
32
|
+
it('sets a plain value and returns it', () => {
|
|
33
|
+
const subject = new BehaviorSubject(1);
|
|
34
|
+
const result = setCurrentValue(subject, 5);
|
|
35
|
+
expect(result).toBe(5);
|
|
36
|
+
expect(subject.getValue()).toBe(5);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('applies a function patch using the current value', () => {
|
|
40
|
+
const subject = new BehaviorSubject(10);
|
|
41
|
+
const result = setCurrentValue(subject, (n) => n * 2);
|
|
42
|
+
expect(result).toBe(20);
|
|
43
|
+
expect(subject.getValue()).toBe(20);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('emits the new value to subscribers', () => {
|
|
47
|
+
const subject = new BehaviorSubject(0);
|
|
48
|
+
const seen: number[] = [];
|
|
49
|
+
const sub = subject.subscribe((v) => seen.push(v));
|
|
50
|
+
setCurrentValue(subject, 1);
|
|
51
|
+
setCurrentValue(subject, (n) => n + 1);
|
|
52
|
+
sub.unsubscribe();
|
|
53
|
+
expect(seen).toEqual([0, 1, 2]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('setCurrentValueAsync', () => {
|
|
58
|
+
it('passes the current value to the update fn and emits the resolved value', async () => {
|
|
59
|
+
const subject = new BehaviorSubject(1);
|
|
60
|
+
const update = vi.fn(async (n: number) => n + 1);
|
|
61
|
+
|
|
62
|
+
const result = await setCurrentValueAsync(subject, update);
|
|
63
|
+
|
|
64
|
+
expect(update).toHaveBeenCalledWith(1);
|
|
65
|
+
expect(result).toBe(2);
|
|
66
|
+
expect(subject.getValue()).toBe(2);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('serializes concurrent calls on the same subject so each sees the previous result', async () => {
|
|
70
|
+
const subject = new BehaviorSubject(0);
|
|
71
|
+
const observed: number[] = [];
|
|
72
|
+
|
|
73
|
+
const append = (delay: number) =>
|
|
74
|
+
setCurrentValueAsync(subject, async (n) => {
|
|
75
|
+
observed.push(n);
|
|
76
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
77
|
+
return n + 1;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const [a, b, c] = await Promise.all([append(10), append(0), append(0)]);
|
|
81
|
+
|
|
82
|
+
expect(observed).toEqual([0, 1, 2]);
|
|
83
|
+
expect([a, b, c]).toEqual([1, 2, 3]);
|
|
84
|
+
expect(subject.getValue()).toBe(3);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('does not block updates on a different subject', async () => {
|
|
88
|
+
const a = new BehaviorSubject('a-0');
|
|
89
|
+
const b = new BehaviorSubject('b-0');
|
|
90
|
+
|
|
91
|
+
const gate = promiseWithResolvers();
|
|
92
|
+
|
|
93
|
+
const aPending = setCurrentValueAsync(a, async (v) => {
|
|
94
|
+
await gate.promise;
|
|
95
|
+
return `${v}-done`;
|
|
96
|
+
});
|
|
97
|
+
const bDone = await setCurrentValueAsync(b, async (v) => `${v}-done`);
|
|
98
|
+
|
|
99
|
+
expect(bDone).toBe('b-0-done');
|
|
100
|
+
expect(b.getValue()).toBe('b-0-done');
|
|
101
|
+
|
|
102
|
+
gate.resolve();
|
|
103
|
+
await expect(aPending).resolves.toBe('a-0-done');
|
|
104
|
+
expect(a.getValue()).toBe('a-0-done');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('propagates rejections without emitting and keeps the prior value', async () => {
|
|
108
|
+
const subject = new BehaviorSubject(7);
|
|
109
|
+
const emitted: number[] = [];
|
|
110
|
+
const sub = subject.subscribe((v) => emitted.push(v));
|
|
111
|
+
|
|
112
|
+
const boom = new Error('boom');
|
|
113
|
+
await expect(
|
|
114
|
+
setCurrentValueAsync(subject, async () => {
|
|
115
|
+
throw boom;
|
|
116
|
+
}),
|
|
117
|
+
).rejects.toBe(boom);
|
|
118
|
+
|
|
119
|
+
expect(subject.getValue()).toBe(7);
|
|
120
|
+
// Only the initial replay from the BehaviorSubject, no second emission.
|
|
121
|
+
expect(emitted).toEqual([7]);
|
|
122
|
+
sub.unsubscribe();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('continues to process queued updates after a rejection', async () => {
|
|
126
|
+
const subject = new BehaviorSubject(0);
|
|
127
|
+
|
|
128
|
+
const failing = setCurrentValueAsync(subject, async () => {
|
|
129
|
+
throw new Error('nope');
|
|
130
|
+
});
|
|
131
|
+
const succeeding = setCurrentValueAsync(subject, async (n) => n + 5);
|
|
132
|
+
|
|
133
|
+
await expect(failing).rejects.toThrow('nope');
|
|
134
|
+
await expect(succeeding).resolves.toBe(5);
|
|
135
|
+
expect(subject.getValue()).toBe(5);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('updateValue', () => {
|
|
140
|
+
it('returns the previous and new values', () => {
|
|
141
|
+
const subject = new BehaviorSubject(1);
|
|
142
|
+
const { lastValue, value } = updateValue(subject, 2);
|
|
143
|
+
expect(lastValue).toBe(1);
|
|
144
|
+
expect(value).toBe(2);
|
|
145
|
+
expect(subject.getValue()).toBe(2);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('rollback restores the previous value', () => {
|
|
149
|
+
const subject = new BehaviorSubject({ count: 3 });
|
|
150
|
+
const prior = subject.getValue();
|
|
151
|
+
|
|
152
|
+
const { rollback } = updateValue(subject, { count: 99 });
|
|
153
|
+
expect(subject.getValue()).toEqual({ count: 99 });
|
|
154
|
+
|
|
155
|
+
rollback();
|
|
156
|
+
expect(subject.getValue()).toBe(prior);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('accepts a function patch', () => {
|
|
160
|
+
const subject = new BehaviorSubject(10);
|
|
161
|
+
const { value } = updateValue(subject, (n) => n + 5);
|
|
162
|
+
expect(value).toBe(15);
|
|
163
|
+
expect(subject.getValue()).toBe(15);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('createSubscription', () => {
|
|
168
|
+
it('invokes the handler with every emitted value', () => {
|
|
169
|
+
const subject = new Subject<number>();
|
|
170
|
+
const handler = vi.fn();
|
|
171
|
+
const unsubscribe = createSubscription(subject, handler);
|
|
172
|
+
|
|
173
|
+
subject.next(1);
|
|
174
|
+
subject.next(2);
|
|
175
|
+
unsubscribe();
|
|
176
|
+
|
|
177
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
178
|
+
expect(handler).toHaveBeenNthCalledWith(1, 1);
|
|
179
|
+
expect(handler).toHaveBeenNthCalledWith(2, 2);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('stops receiving values after unsubscribe is called', () => {
|
|
183
|
+
const subject = new Subject<number>();
|
|
184
|
+
const handler = vi.fn();
|
|
185
|
+
const unsubscribe = createSubscription(subject, handler);
|
|
186
|
+
|
|
187
|
+
subject.next(1);
|
|
188
|
+
unsubscribe();
|
|
189
|
+
subject.next(2);
|
|
190
|
+
|
|
191
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
192
|
+
expect(handler).toHaveBeenCalledWith(1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('routes errors to the provided onError handler', () => {
|
|
196
|
+
const err = new Error('observable failed');
|
|
197
|
+
const onError = vi.fn();
|
|
198
|
+
createSubscription(
|
|
199
|
+
throwError(() => err),
|
|
200
|
+
vi.fn(),
|
|
201
|
+
onError,
|
|
202
|
+
);
|
|
203
|
+
expect(onError).toHaveBeenCalledWith(err);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('swallows errors via the default onError when none is provided', () => {
|
|
207
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
208
|
+
|
|
209
|
+
expect(() =>
|
|
210
|
+
createSubscription(
|
|
211
|
+
throwError(() => new Error('boom')),
|
|
212
|
+
vi.fn(),
|
|
213
|
+
),
|
|
214
|
+
).not.toThrow();
|
|
215
|
+
expect(warn).toHaveBeenCalled();
|
|
216
|
+
|
|
217
|
+
warn.mockRestore();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('createSafeAsyncSubscription', () => {
|
|
222
|
+
it('runs the async handler for each emission', async () => {
|
|
223
|
+
const subject = new Subject<number>();
|
|
224
|
+
const handler = vi.fn(async () => {});
|
|
225
|
+
const unsubscribe = createSafeAsyncSubscription(subject, handler);
|
|
226
|
+
|
|
227
|
+
subject.next(1);
|
|
228
|
+
subject.next(2);
|
|
229
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
230
|
+
|
|
231
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
232
|
+
expect(handler).toHaveBeenNthCalledWith(1, 1);
|
|
233
|
+
expect(handler).toHaveBeenNthCalledWith(2, 2);
|
|
234
|
+
unsubscribe();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('serializes handlers so a slow one blocks the next', async () => {
|
|
238
|
+
const subject = new Subject<number>();
|
|
239
|
+
const events: string[] = [];
|
|
240
|
+
const gate = promiseWithResolvers();
|
|
241
|
+
|
|
242
|
+
const unsubscribe = createSafeAsyncSubscription(subject, async (v) => {
|
|
243
|
+
events.push(`start:${v}`);
|
|
244
|
+
if (v === 1) await gate.promise;
|
|
245
|
+
events.push(`end:${v}`);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
subject.next(1);
|
|
249
|
+
subject.next(2);
|
|
250
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
251
|
+
|
|
252
|
+
// Second handler hasn't started yet because the first is still in-flight.
|
|
253
|
+
expect(events).toEqual(['start:1']);
|
|
254
|
+
|
|
255
|
+
gate.resolve();
|
|
256
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
257
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
258
|
+
|
|
259
|
+
expect(events).toEqual(['start:1', 'end:1', 'start:2', 'end:2']);
|
|
260
|
+
unsubscribe();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('stops invoking the handler after unsubscribe', async () => {
|
|
264
|
+
const subject = new Subject<number>();
|
|
265
|
+
const handler = vi.fn(async () => {});
|
|
266
|
+
const unsubscribe = createSafeAsyncSubscription(subject, handler);
|
|
267
|
+
|
|
268
|
+
subject.next(1);
|
|
269
|
+
unsubscribe();
|
|
270
|
+
subject.next(2);
|
|
271
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
272
|
+
|
|
273
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
274
|
+
expect(handler).toHaveBeenCalledWith(1);
|
|
275
|
+
});
|
|
276
|
+
});
|