@stream-io/video-client 1.53.1 → 1.54.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.
@@ -148,6 +148,12 @@ export type StreamClientOptions = Partial<AxiosRequestConfig> & {
148
148
  */
149
149
  baseURL?: string;
150
150
  browser?: boolean;
151
+ /**
152
+ * Enables the client-side event reporter (call lifecycle telemetry).
153
+ * Set to `false` for non-interactive sessions such as egress/recording
154
+ * where the telemetry adds no value. Defaults to `true`.
155
+ */
156
+ clientEventsReportingEnabled?: boolean;
151
157
  /**
152
158
  * @deprecated Use `logOptions` instead.
153
159
  * Custom logger instance used to handle log messages.
@@ -15,10 +15,12 @@ export type CallReportContext = {
15
15
  };
16
16
  export type ClientEventReporterOptions = {
17
17
  streamClient: StreamClient;
18
+ enabled?: boolean;
18
19
  };
19
20
  export declare class ClientEventReporter {
20
21
  private readonly logger;
21
22
  private streamClient;
23
+ private enabled;
22
24
  private coordinatorConnectId?;
23
25
  private coordinatorConnectUserId?;
24
26
  private coordinatorWsPair?;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.53.1",
3
+ "version": "1.54.0",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
package/src/Call.ts CHANGED
@@ -1230,7 +1230,10 @@ export class Call {
1230
1230
  const isReconnecting =
1231
1231
  this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
1232
1232
  const reconnectDetails = isReconnecting
1233
- ? this.getReconnectDetails(data?.migrating_from, previousSessionId)
1233
+ ? this.getReconnectDetails(
1234
+ previousSfuClient?.edgeName,
1235
+ previousSessionId,
1236
+ )
1234
1237
  : undefined;
1235
1238
  const preferredPublishOptions = !isReconnecting
1236
1239
  ? this.getPreferredPublishOptions()
@@ -1692,6 +1695,7 @@ export class Call {
1692
1695
  const reconnectStartTime = Date.now();
1693
1696
  this.reconnectStrategy = strategy;
1694
1697
  this.reconnectReason = reason;
1698
+ const sfuRejoinFailures = new Map<string, number>();
1695
1699
 
1696
1700
  const markAsReconnectingFailed = async () => {
1697
1701
  try {
@@ -1764,8 +1768,8 @@ export class Call {
1764
1768
  if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
1765
1769
  this.reconnectAttempts++;
1766
1770
  }
1767
- const currentStrategy =
1768
- WebsocketReconnectStrategy[this.reconnectStrategy];
1771
+ const attemptedStrategy = this.reconnectStrategy;
1772
+ const currentStrategy = WebsocketReconnectStrategy[attemptedStrategy];
1769
1773
  try {
1770
1774
  // wait until the network is available
1771
1775
  await this.networkAvailableTask?.promise;
@@ -1786,9 +1790,25 @@ export class Call {
1786
1790
  case WebsocketReconnectStrategy.FAST:
1787
1791
  await this.reconnectFast();
1788
1792
  break;
1789
- case WebsocketReconnectStrategy.REJOIN:
1790
- await this.reconnectRejoin();
1793
+ case WebsocketReconnectStrategy.REJOIN: {
1794
+ const confirmedBadSfus = Array.from(sfuRejoinFailures)
1795
+ .filter(([, failures]) => failures >= 2)
1796
+ .map(([sfu]) => sfu);
1797
+
1798
+ if (this.joinCallData && confirmedBadSfus.length) {
1799
+ this.joinCallData.migrating_from =
1800
+ confirmedBadSfus[confirmedBadSfus.length - 1];
1801
+ this.joinCallData.migrating_from_list = confirmedBadSfus;
1802
+ }
1803
+
1804
+ try {
1805
+ await this.reconnectRejoin();
1806
+ } finally {
1807
+ delete this.joinCallData?.migrating_from;
1808
+ delete this.joinCallData?.migrating_from_list;
1809
+ }
1791
1810
  break;
1811
+ }
1792
1812
  case WebsocketReconnectStrategy.MIGRATE:
1793
1813
  await this.reconnectMigrate();
1794
1814
  break;
@@ -1803,6 +1823,20 @@ export class Call {
1803
1823
  this.consecutiveNegotiationFailures = 0;
1804
1824
  break; // do-while loop, reconnection worked, exit the loop
1805
1825
  } catch (error) {
1826
+ if (attemptedStrategy === WebsocketReconnectStrategy.REJOIN) {
1827
+ const failedSfu = this.credentials?.server.edge_name;
1828
+ if (failedSfu) {
1829
+ const switchSfu =
1830
+ error instanceof SfuJoinError &&
1831
+ SfuJoinError.isJoinErrorCode(error.errorEvent);
1832
+ const failures = (sfuRejoinFailures.get(failedSfu) ?? 0) + 1;
1833
+ sfuRejoinFailures.set(
1834
+ failedSfu,
1835
+ switchSfu ? Math.max(failures, 2) : failures,
1836
+ );
1837
+ }
1838
+ }
1839
+
1806
1840
  if (this.state.callingState === CallingState.OFFLINE) {
1807
1841
  this.logger.debug(
1808
1842
  `[Reconnect] Can't reconnect while offline, stopping reconnection attempts`,
@@ -2057,6 +2091,7 @@ export class Call {
2057
2091
  this.sfuStatsReporter?.stop();
2058
2092
  this.state.setCallingState(CallingState.OFFLINE);
2059
2093
  } else {
2094
+ if (!this.networkAvailableTask) return;
2060
2095
  this.logger.debug('[Reconnect] Going online');
2061
2096
  this.sfuClient?.close(
2062
2097
  StreamSfuClient.DISPOSE_OLD_SOCKET,
@@ -101,6 +101,7 @@ export class StreamVideoClient {
101
101
  this.streamClient = createCoordinatorClient(apiKey, clientOptions);
102
102
  this.clientEventReporter = new ClientEventReporter({
103
103
  streamClient: this.streamClient,
104
+ enabled: clientOptions?.clientEventsReportingEnabled ?? true,
104
105
  });
105
106
 
106
107
  this.writeableStateStore = new StreamVideoWriteableStateStore();
@@ -213,6 +213,13 @@ export type StreamClientOptions = Partial<AxiosRequestConfig> & {
213
213
  baseURL?: string;
214
214
  browser?: boolean;
215
215
 
216
+ /**
217
+ * Enables the client-side event reporter (call lifecycle telemetry).
218
+ * Set to `false` for non-interactive sessions such as egress/recording
219
+ * where the telemetry adds no value. Defaults to `true`.
220
+ */
221
+ clientEventsReportingEnabled?: boolean;
222
+
216
223
  /**
217
224
  * @deprecated Use `logOptions` instead.
218
225
  * Custom logger instance used to handle log messages.
@@ -64,6 +64,7 @@ export type CallReportContext = {
64
64
 
65
65
  export type ClientEventReporterOptions = {
66
66
  streamClient: StreamClient;
67
+ enabled?: boolean;
67
68
  };
68
69
 
69
70
  type StageError = {
@@ -94,6 +95,7 @@ export class ClientEventReporter {
94
95
  private readonly logger = videoLoggerSystem.getLogger('ClientEventReporter');
95
96
 
96
97
  private streamClient: StreamClient;
98
+ private enabled: boolean;
97
99
 
98
100
  private coordinatorConnectId?: string;
99
101
  private coordinatorConnectUserId?: string;
@@ -112,6 +114,7 @@ export class ClientEventReporter {
112
114
 
113
115
  constructor(options: ClientEventReporterOptions) {
114
116
  this.streamClient = options.streamClient;
117
+ this.enabled = options.enabled ?? true;
115
118
  }
116
119
 
117
120
  /**
@@ -393,7 +396,7 @@ export class ClientEventReporter {
393
396
  const ctx = this.callContexts.get(cid);
394
397
 
395
398
  this.send({
396
- user_id: this.streamClient.userID,
399
+ user_id: this.streamClient.userID || this.coordinatorConnectUserId,
397
400
  type: ctx?.callType,
398
401
  id: ctx?.callId,
399
402
  call_cid: cid,
@@ -712,7 +715,7 @@ export class ClientEventReporter {
712
715
  const ctx = this.callContexts.get(cid);
713
716
  const coordinatorConnectId = this.coordinatorConnectId;
714
717
  return {
715
- user_id: this.streamClient.userID,
718
+ user_id: this.streamClient.userID || this.coordinatorConnectUserId,
716
719
  type: ctx?.callType ?? '',
717
720
  id: ctx?.callId ?? '',
718
721
  call_cid: cid,
@@ -731,6 +734,7 @@ export class ClientEventReporter {
731
734
  };
732
735
 
733
736
  private send = (body: Record<string, unknown>) => {
737
+ if (!this.enabled) return;
734
738
  void this.sendWithRetry(body);
735
739
  };
736
740
 
@@ -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
+ });
@@ -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.