@stream-io/video-client 1.56.0 → 1.58.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 (112) hide show
  1. package/CHANGELOG.md +23 -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 +867 -71
  5. package/dist/index.browser.es.js.map +1 -1
  6. package/dist/index.cjs.js +911 -70
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.es.js +900 -71
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/src/Call.d.ts +52 -6
  12. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  13. package/dist/src/devices/CameraManager.d.ts +6 -0
  14. package/dist/src/devices/DeviceManager.d.ts +1 -0
  15. package/dist/src/devices/MicrophoneManager.d.ts +6 -0
  16. package/dist/src/devices/SpeakerManager.d.ts +2 -1
  17. package/dist/src/events/participant.d.ts +10 -3
  18. package/dist/src/gen/coordinator/index.d.ts +1363 -243
  19. package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
  20. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +4 -0
  21. package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
  22. package/dist/src/helpers/ensureExhausted.d.ts +1 -1
  23. package/dist/src/permissions/PermissionsContext.d.ts +2 -2
  24. package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
  25. package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
  26. package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
  27. package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
  28. package/dist/src/rtc/e2ee/events.d.ts +163 -0
  29. package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
  30. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
  31. package/dist/src/rtc/index.d.ts +1 -0
  32. package/dist/src/rtc/mediaEngine.d.ts +23 -0
  33. package/dist/src/rtc/types.d.ts +2 -0
  34. package/dist/src/store/CallState.d.ts +21 -0
  35. package/dist/src/types.d.ts +15 -6
  36. package/index.ts +3 -0
  37. package/package.json +11 -11
  38. package/src/Call.ts +160 -26
  39. package/src/__tests__/Call.test.ts +15 -0
  40. package/src/coordinator/connection/connection.ts +6 -3
  41. package/src/devices/AudioDeviceManager.ts +6 -0
  42. package/src/devices/CameraManager.ts +51 -4
  43. package/src/devices/DeviceManager.ts +17 -0
  44. package/src/devices/MicrophoneManager.ts +55 -5
  45. package/src/devices/ScreenShareManager.ts +3 -0
  46. package/src/devices/SpeakerManager.ts +42 -12
  47. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -22
  48. package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
  49. package/src/devices/__tests__/mocks.ts +36 -3
  50. package/src/devices/devicePersistence.ts +1 -2
  51. package/src/events/__tests__/participant.test.ts +134 -1
  52. package/src/events/callEventHandlers.ts +9 -3
  53. package/src/events/participant.ts +42 -7
  54. package/src/gen/coordinator/index.ts +1384 -244
  55. package/src/gen/video/sfu/models/models.ts +15 -0
  56. package/src/helpers/MediaPlaybackWatchdog.ts +35 -16
  57. package/src/helpers/TypedEventEmitter.ts +71 -0
  58. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +137 -8
  59. package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
  60. package/src/helpers/__tests__/ViewportTracker.test.ts +1 -2
  61. package/src/helpers/ensureExhausted.ts +4 -2
  62. package/src/permissions/PermissionsContext.ts +10 -5
  63. package/src/reporting/ClientEventReporter.ts +2 -8
  64. package/src/rtc/BasePeerConnection.ts +12 -1
  65. package/src/rtc/Publisher.ts +8 -0
  66. package/src/rtc/Subscriber.ts +5 -0
  67. package/src/rtc/__tests__/Publisher.test.ts +101 -0
  68. package/src/rtc/__tests__/Subscriber.test.ts +89 -0
  69. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
  70. package/src/rtc/e2ee/E2EEManager.ts +12 -0
  71. package/src/rtc/e2ee/EncryptionManager.ts +336 -0
  72. package/src/rtc/e2ee/SPEC.md +586 -0
  73. package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
  74. package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
  75. package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
  76. package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
  77. package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
  78. package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
  79. package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
  80. package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
  81. package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
  82. package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
  83. package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
  84. package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
  85. package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
  86. package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
  87. package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
  88. package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
  89. package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
  90. package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
  91. package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
  92. package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
  93. package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
  94. package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
  95. package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
  96. package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
  97. package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
  98. package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
  99. package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
  100. package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
  101. package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
  102. package/src/rtc/e2ee/e2ee-worker.ts +27 -0
  103. package/src/rtc/e2ee/events.ts +179 -0
  104. package/src/rtc/e2ee/transformSupport.ts +35 -0
  105. package/src/rtc/helpers/rtcConfiguration.ts +4 -2
  106. package/src/rtc/index.ts +1 -0
  107. package/src/rtc/mediaEngine.ts +44 -0
  108. package/src/rtc/types.ts +3 -2
  109. package/src/stats/rtc/types.ts +8 -2
  110. package/src/store/CallState.ts +34 -3
  111. package/src/store/__tests__/CallState.test.ts +82 -1
  112. package/src/types.ts +24 -9
