@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.
Files changed (130) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/e2ee-worker.browser.es.js +1342 -0
  3. package/dist/e2ee-worker.browser.es.js.map +1 -0
  4. package/dist/index.browser.es.js +1141 -117
  5. package/dist/index.browser.es.js.map +1 -1
  6. package/dist/index.cjs.js +1196 -115
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +5 -0
  9. package/dist/index.es.js +1174 -117
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/src/Call.d.ts +66 -9
  12. package/dist/src/StreamVideoClient.d.ts +9 -1
  13. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  14. package/dist/src/devices/CameraManager.d.ts +8 -1
  15. package/dist/src/devices/DeviceManager.d.ts +2 -1
  16. package/dist/src/devices/MicrophoneManager.d.ts +8 -1
  17. package/dist/src/devices/SpeakerManager.d.ts +2 -1
  18. package/dist/src/events/participant.d.ts +10 -3
  19. package/dist/src/gen/coordinator/index.d.ts +1386 -240
  20. package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
  21. package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
  22. package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
  23. package/dist/src/helpers/concurrency.d.ts +8 -0
  24. package/dist/src/helpers/ensureExhausted.d.ts +1 -1
  25. package/dist/src/helpers/loopback.d.ts +55 -0
  26. package/dist/src/permissions/PermissionsContext.d.ts +2 -2
  27. package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
  28. package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
  29. package/dist/src/rtc/Subscriber.d.ts +5 -0
  30. package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
  31. package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
  32. package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
  33. package/dist/src/rtc/e2ee/events.d.ts +163 -0
  34. package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
  35. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
  36. package/dist/src/rtc/index.d.ts +1 -0
  37. package/dist/src/rtc/mediaEngine.d.ts +23 -0
  38. package/dist/src/rtc/types.d.ts +2 -0
  39. package/dist/src/store/CallState.d.ts +21 -0
  40. package/dist/src/types.d.ts +15 -6
  41. package/index.ts +5 -0
  42. package/package.json +1 -1
  43. package/src/Call.ts +311 -155
  44. package/src/StreamVideoClient.ts +35 -8
  45. package/src/__tests__/Call.lifecycle.test.ts +33 -2
  46. package/src/__tests__/Call.publishing.test.ts +42 -1
  47. package/src/__tests__/Call.test.ts +15 -0
  48. package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
  49. package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
  50. package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
  51. package/src/__tests__/StreamVideoClient.test.ts +14 -12
  52. package/src/coordinator/connection/connection.ts +6 -3
  53. package/src/devices/AudioDeviceManager.ts +6 -0
  54. package/src/devices/CameraManager.ts +67 -8
  55. package/src/devices/DeviceManager.ts +24 -4
  56. package/src/devices/MicrophoneManager.ts +66 -8
  57. package/src/devices/ScreenShareManager.ts +3 -0
  58. package/src/devices/SpeakerManager.ts +42 -12
  59. package/src/devices/__tests__/CameraManager.test.ts +27 -1
  60. package/src/devices/__tests__/DeviceManager.test.ts +49 -1
  61. package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
  62. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -20
  63. package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
  64. package/src/devices/__tests__/mocks.ts +36 -3
  65. package/src/events/__tests__/participant.test.ts +134 -1
  66. package/src/events/call.ts +6 -3
  67. package/src/events/callEventHandlers.ts +9 -3
  68. package/src/events/participant.ts +42 -7
  69. package/src/gen/coordinator/index.ts +1402 -236
  70. package/src/gen/video/sfu/models/models.ts +15 -0
  71. package/src/helpers/MediaStreamRecorder.ts +104 -0
  72. package/src/helpers/TypedEventEmitter.ts +71 -0
  73. package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
  74. package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
  75. package/src/helpers/__tests__/concurrency.test.ts +74 -0
  76. package/src/helpers/__tests__/loopback.test.ts +367 -0
  77. package/src/helpers/concurrency.ts +24 -0
  78. package/src/helpers/ensureExhausted.ts +4 -2
  79. package/src/helpers/loopback.ts +200 -0
  80. package/src/permissions/PermissionsContext.ts +10 -5
  81. package/src/reporting/ClientEventReporter.ts +15 -28
  82. package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
  83. package/src/rtc/BasePeerConnection.ts +12 -1
  84. package/src/rtc/Publisher.ts +8 -0
  85. package/src/rtc/Subscriber.ts +13 -0
  86. package/src/rtc/__tests__/Publisher.test.ts +101 -0
  87. package/src/rtc/__tests__/Subscriber.test.ts +190 -0
  88. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
  89. package/src/rtc/e2ee/E2EEManager.ts +12 -0
  90. package/src/rtc/e2ee/EncryptionManager.ts +336 -0
  91. package/src/rtc/e2ee/SPEC.md +586 -0
  92. package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
  93. package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
  94. package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
  95. package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
  96. package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
  97. package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
  98. package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
  99. package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
  100. package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
  101. package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
  102. package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
  103. package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
  104. package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
  105. package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
  106. package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
  107. package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
  108. package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
  109. package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
  110. package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
  111. package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
  112. package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
  113. package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
  114. package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
  115. package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
  116. package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
  117. package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
  118. package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
  119. package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
  120. package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
  121. package/src/rtc/e2ee/e2ee-worker.ts +27 -0
  122. package/src/rtc/e2ee/events.ts +179 -0
  123. package/src/rtc/e2ee/transformSupport.ts +35 -0
  124. package/src/rtc/helpers/rtcConfiguration.ts +4 -2
  125. package/src/rtc/index.ts +1 -0
  126. package/src/rtc/mediaEngine.ts +44 -0
  127. package/src/rtc/types.ts +2 -0
  128. package/src/store/CallState.ts +33 -1
  129. package/src/store/__tests__/CallState.test.ts +82 -1
  130. package/src/types.ts +23 -5
