@stream-io/video-client 1.58.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/index.browser.es.js +319 -71
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +329 -69
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.es.js +319 -71
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +15 -4
  10. package/dist/src/StreamVideoClient.d.ts +9 -1
  11. package/dist/src/devices/CameraManager.d.ts +2 -1
  12. package/dist/src/devices/DeviceManager.d.ts +1 -1
  13. package/dist/src/devices/MicrophoneManager.d.ts +2 -1
  14. package/dist/src/gen/coordinator/index.d.ts +26 -0
  15. package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
  16. package/dist/src/helpers/concurrency.d.ts +8 -0
  17. package/dist/src/helpers/loopback.d.ts +55 -0
  18. package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
  19. package/dist/src/rtc/Subscriber.d.ts +5 -0
  20. package/index.ts +2 -0
  21. package/package.json +1 -1
  22. package/src/Call.ts +156 -134
  23. package/src/StreamVideoClient.ts +35 -8
  24. package/src/__tests__/Call.lifecycle.test.ts +33 -2
  25. package/src/__tests__/Call.publishing.test.ts +42 -1
  26. package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
  27. package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
  28. package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
  29. package/src/__tests__/StreamVideoClient.test.ts +14 -12
  30. package/src/devices/CameraManager.ts +16 -4
  31. package/src/devices/DeviceManager.ts +7 -4
  32. package/src/devices/MicrophoneManager.ts +11 -3
  33. package/src/devices/__tests__/CameraManager.test.ts +27 -1
  34. package/src/devices/__tests__/DeviceManager.test.ts +49 -1
  35. package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
  36. package/src/events/call.ts +6 -3
  37. package/src/gen/coordinator/index.ts +26 -0
  38. package/src/helpers/MediaStreamRecorder.ts +104 -0
  39. package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
  40. package/src/helpers/__tests__/concurrency.test.ts +74 -0
  41. package/src/helpers/__tests__/loopback.test.ts +367 -0
  42. package/src/helpers/concurrency.ts +24 -0
  43. package/src/helpers/loopback.ts +200 -0
  44. package/src/reporting/ClientEventReporter.ts +15 -28
  45. package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
  46. package/src/rtc/Subscriber.ts +8 -0
  47. package/src/rtc/__tests__/Subscriber.test.ts +101 -0