@@ -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
  }
@@ -23,8 +23,10 @@ export class MediaPlaybackWatchdog {
23
23
  private tracer: Tracer;
24
24
  private controller = new AbortController();
25
25
  private pendingTimer: ReturnType<typeof setTimeout> | undefined;
26
+ private settleTimer: ReturnType<typeof setTimeout> | undefined;
26
27
  private attempt = 0;
27
28
  private disposed = false;
29
+ private stopped = false;
28
30
 
29
31
  constructor(opts: MediaPlaybackWatchdogOptions) {
30
32
  this.element = opts.element;
@@ -46,33 +48,47 @@ export class MediaPlaybackWatchdog {
46
48
  if (this.disposed) return;
47
49
  this.disposed = true;
48
50
  this.controller.abort();
51
+ this.clearTimers();
52
+ };
53
+
54
+ private clearTimers = () => {
49
55
  if (this.pendingTimer) clearTimeout(this.pendingTimer);
50
56
  this.pendingTimer = undefined;
57
+ if (this.settleTimer) clearTimeout(this.settleTimer);
58
+ this.settleTimer = undefined;
51
59
  };
52
60
 
53
61
  private onPlaying = () => {
54
- if (this.attempt > 0) {
55
- this.tracer.trace('mediaPlayback.recover.success', {
56
- kind: this.kind,
57
- attempts: this.attempt,
58
- });
59
- }
60
- this.attempt = 0;
61
62
  if (this.pendingTimer) clearTimeout(this.pendingTimer);
62
63
  this.pendingTimer = undefined;
64
+ if (this.attempt === 0 || this.settleTimer) return;
65
+ this.settleTimer ??= setTimeout(this.onSettled, 1000);
66
+ };
67
+
68
+ private onSettled = () => {
69
+ this.settleTimer = undefined;
70
+ if (this.element.paused) return;
71
+ this.tracer.trace('mediaPlayback.recover.success', {
72
+ kind: this.kind,
73
+ attempts: this.attempt,
74
+ });
75
+ this.attempt = 0;
76
+ this.stopped = false;
63
77
  };
64
78
 
65
79
  private onPauseOrSuspend = (event: Event) => {
66
- if (this.disposed) return;
80
+ if (this.disposed || this.stopped) return;
67
81
  this.tracer.trace('mediaPlayback.paused', {
68
82
  kind: this.kind,
69
83
  reason: event.type,
70
84
  });
85
+ if (this.settleTimer) clearTimeout(this.settleTimer);
86
+ this.settleTimer = undefined;
71
87
  this.scheduleRecovery();
72
88
  };
73
89
 
74
90
  private scheduleRecovery = () => {
75
- if (this.disposed || this.pendingTimer) return;
91
+ if (this.disposed || this.stopped || this.pendingTimer) return;
76
92
  const skipReason = this.computeSkipReason();
77
93
  if (skipReason) {
78
94
  this.tracer.trace('mediaPlayback.recover.skipped', {
@@ -81,6 +97,16 @@ export class MediaPlaybackWatchdog {
81
97
  });
82
98
  return;
83
99
  }
100
+
101
+ if (this.attempt >= 10) {
102
+ this.stopped = true;
103
+ this.clearTimers();
104
+ this.tracer.trace('mediaPlayback.recover.giveUp', {
105
+ kind: this.kind,
106
+ attempts: this.attempt,
107
+ });
108
+ return;
109
+ }
84
110
  const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
85
111
  this.pendingTimer = setTimeout(this.attemptPlay, delay);
86
112
  };
@@ -109,13 +135,6 @@ export class MediaPlaybackWatchdog {
109
135
  } catch (err) {
110
136
  if (this.disposed) return;
111
137
  this.logger.warn(`Failed to recover ${this.kind} playback`, err);
112
- if (this.attempt >= 10) {
113
- this.tracer.trace('mediaPlayback.recover.giveUp', {
114
- kind: this.kind,
115
- attempts: this.attempt,
116
- });
117
- return;
118
- }
119
138
  this.scheduleRecovery();
120
139
  }
121
140
  };
@@ -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
+ }
@@ -28,20 +28,33 @@ const createMediaElement = (
28
28
  state: FakeMediaState = {},
29
29
  ) => {
30
30
  const el = document.createElement(kind) as HTMLMediaElement;
31
- Object.defineProperty(el, 'srcObject', { writable: true });
31
+ Object.defineProperty(el, 'srcObject', {
32
+ value: 'srcObject' in state ? state.srcObject : new MediaStream(),
33
+ writable: true,
34
+ });
32
35
  Object.defineProperty(el, 'paused', { writable: true, configurable: true });
33
36
  Object.defineProperty(el, 'readyState', {
37
+ value: state.readyState ?? 4,
38
+ writable: true,
39
+ configurable: true,
40
+ });
41
+ Object.defineProperty(el, 'ended', {
42
+ value: state.ended ?? false,
34
43
  writable: true,
35
44
  configurable: true,
36
45
  });
37
- Object.defineProperty(el, 'ended', { writable: true, configurable: true });
38
- el.srcObject = 'srcObject' in state ? state.srcObject : new MediaStream();
39
- el.paused = state.paused ?? true;
40
- el.readyState = state.readyState ?? 4;
41
- el.ended = state.ended ?? false;
46
+ setPaused(el, state.paused ?? true);
42
47
  return el;
43
48
  };
44
49
 
50
+ const setPaused = (el: HTMLMediaElement, paused: boolean) => {
51
+ Object.defineProperty(el, 'paused', {
52
+ value: paused,
53
+ writable: true,
54
+ configurable: true,
55
+ });
56
+ };
57
+
45
58
  type SetupOpts = {
46
59
  kind?: 'audio' | 'video';
47
60
  state?: FakeMediaState;
@@ -111,15 +124,131 @@ describe('MediaPlaybackWatchdog', () => {
111
124
  await vi.advanceTimersByTimeAsync(6000);
112
125
  expect(play).toHaveBeenCalledTimes(2);
113
126
 
114
- // Simulate the element actually starting to play.
127
+ // Simulate the element actually starting to play, and staying up long
128
+ // enough for the recovery to count as settled.
129
+ setPaused(el, false);
115
130
  el.dispatchEvent(new Event('playing'));
131
+ await vi.advanceTimersByTimeAsync(1000);
116
132
 
117
- // A subsequent pause should still trigger a fresh recovery attempt.
133
+ // A subsequent pause should trigger a fresh, immediate recovery attempt.
134
+ setPaused(el, true);
118
135
  el.dispatchEvent(new Event('pause'));
119
136
  await vi.advanceTimersByTimeAsync(0);
120
137
  expect(play).toHaveBeenCalledTimes(3);
121
138
  });
122
139
 
140
+ it('backs off instead of retrying immediately when playback has not settled', async () => {
141
+ el.dispatchEvent(new Event('pause'));
142
+ await vi.advanceTimersByTimeAsync(0);
143
+ expect(play).toHaveBeenCalledTimes(1);
144
+
145
+ // resumed, but re-paused before the settle delay elapsed
146
+ setPaused(el, false);
147
+ el.dispatchEvent(new Event('playing'));
148
+ expect(tracer.trace).not.toHaveBeenCalledWith(
149
+ 'mediaPlayback.recover.success',
150
+ {
151
+ kind: 'audio',
152
+ attempts: 1,
153
+ },
154
+ );
155
+ await vi.advanceTimersByTimeAsync(500);
156
+ setPaused(el, true);
157
+ el.dispatchEvent(new Event('pause'));
158
+
159
+ // the next attempt is subject to backoff, not scheduled on the next tick
160
+ await vi.advanceTimersByTimeAsync(0);
161
+ expect(play).toHaveBeenCalledTimes(1);
162
+
163
+ await vi.advanceTimersByTimeAsync(6000);
164
+ expect(play).toHaveBeenCalledTimes(2);
165
+ });
166
+
167
+ it('stops fighting an element that re-pauses after every successful play', async () => {
168
+ setup({ kind: 'video' });
169
+ // Safari re-pauses the element right after play() resolves. This used to
170
+ // reset the attempt counter, so the loop spun with a zero delay forever.
171
+ play.mockImplementation(async () => {
172
+ setPaused(el, false);
173
+ el.dispatchEvent(new Event('playing'));
174
+ setPaused(el, true);
175
+ el.dispatchEvent(new Event('pause'));
176
+ });
177
+
178
+ el.dispatchEvent(new Event('pause'));
179
+ for (let i = 0; i < 20; i++) {
180
+ await vi.advanceTimersByTimeAsync(6000);
181
+ }
182
+
183
+ expect(play).toHaveBeenCalledTimes(10);
184
+ expect(tracer.trace).toHaveBeenCalledWith('mediaPlayback.recover.giveUp', {
185
+ kind: 'video',
186
+ attempts: 10,
187
+ });
188
+
189
+ // External playback can still emit `playing` after the watchdog gave up
190
+ // (for example from a user gesture or another playback helper). If it
191
+ // re-pauses before settling, it must not revive the watchdog or trace a
192
+ // false recovery success.
193
+ setPaused(el, false);
194
+ el.dispatchEvent(new Event('playing'));
195
+ setPaused(el, true);
196
+ el.dispatchEvent(new Event('pause'));
197
+ await vi.advanceTimersByTimeAsync(1000);
198
+
199
+ expect(tracer.trace).not.toHaveBeenCalledWith(
200
+ 'mediaPlayback.recover.success',
201
+ {
202
+ kind: 'video',
203
+ attempts: 10,
204
+ },
205
+ );
206
+
207
+ // and it stays stopped, without tracing the pause storm
208
+ const traceMock = tracer.trace as unknown as MockInstance;
209
+ const tracesBefore = traceMock.mock.calls.length;
210
+ for (let i = 0; i < 100; i++) {
211
+ el.dispatchEvent(new Event('pause'));
212
+ }
213
+ await vi.advanceTimersByTimeAsync(6000);
214
+
215
+ expect(play).toHaveBeenCalledTimes(10);
216
+ expect(traceMock.mock.calls.length).toBe(tracesBefore);
217
+ });
218
+
219
+ it('revives once playback genuinely holds for the settle window', async () => {
220
+ setup({ kind: 'video' });
221
+ play.mockImplementation(async () => {
222
+ setPaused(el, false);
223
+ el.dispatchEvent(new Event('playing'));
224
+ setPaused(el, true);
225
+ el.dispatchEvent(new Event('pause'));
226
+ });
227
+
228
+ el.dispatchEvent(new Event('pause'));
229
+ for (let i = 0; i < 20; i++) {
230
+ await vi.advanceTimersByTimeAsync(6000);
231
+ }
232
+ expect(play).toHaveBeenCalledTimes(10); // gave up
233
+
234
+ play.mockResolvedValue(undefined);
235
+ setPaused(el, false);
236
+ el.dispatchEvent(new Event('playing'));
237
+ await vi.advanceTimersByTimeAsync(1500);
238
+
239
+ expect(tracer.trace).toHaveBeenCalledWith('mediaPlayback.recover.success', {
240
+ kind: 'video',
241
+ attempts: 10,
242
+ });
243
+
244
+ // recovery is live again: the attempt counter was reset, so a fresh pause
245
+ // gets an immediate attempt rather than being ignored
246
+ setPaused(el, true);
247
+ el.dispatchEvent(new Event('pause'));
248
+ await vi.advanceTimersByTimeAsync(0);
249
+ expect(play).toHaveBeenCalledTimes(11);
250
+ });
251
+
123
252
  it('does not attempt recovery when srcObject is null', async () => {
124
253
  setup({ state: { srcObject: null } });
125
254
 
@@ -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
+ });
@@ -38,8 +38,7 @@ describe('ViewportTracker', () => {
38
38
 
39
39
  it('updates participant viewportVisibilityState as visibility changes', () => {
40
40
  let visibilityHandler:
41
- | ((entry: IntersectionObserverEntry) => void)
42
- | undefined;
41
+ ((entry: IntersectionObserverEntry) => void) | undefined;
43
42
  vi.spyOn(viewportTracker, 'observe').mockImplementation((_el, handler) => {
44
43
  visibilityHandler = handler;
45
44
  return vi.fn();
@@ -1,5 +1,7 @@
1
1
  import { videoLoggerSystem } from '../logger';
2
2
 
3
- export const ensureExhausted = (x: never, message: string) => {
4
- videoLoggerSystem.getLogger('helpers').warn(message, x);
3
+ export const ensureExhausted = (x: never, message?: string) => {
4
+ if (message) {
5
+ videoLoggerSystem.getLogger('helpers').warn(message, x);
6
+ }
5
7
  };
@@ -1,4 +1,8 @@
1
- import { CallSettingsResponse, OwnCapability } from '../gen/coordinator';
1
+ import {
2
+ CallSettingsResponse,
3
+ OwnCapability,
4
+ RequestPermissionRequestPermissionsEnum,
5
+ } from '../gen/coordinator';
2
6
  import { TrackType } from '../gen/video/sfu/models/models';
3
7
  import { ensureExhausted } from '../helpers/ensureExhausted';
4
8
 
@@ -70,20 +74,21 @@ export class PermissionsContext {
70
74
  * @param settings the call settings to check against (optional).
71
75
  */
72
76
  canRequest = (
73
- permission: OwnCapability,
77
+ permission: RequestPermissionRequestPermissionsEnum,
74
78
  settings: CallSettingsResponse | undefined = this.settings,
75
79
  ) => {
76
80
  if (!settings) return false;
77
81
 
78
82
  const { audio, video, screensharing } = settings;
79
83
  switch (permission) {
80
- case OwnCapability.SEND_AUDIO:
84
+ case RequestPermissionRequestPermissionsEnum.SEND_AUDIO:
81
85
  return audio.access_request_enabled;
82
- case OwnCapability.SEND_VIDEO:
86
+ case RequestPermissionRequestPermissionsEnum.SEND_VIDEO:
83
87
  return video.access_request_enabled;
84
- case OwnCapability.SCREENSHARE:
88
+ case RequestPermissionRequestPermissionsEnum.SCREENSHARE:
85
89
  return screensharing.access_request_enabled;
86
90
  default:
91
+ ensureExhausted(permission);
87
92
  return false;
88
93
  }
89
94
  };
@@ -33,18 +33,12 @@ export type ClientEventStage =
33
33
  | 'FirstAudioFrame';
34
34
 
35
35
  export type MediaPermissionState =
36
- | 'INITIATED'
37
- | 'FAILED'
38
- | 'GRANTED'
39
- | 'NOT_INITIATED';
36
+ 'INITIATED' | 'FAILED' | 'GRANTED' | 'NOT_INITIATED';
40
37
 
41
38
  export type ReportedIceState = 'CONNECTED' | 'FAILED' | 'NOT_CONNECTED';
42
39
 
43
40
  export type JoinReason =
44
- | 'first-attempt'
45
- | 'network-available'
46
- | 'migration'
47
- | 'full-rejoin';
41
+ 'first-attempt' | 'network-available' | 'migration' | 'full-rejoin';
48
42
 
49
43
  export type ClientEventStandardCode =
50
44
  | 'CLIENT_ABORTED'
@@ -8,6 +8,8 @@ import {
8
8
  TrackType,
9
9
  WebsocketReconnectStrategy,
10
10
  } from '../gen/video/sfu/models/models';
11
+ import type { E2EEManager } from './e2ee/E2EEManager';
12
+ import { hasInsertableStreams } from './e2ee/transformSupport';
11
13
  import { NegotiationError } from './NegotiationError';
12
14
  import { StreamSfuClient } from '../StreamSfuClient';
13
15
  import { AllSfuEvents, Dispatcher } from './Dispatcher';
@@ -35,6 +37,7 @@ export abstract class BasePeerConnection {
35
37
  protected readonly state: CallState;
36
38
  protected readonly dispatcher: Dispatcher;
37
39
  protected readonly clientPublishOptions?: ClientPublishOptions;
40
+ protected readonly e2ee?: E2EEManager;
38
41
  protected tag: string;
39
42
  protected sfuClient: StreamSfuClient;
40
43
 
@@ -75,6 +78,7 @@ export abstract class BasePeerConnection {
75
78
  tag,
76
79
  enableTracing,
77
80
  clientPublishOptions,
81
+ e2ee,
78
82
  iceRestartDelay = 2500,
79
83
  statsTimestampDriftThresholdMs = 0,
80
84
  }: BasePeerConnectionOpts,
@@ -85,6 +89,7 @@ export abstract class BasePeerConnection {
85
89
  this.dispatcher = dispatcher;
86
90
  this.iceRestartDelay = iceRestartDelay;
87
91
  this.clientPublishOptions = clientPublishOptions;
92
+ this.e2ee = e2ee;
88
93
  this.tag = tag;
89
94
  this.onReconnectionNeeded = onReconnectionNeeded;
90
95
  this.onIceConnected = onIceConnected;
@@ -114,7 +119,13 @@ export abstract class BasePeerConnection {
114
119
  }
115
120
 
116
121
  private createPeerConnection = (connectionConfig?: RTCConfiguration) => {
117
- const pc = new RTCPeerConnection(connectionConfig);
122
+ const config: RTCConfiguration = { ...connectionConfig };
123
+ // The legacy Insertable Streams path requires this non-standard flag.
124
+ if (this.e2ee && hasInsertableStreams()) {
125
+ // @ts-expect-error not part of the standard lib yet
126
+ config.encodedInsertableStreams = true;
127
+ }
128
+ const pc = new RTCPeerConnection(config);
118
129
  pc.addEventListener('icecandidate', this.onIceCandidate);
119
130
  pc.addEventListener('icecandidateerror', this.onIceCandidateError);
120
131
  pc.addEventListener(
@@ -166,6 +166,14 @@ export class Publisher extends BasePeerConnection {
166
166
  toRTCDegradationPreference(publishOption.degradationPreference) ??
167
167
  'maintain-framerate';
168
168
  await transceiver.sender.setParameters(params);
169
+ if (this.e2ee) {
170
+ this.e2ee.encrypt(
171
+ transceiver.sender,
172
+ publishOption.codec?.name.toLowerCase(),
173
+ TrackType[publishOption.trackType],
174
+ );
175
+ this.logger.debug('E2EE encryptor attached to sender');
176
+ }
169
177
 
170
178
  await this.negotiate();
171
179
  };
@@ -151,8 +151,13 @@ export class Subscriber extends BasePeerConnection {
151
151
  trackLookupPrefix: trackId,
152
152
  track: primaryStream,
153
153
  trackType,
154
+ receiver: this.e2ee ? e.receiver : undefined,
154
155
  });
155
156
  return;
157
+ } else if (this.e2ee) {
158
+ const { userId } = participantToUpdate;
159
+ this.e2ee.decrypt(e.receiver, userId, TrackType[trackType]);
160
+ this.logger.debug('E2EE decryptor attached to receiver', userId);
156
161
  }
157
162
 
158
163
  const streamKindProp = trackTypeToParticipantStreamKey(trackType);