@stream-io/video-client 1.54.1-beta.0 → 1.55.1
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 +21 -0
- package/dist/index.browser.es.js +9700 -8873
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +9707 -8880
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +9708 -8881
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +4 -4
- package/dist/src/StreamSfuClient.d.ts +11 -3
- package/dist/src/coordinator/connection/connection.d.ts +2 -1
- package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
- package/dist/src/rtc/BasePeerConnection.d.ts +2 -12
- package/dist/src/rtc/IceTrickleBuffer.d.ts +41 -3
- package/dist/src/rtc/Publisher.d.ts +1 -1
- package/dist/src/rtc/Subscriber.d.ts +2 -1
- package/dist/src/rtc/helpers/iceCandiates.d.ts +12 -0
- package/dist/src/rtc/types.d.ts +3 -0
- package/dist/src/stats/SfuStatsReporter.d.ts +32 -1
- package/dist/src/stats/rtc/StatsTracer.d.ts +38 -8
- package/dist/src/stats/rtc/Tracer.d.ts +9 -2
- package/dist/src/stats/rtc/types.d.ts +10 -4
- package/package.json +5 -3
- package/src/Call.ts +47 -44
- package/src/StreamSfuClient.ts +36 -21
- package/src/__tests__/StreamSfuClient.test.ts +159 -1
- package/src/__tests__/StreamVideoClient.api.test.ts +123 -97
- package/src/coordinator/connection/__tests__/connection.test.ts +69 -0
- package/src/coordinator/connection/connection.ts +28 -13
- package/src/gen/video/sfu/event/events.ts +0 -1
- package/src/gen/video/sfu/models/models.ts +0 -1
- package/src/gen/video/sfu/signal_rpc/signal.client.ts +0 -1
- package/src/gen/video/sfu/signal_rpc/signal.ts +0 -1
- package/src/helpers/MediaPlaybackWatchdog.ts +1 -0
- package/src/helpers/__tests__/browsers.test.ts +12 -12
- package/src/helpers/browsers.ts +5 -5
- package/src/helpers/client-details.ts +1 -1
- package/src/reporting/ClientEventReporter.ts +17 -12
- package/src/reporting/__tests__/ClientEventReporter.test.ts +52 -0
- package/src/rtc/BasePeerConnection.ts +15 -34
- package/src/rtc/IceTrickleBuffer.ts +105 -12
- package/src/rtc/Publisher.ts +26 -19
- package/src/rtc/Subscriber.ts +71 -37
- package/src/rtc/__tests__/Call.reconnect.test.ts +45 -45
- package/src/rtc/__tests__/IceTrickleBuffer.test.ts +127 -0
- package/src/rtc/__tests__/Publisher.test.ts +76 -31
- package/src/rtc/__tests__/Subscriber.test.ts +271 -20
- package/src/rtc/helpers/__tests__/iceCandiates.test.ts +88 -0
- package/src/rtc/helpers/degradationPreference.ts +1 -0
- package/src/rtc/helpers/iceCandiates.ts +35 -0
- package/src/rtc/helpers/sdp.ts +3 -2
- package/src/rtc/helpers/tracks.ts +2 -0
- package/src/rtc/types.ts +3 -0
- package/src/stats/SfuStatsReporter.ts +149 -49
- package/src/stats/__tests__/SfuStatsReporter.test.ts +235 -0
- package/src/stats/rtc/StatsTracer.ts +90 -32
- package/src/stats/rtc/Tracer.ts +23 -2
- package/src/stats/rtc/__tests__/StatsTracer.test.ts +213 -6
- package/src/stats/rtc/__tests__/Tracer.test.ts +34 -0
- package/src/stats/rtc/types.ts +11 -4
package/src/StreamSfuClient.ts
CHANGED
|
@@ -164,13 +164,24 @@ export class StreamSfuClient {
|
|
|
164
164
|
*/
|
|
165
165
|
isClosingClean = false;
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* One-shot latch guarding `onSignalClose`. The signal connection can be
|
|
169
|
+
* detected as dead by more than one source (the health watchdog and the
|
|
170
|
+
* WebSocket `close` event, which on a wedged socket can arrive seconds
|
|
171
|
+
* apart). This ensures revival is triggered at most once per client.
|
|
172
|
+
*/
|
|
173
|
+
private signalClosed = false;
|
|
174
|
+
|
|
167
175
|
private readonly rpc: SignalServerClient;
|
|
168
176
|
private keepAliveInterval?: number;
|
|
169
|
-
private
|
|
177
|
+
private connectionCheckInterval?: number;
|
|
170
178
|
private migrateAwayTimeout?: NodeJS.Timeout;
|
|
171
179
|
private readonly pingIntervalInMs = 5 * 1000;
|
|
172
|
-
private readonly unhealthyTimeoutInMs =
|
|
173
|
-
private
|
|
180
|
+
private readonly unhealthyTimeoutInMs = this.pingIntervalInMs * 2 + 2 * 1000;
|
|
181
|
+
private readonly connectionCheckIntervalInMs = Math.round(
|
|
182
|
+
this.unhealthyTimeoutInMs / 3,
|
|
183
|
+
);
|
|
184
|
+
private lastMessageTimestamp?: number;
|
|
174
185
|
private readonly tracer?: Tracer;
|
|
175
186
|
private readonly unsubscribeIceTrickle: () => void;
|
|
176
187
|
private readonly unsubscribeNetworkChanged: () => void;
|
|
@@ -209,7 +220,7 @@ export class StreamSfuClient {
|
|
|
209
220
|
/**
|
|
210
221
|
* The error code used when the SFU connection is unhealthy.
|
|
211
222
|
* Usually, this means that no message has been received from the SFU for
|
|
212
|
-
* a certain amount of time (`
|
|
223
|
+
* a certain amount of time (`unhealthyTimeoutInMs`).
|
|
213
224
|
*/
|
|
214
225
|
static ERROR_CONNECTION_UNHEALTHY = 4001;
|
|
215
226
|
/**
|
|
@@ -311,7 +322,7 @@ export class StreamSfuClient {
|
|
|
311
322
|
endpoint: `${this.credentials.server.ws_endpoint}?${new URLSearchParams(params).toString()}`,
|
|
312
323
|
tracer: this.tracer,
|
|
313
324
|
onMessage: (message) => {
|
|
314
|
-
this.lastMessageTimestamp =
|
|
325
|
+
this.lastMessageTimestamp = Date.now();
|
|
315
326
|
this.scheduleConnectionCheck();
|
|
316
327
|
const eventKind = message.eventPayload.oneofKind;
|
|
317
328
|
if (eventsToTrace[eventKind]) {
|
|
@@ -336,7 +347,7 @@ export class StreamSfuClient {
|
|
|
336
347
|
this.signalWs.addEventListener('open', onOpen);
|
|
337
348
|
|
|
338
349
|
this.signalWs.addEventListener('close', (e) => {
|
|
339
|
-
this.
|
|
350
|
+
this.notifySignalClose(`${e.code} ${e.reason ?? ''}`);
|
|
340
351
|
// Normally, this shouldn't have any effect, because WS should never emit 'close'
|
|
341
352
|
// before emitting 'open'. However, stranger things have happened, and we don't
|
|
342
353
|
// want to leave signalReady in a pending state.
|
|
@@ -371,11 +382,13 @@ export class StreamSfuClient {
|
|
|
371
382
|
return this.joinResponseTask.promise;
|
|
372
383
|
}
|
|
373
384
|
|
|
374
|
-
private
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
385
|
+
private notifySignalClose = (reason: string) => {
|
|
386
|
+
if (this.signalClosed) return;
|
|
387
|
+
this.signalClosed = true;
|
|
388
|
+
const timers = getTimers();
|
|
389
|
+
timers.clearInterval(this.keepAliveInterval);
|
|
390
|
+
timers.clearInterval(this.connectionCheckInterval);
|
|
391
|
+
this.onSignalClose?.(reason.trim());
|
|
379
392
|
};
|
|
380
393
|
|
|
381
394
|
close = (code: number = StreamSfuClient.NORMAL_CLOSURE, reason?: string) => {
|
|
@@ -392,7 +405,9 @@ export class StreamSfuClient {
|
|
|
392
405
|
) {
|
|
393
406
|
this.logger.debug(`Closing SFU WS connection: ${code} - ${reason}`);
|
|
394
407
|
ws.close(code, `js-client: ${reason}`);
|
|
395
|
-
|
|
408
|
+
}
|
|
409
|
+
if (!this.isClosingClean) {
|
|
410
|
+
this.notifySignalClose(`${code} ${reason ?? ''}`);
|
|
396
411
|
}
|
|
397
412
|
this.dispose(reason);
|
|
398
413
|
};
|
|
@@ -401,8 +416,9 @@ export class StreamSfuClient {
|
|
|
401
416
|
this.logger.debug('Disposing SFU client');
|
|
402
417
|
this.unsubscribeIceTrickle();
|
|
403
418
|
this.unsubscribeNetworkChanged();
|
|
404
|
-
|
|
405
|
-
|
|
419
|
+
const timers = getTimers();
|
|
420
|
+
timers.clearInterval(this.keepAliveInterval);
|
|
421
|
+
timers.clearInterval(this.connectionCheckInterval);
|
|
406
422
|
clearTimeout(this.migrateAwayTimeout);
|
|
407
423
|
this.abortController.abort();
|
|
408
424
|
this.migrationTask?.resolve();
|
|
@@ -697,7 +713,7 @@ export class StreamSfuClient {
|
|
|
697
713
|
return;
|
|
698
714
|
}
|
|
699
715
|
this.logger.debug(`Sending message to: ${this.edgeName}`, msgJson);
|
|
700
|
-
this.signalWs.send(SfuRequest.toBinary(message));
|
|
716
|
+
this.signalWs.send(SfuRequest.toBinary(message) as Uint8Array<ArrayBuffer>);
|
|
701
717
|
};
|
|
702
718
|
|
|
703
719
|
private keepAlive = () => {
|
|
@@ -711,12 +727,11 @@ export class StreamSfuClient {
|
|
|
711
727
|
};
|
|
712
728
|
|
|
713
729
|
private scheduleConnectionCheck = () => {
|
|
714
|
-
|
|
715
|
-
this.
|
|
730
|
+
const timers = getTimers();
|
|
731
|
+
timers.clearInterval(this.connectionCheckInterval);
|
|
732
|
+
this.connectionCheckInterval = timers.setInterval(() => {
|
|
716
733
|
if (this.lastMessageTimestamp) {
|
|
717
|
-
const timeSinceLastMessage =
|
|
718
|
-
new Date().getTime() - this.lastMessageTimestamp.getTime();
|
|
719
|
-
|
|
734
|
+
const timeSinceLastMessage = Date.now() - this.lastMessageTimestamp;
|
|
720
735
|
if (timeSinceLastMessage > this.unhealthyTimeoutInMs) {
|
|
721
736
|
this.close(
|
|
722
737
|
StreamSfuClient.ERROR_CONNECTION_UNHEALTHY,
|
|
@@ -724,6 +739,6 @@ export class StreamSfuClient {
|
|
|
724
739
|
);
|
|
725
740
|
}
|
|
726
741
|
}
|
|
727
|
-
}, this.
|
|
742
|
+
}, this.connectionCheckIntervalInMs);
|
|
728
743
|
};
|
|
729
744
|
}
|
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
import { StreamSfuClient } from '../StreamSfuClient';
|
|
3
3
|
import { Dispatcher } from '../rtc';
|
|
4
4
|
import { StreamClient } from '../coordinator/connection/client';
|
|
5
|
+
import { getTimers } from '../timers';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Minimal `WebSocket` stub used to drive `StreamSfuClient.close()` while the
|
|
@@ -38,9 +39,13 @@ class CapturingWebSocket {
|
|
|
38
39
|
this.closeArgs = { code, reason };
|
|
39
40
|
this.readyState = CapturingWebSocket.CLOSED;
|
|
40
41
|
}
|
|
42
|
+
/** Test helper: synchronously fire a registered event (e.g. `close`). */
|
|
43
|
+
emit(event: string, payload: unknown) {
|
|
44
|
+
this.listeners.get(event)?.forEach((listener) => listener(payload));
|
|
45
|
+
}
|
|
41
46
|
}
|
|
42
47
|
|
|
43
|
-
const buildSfuClient = () => {
|
|
48
|
+
const buildSfuClient = (onSignalClose?: (reason: string) => void) => {
|
|
44
49
|
const dispatcher = new Dispatcher();
|
|
45
50
|
const streamClient = new StreamClient('test-key');
|
|
46
51
|
return new StreamSfuClient({
|
|
@@ -59,9 +64,109 @@ const buildSfuClient = () => {
|
|
|
59
64
|
},
|
|
60
65
|
tag: 'test',
|
|
61
66
|
enableTracing: false,
|
|
67
|
+
onSignalClose,
|
|
62
68
|
});
|
|
63
69
|
};
|
|
64
70
|
|
|
71
|
+
describe('StreamSfuClient unhealthy watchdog timer source', () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
CapturingWebSocket.instances = [];
|
|
74
|
+
vi.stubGlobal('WebSocket', CapturingWebSocket);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
vi.unstubAllGlobals();
|
|
79
|
+
vi.restoreAllMocks();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('arms the unhealthy watchdog on the worker timer, not the main-thread setInterval', () => {
|
|
83
|
+
const sfuClient = buildSfuClient();
|
|
84
|
+
const workerSetInterval = vi
|
|
85
|
+
.spyOn(getTimers(), 'setInterval')
|
|
86
|
+
.mockReturnValue(1 as unknown as number);
|
|
87
|
+
const mainSetInterval = vi.spyOn(globalThis, 'setInterval');
|
|
88
|
+
|
|
89
|
+
(
|
|
90
|
+
sfuClient as unknown as { scheduleConnectionCheck: () => void }
|
|
91
|
+
).scheduleConnectionCheck();
|
|
92
|
+
|
|
93
|
+
expect(workerSetInterval).toHaveBeenCalledTimes(1);
|
|
94
|
+
expect(mainSetInterval).not.toHaveBeenCalled();
|
|
95
|
+
|
|
96
|
+
sfuClient.close(1000, 'test cleanup');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('StreamSfuClient unhealthy watchdog resilience', () => {
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
CapturingWebSocket.instances = [];
|
|
103
|
+
vi.stubGlobal('WebSocket', CapturingWebSocket);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterEach(() => {
|
|
107
|
+
vi.useRealTimers();
|
|
108
|
+
vi.unstubAllGlobals();
|
|
109
|
+
vi.restoreAllMocks();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('re-arms the unhealthy watchdog after a check passes (not single-shot)', () => {
|
|
113
|
+
vi.useFakeTimers();
|
|
114
|
+
const sfuClient = buildSfuClient();
|
|
115
|
+
const closeSpy = vi.spyOn(sfuClient, 'close').mockImplementation(() => {});
|
|
116
|
+
const c = sfuClient as unknown as {
|
|
117
|
+
lastMessageTimestamp?: number;
|
|
118
|
+
unhealthyTimeoutInMs: number;
|
|
119
|
+
scheduleConnectionCheck: () => void;
|
|
120
|
+
};
|
|
121
|
+
const window = c.unhealthyTimeoutInMs;
|
|
122
|
+
|
|
123
|
+
c.lastMessageTimestamp = Date.now();
|
|
124
|
+
c.scheduleConnectionCheck();
|
|
125
|
+
|
|
126
|
+
// At exactly the threshold the connection is still healthy (strict `>`),
|
|
127
|
+
// so no poll within the first window closes it. A single-shot watchdog
|
|
128
|
+
// armed for the threshold would now be dead.
|
|
129
|
+
vi.advanceTimersByTime(window);
|
|
130
|
+
expect(closeSpy).not.toHaveBeenCalled();
|
|
131
|
+
|
|
132
|
+
// No further messages arrive; the self-rescheduling watchdog keeps polling
|
|
133
|
+
// and detects the connection as unhealthy on a later tick.
|
|
134
|
+
vi.advanceTimersByTime(window);
|
|
135
|
+
expect(closeSpy).toHaveBeenCalledWith(
|
|
136
|
+
StreamSfuClient.ERROR_CONNECTION_UNHEALTHY,
|
|
137
|
+
expect.stringContaining('unhealthy'),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('detects an unhealthy connection shortly after the threshold, not a full window later', () => {
|
|
142
|
+
vi.useFakeTimers();
|
|
143
|
+
const sfuClient = buildSfuClient();
|
|
144
|
+
const closeSpy = vi.spyOn(sfuClient, 'close').mockImplementation(() => {});
|
|
145
|
+
const c = sfuClient as unknown as {
|
|
146
|
+
lastMessageTimestamp?: number;
|
|
147
|
+
unhealthyTimeoutInMs: number;
|
|
148
|
+
scheduleConnectionCheck: () => void;
|
|
149
|
+
};
|
|
150
|
+
const window = c.unhealthyTimeoutInMs;
|
|
151
|
+
|
|
152
|
+
c.lastMessageTimestamp = Date.now();
|
|
153
|
+
c.scheduleConnectionCheck();
|
|
154
|
+
|
|
155
|
+
// healthy up to (and exactly at) the threshold
|
|
156
|
+
vi.advanceTimersByTime(window);
|
|
157
|
+
expect(closeSpy).not.toHaveBeenCalled();
|
|
158
|
+
|
|
159
|
+
// the watchdog polls finer than the window, so silence is caught well
|
|
160
|
+
// before a second full window elapses (the old period == window design
|
|
161
|
+
// could take up to 2x the window to notice).
|
|
162
|
+
vi.advanceTimersByTime(window / 2);
|
|
163
|
+
expect(closeSpy).toHaveBeenCalledWith(
|
|
164
|
+
StreamSfuClient.ERROR_CONNECTION_UNHEALTHY,
|
|
165
|
+
expect.stringContaining('unhealthy'),
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
65
170
|
describe('StreamSfuClient.close()', () => {
|
|
66
171
|
beforeEach(() => {
|
|
67
172
|
CapturingWebSocket.instances = [];
|
|
@@ -164,6 +269,59 @@ describe('StreamSfuClient.close()', () => {
|
|
|
164
269
|
});
|
|
165
270
|
});
|
|
166
271
|
|
|
272
|
+
describe('StreamSfuClient signal-close revival', () => {
|
|
273
|
+
beforeEach(() => {
|
|
274
|
+
CapturingWebSocket.instances = [];
|
|
275
|
+
vi.stubGlobal('WebSocket', CapturingWebSocket);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
afterEach(() => {
|
|
279
|
+
vi.unstubAllGlobals();
|
|
280
|
+
vi.clearAllMocks();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('drives revival immediately on an unhealthy close, without waiting for the onclose event', () => {
|
|
284
|
+
const onSignalClose = vi.fn();
|
|
285
|
+
const sfuClient = buildSfuClient(onSignalClose);
|
|
286
|
+
|
|
287
|
+
// A wedged socket may fire `onclose` only after the OS TCP timeout. The
|
|
288
|
+
// health watchdog closes with ERROR_CONNECTION_UNHEALTHY; revival must
|
|
289
|
+
// start now, not when (or if) the transport `close` event arrives.
|
|
290
|
+
sfuClient.close(
|
|
291
|
+
StreamSfuClient.ERROR_CONNECTION_UNHEALTHY,
|
|
292
|
+
'SFU connection unhealthy',
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
expect(onSignalClose).toHaveBeenCalledTimes(1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('notifies revival only once when the late onclose event follows an unhealthy close', () => {
|
|
299
|
+
const onSignalClose = vi.fn();
|
|
300
|
+
const sfuClient = buildSfuClient(onSignalClose);
|
|
301
|
+
const ws = CapturingWebSocket.instances.at(-1)!;
|
|
302
|
+
|
|
303
|
+
// watchdog closes the dead socket (revival triggered proactively)...
|
|
304
|
+
sfuClient.close(
|
|
305
|
+
StreamSfuClient.ERROR_CONNECTION_UNHEALTHY,
|
|
306
|
+
'SFU connection unhealthy',
|
|
307
|
+
);
|
|
308
|
+
// ...then the OS finally surfaces the wedged socket's `close` event.
|
|
309
|
+
ws.emit('close', { code: 1006, reason: '' });
|
|
310
|
+
|
|
311
|
+
expect(onSignalClose).toHaveBeenCalledTimes(1);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('notifies revival when only the onclose event fires (server-initiated close)', () => {
|
|
315
|
+
const onSignalClose = vi.fn();
|
|
316
|
+
buildSfuClient(onSignalClose);
|
|
317
|
+
const ws = CapturingWebSocket.instances.at(-1)!;
|
|
318
|
+
|
|
319
|
+
ws.emit('close', { code: 1006, reason: '' });
|
|
320
|
+
|
|
321
|
+
expect(onSignalClose).toHaveBeenCalledTimes(1);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
167
325
|
describe('StreamSfuClient.leaveAndClose()', () => {
|
|
168
326
|
beforeEach(() => {
|
|
169
327
|
CapturingWebSocket.instances = [];
|
|
@@ -1,94 +1,122 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
7
|
+
type Mock,
|
|
8
|
+
vi,
|
|
9
|
+
} from 'vitest';
|
|
2
10
|
import { StreamVideoClient } from '../StreamVideoClient';
|
|
3
|
-
import '
|
|
11
|
+
import { Call } from '../Call';
|
|
12
|
+
import { CallCreatedPayload } from './data';
|
|
4
13
|
import { generateUUIDv4 } from '../coordinator/connection/utils';
|
|
5
|
-
import { StreamClient } from '
|
|
6
|
-
import {
|
|
14
|
+
import type { StreamClient } from '../coordinator/connection/client';
|
|
15
|
+
import type {
|
|
16
|
+
CreateDeviceRequest,
|
|
17
|
+
GetEdgesResponse,
|
|
18
|
+
ListDevicesResponse,
|
|
19
|
+
QueryCallsResponse,
|
|
20
|
+
QueryCallStatsResponse,
|
|
21
|
+
} from '../gen/coordinator';
|
|
7
22
|
|
|
8
|
-
const apiKey =
|
|
9
|
-
const secret = process.env.STREAM_SECRET!;
|
|
10
|
-
|
|
11
|
-
const serverClient = new StreamClient(apiKey, secret);
|
|
23
|
+
const apiKey = 'mock-api-key';
|
|
12
24
|
|
|
13
25
|
describe('StreamVideoClient - coordinator API', () => {
|
|
14
26
|
let client: StreamVideoClient;
|
|
27
|
+
// the client only talks to the backend through streamClient.post/get/delete,
|
|
28
|
+
// so we spy on those and assert against them instead of a live backend.
|
|
29
|
+
let post: Mock<StreamClient['post']>;
|
|
30
|
+
let get: Mock<StreamClient['get']>;
|
|
31
|
+
let del: Mock<StreamClient['delete']>;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
client = new StreamVideoClient(apiKey, { browser: true });
|
|
35
|
+
post = vi.spyOn(client.streamClient, 'post');
|
|
36
|
+
get = vi.spyOn(client.streamClient, 'get');
|
|
37
|
+
del = vi.spyOn(client.streamClient, 'delete');
|
|
38
|
+
});
|
|
15
39
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
client = new StreamVideoClient(apiKey, {
|
|
19
|
-
// tests run in node, so we have to fake being in browser env
|
|
20
|
-
browser: true,
|
|
21
|
-
timeout: 15000,
|
|
22
|
-
});
|
|
23
|
-
client.connectUser(
|
|
24
|
-
user,
|
|
25
|
-
serverClient.generateUserToken({ user_id: user.id }),
|
|
26
|
-
);
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
vi.restoreAllMocks();
|
|
27
42
|
});
|
|
28
43
|
|
|
29
|
-
it('query calls',
|
|
30
|
-
|
|
44
|
+
it('query calls', async () => {
|
|
45
|
+
const response: QueryCallsResponse = {
|
|
46
|
+
duration: '1ms',
|
|
47
|
+
next: 'next-page-token',
|
|
48
|
+
calls: [
|
|
49
|
+
{
|
|
50
|
+
call: CallCreatedPayload.call,
|
|
51
|
+
members: CallCreatedPayload.members,
|
|
52
|
+
own_capabilities: [],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
post.mockResolvedValue(response);
|
|
31
57
|
|
|
32
|
-
|
|
33
|
-
expect(
|
|
58
|
+
await client.queryCalls();
|
|
59
|
+
expect(post).toHaveBeenCalledWith('/calls', {});
|
|
34
60
|
|
|
35
61
|
const queryCallsReq = {
|
|
36
62
|
sort: [{ field: 'starts_at', direction: -1 }],
|
|
37
63
|
limit: 2,
|
|
38
64
|
};
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
expect(response.calls.length).toBeLessThanOrEqual(2);
|
|
50
|
-
|
|
51
|
-
response = await client.queryCalls({
|
|
52
|
-
filter_conditions: { backstage: { $eq: false } },
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
expect(response.calls.length).toBeGreaterThanOrEqual(1);
|
|
65
|
+
const result = await client.queryCalls(queryCallsReq);
|
|
66
|
+
expect(post).toHaveBeenCalledWith('/calls', queryCallsReq);
|
|
67
|
+
|
|
68
|
+
// each response entry is wrapped into a Call instance
|
|
69
|
+
expect(result.next).toBe('next-page-token');
|
|
70
|
+
expect(result.calls).toHaveLength(1);
|
|
71
|
+
const [call] = result.calls;
|
|
72
|
+
expect(call).toBeInstanceOf(Call);
|
|
73
|
+
expect(call.cid).toBe(CallCreatedPayload.call.cid);
|
|
56
74
|
});
|
|
57
75
|
|
|
58
76
|
it('query calls - ongoing', async () => {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
77
|
+
post.mockResolvedValue({ duration: '1ms', calls: [] });
|
|
78
|
+
|
|
79
|
+
const queryCallsReq = { filter_conditions: { ongoing: { $eq: true } } };
|
|
80
|
+
await client.queryCalls(queryCallsReq);
|
|
62
81
|
|
|
63
|
-
|
|
64
|
-
expect(response.calls).toBeDefined();
|
|
82
|
+
expect(post).toHaveBeenCalledWith('/calls', queryCallsReq);
|
|
65
83
|
});
|
|
66
84
|
|
|
67
85
|
it('query calls - upcoming', async () => {
|
|
86
|
+
post.mockResolvedValue({ duration: '1ms', calls: [] });
|
|
87
|
+
|
|
68
88
|
const mins30 = 1000 * 60 * 60 * 30;
|
|
69
89
|
const inNext30mins = new Date(Date.now() + mins30);
|
|
70
|
-
const
|
|
71
|
-
filter_conditions: {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
});
|
|
90
|
+
const queryCallsReq = {
|
|
91
|
+
filter_conditions: { starts_at: { $gt: inNext30mins.toISOString() } },
|
|
92
|
+
};
|
|
93
|
+
await client.queryCalls(queryCallsReq);
|
|
75
94
|
|
|
76
|
-
|
|
77
|
-
expect(response.calls).toBeDefined();
|
|
95
|
+
expect(post).toHaveBeenCalledWith('/calls', queryCallsReq);
|
|
78
96
|
});
|
|
79
97
|
|
|
80
98
|
it('query call stats', async () => {
|
|
81
|
-
const response =
|
|
99
|
+
const response: QueryCallStatsResponse = { duration: '1ms', reports: [] };
|
|
100
|
+
post.mockResolvedValue(response);
|
|
101
|
+
|
|
102
|
+
const result = await client.queryCallStats({
|
|
82
103
|
filter_conditions: { call_cid: 'default:test' },
|
|
83
104
|
});
|
|
84
105
|
|
|
85
|
-
expect(
|
|
106
|
+
expect(post).toHaveBeenCalledWith('/call/stats', {
|
|
107
|
+
filter_conditions: { call_cid: 'default:test' },
|
|
108
|
+
});
|
|
109
|
+
expect(result).toBe(response);
|
|
86
110
|
});
|
|
87
111
|
|
|
88
112
|
it('edges', async () => {
|
|
89
|
-
const response =
|
|
113
|
+
const response: GetEdgesResponse = { duration: '1ms', edges: [] };
|
|
114
|
+
get.mockResolvedValue(response);
|
|
115
|
+
|
|
116
|
+
const result = await client.edges();
|
|
90
117
|
|
|
91
|
-
expect(
|
|
118
|
+
expect(get).toHaveBeenCalledWith('/edges');
|
|
119
|
+
expect(result).toBe(response);
|
|
92
120
|
});
|
|
93
121
|
|
|
94
122
|
describe('devices', () => {
|
|
@@ -99,57 +127,55 @@ describe('StreamVideoClient - coordinator API', () => {
|
|
|
99
127
|
};
|
|
100
128
|
|
|
101
129
|
it('add device', async () => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
130
|
+
post.mockResolvedValue(undefined);
|
|
131
|
+
|
|
132
|
+
await client.addDevice(
|
|
133
|
+
device.id,
|
|
134
|
+
device.push_provider,
|
|
135
|
+
device.push_provider_name,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
expect(post).toHaveBeenCalledWith('/devices', {
|
|
139
|
+
id: device.id,
|
|
140
|
+
push_provider: device.push_provider,
|
|
141
|
+
voip_token: undefined,
|
|
142
|
+
push_provider_name: device.push_provider_name,
|
|
143
|
+
});
|
|
110
144
|
});
|
|
111
145
|
|
|
112
146
|
it('add voip device', async () => {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
147
|
+
post.mockResolvedValue(undefined);
|
|
148
|
+
|
|
149
|
+
await client.addVoipDevice(
|
|
150
|
+
device.id + 'voip',
|
|
151
|
+
device.push_provider,
|
|
152
|
+
device.push_provider_name!,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(post).toHaveBeenCalledWith('/devices', {
|
|
156
|
+
id: device.id + 'voip',
|
|
157
|
+
push_provider: device.push_provider,
|
|
158
|
+
voip_token: true,
|
|
159
|
+
push_provider_name: device.push_provider_name,
|
|
160
|
+
});
|
|
121
161
|
});
|
|
122
162
|
|
|
123
|
-
it('get devices',
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
setTimeout(resolve, 5000);
|
|
127
|
-
});
|
|
163
|
+
it('get devices', async () => {
|
|
164
|
+
const response: ListDevicesResponse = { duration: '1ms', devices: [] };
|
|
165
|
+
get.mockResolvedValue(response);
|
|
128
166
|
|
|
129
|
-
const
|
|
167
|
+
const result = await client.getDevices();
|
|
130
168
|
|
|
131
|
-
expect(
|
|
132
|
-
expect(
|
|
133
|
-
response.devices.find((d) => d.id === device.id + 'voip'),
|
|
134
|
-
).toBeDefined();
|
|
169
|
+
expect(get).toHaveBeenCalledWith('/devices', {});
|
|
170
|
+
expect(result).toBe(response);
|
|
135
171
|
});
|
|
136
172
|
|
|
137
|
-
it('remove device',
|
|
138
|
-
|
|
139
|
-
await new Promise<void>((resolve) => {
|
|
140
|
-
setTimeout(resolve, 5000);
|
|
141
|
-
});
|
|
173
|
+
it('remove device', async () => {
|
|
174
|
+
del.mockResolvedValue(undefined);
|
|
142
175
|
|
|
143
|
-
|
|
144
|
-
async () => await client.removeDevice(device.id),
|
|
145
|
-
).not.toThrowError();
|
|
146
|
-
expect(
|
|
147
|
-
async () => await client.removeDevice(device.id + 'void'),
|
|
148
|
-
).not.toThrowError();
|
|
149
|
-
});
|
|
150
|
-
});
|
|
176
|
+
await client.removeDevice(device.id);
|
|
151
177
|
|
|
152
|
-
|
|
153
|
-
|
|
178
|
+
expect(del).toHaveBeenCalledWith('/devices', { id: device.id });
|
|
179
|
+
});
|
|
154
180
|
});
|
|
155
181
|
});
|
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
import { StreamClient } from '../client';
|
|
3
3
|
import { StableWSConnection } from '../connection';
|
|
4
4
|
import type { WSConnectionError } from '../types';
|
|
5
|
+
import { getTimers } from '../../../timers';
|
|
5
6
|
|
|
6
7
|
class StuckWebSocket {
|
|
7
8
|
static CONNECTING = 0;
|
|
@@ -108,6 +109,27 @@ const buildClient = () => {
|
|
|
108
109
|
return client;
|
|
109
110
|
};
|
|
110
111
|
|
|
112
|
+
describe('StableWSConnection connection-check timer source', () => {
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
vi.restoreAllMocks();
|
|
115
|
+
vi.useRealTimers();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('arms the connection-check watchdog on the worker timer, not the main-thread setTimeout', () => {
|
|
119
|
+
const client = buildClient();
|
|
120
|
+
const wsConnection = new StableWSConnection(client);
|
|
121
|
+
const workerSetTimeout = vi
|
|
122
|
+
.spyOn(getTimers(), 'setTimeout')
|
|
123
|
+
.mockReturnValue(1 as unknown as number);
|
|
124
|
+
const mainSetTimeout = vi.spyOn(globalThis, 'setTimeout');
|
|
125
|
+
|
|
126
|
+
wsConnection.scheduleConnectionCheck();
|
|
127
|
+
|
|
128
|
+
expect(workerSetTimeout).toHaveBeenCalledTimes(1);
|
|
129
|
+
expect(mainSetTimeout).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
111
133
|
describe('StableWSConnection - silent handshake hang', () => {
|
|
112
134
|
beforeEach(() => {
|
|
113
135
|
StuckWebSocket.instances = [];
|
|
@@ -193,6 +215,53 @@ describe('StableWSConnection - silent handshake hang', () => {
|
|
|
193
215
|
expect(outcome.kind).toBe('rejected');
|
|
194
216
|
});
|
|
195
217
|
|
|
218
|
+
it('preserves an initial WS close reason when reconnect cannot get healthy', async () => {
|
|
219
|
+
const client = new StreamClient('test-key', {
|
|
220
|
+
browser: false,
|
|
221
|
+
defaultWsTimeout: 5000,
|
|
222
|
+
WebSocketImpl: ManualWebSocket as unknown as typeof WebSocket,
|
|
223
|
+
timeout: 1000,
|
|
224
|
+
});
|
|
225
|
+
vi.spyOn(client.tokenManager, 'tokenReady').mockResolvedValue('fake-token');
|
|
226
|
+
vi.spyOn(client.tokenManager, 'loadToken').mockResolvedValue('fake-token');
|
|
227
|
+
vi.spyOn(client.tokenManager, 'getToken').mockReturnValue('fake-token');
|
|
228
|
+
|
|
229
|
+
client._setUser({ id: 'test-user' });
|
|
230
|
+
client.userID = 'test-user';
|
|
231
|
+
client.clientID = 'test-user--abcdef';
|
|
232
|
+
client._setupConnectionIdPromise();
|
|
233
|
+
|
|
234
|
+
const wsConnection = new StableWSConnection(client);
|
|
235
|
+
client.wsConnection = wsConnection;
|
|
236
|
+
|
|
237
|
+
const connectAttemptOutcome = wsConnection.connect(5000).then(
|
|
238
|
+
() => ({ kind: 'resolved' as const }),
|
|
239
|
+
(error: Error) => ({ kind: 'rejected' as const, error }),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
243
|
+
const ws = ManualWebSocket.instances.at(-1)!;
|
|
244
|
+
expect(ws).toBeDefined();
|
|
245
|
+
|
|
246
|
+
ws.onclose?.({
|
|
247
|
+
code: 1006,
|
|
248
|
+
reason: 'specific ws close reason',
|
|
249
|
+
wasClean: false,
|
|
250
|
+
target: ws,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
await vi.advanceTimersByTimeAsync(20000);
|
|
254
|
+
const outcome = await connectAttemptOutcome;
|
|
255
|
+
|
|
256
|
+
expect(outcome.kind).toBe('rejected');
|
|
257
|
+
if (outcome.kind === 'rejected') {
|
|
258
|
+
expect(outcome.error.message).toContain('specific ws close reason');
|
|
259
|
+
expect(outcome.error.message).not.toContain(
|
|
260
|
+
'initial WS connection could not be established',
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
196
265
|
it('does not schedule a reconnect (and leaves connectionIdPromise rejected) on a permanent, non-WS failure', async () => {
|
|
197
266
|
const client = new StreamClient('test-key', {
|
|
198
267
|
browser: false,
|