@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.
- package/CHANGELOG.md +17 -0
- package/dist/index.browser.es.js +41 -9
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +41 -9
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +41 -9
- package/dist/index.es.js.map +1 -1
- package/dist/src/coordinator/connection/types.d.ts +6 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +2 -0
- package/package.json +1 -1
- package/src/Call.ts +40 -5
- package/src/StreamVideoClient.ts +1 -0
- package/src/coordinator/connection/types.ts +7 -0
- package/src/reporting/ClientEventReporter.ts +6 -2
- package/src/reporting/__tests__/ClientEventReporter.test.ts +33 -0
- package/src/rtc/__tests__/Call.reconnect.test.ts +149 -2
|
@@ -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
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(
|
|
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
|
|
1768
|
-
|
|
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
|
-
|
|
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,
|
package/src/StreamVideoClient.ts
CHANGED
|
@@ -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({
|
|
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.
|