@@ -40,6 +40,14 @@ export interface CallState {
40
40
  * @generated from protobuf field: repeated stream.video.sfu.models.Pin pins = 4;
41
41
  */
42
42
  pins: Pin[];
43
+ /**
44
+ * e2ee_enabled is true when the call uses end-to-end encryption. Clients
45
+ * must enable their frame encryptor; the SFU forwards encrypted frames
46
+ * opaquely and server-side recording/transcription/broadcasting are disabled.
47
+ *
48
+ * @generated from protobuf field: bool e2ee_enabled = 5 [json_name = "e2eeEnabled"];
49
+ */
50
+ e2EeEnabled: boolean;
43
51
  }
44
52
  /**
45
53
  * @generated from protobuf message stream.video.sfu.models.ParticipantCount
@@ -1401,6 +1409,13 @@ class CallState$Type extends MessageType<CallState> {
1401
1409
  repeat: 2 /*RepeatType.UNPACKED*/,
1402
1410
  T: () => Pin,
1403
1411
  },
1412
+ {
1413
+ no: 5,
1414
+ name: 'e2ee_enabled',
1415
+ kind: 'scalar',
1416
+ jsonName: 'e2eeEnabled',
1417
+ T: 8 /*ScalarType.BOOL*/,
1418
+ },
1404
1419
  ]);
1405
1420
  }
1406
1421
  }
