@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/index.browser.es.js +111 -12
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +111 -12
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +111 -12
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +13 -1
  9. package/dist/src/coordinator/connection/types.d.ts +6 -0
  10. package/dist/src/gen/google/protobuf/struct.d.ts +3 -1
  11. package/dist/src/gen/google/protobuf/timestamp.d.ts +3 -1
  12. package/dist/src/gen/video/sfu/event/events.d.ts +22 -1
  13. package/dist/src/gen/video/sfu/models/models.d.ts +4 -0
  14. package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +23 -2
  15. package/dist/src/reporting/ClientEventReporter.d.ts +2 -0
  16. package/dist/src/rtc/Publisher.d.ts +4 -1
  17. package/dist/src/rtc/Subscriber.d.ts +7 -0
  18. package/package.json +1 -1
  19. package/src/Call.ts +86 -6
  20. package/src/StreamVideoClient.ts +1 -0
  21. package/src/coordinator/connection/types.ts +7 -0
  22. package/src/gen/google/protobuf/struct.ts +7 -12
  23. package/src/gen/google/protobuf/timestamp.ts +6 -7
  24. package/src/gen/video/sfu/event/events.ts +23 -25
  25. package/src/gen/video/sfu/models/models.ts +11 -1
  26. package/src/gen/video/sfu/signal_rpc/signal.client.ts +25 -29
  27. package/src/gen/video/sfu/signal_rpc/signal.ts +1 -0
  28. package/src/helpers/client-details.ts +1 -1
  29. package/src/reporting/ClientEventReporter.ts +4 -0
  30. package/src/reporting/__tests__/ClientEventReporter.test.ts +33 -0
  31. package/src/rtc/Publisher.ts +4 -0
  32. package/src/rtc/Subscriber.ts +28 -1
  33. 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
+ });
@@ -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
 
@@ -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({ streamClient }),
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.