@@ -0,0 +1,104 @@
1
+ import { videoLoggerSystem } from '../logger';
2
+ import { promiseWithResolvers } from './promise';
3
+
4
+ export type MediaStreamRecorderOptions = {
5
+ /** Auto-stop after this many milliseconds. */
6
+ maxDurationMs?: number;
7
+ };
8
+
9
+ export type MediaRecordingResult = {
10
+ blob: Blob;
11
+ };
12
+
13
+ /** Single-use `MediaRecorder` wrapper that returns an in-memory blob. */
14
+ export class MediaStreamRecorder {
15
+ private recording = promiseWithResolvers<MediaRecordingResult | null>();
16
+ private logger = videoLoggerSystem.getLogger('MediaStreamRecorder');
17
+ private chunks: Blob[] = [];
18
+ private timeoutId: ReturnType<typeof setTimeout> | undefined;
19
+
20
+ private recorder: MediaRecorder;
21
+ private readonly tracks: MediaStreamTrack[];
22
+ private readonly maxDurationMs: number | undefined;
23
+
24
+ constructor(stream: MediaStream, options: MediaStreamRecorderOptions = {}) {
25
+ if (typeof MediaRecorder === 'undefined') {
26
+ throw new Error('MediaRecorder is not available in this environment');
27
+ }
28
+
29
+ this.tracks = stream.getTracks();
30
+ if (this.tracks.length === 0) {
31
+ throw new Error('cannot record a MediaStream that has no tracks');
32
+ }
33
+
34
+ this.maxDurationMs = options.maxDurationMs;
35
+ this.recorder = new MediaRecorder(new MediaStream(this.tracks));
36
+
37
+ this.recorder.ondataavailable = (event: BlobEvent) => {
38
+ if (event.data?.size) this.chunks.push(event.data);
39
+ };
40
+
41
+ this.recorder.onstop = this.handleStop;
42
+ this.recorder.onerror = this.handleError;
43
+ }
44
+
45
+ /** Starts recording and resolves when recording stops. */
46
+ start = (): Promise<MediaRecordingResult | null> => {
47
+ if (this.isSettled()) {
48
+ throw new Error(
49
+ 'MediaStreamRecorder is single-use: this instance has already ' +
50
+ 'recorded. Construct a new one.',
51
+ );
52
+ }
53
+
54
+ if (this.recorder.state === 'inactive') {
55
+ this.tracks.forEach((track) =>
56
+ track.addEventListener('ended', this.stop),
57
+ );
58
+ if (this.maxDurationMs !== undefined) {
59
+ this.timeoutId = setTimeout(this.stop, this.maxDurationMs);
60
+ }
61
+ this.recorder.start();
62
+ }
63
+
64
+ return this.recording.promise;
65
+ };
66
+
67
+ /** Stops recording early. */
68
+ stop = (): void => {
69
+ if (this.recorder.state !== 'inactive') this.recorder.stop();
70
+ };
71
+
72
+ private isSettled = () =>
73
+ this.recording.isResolved() || this.recording.isRejected();
74
+
75
+ private detach = () => {
76
+ clearTimeout(this.timeoutId);
77
+ this.tracks.forEach((track) =>
78
+ track.removeEventListener('ended', this.stop),
79
+ );
80
+ };
81
+
82
+ private handleStop = () => {
83
+ this.detach();
84
+
85
+ if (this.chunks.length === 0) {
86
+ this.logger.warn('MediaRecorder recording produced no data');
87
+ this.recording.resolve(null);
88
+ return;
89
+ }
90
+
91
+ const mimeType = this.chunks[0].type || this.recorder.mimeType;
92
+ this.recording.resolve({
93
+ blob: new Blob(this.chunks, { type: mimeType }),
94
+ });
95
+ };
96
+
97
+ private handleError = (event: Event) => {
98
+ if (this.isSettled()) return;
99
+ this.detach();
100
+ const error = (event as unknown as { error?: DOMException }).error;
101
+ this.logger.error('MediaRecorder error', error);
102
+ this.recording.reject(error ?? new Error('MediaRecorder failed'));
103
+ };
104
+ }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { fromPartial } from '@total-typescript/shoehorn';
6
+ import { MediaStreamRecorder } from '../MediaStreamRecorder';
7
+ import { videoLoggerSystem } from '../../logger';
8
+
9
+ const createTrack = (kind: 'audio' | 'video' = 'video') => {
10
+ const events = new EventTarget();
11
+ return fromPartial<MediaStreamTrack>({
12
+ kind,
13
+ id: `${kind}-track`,
14
+ readyState: 'live',
15
+ stop: vi.fn(),
16
+ addEventListener: events.addEventListener.bind(events),
17
+ removeEventListener: events.removeEventListener.bind(events),
18
+ dispatchEvent: events.dispatchEvent.bind(events),
19
+ });
20
+ };
21
+
22
+ class FakeMediaStream {
23
+ private tracks: MediaStreamTrack[];
24
+
25
+ constructor(tracks: MediaStreamTrack[] = []) {
26
+ this.tracks = [...tracks];
27
+ }
28
+
29
+ getTracks = () => [...this.tracks];
30
+ addTrack = (track: MediaStreamTrack) => this.tracks.push(track);
31
+ }
32
+
33
+ const createStream = (...tracks: MediaStreamTrack[]) =>
34
+ new FakeMediaStream(tracks) as unknown as MediaStream;
35
+
36
+ class FakeMediaRecorder {
37
+ static instances: FakeMediaRecorder[] = [];
38
+ static isTypeSupported = vi.fn(() => true);
39
+
40
+ stream: MediaStream;
41
+ options: MediaRecorderOptions | undefined;
42
+ state: 'inactive' | 'recording' | 'paused' = 'inactive';
43
+ mimeType: string;
44
+ ondataavailable: ((event: BlobEvent) => void) | null = null;
45
+ onstop: (() => void) | null = null;
46
+ onerror: ((event: Event) => void) | null = null;
47
+
48
+ finalChunks: Blob[] = [new Blob(['payload'], { type: 'video/webm' })];
49
+
50
+ constructor(stream: MediaStream, options?: MediaRecorderOptions) {
51
+ this.stream = stream;
52
+ this.options = options;
53
+ this.mimeType = options?.mimeType ?? 'video/webm;codecs=vp8,opus';
54
+ FakeMediaRecorder.instances.push(this);
55
+ }
56
+
57
+ start = vi.fn(() => {
58
+ this.state = 'recording';
59
+ });
60
+
61
+ stop = vi.fn(() => {
62
+ if (this.state === 'inactive') return;
63
+ this.state = 'inactive';
64
+ this.finalChunks.forEach((data) => this.emitData(data));
65
+ this.onstop?.();
66
+ });
67
+
68
+ emitData = (data: Blob) => {
69
+ this.ondataavailable?.(fromPartial<BlobEvent>({ data }));
70
+ };
71
+ }
72
+
73
+ const lastRecorder = () =>
74
+ FakeMediaRecorder.instances[FakeMediaRecorder.instances.length - 1];
75
+
76
+ describe('MediaStreamRecorder', () => {
77
+ let logger: { debug: any; warn: any; error: any };
78
+
79
+ beforeEach(() => {
80
+ FakeMediaRecorder.instances = [];
81
+ vi.stubGlobal('MediaRecorder', FakeMediaRecorder);
82
+ vi.stubGlobal('MediaStream', FakeMediaStream);
83
+ logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() };
84
+ vi.spyOn(videoLoggerSystem, 'getLogger').mockReturnValue(
85
+ fromPartial(logger),
86
+ );
87
+ });
88
+
89
+ afterEach(() => {
90
+ vi.unstubAllGlobals();
91
+ vi.restoreAllMocks();
92
+ vi.useRealTimers();
93
+ });
94
+
95
+ describe('construction', () => {
96
+ it('throws when MediaRecorder is unavailable', () => {
97
+ vi.stubGlobal('MediaRecorder', undefined);
98
+ expect(
99
+ () => new MediaStreamRecorder(createStream(createTrack())),
100
+ ).toThrow(/MediaRecorder is not available/);
101
+ });
102
+
103
+ it('throws when the stream has no tracks', () => {
104
+ expect(() => new MediaStreamRecorder(createStream())).toThrow(
105
+ /has no tracks/,
106
+ );
107
+ });
108
+
109
+ it('lets the browser choose the recording MIME type', () => {
110
+ new MediaStreamRecorder(createStream(createTrack()));
111
+
112
+ expect(lastRecorder().options).toBeUndefined();
113
+ });
114
+
115
+ it('does not start recording until `start` is called', () => {
116
+ new MediaStreamRecorder(createStream(createTrack()));
117
+
118
+ expect(lastRecorder().start).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it('creates no DOM elements of its own', () => {
122
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
123
+ recorder.start();
124
+ expect(document.querySelectorAll('video, audio')).toHaveLength(0);
125
+ });
126
+
127
+ it('records a snapshot, so mutating the caller stream cannot disturb it', () => {
128
+ const track = createTrack();
129
+ const stream = createStream(track);
130
+ new MediaStreamRecorder(stream);
131
+
132
+ stream.addTrack(createTrack('audio'));
133
+
134
+ expect(lastRecorder().stream).not.toBe(stream);
135
+ expect(lastRecorder().stream.getTracks()).toEqual([track]);
136
+ });
137
+ });
138
+
139
+ describe('recording', () => {
140
+ it('resolves with the recorded blob', async () => {
141
+ vi.useFakeTimers();
142
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
143
+
144
+ const recording = recorder.start();
145
+ vi.advanceTimersByTime(2500);
146
+ recorder.stop();
147
+
148
+ const result = await recording;
149
+
150
+ expect(result?.blob.size).toBeGreaterThan(0);
151
+ expect(result?.blob.type).toBe('video/webm');
152
+ });
153
+
154
+ it('hands every awaiter of `start` the same result', async () => {
155
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
156
+
157
+ const first = recorder.start();
158
+ const second = recorder.start();
159
+ recorder.stop();
160
+
161
+ expect(await first).toBe(await second);
162
+ expect(lastRecorder().start).toHaveBeenCalledTimes(1);
163
+ });
164
+
165
+ it('sets the blob type from the recorder when the chunk carries none', async () => {
166
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
167
+ lastRecorder().finalChunks = [new Blob(['payload'])];
168
+
169
+ const recording = recorder.start();
170
+ recorder.stop();
171
+
172
+ expect((await recording)?.blob.type).toBe('video/webm;codecs=vp8,opus');
173
+ });
174
+
175
+ it('keeps chunks that arrive before stop', async () => {
176
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
177
+ const recording = recorder.start();
178
+ lastRecorder().emitData(new Blob(['early'], { type: 'video/webm' }));
179
+
180
+ recorder.stop();
181
+
182
+ expect((await recording)?.blob.size).toBe(12);
183
+ });
184
+
185
+ it('ignores empty chunks', async () => {
186
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
187
+ const recording = recorder.start();
188
+ lastRecorder().emitData(new Blob([]));
189
+ lastRecorder().finalChunks = [];
190
+
191
+ recorder.stop();
192
+
193
+ await expect(recording).resolves.toBeNull();
194
+ });
195
+
196
+ it('resolves null and warns when nothing was captured', async () => {
197
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
198
+ lastRecorder().finalChunks = [];
199
+
200
+ const recording = recorder.start();
201
+ recorder.stop();
202
+
203
+ await expect(recording).resolves.toBeNull();
204
+ expect(logger.warn).toHaveBeenCalledWith(
205
+ expect.stringContaining('recording produced no data'),
206
+ );
207
+ });
208
+
209
+ it('never stops the tracks it was handed', async () => {
210
+ const track = createTrack();
211
+ const recorder = new MediaStreamRecorder(createStream(track));
212
+
213
+ const recording = recorder.start();
214
+ recorder.stop();
215
+ await recording;
216
+
217
+ expect(track.stop).not.toHaveBeenCalled();
218
+ });
219
+ });
220
+
221
+ describe('auto-stop', () => {
222
+ it('stops once maxDurationMs elapses', async () => {
223
+ vi.useFakeTimers();
224
+ const recorder = new MediaStreamRecorder(createStream(createTrack()), {
225
+ maxDurationMs: 5000,
226
+ });
227
+
228
+ const recording = recorder.start();
229
+ vi.advanceTimersByTime(5000);
230
+
231
+ await expect(recording).resolves.toMatchObject({
232
+ blob: expect.any(Blob),
233
+ });
234
+ });
235
+
236
+ it('runs indefinitely when maxDurationMs is omitted', async () => {
237
+ vi.useFakeTimers();
238
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
239
+
240
+ const recording = recorder.start();
241
+ vi.advanceTimersByTime(10 * 60 * 1000);
242
+
243
+ expect(lastRecorder().stop).not.toHaveBeenCalled();
244
+ recorder.stop();
245
+ await recording;
246
+ });
247
+
248
+ it('stops when any source track ends, keeping what was captured', async () => {
249
+ const audioTrack = createTrack('audio');
250
+ const recorder = new MediaStreamRecorder(
251
+ createStream(createTrack(), audioTrack),
252
+ );
253
+
254
+ const recording = recorder.start();
255
+ audioTrack.dispatchEvent(new Event('ended'));
256
+
257
+ await expect(recording).resolves.toMatchObject({
258
+ blob: expect.any(Blob),
259
+ });
260
+ });
261
+
262
+ it('resolves when the user agent stops the recorder on its own', async () => {
263
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
264
+ const recording = recorder.start();
265
+
266
+ lastRecorder().stop();
267
+
268
+ await expect(recording).resolves.toMatchObject({
269
+ blob: expect.any(Blob),
270
+ });
271
+ });
272
+ });
273
+
274
+ describe('lifecycle guards', () => {
275
+ it('is a no-op when stopped before it was started', () => {
276
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
277
+
278
+ expect(() => recorder.stop()).not.toThrow();
279
+ expect(lastRecorder().stop).not.toHaveBeenCalled();
280
+ expect(lastRecorder().start).not.toHaveBeenCalled();
281
+ });
282
+
283
+ it('throws when restarted after finishing', async () => {
284
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
285
+ const recording = recorder.start();
286
+ recorder.stop();
287
+ await recording;
288
+
289
+ expect(() => recorder.start()).toThrow(/single-use/);
290
+ });
291
+
292
+ it('is idempotent across repeated stops', async () => {
293
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
294
+ const recording = recorder.start();
295
+
296
+ recorder.stop();
297
+ recorder.stop();
298
+
299
+ await expect(recording).resolves.toMatchObject({
300
+ blob: expect.any(Blob),
301
+ });
302
+ expect(lastRecorder().stop).toHaveBeenCalledTimes(1);
303
+ });
304
+ });
305
+
306
+ it('leaves no timer or listener behind after finishing', async () => {
307
+ vi.useFakeTimers();
308
+ const track = createTrack();
309
+ const recorder = new MediaStreamRecorder(createStream(track), {
310
+ maxDurationMs: 5000,
311
+ });
312
+
313
+ const recording = recorder.start();
314
+ recorder.stop();
315
+ const result = await recording;
316
+
317
+ expect(() => {
318
+ vi.advanceTimersByTime(10_000);
319
+ track.dispatchEvent(new Event('ended'));
320
+ }).not.toThrow();
321
+ expect(lastRecorder().stop).toHaveBeenCalledTimes(1);
322
+ expect(result?.blob).toBeInstanceOf(Blob);
323
+ });
324
+ });
@@ -1,6 +1,7 @@
1
1
  import { beforeEach, describe, expect, it } from 'vitest';
2
2
  import {
3
3
  hasPending,
4
+ singleFlight,
4
5
  withCancellation,
5
6
  withoutConcurrency,
6
7
  } from '../concurrency';
@@ -168,6 +169,79 @@ it('keeps track of pending promises', async () => {
168
169
  expect(hasPending(tag)).toBeFalsy();
169
170
  });
170
171
 
172
+ describe('single flight', () => {
173
+ it('shares an in-flight promise for the wrapped function', async () => {
174
+ const [run1, resolve1] = mockAsyncFn('promise1');
175
+ const run = singleFlight(run1);
176
+
177
+ const ready1 = run();
178
+ const ready2 = run();
179
+
180
+ expect(ready2).toBe(ready1);
181
+ expect(log).toMatchObject(['promise1 start']);
182
+
183
+ resolve1();
184
+ await expect(Promise.all([ready1, ready2])).resolves.toEqual([
185
+ undefined,
186
+ undefined,
187
+ ]);
188
+ expect(log).toMatchObject(['promise1 start', 'promise1 end']);
189
+ });
190
+
191
+ it('allows a new execution after the in-flight promise settles', async () => {
192
+ const [run1, resolve1] = mockAsyncFn('promise1');
193
+ const run = singleFlight(run1);
194
+
195
+ const ready1 = run();
196
+ resolve1();
197
+ await ready1;
198
+
199
+ const ready2 = run();
200
+ expect(ready2).not.toBe(ready1);
201
+
202
+ resolve1();
203
+ await ready2;
204
+ expect(log).toMatchObject([
205
+ 'promise1 start',
206
+ 'promise1 end',
207
+ 'promise1 start',
208
+ 'promise1 end',
209
+ ]);
210
+ });
211
+
212
+ it('does not share promises across different wrapped functions', async () => {
213
+ const [runTom, resolveTom] = mockAsyncFn('tom');
214
+ const [runJerry, resolveJerry] = mockAsyncFn('jerry');
215
+ const tom = singleFlight(runTom);
216
+ const jerry = singleFlight(runJerry);
217
+
218
+ const readyTom = tom();
219
+ const readyJerry = jerry();
220
+
221
+ expect(readyJerry).not.toBe(readyTom);
222
+ expect(log).toMatchObject(['tom start', 'jerry start']);
223
+
224
+ resolveTom();
225
+ resolveJerry();
226
+ await Promise.all([readyTom, readyJerry]);
227
+ });
228
+
229
+ it('shares the first call arguments while a call is in flight', async () => {
230
+ const run = singleFlight(async (value: string) => {
231
+ log.push(`${value} start`);
232
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
233
+ log.push(`${value} end`);
234
+ return value;
235
+ });
236
+
237
+ await expect(Promise.all([run('first'), run('second')])).resolves.toEqual([
238
+ 'first',
239
+ 'first',
240
+ ]);
241
+ expect(log).toMatchObject(['first start', 'first end']);
242
+ });
243
+ });
244
+
171
245
  describe('cancelation', () => {
172
246
  it('cancels promises mid-action', async () => {
173
247
  const tag = Symbol();