@stream-io/video-client 1.57.0 → 1.59.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 +28 -0
- package/dist/e2ee-worker.browser.es.js +1342 -0
- package/dist/e2ee-worker.browser.es.js.map +1 -0
- package/dist/index.browser.es.js +1141 -117
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1196 -115
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.es.js +1174 -117
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +66 -9
- package/dist/src/StreamVideoClient.d.ts +9 -1
- package/dist/src/coordinator/connection/connection.d.ts +1 -1
- package/dist/src/devices/CameraManager.d.ts +8 -1
- package/dist/src/devices/DeviceManager.d.ts +2 -1
- package/dist/src/devices/MicrophoneManager.d.ts +8 -1
- package/dist/src/devices/SpeakerManager.d.ts +2 -1
- package/dist/src/events/participant.d.ts +10 -3
- package/dist/src/gen/coordinator/index.d.ts +1386 -240
- package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
- package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
- package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
- package/dist/src/helpers/concurrency.d.ts +8 -0
- package/dist/src/helpers/ensureExhausted.d.ts +1 -1
- package/dist/src/helpers/loopback.d.ts +55 -0
- package/dist/src/permissions/PermissionsContext.d.ts +2 -2
- package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
- package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
- package/dist/src/rtc/Subscriber.d.ts +5 -0
- package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
- package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
- package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
- package/dist/src/rtc/e2ee/events.d.ts +163 -0
- package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
- package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
- package/dist/src/rtc/index.d.ts +1 -0
- package/dist/src/rtc/mediaEngine.d.ts +23 -0
- package/dist/src/rtc/types.d.ts +2 -0
- package/dist/src/store/CallState.d.ts +21 -0
- package/dist/src/types.d.ts +15 -6
- package/index.ts +5 -0
- package/package.json +1 -1
- package/src/Call.ts +311 -155
- package/src/StreamVideoClient.ts +35 -8
- package/src/__tests__/Call.lifecycle.test.ts +33 -2
- package/src/__tests__/Call.publishing.test.ts +42 -1
- package/src/__tests__/Call.test.ts +15 -0
- package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
- package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
- package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
- package/src/__tests__/StreamVideoClient.test.ts +14 -12
- package/src/coordinator/connection/connection.ts +6 -3
- package/src/devices/AudioDeviceManager.ts +6 -0
- package/src/devices/CameraManager.ts +67 -8
- package/src/devices/DeviceManager.ts +24 -4
- package/src/devices/MicrophoneManager.ts +66 -8
- package/src/devices/ScreenShareManager.ts +3 -0
- package/src/devices/SpeakerManager.ts +42 -12
- package/src/devices/__tests__/CameraManager.test.ts +27 -1
- package/src/devices/__tests__/DeviceManager.test.ts +49 -1
- package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -20
- package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
- package/src/devices/__tests__/mocks.ts +36 -3
- package/src/events/__tests__/participant.test.ts +134 -1
- package/src/events/call.ts +6 -3
- package/src/events/callEventHandlers.ts +9 -3
- package/src/events/participant.ts +42 -7
- package/src/gen/coordinator/index.ts +1402 -236
- package/src/gen/video/sfu/models/models.ts +15 -0
- package/src/helpers/MediaStreamRecorder.ts +104 -0
- package/src/helpers/TypedEventEmitter.ts +71 -0
- package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
- package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
- package/src/helpers/__tests__/concurrency.test.ts +74 -0
- package/src/helpers/__tests__/loopback.test.ts +367 -0
- package/src/helpers/concurrency.ts +24 -0
- package/src/helpers/ensureExhausted.ts +4 -2
- package/src/helpers/loopback.ts +200 -0
- package/src/permissions/PermissionsContext.ts +10 -5
- package/src/reporting/ClientEventReporter.ts +15 -28
- package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
- package/src/rtc/BasePeerConnection.ts +12 -1
- package/src/rtc/Publisher.ts +8 -0
- package/src/rtc/Subscriber.ts +13 -0
- package/src/rtc/__tests__/Publisher.test.ts +101 -0
- package/src/rtc/__tests__/Subscriber.test.ts +190 -0
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
- package/src/rtc/e2ee/E2EEManager.ts +12 -0
- package/src/rtc/e2ee/EncryptionManager.ts +336 -0
- package/src/rtc/e2ee/SPEC.md +586 -0
- package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
- package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
- package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
- package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
- package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
- package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
- package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
- package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
- package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
- package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
- package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
- package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
- package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
- package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
- package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
- package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
- package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
- package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
- package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
- package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
- package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
- package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
- package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
- package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
- package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
- package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
- package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
- package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
- package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
- package/src/rtc/e2ee/e2ee-worker.ts +27 -0
- package/src/rtc/e2ee/events.ts +179 -0
- package/src/rtc/e2ee/transformSupport.ts +35 -0
- package/src/rtc/helpers/rtcConfiguration.ts +4 -2
- package/src/rtc/index.ts +1 -0
- package/src/rtc/mediaEngine.ts +44 -0
- package/src/rtc/types.ts +2 -0
- package/src/store/CallState.ts +33 -1
- package/src/store/__tests__/CallState.test.ts +82 -1
- package/src/types.ts +23 -5
package/src/StreamVideoClient.ts
CHANGED
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
getInstanceKey,
|
|
40
40
|
} from './helpers/clientUtils';
|
|
41
41
|
import { logToConsole, ScopedLogger, videoLoggerSystem } from './logger';
|
|
42
|
-
import { isReactNative } from './helpers/platforms';
|
|
43
42
|
import { withoutConcurrency } from './helpers/concurrency';
|
|
44
43
|
import { enableTimerWorker } from './timers';
|
|
45
44
|
import { ClientEventReporter } from './reporting';
|
|
@@ -211,6 +210,7 @@ export class StreamVideoClient {
|
|
|
211
210
|
await call.reject('busy');
|
|
212
211
|
} else {
|
|
213
212
|
await call.updateFromRingingEvent(e as CallRingEvent);
|
|
213
|
+
await call.get();
|
|
214
214
|
}
|
|
215
215
|
} else {
|
|
216
216
|
call.state.updateFromCallResponse(e.call);
|
|
@@ -248,6 +248,17 @@ export class StreamVideoClient {
|
|
|
248
248
|
}
|
|
249
249
|
};
|
|
250
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Queries the API for calls matching the given filters.
|
|
253
|
+
* @param data the query data.
|
|
254
|
+
*/
|
|
255
|
+
private doQueryCalls = (data: QueryCallsRequest) => {
|
|
256
|
+
return this.streamClient.post<QueryCallsResponse, QueryCallsRequest>(
|
|
257
|
+
'/calls',
|
|
258
|
+
data,
|
|
259
|
+
);
|
|
260
|
+
};
|
|
261
|
+
|
|
251
262
|
/**
|
|
252
263
|
* Rewatches the given calls with retry logic.
|
|
253
264
|
* @param callsToReWatch array of call IDs to rewatch
|
|
@@ -261,11 +272,22 @@ export class StreamVideoClient {
|
|
|
261
272
|
`Rewatching calls ${callsToReWatch.join(', ')} attempt ${attempt + 1}`,
|
|
262
273
|
);
|
|
263
274
|
|
|
264
|
-
await this.
|
|
275
|
+
const response = await this.doQueryCalls({
|
|
265
276
|
watch: true,
|
|
266
277
|
filter_conditions: { cid: { $in: callsToReWatch } },
|
|
267
278
|
});
|
|
268
279
|
|
|
280
|
+
for (const c of response.calls) {
|
|
281
|
+
const call = this.writeableStateStore.findCall(
|
|
282
|
+
c.call.type,
|
|
283
|
+
c.call.id,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
if (call) {
|
|
287
|
+
call.updateFromCallStateResponse(c);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
269
291
|
return;
|
|
270
292
|
} catch (err) {
|
|
271
293
|
if (err instanceof ErrorFromResponse && err.unrecoverable) {
|
|
@@ -452,12 +474,14 @@ export class StreamVideoClient {
|
|
|
452
474
|
* Will query the API for calls matching the given filters.
|
|
453
475
|
*
|
|
454
476
|
* @param data the query data.
|
|
477
|
+
* @param opts additional options, for tweaking the API behavior.
|
|
455
478
|
*/
|
|
456
|
-
queryCalls = async (
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
479
|
+
queryCalls = async (
|
|
480
|
+
data: QueryCallsRequest = {},
|
|
481
|
+
opts: { withDisabledDevices?: boolean } = {},
|
|
482
|
+
) => {
|
|
483
|
+
const { withDisabledDevices = true } = opts;
|
|
484
|
+
const response = await this.doQueryCalls(data);
|
|
461
485
|
const calls = [];
|
|
462
486
|
for (const c of response.calls) {
|
|
463
487
|
const call = new Call({
|
|
@@ -471,7 +495,10 @@ export class StreamVideoClient {
|
|
|
471
495
|
clientStore: this.writeableStateStore,
|
|
472
496
|
});
|
|
473
497
|
call.state.updateFromCallResponse(c.call);
|
|
474
|
-
await call.applyDeviceConfig(c.call.settings,
|
|
498
|
+
await call.applyDeviceConfig(c.call.settings, {
|
|
499
|
+
publish: false,
|
|
500
|
+
withDisabledDevices,
|
|
501
|
+
});
|
|
475
502
|
if (data.watch) {
|
|
476
503
|
await call.setup();
|
|
477
504
|
this.writeableStateStore.registerCall(call);
|
|
@@ -4,27 +4,38 @@
|
|
|
4
4
|
|
|
5
5
|
import '../rtc/__tests__/mocks/webrtc.mocks';
|
|
6
6
|
|
|
7
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
8
|
import { Call } from '../Call';
|
|
9
9
|
import { StreamClient } from '../coordinator/connection/client';
|
|
10
10
|
import { ClientEventReporter } from '../reporting';
|
|
11
11
|
import { generateUUIDv4 } from '../coordinator/connection/utils';
|
|
12
12
|
import { StreamVideoWriteableStateStore } from '../store';
|
|
13
|
+
import { promiseWithResolvers } from '../helpers/promise';
|
|
13
14
|
|
|
14
15
|
describe('Call lifecycle wiring', () => {
|
|
15
16
|
let call: Call;
|
|
16
17
|
|
|
17
18
|
beforeEach(() => {
|
|
19
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
20
|
+
headers: { get: () => 'AMS1-P2' },
|
|
21
|
+
} as Response);
|
|
18
22
|
const streamClient = new StreamClient('abc');
|
|
19
23
|
call = new Call({
|
|
20
24
|
type: 'test',
|
|
21
25
|
id: generateUUIDv4(),
|
|
22
26
|
streamClient,
|
|
23
|
-
clientEventReporter: new ClientEventReporter({
|
|
27
|
+
clientEventReporter: new ClientEventReporter({
|
|
28
|
+
streamClient,
|
|
29
|
+
enabled: false,
|
|
30
|
+
}),
|
|
24
31
|
clientStore: new StreamVideoWriteableStateStore(),
|
|
25
32
|
});
|
|
26
33
|
});
|
|
27
34
|
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
vi.restoreAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
28
39
|
// Regression guard for the Call-owned helper teardown chain. Each of
|
|
29
40
|
// these helpers holds a resource (timer, listener, AudioContext) that
|
|
30
41
|
// leaks across calls if teardown is dropped during a refactor.
|
|
@@ -67,4 +78,24 @@ describe('Call lifecycle wiring', () => {
|
|
|
67
78
|
expect(trackSubOrder).toBeLessThan(audioBindingsOrder);
|
|
68
79
|
expect(audioBindingsOrder).toBeLessThan(dynascaleOrder);
|
|
69
80
|
});
|
|
81
|
+
|
|
82
|
+
it('call.join() shares an in-flight join flow', async () => {
|
|
83
|
+
const joinTask = promiseWithResolvers<void>();
|
|
84
|
+
vi.spyOn(call, 'setup').mockResolvedValue(undefined);
|
|
85
|
+
const doJoin = vi
|
|
86
|
+
.spyOn(call as unknown as { doJoin: Call['join'] }, 'doJoin')
|
|
87
|
+
.mockReturnValue(joinTask.promise);
|
|
88
|
+
|
|
89
|
+
const firstJoin = call.join();
|
|
90
|
+
const secondJoin = call.join();
|
|
91
|
+
|
|
92
|
+
await Promise.resolve();
|
|
93
|
+
expect(doJoin).toHaveBeenCalledTimes(1);
|
|
94
|
+
|
|
95
|
+
joinTask.resolve();
|
|
96
|
+
await expect(Promise.all([firstJoin, secondJoin])).resolves.toEqual([
|
|
97
|
+
undefined,
|
|
98
|
+
undefined,
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
70
101
|
});
|
|
@@ -2,7 +2,7 @@ import '../rtc/__tests__/mocks/webrtc.mocks';
|
|
|
2
2
|
|
|
3
3
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { Call } from '../Call';
|
|
5
|
-
import { Publisher } from '../rtc';
|
|
5
|
+
import { Publisher, Subscriber } from '../rtc';
|
|
6
6
|
import { StreamClient } from '../coordinator/connection/client';
|
|
7
7
|
import { ClientEventReporter } from '../reporting';
|
|
8
8
|
import { generateUUIDv4 } from '../coordinator/connection/utils';
|
|
@@ -183,6 +183,47 @@ describe('Publishing and Unpublishing tracks', () => {
|
|
|
183
183
|
expect(participant!.audioStream).toEqual(mediaStream);
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
+
it('keeps a stream the SFU already echoed back instead of the capture one', async () => {
|
|
187
|
+
// `allowOwnTracksLoopback`: the echo can land on the participant before
|
|
188
|
+
// publishing gets to write the capture stream to the same field.
|
|
189
|
+
const echoedStream = new MediaStream();
|
|
190
|
+
call.subscriber = {
|
|
191
|
+
isSelfSubscribedStream: (stream: MediaStream | undefined) =>
|
|
192
|
+
stream === echoedStream,
|
|
193
|
+
} as unknown as Subscriber;
|
|
194
|
+
call.state.updateParticipant(sessionId, { audioStream: echoedStream });
|
|
195
|
+
|
|
196
|
+
const track = new MediaStreamTrack();
|
|
197
|
+
const captureStream = new MediaStream();
|
|
198
|
+
vi.spyOn(captureStream, 'getAudioTracks').mockReturnValue([track]);
|
|
199
|
+
|
|
200
|
+
await call.publish(captureStream, TrackType.AUDIO);
|
|
201
|
+
|
|
202
|
+
const participant = call.state.findParticipantBySessionId(sessionId);
|
|
203
|
+
expect(participant!.publishedTracks).toEqual([TrackType.AUDIO]);
|
|
204
|
+
expect(participant!.audioStream).toBe(echoedStream);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('keeps an echoed stream when the track is unpublished', async () => {
|
|
208
|
+
// a device switch unpublishes and republishes; the echoed track goes
|
|
209
|
+
// briefly silent but is never replaced, so the reference has to survive
|
|
210
|
+
const echoedStream = new MediaStream();
|
|
211
|
+
call.subscriber = {
|
|
212
|
+
isSelfSubscribedStream: (stream: MediaStream | undefined) =>
|
|
213
|
+
stream === echoedStream,
|
|
214
|
+
} as unknown as Subscriber;
|
|
215
|
+
call.state.updateParticipant(sessionId, {
|
|
216
|
+
audioStream: echoedStream,
|
|
217
|
+
publishedTracks: [TrackType.AUDIO],
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await call.stopPublish(TrackType.AUDIO);
|
|
221
|
+
|
|
222
|
+
const participant = call.state.findParticipantBySessionId(sessionId);
|
|
223
|
+
expect(participant!.publishedTracks).toEqual([]);
|
|
224
|
+
expect(participant!.audioStream).toBe(echoedStream);
|
|
225
|
+
});
|
|
226
|
+
|
|
186
227
|
it('publish screen share stream', async () => {
|
|
187
228
|
const track = new MediaStreamTrack();
|
|
188
229
|
const mediaStream = new MediaStream();
|
|
@@ -17,6 +17,7 @@ import { Dispatcher } from '../rtc';
|
|
|
17
17
|
import { Call } from '../Call';
|
|
18
18
|
import { StreamVideoParticipant } from '../types';
|
|
19
19
|
import { TrackType } from '../gen/video/sfu/models/models';
|
|
20
|
+
import type { E2EEManager } from '../rtc/e2ee/E2EEManager';
|
|
20
21
|
|
|
21
22
|
const apiKey = process.env.STREAM_API_KEY!;
|
|
22
23
|
const secret = process.env.STREAM_SECRET!;
|
|
@@ -300,6 +301,20 @@ describe('muting logic', () => {
|
|
|
300
301
|
});
|
|
301
302
|
});
|
|
302
303
|
|
|
304
|
+
describe('setE2EEManager', () => {
|
|
305
|
+
it('throws when called after the peer connections exist', () => {
|
|
306
|
+
const call = client.call('default', generateUUIDv4());
|
|
307
|
+
const e2ee = fromPartial<E2EEManager>({});
|
|
308
|
+
// Before join there are no peer connections, so it is accepted.
|
|
309
|
+
expect(() => call.setE2EEManager(e2ee)).not.toThrow();
|
|
310
|
+
// Once a subscriber/publisher exists (i.e. after join) the PCs were already
|
|
311
|
+
// built without the manager; setting it now would leave the live session
|
|
312
|
+
// half-encrypted, so it must be rejected (finding 12).
|
|
313
|
+
call['subscriber'] = fromPartial({});
|
|
314
|
+
expect(() => call.setE2EEManager(e2ee)).toThrow(/before join/);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
303
318
|
describe('client event reporting', () => {
|
|
304
319
|
afterEach(() => {
|
|
305
320
|
vi.restoreAllMocks();
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
} from 'vitest';
|
|
10
10
|
import { StreamVideoClient } from '../StreamVideoClient';
|
|
11
11
|
import { Call } from '../Call';
|
|
12
|
+
import { CameraManager } from '../devices/CameraManager';
|
|
13
|
+
import { MicrophoneManager } from '../devices/MicrophoneManager';
|
|
12
14
|
import { CallCreatedPayload } from './data';
|
|
13
15
|
import { generateUUIDv4 } from '../coordinator/connection/utils';
|
|
14
16
|
import type { StreamClient } from '../coordinator/connection/client';
|
|
@@ -73,6 +75,37 @@ describe('StreamVideoClient - coordinator API', () => {
|
|
|
73
75
|
expect(call.cid).toBe(CallCreatedPayload.call.cid);
|
|
74
76
|
});
|
|
75
77
|
|
|
78
|
+
it('query calls keeps devices disabled by default', async () => {
|
|
79
|
+
const response: QueryCallsResponse = {
|
|
80
|
+
duration: '1ms',
|
|
81
|
+
calls: [
|
|
82
|
+
{
|
|
83
|
+
call: CallCreatedPayload.call,
|
|
84
|
+
members: CallCreatedPayload.members,
|
|
85
|
+
own_capabilities: [],
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
post.mockResolvedValue(response);
|
|
90
|
+
const applyCamera = vi
|
|
91
|
+
.spyOn(CameraManager.prototype, 'apply')
|
|
92
|
+
.mockResolvedValue();
|
|
93
|
+
const applyMicrophone = vi
|
|
94
|
+
.spyOn(MicrophoneManager.prototype, 'apply')
|
|
95
|
+
.mockResolvedValue();
|
|
96
|
+
|
|
97
|
+
await client.queryCalls();
|
|
98
|
+
await client.queryCalls({}, {});
|
|
99
|
+
await client.queryCalls({}, { withDisabledDevices: false });
|
|
100
|
+
|
|
101
|
+
expect(
|
|
102
|
+
applyCamera.mock.calls.map(([, , forceDisabled]) => forceDisabled),
|
|
103
|
+
).toEqual([true, true, false]);
|
|
104
|
+
expect(
|
|
105
|
+
applyMicrophone.mock.calls.map(([, , forceDisabled]) => forceDisabled),
|
|
106
|
+
).toEqual([true, true, false]);
|
|
107
|
+
});
|
|
108
|
+
|
|
76
109
|
it('query calls - ongoing', async () => {
|
|
77
110
|
post.mockResolvedValue({ duration: '1ms', calls: [] });
|
|
78
111
|
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import '../rtc/__tests__/mocks/webrtc.mocks';
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { StreamVideoClient } from '../StreamVideoClient';
|
|
5
|
+
import { Call } from '../Call';
|
|
6
|
+
import { CallRingPayload } from './data';
|
|
7
|
+
import { settled, withoutConcurrency } from '../helpers/concurrency';
|
|
8
|
+
import { getCallInitConcurrencyTag } from '../helpers/clientUtils';
|
|
9
|
+
import { CallingState } from '../store';
|
|
10
|
+
import type { StreamVideoEvent } from '../coordinator/connection/types';
|
|
11
|
+
import type {
|
|
12
|
+
CallResponse,
|
|
13
|
+
ConnectedEvent,
|
|
14
|
+
GetCallResponse,
|
|
15
|
+
QueryCallsResponse,
|
|
16
|
+
} from '../gen/coordinator';
|
|
17
|
+
|
|
18
|
+
const apiKey = 'mock-api-key';
|
|
19
|
+
// the receiver of the CallRingPayload fixture
|
|
20
|
+
const userId = 'marcelo';
|
|
21
|
+
|
|
22
|
+
const queryCallsResponse = (call: CallResponse): QueryCallsResponse => ({
|
|
23
|
+
duration: '1ms',
|
|
24
|
+
calls: [{ call, members: CallRingPayload.members, own_capabilities: [] }],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const countListeners = (client: StreamVideoClient) =>
|
|
28
|
+
Object.values(client.streamClient.listeners).reduce(
|
|
29
|
+
(count, listeners) => count + (listeners?.length ?? 0),
|
|
30
|
+
0,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
describe('StreamVideoClient re-watching calls on reconnect', () => {
|
|
34
|
+
let client: StreamVideoClient;
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
client = new StreamVideoClient(apiKey, {
|
|
38
|
+
// tests run in node, so we have to fake being in browser env
|
|
39
|
+
browser: true,
|
|
40
|
+
});
|
|
41
|
+
client.streamClient.connectUser = vi.fn().mockResolvedValue({
|
|
42
|
+
me: { id: userId },
|
|
43
|
+
} as ConnectedEvent);
|
|
44
|
+
await client.connectUser({ id: userId }, 'mock-token');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
for (const call of client.state.calls) {
|
|
49
|
+
call['cancelAutoDrop']();
|
|
50
|
+
}
|
|
51
|
+
vi.restoreAllMocks();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const setupRingingCall = async () => {
|
|
55
|
+
vi.spyOn(client.streamClient, 'get').mockResolvedValue({
|
|
56
|
+
duration: '1ms',
|
|
57
|
+
call: CallRingPayload.call,
|
|
58
|
+
members: CallRingPayload.members,
|
|
59
|
+
own_capabilities: [],
|
|
60
|
+
} as GetCallResponse);
|
|
61
|
+
|
|
62
|
+
client.streamClient.dispatchEvent(CallRingPayload as StreamVideoEvent);
|
|
63
|
+
await settled(getCallInitConcurrencyTag(CallRingPayload.call_cid));
|
|
64
|
+
|
|
65
|
+
const [call] = client.state.calls;
|
|
66
|
+
expect(call).toBeDefined();
|
|
67
|
+
expect(call.watching).toBe(true);
|
|
68
|
+
expect(call.state.callingState).toBe(CallingState.RINGING);
|
|
69
|
+
return call;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const reconnect = () => {
|
|
73
|
+
client.streamClient.dispatchEvent({
|
|
74
|
+
type: 'connection.changed',
|
|
75
|
+
online: true,
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
it('reuses the registered instance and refreshes its state', async () => {
|
|
80
|
+
const call = await setupRingingCall();
|
|
81
|
+
const listenerCountBeforeReconnect = countListeners(client);
|
|
82
|
+
|
|
83
|
+
const post = vi
|
|
84
|
+
.spyOn(client.streamClient, 'post')
|
|
85
|
+
.mockResolvedValue(
|
|
86
|
+
queryCallsResponse({ ...CallRingPayload.call, recording: true }),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
reconnect();
|
|
90
|
+
|
|
91
|
+
await vi.waitFor(() => expect(post).toHaveBeenCalled());
|
|
92
|
+
expect(post).toHaveBeenCalledWith('/calls', {
|
|
93
|
+
watch: true,
|
|
94
|
+
filter_conditions: { cid: { $in: [call.cid] } },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// the refreshed state must reach the instance the integrator holds
|
|
98
|
+
await vi.waitFor(() => expect(call.state.recording).toBe(true));
|
|
99
|
+
expect(client.state.calls).toHaveLength(1);
|
|
100
|
+
expect(client.state.calls[0]).toBe(call);
|
|
101
|
+
expect(call.watching).toBe(true);
|
|
102
|
+
|
|
103
|
+
// re-watching must not leak event handlers (one orphaned Call
|
|
104
|
+
// used to register its handlers on every reconnect)
|
|
105
|
+
expect(countListeners(client)).toBe(listenerCountBeforeReconnect);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('drops a ringing call accepted elsewhere while the socket was down', async () => {
|
|
109
|
+
const call = await setupRingingCall();
|
|
110
|
+
const leave = vi.spyOn(call, 'leave').mockResolvedValue(undefined);
|
|
111
|
+
|
|
112
|
+
const session = CallRingPayload.call.session!;
|
|
113
|
+
vi.spyOn(client.streamClient, 'post').mockResolvedValue(
|
|
114
|
+
queryCallsResponse({
|
|
115
|
+
...CallRingPayload.call,
|
|
116
|
+
session: {
|
|
117
|
+
...session,
|
|
118
|
+
accepted_by: { [userId]: '2025-08-14T14:49:00Z' },
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
reconnect();
|
|
124
|
+
|
|
125
|
+
// accepted on another device -> this device should stop ringing
|
|
126
|
+
await vi.waitFor(() => expect(leave).toHaveBeenCalled());
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('queryCalls returns an independent instance for registered calls', async () => {
|
|
130
|
+
// e.g. being on a call while watching a dashboard of calls:
|
|
131
|
+
// leaving the joined instance must not silence the dashboard instance
|
|
132
|
+
const joinedCall = await setupRingingCall();
|
|
133
|
+
vi.spyOn(client.streamClient, 'post').mockResolvedValue(
|
|
134
|
+
queryCallsResponse(CallRingPayload.call),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const result = await client.queryCalls({ watch: true });
|
|
138
|
+
const [dashboardCall] = result.calls;
|
|
139
|
+
expect(dashboardCall).not.toBe(joinedCall);
|
|
140
|
+
expect(client.state.calls[0]).toBe(joinedCall);
|
|
141
|
+
|
|
142
|
+
await joinedCall.leave({ reject: false });
|
|
143
|
+
|
|
144
|
+
client.streamClient.dispatchEvent({
|
|
145
|
+
type: 'call.updated',
|
|
146
|
+
call_cid: joinedCall.cid,
|
|
147
|
+
created_at: '2025-08-14T14:50:00Z',
|
|
148
|
+
call: { ...CallRingPayload.call, recording: true },
|
|
149
|
+
} as StreamVideoEvent);
|
|
150
|
+
|
|
151
|
+
await vi.waitFor(() => expect(dashboardCall.state.recording).toBe(true));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('creates and registers a new instance for unknown cids', async () => {
|
|
155
|
+
vi.spyOn(client.streamClient, 'post').mockResolvedValue(
|
|
156
|
+
queryCallsResponse(CallRingPayload.call),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const result = await client.queryCalls({ watch: true });
|
|
160
|
+
|
|
161
|
+
expect(result.calls).toHaveLength(1);
|
|
162
|
+
const [call] = result.calls;
|
|
163
|
+
expect(call).toBeInstanceOf(Call);
|
|
164
|
+
expect(call.watching).toBe(true);
|
|
165
|
+
expect(client.state.calls[0]).toBe(call);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('does not resurrect a call that leaves during a re-watch', async () => {
|
|
169
|
+
const listenersBeforeRing = countListeners(client);
|
|
170
|
+
const call = await setupRingingCall();
|
|
171
|
+
const post = vi
|
|
172
|
+
.spyOn(client.streamClient, 'post')
|
|
173
|
+
.mockResolvedValue(queryCallsResponse(CallRingPayload.call));
|
|
174
|
+
|
|
175
|
+
// hold the call's join/leave queue so leave() is still in flight
|
|
176
|
+
// while the re-watch response is being processed
|
|
177
|
+
let releaseGate!: () => void;
|
|
178
|
+
const gate = new Promise<void>((resolve) => (releaseGate = resolve));
|
|
179
|
+
withoutConcurrency(call['joinLeaveConcurrencyTag'], () => gate);
|
|
180
|
+
const leavePromise = call.leave({ reject: false });
|
|
181
|
+
|
|
182
|
+
reconnect();
|
|
183
|
+
await vi.waitFor(() => expect(post).toHaveBeenCalled());
|
|
184
|
+
await Promise.resolve();
|
|
185
|
+
|
|
186
|
+
releaseGate();
|
|
187
|
+
await leavePromise;
|
|
188
|
+
|
|
189
|
+
// the left call must stay dead: not re-registered, not re-initialized,
|
|
190
|
+
// and all of its event handlers released
|
|
191
|
+
expect(client.state.calls).toHaveLength(0);
|
|
192
|
+
expect(call.state.callingState).toBe(CallingState.LEFT);
|
|
193
|
+
expect(countListeners(client)).toBe(listenersBeforeRing);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('creates a fresh instance when the previous one has left', async () => {
|
|
197
|
+
const call = await setupRingingCall();
|
|
198
|
+
const post = vi
|
|
199
|
+
.spyOn(client.streamClient, 'post')
|
|
200
|
+
.mockResolvedValue(queryCallsResponse(CallRingPayload.call));
|
|
201
|
+
|
|
202
|
+
// leaving unregisters the call from the client store
|
|
203
|
+
await call.leave({ reject: false });
|
|
204
|
+
expect(client.state.calls).toHaveLength(0);
|
|
205
|
+
|
|
206
|
+
const result = await client.queryCalls({ watch: true });
|
|
207
|
+
expect(post).toHaveBeenCalled();
|
|
208
|
+
expect(result.calls).toHaveLength(1);
|
|
209
|
+
expect(result.calls[0]).not.toBe(call);
|
|
210
|
+
expect(client.state.calls[0]).toBe(result.calls[0]);
|
|
211
|
+
expect(result.calls[0].watching).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -45,12 +45,21 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
45
45
|
]);
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
const leaveAllCalls = async () => {
|
|
49
|
+
await Promise.all(
|
|
50
|
+
[oliverClient, sachaClient, marceloClient].flatMap((client) =>
|
|
51
|
+
client.state.calls.map((call) => call.leave().catch(() => {})),
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
48
56
|
beforeEach(async () => {
|
|
49
57
|
[oliverClient, sachaClient, marceloClient] =
|
|
50
58
|
await createClients(testUserIds);
|
|
51
59
|
});
|
|
52
60
|
|
|
53
61
|
afterEach(async () => {
|
|
62
|
+
await leaveAllCalls();
|
|
54
63
|
await disconnectClients();
|
|
55
64
|
});
|
|
56
65
|
|
|
@@ -275,6 +284,7 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
275
284
|
};
|
|
276
285
|
|
|
277
286
|
it('should reject the call when the caller is busy', async () => {
|
|
287
|
+
await disconnectClients();
|
|
278
288
|
[oliverClient, sachaClient, marceloClient] = await createClients(
|
|
279
289
|
testUserIds,
|
|
280
290
|
{
|
|
@@ -301,11 +311,10 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
301
311
|
marceloCall.cid,
|
|
302
312
|
);
|
|
303
313
|
expect(oliverClient.state.calls.length).toBe(1);
|
|
304
|
-
|
|
305
|
-
await disconnectClients();
|
|
306
314
|
});
|
|
307
315
|
|
|
308
316
|
it('should reject the call when the callee is busy', async () => {
|
|
317
|
+
await disconnectClients();
|
|
309
318
|
[oliverClient, sachaClient, marceloClient] = await createClients(
|
|
310
319
|
testUserIds,
|
|
311
320
|
{
|
|
@@ -332,8 +341,6 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
332
341
|
marceloCall.cid,
|
|
333
342
|
);
|
|
334
343
|
expect(sachaClient.state.calls.length).toBe(1);
|
|
335
|
-
|
|
336
|
-
await disconnectClients();
|
|
337
344
|
});
|
|
338
345
|
|
|
339
346
|
it('should allow multiple simultaneous calls to the same caller when rejectCallWhenBusy is false', async () => {
|
|
@@ -277,7 +277,7 @@ describe('StreamVideoClient.connectUser retries', () => {
|
|
|
277
277
|
});
|
|
278
278
|
|
|
279
279
|
describe('StreamVideoClient.watchCalls retries', () => {
|
|
280
|
-
it('should retry
|
|
280
|
+
it('should retry the re-watch request up to 3 times after reconnecting', async () => {
|
|
281
281
|
vi.useFakeTimers();
|
|
282
282
|
const client = new StreamVideoClient(apiKey, {
|
|
283
283
|
browser: true,
|
|
@@ -290,8 +290,8 @@ describe('StreamVideoClient.watchCalls retries', () => {
|
|
|
290
290
|
const call = client.call('default', generateUUIDv4());
|
|
291
291
|
await call.getOrCreate();
|
|
292
292
|
|
|
293
|
-
const
|
|
294
|
-
.spyOn(client, '
|
|
293
|
+
const doQueryCalls = vi
|
|
294
|
+
.spyOn(client as any, 'doQueryCalls')
|
|
295
295
|
.mockRejectedValueOnce(new Error('fail 1'))
|
|
296
296
|
.mockRejectedValueOnce(new Error('fail 2'))
|
|
297
297
|
.mockResolvedValue({
|
|
@@ -306,7 +306,7 @@ describe('StreamVideoClient.watchCalls retries', () => {
|
|
|
306
306
|
|
|
307
307
|
await vi.runAllTimersAsync();
|
|
308
308
|
|
|
309
|
-
expect(
|
|
309
|
+
expect(doQueryCalls).toHaveBeenCalledTimes(3);
|
|
310
310
|
|
|
311
311
|
await client.disconnectUser();
|
|
312
312
|
|
|
@@ -327,8 +327,8 @@ describe('StreamVideoClient.watchCalls retries', () => {
|
|
|
327
327
|
const call = client.call('default', generateUUIDv4());
|
|
328
328
|
await call.getOrCreate();
|
|
329
329
|
|
|
330
|
-
const
|
|
331
|
-
.spyOn(client, '
|
|
330
|
+
const doQueryCalls = vi
|
|
331
|
+
.spyOn(client as any, 'doQueryCalls')
|
|
332
332
|
.mockRejectedValueOnce(new Error('fail 1'))
|
|
333
333
|
.mockRejectedValueOnce(new Error('fail 2'))
|
|
334
334
|
.mockRejectedValueOnce(new Error('fail 3'))
|
|
@@ -344,7 +344,7 @@ describe('StreamVideoClient.watchCalls retries', () => {
|
|
|
344
344
|
|
|
345
345
|
await vi.runAllTimersAsync();
|
|
346
346
|
|
|
347
|
-
expect(
|
|
347
|
+
expect(doQueryCalls).toHaveBeenCalledTimes(3);
|
|
348
348
|
|
|
349
349
|
await client.disconnectUser();
|
|
350
350
|
vi.useRealTimers();
|
|
@@ -363,10 +363,12 @@ describe('StreamVideoClient.watchCalls retries', () => {
|
|
|
363
363
|
const call = client.call('default', generateUUIDv4());
|
|
364
364
|
await call.getOrCreate();
|
|
365
365
|
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
366
|
+
const doQueryCalls = vi
|
|
367
|
+
.spyOn(client as any, 'doQueryCalls')
|
|
368
|
+
.mockResolvedValue({
|
|
369
|
+
calls: [],
|
|
370
|
+
duration: '0ms',
|
|
371
|
+
});
|
|
370
372
|
|
|
371
373
|
client.streamClient.dispatchEvent({
|
|
372
374
|
type: 'connection.changed',
|
|
@@ -375,7 +377,7 @@ describe('StreamVideoClient.watchCalls retries', () => {
|
|
|
375
377
|
|
|
376
378
|
await vi.runAllTimersAsync();
|
|
377
379
|
|
|
378
|
-
expect(
|
|
380
|
+
expect(doQueryCalls).toHaveBeenCalledTimes(1);
|
|
379
381
|
|
|
380
382
|
await client.disconnectUser();
|
|
381
383
|
vi.useRealTimers();
|
|
@@ -9,10 +9,11 @@ import {
|
|
|
9
9
|
} from './utils';
|
|
10
10
|
import type { StreamVideoEvent, UR, WSConnectionError } from './types';
|
|
11
11
|
import type { LogLevel } from '@stream-io/logger';
|
|
12
|
-
import
|
|
12
|
+
import {
|
|
13
13
|
ConnectedEvent,
|
|
14
14
|
ConnectionErrorEvent,
|
|
15
15
|
WSAuthMessage,
|
|
16
|
+
WSAuthMessageProductsEnum,
|
|
16
17
|
} from '../../gen/coordinator';
|
|
17
18
|
import { makeSafePromise, type SafePromise } from '../../helpers/promise';
|
|
18
19
|
import { getTimers } from '../../timers';
|
|
@@ -529,15 +530,17 @@ export class StableWSConnection {
|
|
|
529
530
|
return;
|
|
530
531
|
}
|
|
531
532
|
|
|
532
|
-
const
|
|
533
|
+
const wsAuthMessage: WSAuthMessage = {
|
|
533
534
|
token,
|
|
535
|
+
products: [WSAuthMessageProductsEnum.VIDEO],
|
|
534
536
|
user_details: {
|
|
535
537
|
id: user.id,
|
|
536
538
|
name: user.name,
|
|
537
539
|
image: user.image,
|
|
538
540
|
custom: user.custom,
|
|
539
541
|
},
|
|
540
|
-
}
|
|
542
|
+
};
|
|
543
|
+
const authMessage = JSON.stringify(wsAuthMessage);
|
|
541
544
|
|
|
542
545
|
this._log(`onopen() - Sending auth message ${authMessage}`, {}, 'trace');
|
|
543
546
|
|
|
@@ -2,6 +2,7 @@ import { DeviceManager } from './DeviceManager';
|
|
|
2
2
|
import { AudioDeviceManagerState } from './AudioDeviceManagerState';
|
|
3
3
|
import { AudioBitrateProfile } from '../gen/video/sfu/models/models';
|
|
4
4
|
import { TrackPublishOptions } from '../rtc';
|
|
5
|
+
import { isReactNative } from '../helpers/platforms';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Base class for High Fidelity enabled Device Managers.
|
|
@@ -17,6 +18,11 @@ export abstract class AudioDeviceManager<
|
|
|
17
18
|
if (!this.call.state.settings?.audio.hifi_audio_enabled) {
|
|
18
19
|
throw new Error('High Fidelity audio is not enabled for this call');
|
|
19
20
|
}
|
|
21
|
+
if (isReactNative() && this.call.hasMediaEngine) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
'setAudioBitrateProfile must be called before joining the call.',
|
|
24
|
+
);
|
|
25
|
+
}
|
|
20
26
|
this.doSetAudioBitrateProfile(profile);
|
|
21
27
|
this.state.setAudioBitrateProfile(profile);
|
|
22
28
|
if (this.enabled) {
|