@@ -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,71 @@
1
+ import { ScopedLogger, videoLoggerSystem } from '../logger';
2
+
3
+ export type EventMap = Record<string, unknown>;
4
+
5
+ export type Listener<P> = (payload: P) => void | Promise<void>;
6
+
7
+ /**
8
+ * Tiny, type-safe event emitter.
9
+ *
10
+ * Usage styles:
11
+ * - compose: `private readonly events = new TypedEventEmitter<MyMap>()`
12
+ * - extend: `class Foo extends TypedEventEmitter<MyMap> {}`
13
+ *
14
+ * Listener exceptions (sync throw or rejected promise) are caught and logged,
15
+ * so one bad listener cannot break dispatch for the rest.
16
+ */
17
+ export class TypedEventEmitter<M extends EventMap> {
18
+ protected readonly logger: ScopedLogger;
19
+ private readonly byEvent = new Map<keyof M, Set<Listener<any>>>();
20
+
21
+ constructor(logger: ScopedLogger | string = 'TypedEventEmitter') {
22
+ this.logger =
23
+ typeof logger === 'string' ? videoLoggerSystem.getLogger(logger) : logger;
24
+ }
25
+
26
+ on = <E extends keyof M>(event: E, fn: Listener<M[E]>): (() => void) => {
27
+ let listeners = this.byEvent.get(event);
28
+ if (!listeners) {
29
+ listeners = new Set();
30
+ this.byEvent.set(event, listeners);
31
+ }
32
+ listeners.add(fn as Listener<any>);
33
+ return () => this.off(event, fn);
34
+ };
35
+
36
+ off = <E extends keyof M>(event: E, fn: Listener<M[E]>): void => {
37
+ const listeners = this.byEvent.get(event);
38
+ if (!listeners) return;
39
+ listeners.delete(fn as Listener<any>);
40
+ if (listeners.size === 0) this.byEvent.delete(event);
41
+ };
42
+
43
+ emit = <E extends keyof M>(event: E, payload: M[E]): void => {
44
+ const listeners = this.byEvent.get(event);
45
+ if (!listeners || listeners.size === 0) return;
46
+ for (const listener of [...listeners]) {
47
+ this.invoke(() => listener(payload), event);
48
+ }
49
+ };
50
+
51
+ removeAllListeners = (event?: keyof M): void => {
52
+ if (event === undefined) {
53
+ this.byEvent.clear();
54
+ } else {
55
+ this.byEvent.delete(event);
56
+ }
57
+ };
58
+
59
+ private invoke = (run: () => void | Promise<void>, event: keyof M): void => {
60
+ try {
61
+ const result = run();
62
+ if (result && typeof (result as Promise<void>).then === 'function') {
63
+ (result as Promise<void>).catch((err) => {
64
+ this.logger.warn(`Listener for '${String(event)}' rejected`, err);
65
+ });
66
+ }
67
+ } catch (err) {
68
+ this.logger.warn(`Listener for '${String(event)}' threw`, err);
69
+ }
70
+ };
71
+ }
@@ -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
+ });
@@ -0,0 +1,133 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { TypedEventEmitter } from '../TypedEventEmitter';
3
+
4
+ type TestEvents = {
5
+ hello: string;
6
+ ping: { n: number };
7
+ empty: undefined;
8
+ };
9
+
10
+ describe('TypedEventEmitter', () => {
11
+ let emitter: TypedEventEmitter<TestEvents>;
12
+
13
+ beforeEach(() => {
14
+ emitter = new TypedEventEmitter<TestEvents>();
15
+ });
16
+
17
+ it('invokes listeners registered with on()', () => {
18
+ const listener = vi.fn();
19
+ emitter.on('hello', listener);
20
+ emitter.emit('hello', 'world');
21
+ expect(listener).toHaveBeenCalledWith('world');
22
+ });
23
+
24
+ it('returns an unsubscribe function from on()', () => {
25
+ const listener = vi.fn();
26
+ const unsubscribe = emitter.on('hello', listener);
27
+ unsubscribe();
28
+ emitter.emit('hello', 'world');
29
+ expect(listener).not.toHaveBeenCalled();
30
+ });
31
+
32
+ it('removes listeners via off()', () => {
33
+ const listener = vi.fn();
34
+ emitter.on('hello', listener);
35
+ emitter.off('hello', listener);
36
+ emitter.emit('hello', 'world');
37
+ expect(listener).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it('supports multiple listeners for the same event', () => {
41
+ const a = vi.fn();
42
+ const b = vi.fn();
43
+ emitter.on('ping', a);
44
+ emitter.on('ping', b);
45
+ emitter.emit('ping', { n: 1 });
46
+ expect(a).toHaveBeenCalledWith({ n: 1 });
47
+ expect(b).toHaveBeenCalledWith({ n: 1 });
48
+ });
49
+
50
+ it('deduplicates identical listener references', () => {
51
+ const listener = vi.fn();
52
+ emitter.on('hello', listener);
53
+ emitter.on('hello', listener);
54
+ emitter.emit('hello', 'once');
55
+ expect(listener).toHaveBeenCalledTimes(1);
56
+ });
57
+
58
+ it('isolates a throwing listener from subsequent listeners', () => {
59
+ const bad = vi.fn(() => {
60
+ throw new Error('boom');
61
+ });
62
+ const good = vi.fn();
63
+ emitter.on('hello', bad);
64
+ emitter.on('hello', good);
65
+ expect(() => emitter.emit('hello', 'world')).not.toThrow();
66
+ expect(bad).toHaveBeenCalled();
67
+ expect(good).toHaveBeenCalledWith('world');
68
+ });
69
+
70
+ it('isolates a rejecting async listener', async () => {
71
+ const rejected = vi.fn(async () => {
72
+ throw new Error('async boom');
73
+ });
74
+ const good = vi.fn();
75
+ emitter.on('hello', rejected);
76
+ emitter.on('hello', good);
77
+ expect(() => emitter.emit('hello', 'world')).not.toThrow();
78
+ expect(good).toHaveBeenCalled();
79
+ // allow microtask to settle; rejection should have been swallowed by the emitter
80
+ await Promise.resolve();
81
+ });
82
+
83
+ it('is safe to call off() from within a listener (current emit still fires siblings)', () => {
84
+ const sibling = vi.fn();
85
+ const self = vi.fn(() => {
86
+ emitter.off('hello', self);
87
+ });
88
+ emitter.on('hello', self);
89
+ emitter.on('hello', sibling);
90
+ emitter.emit('hello', 'world');
91
+ expect(self).toHaveBeenCalledTimes(1);
92
+ expect(sibling).toHaveBeenCalledTimes(1);
93
+ // second emit should not invoke self again
94
+ emitter.emit('hello', 'again');
95
+ expect(self).toHaveBeenCalledTimes(1);
96
+ expect(sibling).toHaveBeenCalledTimes(2);
97
+ });
98
+
99
+ it('is safe to add a listener from within a listener (added listener does not fire in current emit)', () => {
100
+ const later = vi.fn();
101
+ emitter.on('hello', () => {
102
+ emitter.on('hello', later);
103
+ });
104
+ emitter.emit('hello', 'first');
105
+ expect(later).not.toHaveBeenCalled();
106
+ emitter.emit('hello', 'second');
107
+ expect(later).toHaveBeenCalledWith('second');
108
+ });
109
+
110
+ it('removeAllListeners() clears every subscription when called without args', () => {
111
+ const a = vi.fn();
112
+ const b = vi.fn();
113
+ emitter.on('hello', a);
114
+ emitter.on('ping', b);
115
+ emitter.removeAllListeners();
116
+ emitter.emit('hello', 'world');
117
+ emitter.emit('ping', { n: 1 });
118
+ expect(a).not.toHaveBeenCalled();
119
+ expect(b).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it('removeAllListeners(event) clears only that event', () => {
123
+ const a = vi.fn();
124
+ const b = vi.fn();
125
+ emitter.on('hello', a);
126
+ emitter.on('ping', b);
127
+ emitter.removeAllListeners('hello');
128
+ emitter.emit('hello', 'world');
129
+ emitter.emit('ping', { n: 1 });
130
+ expect(a).not.toHaveBeenCalled();
131
+ expect(b).toHaveBeenCalledWith({ n: 1 });
132
+ });
133
+ });