@stream-io/video-client 1.53.2 → 1.54.1-beta.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 +11 -0
- package/dist/index.browser.es.js +111 -12
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +111 -12
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +111 -12
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +13 -1
- package/dist/src/coordinator/connection/types.d.ts +6 -0
- package/dist/src/gen/google/protobuf/struct.d.ts +3 -1
- package/dist/src/gen/google/protobuf/timestamp.d.ts +3 -1
- package/dist/src/gen/video/sfu/event/events.d.ts +22 -1
- package/dist/src/gen/video/sfu/models/models.d.ts +4 -0
- package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +23 -2
- package/dist/src/reporting/ClientEventReporter.d.ts +2 -0
- package/dist/src/rtc/Publisher.d.ts +4 -1
- package/dist/src/rtc/Subscriber.d.ts +7 -0
- package/package.json +1 -1
- package/src/Call.ts +86 -6
- package/src/StreamVideoClient.ts +1 -0
- package/src/coordinator/connection/types.ts +7 -0
- package/src/gen/google/protobuf/struct.ts +7 -12
- package/src/gen/google/protobuf/timestamp.ts +6 -7
- package/src/gen/video/sfu/event/events.ts +23 -25
- package/src/gen/video/sfu/models/models.ts +11 -1
- package/src/gen/video/sfu/signal_rpc/signal.client.ts +25 -29
- package/src/gen/video/sfu/signal_rpc/signal.ts +1 -0
- package/src/helpers/client-details.ts +1 -1
- package/src/reporting/ClientEventReporter.ts +4 -0
- package/src/reporting/__tests__/ClientEventReporter.test.ts +33 -0
- package/src/rtc/Publisher.ts +4 -0
- package/src/rtc/Subscriber.ts +28 -1
- package/src/rtc/__tests__/Call.reconnect.test.ts +149 -2
|
@@ -618,3 +618,36 @@ describe('ClientEventReporter', () => {
|
|
|
618
618
|
expect(ws2.every((e) => e.sfu_id === 'sfu-2')).toBe(true);
|
|
619
619
|
});
|
|
620
620
|
});
|
|
621
|
+
|
|
622
|
+
describe('ClientEventReporter (disabled)', () => {
|
|
623
|
+
const cid = 'default:call-1';
|
|
624
|
+
let doAxiosRequest: ReturnType<typeof vi.fn>;
|
|
625
|
+
let reporter: ClientEventReporter;
|
|
626
|
+
|
|
627
|
+
beforeEach(() => {
|
|
628
|
+
doAxiosRequest = vi.fn().mockResolvedValue({});
|
|
629
|
+
const streamClient = fromPartial<StreamClient>({
|
|
630
|
+
userID: 'user-1',
|
|
631
|
+
doAxiosRequest,
|
|
632
|
+
getUserAgent: () => 'test-agent',
|
|
633
|
+
getSdkVersion: () => '1.0.0',
|
|
634
|
+
});
|
|
635
|
+
reporter = new ClientEventReporter({ streamClient, enabled: false });
|
|
636
|
+
reporter.startCoordinatorConnection('user-1');
|
|
637
|
+
reporter.registerCall(cid, {
|
|
638
|
+
callType: 'default',
|
|
639
|
+
callId: 'call-1',
|
|
640
|
+
getCallSessionId: () => 'session-1',
|
|
641
|
+
getSfuId: () => 'sfu-1',
|
|
642
|
+
getUserSessionId: () => 'user-session-1',
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('does not post any events when disabled', async () => {
|
|
647
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
648
|
+
await reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok'));
|
|
649
|
+
await flush();
|
|
650
|
+
|
|
651
|
+
expect(doAxiosRequest).not.toHaveBeenCalled();
|
|
652
|
+
});
|
|
653
|
+
});
|
package/src/rtc/Publisher.ts
CHANGED
|
@@ -39,6 +39,7 @@ export class Publisher extends BasePeerConnection {
|
|
|
39
39
|
private readonly transceiverCache = new TransceiverCache();
|
|
40
40
|
private readonly clonedTracks = new Set<MediaStreamTrack>();
|
|
41
41
|
private publishOptions: PublishOption[];
|
|
42
|
+
private readonly selfSubEnabled: boolean;
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
45
|
* Constructs a new `Publisher` instance.
|
|
@@ -46,9 +47,11 @@ export class Publisher extends BasePeerConnection {
|
|
|
46
47
|
constructor(
|
|
47
48
|
baseOptions: BasePeerConnectionOpts,
|
|
48
49
|
publishOptions: PublishOption[],
|
|
50
|
+
opts: { selfSubEnabled?: boolean } = {},
|
|
49
51
|
) {
|
|
50
52
|
super(PeerType.PUBLISHER_UNSPECIFIED, baseOptions);
|
|
51
53
|
this.publishOptions = publishOptions;
|
|
54
|
+
this.selfSubEnabled = opts.selfSubEnabled ?? false;
|
|
52
55
|
|
|
53
56
|
this.on('iceRestart', (iceRestart) => {
|
|
54
57
|
if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED) return;
|
|
@@ -576,6 +579,7 @@ export class Publisher extends BasePeerConnection {
|
|
|
576
579
|
muted: !isTrackLive,
|
|
577
580
|
codec: publishOption.codec,
|
|
578
581
|
publishOptionId: publishOption.id,
|
|
582
|
+
selfSubAudioVideo: this.selfSubEnabled,
|
|
579
583
|
};
|
|
580
584
|
};
|
|
581
585
|
|
package/src/rtc/Subscriber.ts
CHANGED
|
@@ -14,6 +14,14 @@ import { enableStereo, removeCodecsExcept } from './helpers/sdp';
|
|
|
14
14
|
* @internal
|
|
15
15
|
*/
|
|
16
16
|
export class Subscriber extends BasePeerConnection {
|
|
17
|
+
/**
|
|
18
|
+
* Remote streams received from the SFU. For a self-sub case
|
|
19
|
+
* we need to be able to distinguish between the local capture stream.
|
|
20
|
+
* The map will never contain local streams so we can safely use it to
|
|
21
|
+
* check if the stream is remote and dispose it when needed.
|
|
22
|
+
*/
|
|
23
|
+
private trackedStreams: WeakSet<MediaStream> = new WeakSet();
|
|
24
|
+
|
|
17
25
|
/**
|
|
18
26
|
* Constructs a new `Subscriber` instance.
|
|
19
27
|
*/
|
|
@@ -75,6 +83,7 @@ export class Subscriber extends BasePeerConnection {
|
|
|
75
83
|
const participantToUpdate = this.state.participants.find(
|
|
76
84
|
(p) => p.trackLookupPrefix === trackId,
|
|
77
85
|
);
|
|
86
|
+
const isSelfSub = !!participantToUpdate?.isLocalParticipant;
|
|
78
87
|
this.logger.debug(
|
|
79
88
|
`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`,
|
|
80
89
|
track.id,
|
|
@@ -108,6 +117,10 @@ export class Subscriber extends BasePeerConnection {
|
|
|
108
117
|
|
|
109
118
|
this.trackIdToTrackType.set(track.id, trackType);
|
|
110
119
|
|
|
120
|
+
if (isSelfSub) {
|
|
121
|
+
this.trackedStreams.add(primaryStream);
|
|
122
|
+
}
|
|
123
|
+
|
|
111
124
|
if (!participantToUpdate) {
|
|
112
125
|
this.logger.warn(
|
|
113
126
|
`[onTrack]: Received track for unknown participant: ${trackId}`,
|
|
@@ -128,6 +141,13 @@ export class Subscriber extends BasePeerConnection {
|
|
|
128
141
|
return;
|
|
129
142
|
}
|
|
130
143
|
|
|
144
|
+
// Self-sub loopback audio routes to the speaker by default, which
|
|
145
|
+
// would echo the local user's voice. Default-mute here; consumers
|
|
146
|
+
// (the loopback recording hook) re-enable explicitly when needed.
|
|
147
|
+
if (isSelfSub && e.track.kind === 'audio') {
|
|
148
|
+
e.track.enabled = false;
|
|
149
|
+
}
|
|
150
|
+
|
|
131
151
|
// get the previous stream to dispose it later
|
|
132
152
|
// usually this happens during migration, when the stream is replaced
|
|
133
153
|
// with a new one but the old one is still in the state
|
|
@@ -138,8 +158,15 @@ export class Subscriber extends BasePeerConnection {
|
|
|
138
158
|
[streamKindProp]: primaryStream,
|
|
139
159
|
});
|
|
140
160
|
|
|
141
|
-
// now, dispose the previous stream if it exists
|
|
142
161
|
if (previousStream) {
|
|
162
|
+
if (isSelfSub && !this.trackedStreams.has(previousStream)) {
|
|
163
|
+
// this is the local capture stream, we don't want to dispose it
|
|
164
|
+
this.logger.debug(
|
|
165
|
+
`[onTrack]: Skipping cleanup of previous ${e.track.kind} stream for userId: ${participantToUpdate.userId} because it is not tracked`,
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
143
170
|
this.logger.info(
|
|
144
171
|
`[onTrack]: Cleaning up previous remote ${track.kind} tracks for userId: ${participantToUpdate.userId}`,
|
|
145
172
|
);
|
|
@@ -19,19 +19,23 @@ import { Subscriber } from '../Subscriber';
|
|
|
19
19
|
import { Dispatcher } from '../Dispatcher';
|
|
20
20
|
import { StreamSfuClient } from '../../StreamSfuClient';
|
|
21
21
|
import { IceTrickleBuffer } from '../IceTrickleBuffer';
|
|
22
|
+
import { SfuJoinError } from '../../errors';
|
|
22
23
|
|
|
23
24
|
vi.mock('../../StreamSfuClient', () => ({
|
|
24
25
|
StreamSfuClient: vi.fn(),
|
|
25
26
|
}));
|
|
26
27
|
|
|
27
|
-
const makeCall = () => {
|
|
28
|
+
const makeCall = ({ reportingEnabled = false } = {}) => {
|
|
28
29
|
const streamClient = new StreamClient('test-key');
|
|
29
30
|
const clientStore = new StreamVideoWriteableStateStore();
|
|
30
31
|
return new Call({
|
|
31
32
|
type: 'default',
|
|
32
33
|
id: 'test-call',
|
|
33
34
|
streamClient,
|
|
34
|
-
clientEventReporter: new ClientEventReporter({
|
|
35
|
+
clientEventReporter: new ClientEventReporter({
|
|
36
|
+
streamClient,
|
|
37
|
+
enabled: reportingEnabled,
|
|
38
|
+
}),
|
|
35
39
|
clientStore,
|
|
36
40
|
ringing: false,
|
|
37
41
|
watching: false,
|
|
@@ -406,6 +410,149 @@ describe('Call reconnect stopping conditions', () => {
|
|
|
406
410
|
});
|
|
407
411
|
});
|
|
408
412
|
|
|
413
|
+
describe('Call reconnect rejoin SFU migration hints', () => {
|
|
414
|
+
let call: Call;
|
|
415
|
+
const credentials = {
|
|
416
|
+
server: {
|
|
417
|
+
url: 'https://getstream.io/',
|
|
418
|
+
ws_endpoint: 'https://getstream.io/ws',
|
|
419
|
+
edge_name: 'sfu-1',
|
|
420
|
+
},
|
|
421
|
+
token: 'token',
|
|
422
|
+
ice_servers: [],
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
beforeEach(() => {
|
|
426
|
+
call = makeCall();
|
|
427
|
+
call['credentials'] = credentials;
|
|
428
|
+
call['joinCallData'] = { create: true };
|
|
429
|
+
primeForReconnect(call);
|
|
430
|
+
call.setRejoinAttemptLimit(3, 60);
|
|
431
|
+
vi.spyOn(connectionUtils, 'sleep').mockResolvedValue(undefined);
|
|
432
|
+
vi.spyOn(call, 'leave').mockResolvedValue(undefined);
|
|
433
|
+
vi.spyOn(call, 'get').mockResolvedValue({} as never);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
afterEach(() => {
|
|
437
|
+
vi.clearAllMocks();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('asks the coordinator to avoid an SFU after repeated rejoin failures without leaking migration hints', async () => {
|
|
441
|
+
const doJoinRequestArgs: unknown[] = [];
|
|
442
|
+
const doJoinRequest = vi
|
|
443
|
+
.spyOn(
|
|
444
|
+
call as unknown as {
|
|
445
|
+
doJoinRequest: (data?: unknown) => Promise<unknown>;
|
|
446
|
+
},
|
|
447
|
+
'doJoinRequest',
|
|
448
|
+
)
|
|
449
|
+
.mockImplementation(async (data?: unknown) => {
|
|
450
|
+
doJoinRequestArgs.push(JSON.parse(JSON.stringify(data)));
|
|
451
|
+
throw new Error('rejoin failed');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
455
|
+
|
|
456
|
+
expect(doJoinRequest).toHaveBeenCalledTimes(3);
|
|
457
|
+
expect(doJoinRequestArgs[0]).toEqual({ create: true });
|
|
458
|
+
expect(doJoinRequestArgs[1]).toEqual({ create: true });
|
|
459
|
+
expect(doJoinRequestArgs[2]).toEqual({
|
|
460
|
+
create: true,
|
|
461
|
+
migrating_from: 'sfu-1',
|
|
462
|
+
migrating_from_list: ['sfu-1'],
|
|
463
|
+
});
|
|
464
|
+
expect(call['joinCallData']).toEqual({ create: true });
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('asks the coordinator to avoid an SFU immediately after an SFU join error', async () => {
|
|
468
|
+
call.setRejoinAttemptLimit(2, 60);
|
|
469
|
+
const doJoinRequestArgs: unknown[] = [];
|
|
470
|
+
const sfuFullError = new SfuJoinError({
|
|
471
|
+
error: {
|
|
472
|
+
code: ErrorCode.SFU_FULL,
|
|
473
|
+
message: 'SFU is full',
|
|
474
|
+
},
|
|
475
|
+
reconnectStrategy: WebsocketReconnectStrategy.REJOIN,
|
|
476
|
+
} as never);
|
|
477
|
+
const doJoinRequest = vi
|
|
478
|
+
.spyOn(
|
|
479
|
+
call as unknown as {
|
|
480
|
+
doJoinRequest: (data?: unknown) => Promise<unknown>;
|
|
481
|
+
},
|
|
482
|
+
'doJoinRequest',
|
|
483
|
+
)
|
|
484
|
+
.mockImplementationOnce(async (data?: unknown) => {
|
|
485
|
+
doJoinRequestArgs.push(JSON.parse(JSON.stringify(data)));
|
|
486
|
+
throw sfuFullError;
|
|
487
|
+
})
|
|
488
|
+
.mockImplementation(async (data?: unknown) => {
|
|
489
|
+
doJoinRequestArgs.push(JSON.parse(JSON.stringify(data)));
|
|
490
|
+
throw new Error('rejoin failed');
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
494
|
+
|
|
495
|
+
expect(doJoinRequest).toHaveBeenCalledTimes(2);
|
|
496
|
+
expect(doJoinRequestArgs[0]).toEqual({ create: true });
|
|
497
|
+
expect(doJoinRequestArgs[1]).toEqual({
|
|
498
|
+
create: true,
|
|
499
|
+
migrating_from: 'sfu-1',
|
|
500
|
+
migrating_from_list: ['sfu-1'],
|
|
501
|
+
});
|
|
502
|
+
expect(call['joinCallData']).toEqual({ create: true });
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('gives every SFU two tries before excluding it (a once-failed SFU is re-served, not yet listed)', async () => {
|
|
506
|
+
call.setRejoinAttemptLimit(5, 60);
|
|
507
|
+
call['credentials'] = {
|
|
508
|
+
...credentials,
|
|
509
|
+
server: { ...credentials.server, edge_name: 'sfu-a' },
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const pool = ['sfu-a', 'sfu-b', 'sfu-c'];
|
|
513
|
+
const sent: Array<{
|
|
514
|
+
migrating_from?: string;
|
|
515
|
+
migrating_from_list?: string[];
|
|
516
|
+
}> = [];
|
|
517
|
+
vi.spyOn(
|
|
518
|
+
call as unknown as {
|
|
519
|
+
doJoinRequest: (data?: unknown) => Promise<unknown>;
|
|
520
|
+
},
|
|
521
|
+
'doJoinRequest',
|
|
522
|
+
).mockImplementation(async (data?: unknown) => {
|
|
523
|
+
const clone = JSON.parse(JSON.stringify(data ?? {}));
|
|
524
|
+
sent.push(clone);
|
|
525
|
+
const excluded = new Set<string>(clone.migrating_from_list ?? []);
|
|
526
|
+
const assigned = pool.find((s) => !excluded.has(s)) ?? pool.at(-1)!;
|
|
527
|
+
call['credentials'] = {
|
|
528
|
+
...credentials,
|
|
529
|
+
server: { ...credentials.server, edge_name: assigned },
|
|
530
|
+
};
|
|
531
|
+
throw new Error('connect failed');
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
535
|
+
|
|
536
|
+
expect(sent).toHaveLength(5);
|
|
537
|
+
expect(sent[0].migrating_from_list).toBeUndefined();
|
|
538
|
+
expect(sent[1].migrating_from_list).toBeUndefined();
|
|
539
|
+
expect(sent[2].migrating_from).toBe('sfu-a');
|
|
540
|
+
expect(sent[2].migrating_from_list).toEqual(['sfu-a']);
|
|
541
|
+
expect(sent[3].migrating_from).toBe('sfu-a');
|
|
542
|
+
expect(sent[3].migrating_from_list).toEqual(['sfu-a']);
|
|
543
|
+
expect(sent[3].migrating_from_list).not.toContain('sfu-b');
|
|
544
|
+
expect(sent[4].migrating_from).toBe('sfu-b');
|
|
545
|
+
expect(sent[4].migrating_from_list).toEqual(['sfu-a', 'sfu-b']);
|
|
546
|
+
|
|
547
|
+
for (const req of sent) {
|
|
548
|
+
if (req.migrating_from) {
|
|
549
|
+
expect(req.migrating_from_list).toContain(req.migrating_from);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
expect(call['joinCallData']).toEqual({ create: true });
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
409
556
|
/**
|
|
410
557
|
* Entry-condition bails. `reconnect()` must drop new triggers when:
|
|
411
558
|
* - A join/reconnect/migrate lifecycle is already in progress.
|