@stream-io/video-client 1.48.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 +14 -0
- package/dist/index.browser.es.js +376 -48
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +376 -48
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +376 -48
- 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/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/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/__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/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
|
|
@@ -290,6 +290,109 @@ describe('Publishing and Unpublishing tracks', () => {
|
|
|
290
290
|
expect(participant!.screenShareStream).toBeUndefined();
|
|
291
291
|
expect(participant!.screenShareAudioStream).toBeUndefined();
|
|
292
292
|
});
|
|
293
|
+
|
|
294
|
+
it('does not throw if sfuClient is cleared while the mute-state RPC is in flight', async () => {
|
|
295
|
+
let releaseMuteUpdate!: () => void;
|
|
296
|
+
let signalMuteUpdateEntered!: () => void;
|
|
297
|
+
const muteUpdateEntered = new Promise<void>(
|
|
298
|
+
(resolve) => (signalMuteUpdateEntered = resolve),
|
|
299
|
+
);
|
|
300
|
+
sfuClient.updateMuteStates = vi.fn().mockImplementation(() => {
|
|
301
|
+
signalMuteUpdateEntered();
|
|
302
|
+
return new Promise<void>((resolve) => (releaseMuteUpdate = resolve));
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const track = new MediaStreamTrack() as MediaStreamAudioTrack;
|
|
306
|
+
const mediaStream = new MediaStream();
|
|
307
|
+
vi.spyOn(mediaStream, 'getAudioTracks').mockReturnValue([track]);
|
|
308
|
+
|
|
309
|
+
const inflight = call.publish(mediaStream, TrackType.AUDIO);
|
|
310
|
+
|
|
311
|
+
await muteUpdateEntered;
|
|
312
|
+
|
|
313
|
+
call['sfuClient'] = undefined;
|
|
314
|
+
releaseMuteUpdate();
|
|
315
|
+
|
|
316
|
+
await inflight;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('updates local stream state when sfuClient is replaced with the same session id', async () => {
|
|
320
|
+
let releaseMuteUpdate!: () => void;
|
|
321
|
+
let signalMuteUpdateEntered!: () => void;
|
|
322
|
+
const muteUpdateEntered = new Promise<void>(
|
|
323
|
+
(resolve) => (signalMuteUpdateEntered = resolve),
|
|
324
|
+
);
|
|
325
|
+
sfuClient.updateMuteStates = vi.fn().mockImplementation(() => {
|
|
326
|
+
signalMuteUpdateEntered();
|
|
327
|
+
return new Promise<void>((resolve) => (releaseMuteUpdate = resolve));
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const track = new MediaStreamTrack() as MediaStreamAudioTrack;
|
|
331
|
+
const mediaStream = new MediaStream();
|
|
332
|
+
vi.spyOn(mediaStream, 'getAudioTracks').mockReturnValue([track]);
|
|
333
|
+
|
|
334
|
+
const inflight = call.publish(mediaStream, TrackType.AUDIO);
|
|
335
|
+
|
|
336
|
+
await muteUpdateEntered;
|
|
337
|
+
|
|
338
|
+
const replacementSfuClient = vi.fn() as unknown as StreamSfuClient;
|
|
339
|
+
// @ts-expect-error sessionId is readonly
|
|
340
|
+
replacementSfuClient['sessionId'] = sessionId;
|
|
341
|
+
replacementSfuClient.updateMuteStates = vi.fn();
|
|
342
|
+
call['sfuClient'] = replacementSfuClient;
|
|
343
|
+
releaseMuteUpdate();
|
|
344
|
+
|
|
345
|
+
await inflight;
|
|
346
|
+
|
|
347
|
+
const participant = call.state.findParticipantBySessionId(sessionId);
|
|
348
|
+
expect(participant?.publishedTracks).toEqual([TrackType.AUDIO]);
|
|
349
|
+
expect(participant?.audioStream).toBe(mediaStream);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('skips local stream state update when sfuClient is replaced with a new session id', async () => {
|
|
353
|
+
let releaseMuteUpdate!: () => void;
|
|
354
|
+
let signalMuteUpdateEntered!: () => void;
|
|
355
|
+
const muteUpdateEntered = new Promise<void>(
|
|
356
|
+
(resolve) => (signalMuteUpdateEntered = resolve),
|
|
357
|
+
);
|
|
358
|
+
sfuClient.updateMuteStates = vi.fn().mockImplementation(() => {
|
|
359
|
+
signalMuteUpdateEntered();
|
|
360
|
+
return new Promise<void>((resolve) => (releaseMuteUpdate = resolve));
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const track = new MediaStreamTrack() as MediaStreamAudioTrack;
|
|
364
|
+
const mediaStream = new MediaStream();
|
|
365
|
+
vi.spyOn(mediaStream, 'getAudioTracks').mockReturnValue([track]);
|
|
366
|
+
|
|
367
|
+
const inflight = call.publish(mediaStream, TrackType.AUDIO);
|
|
368
|
+
|
|
369
|
+
await muteUpdateEntered;
|
|
370
|
+
|
|
371
|
+
const replacementSessionId = 'replacement-session-id';
|
|
372
|
+
// @ts-expect-error partial data
|
|
373
|
+
call.state.updateOrAddParticipant(replacementSessionId, {
|
|
374
|
+
sessionId: replacementSessionId,
|
|
375
|
+
publishedTracks: [],
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const replacementSfuClient = vi.fn() as unknown as StreamSfuClient;
|
|
379
|
+
// @ts-expect-error sessionId is readonly
|
|
380
|
+
replacementSfuClient['sessionId'] = replacementSessionId;
|
|
381
|
+
replacementSfuClient.updateMuteStates = vi.fn();
|
|
382
|
+
call['sfuClient'] = replacementSfuClient;
|
|
383
|
+
releaseMuteUpdate();
|
|
384
|
+
|
|
385
|
+
await inflight;
|
|
386
|
+
|
|
387
|
+
const originalParticipant =
|
|
388
|
+
call.state.findParticipantBySessionId(sessionId);
|
|
389
|
+
const replacementParticipant =
|
|
390
|
+
call.state.findParticipantBySessionId(replacementSessionId);
|
|
391
|
+
expect(originalParticipant?.publishedTracks).toEqual([]);
|
|
392
|
+
expect(originalParticipant?.audioStream).toBeUndefined();
|
|
393
|
+
expect(replacementParticipant?.publishedTracks).toEqual([]);
|
|
394
|
+
expect(replacementParticipant?.audioStream).toBeUndefined();
|
|
395
|
+
});
|
|
293
396
|
});
|
|
294
397
|
|
|
295
398
|
describe('Deprecated methods', () => {
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { StreamSfuClient } from '../StreamSfuClient';
|
|
3
|
+
import { Dispatcher } from '../rtc';
|
|
4
|
+
import { StreamClient } from '../coordinator/connection/client';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Minimal `WebSocket` stub used to drive `StreamSfuClient.close()` while the
|
|
8
|
+
* underlying connection is still in `CONNECTING` state. The constructor
|
|
9
|
+
* leaves `readyState = CONNECTING`; `close()` records the call and flips
|
|
10
|
+
* to `CLOSED` so subsequent assertions can see what happened.
|
|
11
|
+
*/
|
|
12
|
+
class CapturingWebSocket {
|
|
13
|
+
static CONNECTING = 0;
|
|
14
|
+
static OPEN = 1;
|
|
15
|
+
static CLOSING = 2;
|
|
16
|
+
static CLOSED = 3;
|
|
17
|
+
static instances: CapturingWebSocket[] = [];
|
|
18
|
+
|
|
19
|
+
readyState = CapturingWebSocket.CONNECTING;
|
|
20
|
+
url: string;
|
|
21
|
+
binaryType = 'blob';
|
|
22
|
+
closeArgs: { code?: number; reason?: string } | undefined;
|
|
23
|
+
private listeners = new Map<string, Set<(e: unknown) => void>>();
|
|
24
|
+
|
|
25
|
+
constructor(url: string | URL) {
|
|
26
|
+
this.url = typeof url === 'string' ? url : url.toString();
|
|
27
|
+
CapturingWebSocket.instances.push(this);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
addEventListener(event: string, listener: (e: unknown) => void) {
|
|
31
|
+
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
|
|
32
|
+
this.listeners.get(event)!.add(listener);
|
|
33
|
+
}
|
|
34
|
+
removeEventListener(event: string, listener: (e: unknown) => void) {
|
|
35
|
+
this.listeners.get(event)?.delete(listener);
|
|
36
|
+
}
|
|
37
|
+
close(code?: number, reason?: string) {
|
|
38
|
+
this.closeArgs = { code, reason };
|
|
39
|
+
this.readyState = CapturingWebSocket.CLOSED;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const buildSfuClient = () => {
|
|
44
|
+
const dispatcher = new Dispatcher();
|
|
45
|
+
const streamClient = new StreamClient('test-key');
|
|
46
|
+
return new StreamSfuClient({
|
|
47
|
+
dispatcher,
|
|
48
|
+
sessionId: 'session-id-test',
|
|
49
|
+
streamClient,
|
|
50
|
+
cid: 'default:test',
|
|
51
|
+
credentials: {
|
|
52
|
+
server: {
|
|
53
|
+
url: 'https://test.invalid',
|
|
54
|
+
ws_endpoint: 'wss://test.invalid/ws',
|
|
55
|
+
edge_name: 'sfu-test',
|
|
56
|
+
},
|
|
57
|
+
token: 'token',
|
|
58
|
+
ice_servers: [],
|
|
59
|
+
},
|
|
60
|
+
tag: 'test',
|
|
61
|
+
enableTracing: false,
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
describe('StreamSfuClient.close()', () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
CapturingWebSocket.instances = [];
|
|
68
|
+
vi.stubGlobal('WebSocket', CapturingWebSocket);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
vi.unstubAllGlobals();
|
|
73
|
+
vi.clearAllMocks();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('closes the WebSocket even when it is still in CONNECTING state', () => {
|
|
77
|
+
const sfuClient = buildSfuClient();
|
|
78
|
+
const ws = CapturingWebSocket.instances.at(-1)!;
|
|
79
|
+
expect(ws.readyState).toBe(CapturingWebSocket.CONNECTING);
|
|
80
|
+
|
|
81
|
+
sfuClient.close(1000, 'tearing down');
|
|
82
|
+
|
|
83
|
+
expect(ws.closeArgs).toBeDefined();
|
|
84
|
+
expect(ws.closeArgs?.code).toBe(1000);
|
|
85
|
+
expect(ws.readyState).toBe(CapturingWebSocket.CLOSED);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('rejects a pending joinResponseTask on close so awaiters do not hang', async () => {
|
|
89
|
+
const sfuClient = buildSfuClient();
|
|
90
|
+
const joinTask = sfuClient.joinTask;
|
|
91
|
+
|
|
92
|
+
sfuClient.close(1000, 'aborting');
|
|
93
|
+
|
|
94
|
+
await expect(joinTask).rejects.toThrow(/SFU client disposed/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('does not blow up when the WebSocket is already CLOSED', () => {
|
|
98
|
+
const sfuClient = buildSfuClient();
|
|
99
|
+
const ws = CapturingWebSocket.instances.at(-1)!;
|
|
100
|
+
ws.readyState = CapturingWebSocket.CLOSED;
|
|
101
|
+
|
|
102
|
+
expect(() => sfuClient.close(1000, 'noop')).not.toThrow();
|
|
103
|
+
// close() should not be called twice
|
|
104
|
+
expect(ws.closeArgs).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('leaveAndClose returns within ~grace period when the SFU is silent (no hang)', async () => {
|
|
108
|
+
const sfuClient = buildSfuClient();
|
|
109
|
+
vi.spyOn(
|
|
110
|
+
sfuClient as unknown as {
|
|
111
|
+
notifyLeave: (reason: string) => Promise<void>;
|
|
112
|
+
},
|
|
113
|
+
'notifyLeave',
|
|
114
|
+
).mockResolvedValue(undefined);
|
|
115
|
+
|
|
116
|
+
// joinResponseTask stays pending forever — verify leaveAndClose still returns.
|
|
117
|
+
const start = Date.now();
|
|
118
|
+
await Promise.race([
|
|
119
|
+
sfuClient.leaveAndClose('silent-sfu'),
|
|
120
|
+
new Promise((_, reject) =>
|
|
121
|
+
setTimeout(
|
|
122
|
+
() => reject(new Error('leaveAndClose hung past 2x grace')),
|
|
123
|
+
StreamSfuClient.LEAVE_NOTIFY_GRACE_MS * 2,
|
|
124
|
+
),
|
|
125
|
+
),
|
|
126
|
+
]);
|
|
127
|
+
const elapsed = Date.now() - start;
|
|
128
|
+
expect(elapsed).toBeLessThan(StreamSfuClient.LEAVE_NOTIFY_GRACE_MS * 2);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('close() does NOT produce an unhandled rejection when nobody awaits joinTask', async () => {
|
|
132
|
+
// Capture unhandledrejection events that fire during this test. Without
|
|
133
|
+
// the safe-catch attached to `joinResponseTask.promise`, dispose-time
|
|
134
|
+
// reject would surface here.
|
|
135
|
+
const unhandled: PromiseRejectionEvent[] = [];
|
|
136
|
+
const onUnhandled = (e: PromiseRejectionEvent) => {
|
|
137
|
+
unhandled.push(e);
|
|
138
|
+
// mark as handled so it doesn't crash the test runner
|
|
139
|
+
e.preventDefault?.();
|
|
140
|
+
};
|
|
141
|
+
if (typeof window !== 'undefined') {
|
|
142
|
+
window.addEventListener('unhandledrejection', onUnhandled);
|
|
143
|
+
}
|
|
144
|
+
// Node-side fallback so the test passes regardless of test environment.
|
|
145
|
+
const onProcessUnhandled = (reason: unknown) => {
|
|
146
|
+
unhandled.push({ reason } as unknown as PromiseRejectionEvent);
|
|
147
|
+
};
|
|
148
|
+
process.on('unhandledRejection', onProcessUnhandled);
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const sfuClient = buildSfuClient();
|
|
152
|
+
// Intentionally do NOT touch joinTask anywhere — no .catch, no await.
|
|
153
|
+
sfuClient.close(1000, 'aborting before any join');
|
|
154
|
+
|
|
155
|
+
// give microtasks + a tick for any unhandledrejection event to fire
|
|
156
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
157
|
+
expect(unhandled).toHaveLength(0);
|
|
158
|
+
} finally {
|
|
159
|
+
if (typeof window !== 'undefined') {
|
|
160
|
+
window.removeEventListener('unhandledrejection', onUnhandled);
|
|
161
|
+
}
|
|
162
|
+
process.off('unhandledRejection', onProcessUnhandled);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('StreamSfuClient.leaveAndClose()', () => {
|
|
168
|
+
beforeEach(() => {
|
|
169
|
+
CapturingWebSocket.instances = [];
|
|
170
|
+
vi.stubGlobal('WebSocket', CapturingWebSocket);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
afterEach(() => {
|
|
174
|
+
vi.useRealTimers();
|
|
175
|
+
vi.unstubAllGlobals();
|
|
176
|
+
vi.clearAllMocks();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
type JoinResponseTaskHandle = {
|
|
180
|
+
joinResponseTask: {
|
|
181
|
+
resolve: (v: unknown) => void;
|
|
182
|
+
reject: (err: unknown) => void;
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
it('notifies the SFU when joinResponseTask is already resolved', async () => {
|
|
187
|
+
const sfuClient = buildSfuClient();
|
|
188
|
+
(sfuClient as unknown as JoinResponseTaskHandle).joinResponseTask.resolve(
|
|
189
|
+
{},
|
|
190
|
+
);
|
|
191
|
+
const notifyLeaveSpy = vi
|
|
192
|
+
.spyOn(
|
|
193
|
+
sfuClient as unknown as {
|
|
194
|
+
notifyLeave: (reason: string) => Promise<void>;
|
|
195
|
+
},
|
|
196
|
+
'notifyLeave',
|
|
197
|
+
)
|
|
198
|
+
.mockResolvedValue(undefined);
|
|
199
|
+
|
|
200
|
+
await sfuClient.leaveAndClose('user-leaving');
|
|
201
|
+
|
|
202
|
+
expect(notifyLeaveSpy).toHaveBeenCalledWith('user-leaving');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('waits for an in-flight join and notifies the SFU when it resolves within the grace period', async () => {
|
|
206
|
+
const sfuClient = buildSfuClient();
|
|
207
|
+
const notifyLeaveSpy = vi
|
|
208
|
+
.spyOn(
|
|
209
|
+
sfuClient as unknown as {
|
|
210
|
+
notifyLeave: (reason: string) => Promise<void>;
|
|
211
|
+
},
|
|
212
|
+
'notifyLeave',
|
|
213
|
+
)
|
|
214
|
+
.mockResolvedValue(undefined);
|
|
215
|
+
|
|
216
|
+
vi.useFakeTimers();
|
|
217
|
+
const leavePromise = sfuClient.leaveAndClose('user-leaving');
|
|
218
|
+
|
|
219
|
+
// simulate the SFU sending JoinResponse 50 ms in (well within the grace window)
|
|
220
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
221
|
+
(sfuClient as unknown as JoinResponseTaskHandle).joinResponseTask.resolve(
|
|
222
|
+
{},
|
|
223
|
+
);
|
|
224
|
+
// flush remaining timers (the losing race branch and any microtasks)
|
|
225
|
+
await vi.runAllTimersAsync();
|
|
226
|
+
await leavePromise;
|
|
227
|
+
|
|
228
|
+
expect(notifyLeaveSpy).toHaveBeenCalledWith('user-leaving');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('skips notifyLeave when the join does not complete within the grace period', async () => {
|
|
232
|
+
const sfuClient = buildSfuClient();
|
|
233
|
+
const notifyLeaveSpy = vi
|
|
234
|
+
.spyOn(
|
|
235
|
+
sfuClient as unknown as {
|
|
236
|
+
notifyLeave: (reason: string) => Promise<void>;
|
|
237
|
+
},
|
|
238
|
+
'notifyLeave',
|
|
239
|
+
)
|
|
240
|
+
.mockResolvedValue(undefined);
|
|
241
|
+
|
|
242
|
+
vi.useFakeTimers();
|
|
243
|
+
const leavePromise = sfuClient.leaveAndClose('silent-sfu');
|
|
244
|
+
// run past the grace window — the task is never resolved
|
|
245
|
+
await vi.advanceTimersByTimeAsync(
|
|
246
|
+
StreamSfuClient.LEAVE_NOTIFY_GRACE_MS + 50,
|
|
247
|
+
);
|
|
248
|
+
await leavePromise;
|
|
249
|
+
|
|
250
|
+
expect(notifyLeaveSpy).not.toHaveBeenCalled();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('skips notifyLeave when joinResponseTask rejects within the grace period', async () => {
|
|
254
|
+
const sfuClient = buildSfuClient();
|
|
255
|
+
const notifyLeaveSpy = vi
|
|
256
|
+
.spyOn(
|
|
257
|
+
sfuClient as unknown as {
|
|
258
|
+
notifyLeave: (reason: string) => Promise<void>;
|
|
259
|
+
},
|
|
260
|
+
'notifyLeave',
|
|
261
|
+
)
|
|
262
|
+
.mockResolvedValue(undefined);
|
|
263
|
+
|
|
264
|
+
vi.useFakeTimers();
|
|
265
|
+
const leavePromise = sfuClient.leaveAndClose('rejected');
|
|
266
|
+
await vi.advanceTimersByTimeAsync(20);
|
|
267
|
+
(sfuClient as unknown as JoinResponseTaskHandle).joinResponseTask.reject(
|
|
268
|
+
new Error('SFU went away'),
|
|
269
|
+
);
|
|
270
|
+
await vi.runAllTimersAsync();
|
|
271
|
+
await leavePromise;
|
|
272
|
+
|
|
273
|
+
expect(notifyLeaveSpy).not.toHaveBeenCalled();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -19,6 +19,9 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
19
19
|
protected mediaStreamSubject = new BehaviorSubject<MediaStream | undefined>(
|
|
20
20
|
undefined,
|
|
21
21
|
);
|
|
22
|
+
protected rootMediaStreamSubject = new BehaviorSubject<
|
|
23
|
+
MediaStream | undefined
|
|
24
|
+
>(undefined);
|
|
22
25
|
protected selectedDeviceSubject = new BehaviorSubject<string | undefined>(
|
|
23
26
|
undefined,
|
|
24
27
|
);
|
|
@@ -37,6 +40,13 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
37
40
|
*/
|
|
38
41
|
mediaStream$ = this.mediaStreamSubject.asObservable();
|
|
39
42
|
|
|
43
|
+
/**
|
|
44
|
+
* An Observable that emits the raw device media stream (before any filters are applied),
|
|
45
|
+
* or `undefined` if the device is currently disabled. When no filters are active, this
|
|
46
|
+
* emits the same stream as `mediaStream$`.
|
|
47
|
+
*/
|
|
48
|
+
rootMediaStream$ = this.rootMediaStreamSubject.asObservable();
|
|
49
|
+
|
|
40
50
|
/**
|
|
41
51
|
* An Observable that emits the currently selected device
|
|
42
52
|
*/
|
|
@@ -134,6 +144,15 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
134
144
|
return RxUtils.getCurrentValue(this.mediaStream$);
|
|
135
145
|
}
|
|
136
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The raw device media stream (before any filters are applied), or `undefined`
|
|
149
|
+
* if the device is currently disabled. When no filters are active, this is the
|
|
150
|
+
* same as `mediaStream`.
|
|
151
|
+
*/
|
|
152
|
+
get rootMediaStream() {
|
|
153
|
+
return RxUtils.getCurrentValue(this.rootMediaStream$);
|
|
154
|
+
}
|
|
155
|
+
|
|
137
156
|
/**
|
|
138
157
|
* @internal
|
|
139
158
|
* @param status
|
|
@@ -163,6 +182,7 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
163
182
|
rootStream: MediaStream | undefined,
|
|
164
183
|
) {
|
|
165
184
|
RxUtils.setCurrentValue(this.mediaStreamSubject, stream);
|
|
185
|
+
RxUtils.setCurrentValue(this.rootMediaStreamSubject, rootStream);
|
|
166
186
|
if (rootStream) {
|
|
167
187
|
this.setDevice(this.getDeviceIdFromStream(rootStream));
|
|
168
188
|
}
|
|
@@ -34,7 +34,6 @@ describe('ScreenShareManager', () => {
|
|
|
34
34
|
let manager: ScreenShareManager;
|
|
35
35
|
|
|
36
36
|
beforeEach(() => {
|
|
37
|
-
const devicePersistence = { enabled: false, storageKey: '' };
|
|
38
37
|
manager = new ScreenShareManager(
|
|
39
38
|
new Call({
|
|
40
39
|
id: '',
|
|
@@ -42,7 +41,6 @@ describe('ScreenShareManager', () => {
|
|
|
42
41
|
streamClient: new StreamClient('abc123'),
|
|
43
42
|
clientStore: new StreamVideoWriteableStateStore(),
|
|
44
43
|
}),
|
|
45
|
-
devicePersistence,
|
|
46
44
|
);
|
|
47
45
|
});
|
|
48
46
|
|
package/src/devices/devices.ts
CHANGED
|
@@ -340,7 +340,6 @@ export const getScreenShareStream = async (
|
|
|
340
340
|
const tag = `navigator.mediaDevices.getDisplayMedia.${getDisplayMediaExecId++}.`;
|
|
341
341
|
try {
|
|
342
342
|
const constraints: DisplayMediaStreamOptions = {
|
|
343
|
-
// @ts-expect-error - not present in types yet
|
|
344
343
|
systemAudio: 'include',
|
|
345
344
|
...options,
|
|
346
345
|
video:
|
|
@@ -357,6 +356,8 @@ export const getScreenShareStream = async (
|
|
|
357
356
|
? options.audio
|
|
358
357
|
: {
|
|
359
358
|
channelCount: { ideal: 2 },
|
|
359
|
+
// @ts-expect-error not yet present in the types
|
|
360
|
+
restrictOwnAudio: true,
|
|
360
361
|
echoCancellation: false,
|
|
361
362
|
autoGainControl: false,
|
|
362
363
|
noiseSuppression: false,
|
|
@@ -0,0 +1,49 @@
|
|
|
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 class SlidingWindowRateLimiter {
|
|
8
|
+
private maxAttempts: number;
|
|
9
|
+
private windowMs: number;
|
|
10
|
+
private timestamps: number[] = [];
|
|
11
|
+
|
|
12
|
+
constructor(maxAttempts: number, windowMs: number) {
|
|
13
|
+
this.maxAttempts = maxAttempts;
|
|
14
|
+
this.windowMs = windowMs;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Attempts to register a new event at `now`. Returns `true` if the attempt
|
|
19
|
+
* fits inside the budget (and records it), or `false` if the budget is
|
|
20
|
+
* exhausted (in which case no timestamp is recorded).
|
|
21
|
+
*/
|
|
22
|
+
tryRegister = (now: number = Date.now()): boolean => {
|
|
23
|
+
this.prune(now);
|
|
24
|
+
if (this.timestamps.length >= this.maxAttempts) return false;
|
|
25
|
+
this.timestamps.push(now);
|
|
26
|
+
return true;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Clears the attempt history.
|
|
31
|
+
*/
|
|
32
|
+
reset = (): void => {
|
|
33
|
+
this.timestamps = [];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Updates the budget and window size. Existing timestamps are kept; they
|
|
38
|
+
* will be pruned by the next `tryRegister` call.
|
|
39
|
+
*/
|
|
40
|
+
setLimits = (maxAttempts: number, windowMs: number): void => {
|
|
41
|
+
this.maxAttempts = maxAttempts;
|
|
42
|
+
this.windowMs = windowMs;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
private prune = (now: number): void => {
|
|
46
|
+
const cutoff = now - this.windowMs;
|
|
47
|
+
this.timestamps = this.timestamps.filter((t) => t >= cutoff);
|
|
48
|
+
};
|
|
49
|
+
}
|