@stream-io/video-client 1.60.0 → 1.61.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.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+
5
+ import '../rtc/__tests__/mocks/webrtc.mocks';
6
+
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import { Call } from '../Call';
9
+ import { CallingState } from '../store';
10
+ import { StreamClient } from '../coordinator/connection/client';
11
+ import { ClientEventReporter } from '../reporting';
12
+ import { generateUUIDv4 } from '../coordinator/connection/utils';
13
+ import { StreamVideoWriteableStateStore } from '../store';
14
+
15
+ /**
16
+ * The core's whole part in the React Native ringing lifecycle: await RN's
17
+ * preparation inside the join, tell RN when a ringing join failed terminally or
18
+ * the call ended, and stop a join that a `leave()` overtook. What preparation
19
+ * and release actually do is RN's, and is tested there.
20
+ */
21
+
22
+ const createCall = (ringing: boolean) => {
23
+ const streamClient = new StreamClient('abc');
24
+ return new Call({
25
+ type: 'test',
26
+ id: generateUUIDv4(),
27
+ ringing,
28
+ streamClient,
29
+ clientEventReporter: new ClientEventReporter({
30
+ streamClient,
31
+ enabled: false,
32
+ }),
33
+ clientStore: new StreamVideoWriteableStateStore(),
34
+ });
35
+ };
36
+
37
+ const install = (overrides: Record<string, unknown> = {}) => {
38
+ const beforeJoin = vi.fn(() => Promise.resolve());
39
+ const onJoinFailed = vi.fn(() => Promise.resolve());
40
+ const onLeave = vi.fn();
41
+ const callingX = { joinCall: vi.fn(), endCall: vi.fn() };
42
+ globalThis.streamRNVideoSDK = {
43
+ ringingCallLifecycle: { beforeJoin, onJoinFailed, onLeave },
44
+ callingX,
45
+ // `leave()` reaches this unconditionally; the globals object is all-or-nothing
46
+ callManager: { setup: vi.fn(), start: vi.fn(), stop: vi.fn() },
47
+ ...overrides,
48
+ } as any;
49
+ return { beforeJoin, onJoinFailed, onLeave, callingX };
50
+ };
51
+
52
+ describe('ringing call lifecycle integration', () => {
53
+ beforeEach(() => {
54
+ globalThis.streamRNVideoSDK = undefined;
55
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
56
+ headers: { get: () => 'AMS1-P2' },
57
+ } as Response);
58
+ });
59
+
60
+ afterEach(() => {
61
+ globalThis.streamRNVideoSDK = undefined;
62
+ vi.restoreAllMocks();
63
+ });
64
+
65
+ it('prepares a ringing call before it joins', async () => {
66
+ const { beforeJoin, callingX } = install();
67
+ const call = createCall(true);
68
+ const doJoin = vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined);
69
+
70
+ await call.join();
71
+
72
+ expect(beforeJoin).toHaveBeenCalledTimes(1);
73
+ expect(beforeJoin).toHaveBeenCalledBefore(callingX.joinCall);
74
+ expect(doJoin).toHaveBeenCalledTimes(1);
75
+ });
76
+
77
+ it('leaves ordinary calls to join themselves', async () => {
78
+ const { beforeJoin, onJoinFailed } = install();
79
+ const call = createCall(false);
80
+ vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined);
81
+
82
+ await call.join();
83
+
84
+ expect(beforeJoin).not.toHaveBeenCalled();
85
+ expect(onJoinFailed).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it('does not join when preparation fails, and reports the failure', async () => {
89
+ const { beforeJoin, onJoinFailed } = install();
90
+ beforeJoin.mockRejectedValue(new Error('no key'));
91
+ const call = createCall(true);
92
+ const doJoin = vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined);
93
+
94
+ await expect(call.join()).rejects.toThrow('no key');
95
+
96
+ expect(doJoin).not.toHaveBeenCalled();
97
+ expect(onJoinFailed).toHaveBeenCalledWith(call);
98
+ });
99
+
100
+ it('reports a terminal join failure, keeping the original error', async () => {
101
+ const { onJoinFailed } = install();
102
+ const call = createCall(true);
103
+ vi.spyOn(call as any, 'doJoin').mockRejectedValue(new Error('sfu down'));
104
+
105
+ await expect(call.join({ maxJoinRetries: 1 })).rejects.toThrow('sfu down');
106
+
107
+ expect(onJoinFailed).toHaveBeenCalledWith(call);
108
+ });
109
+
110
+ it('refuses a duplicate join on a live call without tearing it down', async () => {
111
+ const { beforeJoin, onJoinFailed, onLeave } = install();
112
+ const call = createCall(true);
113
+ vi.spyOn(call as any, 'doJoin').mockImplementation(async () => {
114
+ call.state.setCallingState(CallingState.JOINED);
115
+ });
116
+
117
+ await call.join();
118
+ await expect(call.join()).rejects.toThrow('Illegal State');
119
+
120
+ expect(beforeJoin).toHaveBeenCalledTimes(1);
121
+ // the guard sits ahead of the failure boundary, so nothing is released
122
+ expect(onJoinFailed).not.toHaveBeenCalled();
123
+ expect(onLeave).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it('tells the owner when a ringing call ends', async () => {
127
+ const { onLeave } = install();
128
+ const call = createCall(true);
129
+
130
+ await call.leave();
131
+
132
+ expect(onLeave).toHaveBeenCalledWith(call);
133
+ expect(call.state.callingState).toBe(CallingState.LEFT);
134
+ });
135
+
136
+ it('does not tell the owner about an ordinary call', async () => {
137
+ const { onLeave } = install();
138
+
139
+ await createCall(false).leave();
140
+
141
+ expect(onLeave).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it('keeps an app-owned manager across leave, as setE2EEManager promises', async () => {
145
+ install();
146
+ const call = createCall(true);
147
+ const manager = { encrypt: vi.fn(), decrypt: vi.fn() } as any;
148
+ call.setE2EEManager(manager);
149
+
150
+ await call.leave();
151
+
152
+ expect(call.e2eeManager).toBe(manager);
153
+ });
154
+
155
+ it('abandons a join that a leave overtook during native registration', async () => {
156
+ let finishRegistration: () => void = () => {};
157
+ const { callingX, onLeave, onJoinFailed } = install();
158
+ callingX.joinCall.mockImplementation(
159
+ () => new Promise<void>((resolve) => (finishRegistration = resolve)),
160
+ );
161
+ const call = createCall(true);
162
+ const doJoin = vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined);
163
+
164
+ const joining = call.join();
165
+ await vi.waitFor(() => expect(callingX.joinCall).toHaveBeenCalled());
166
+ await call.leave({ reject: false });
167
+ finishRegistration();
168
+
169
+ // A superseding leave is not a join failure: the join settles quietly rather
170
+ // than rejecting, and the error path stays shut.
171
+ await expect(joining).resolves.toBeUndefined();
172
+ expect(onJoinFailed).not.toHaveBeenCalled();
173
+ expect(callingX.endCall).not.toHaveBeenCalledWith(call, 'error');
174
+ // Release still happens - `leave()` itself fires it, which is why the join
175
+ // does not need to.
176
+ expect(onLeave).toHaveBeenCalledTimes(1);
177
+ // `doJoin` captures the generation itself, so this check has to be here
178
+ expect(doJoin).not.toHaveBeenCalled();
179
+ });
180
+
181
+ it('does not resume the retry loop after a leave during backoff', async () => {
182
+ vi.useFakeTimers();
183
+ try {
184
+ install();
185
+ const call = createCall(true);
186
+ const doJoin = vi
187
+ .spyOn(call as any, 'doJoin')
188
+ .mockRejectedValue(new Error('recoverable'));
189
+
190
+ const joining = call.join().catch(() => {});
191
+ await vi.waitFor(() => expect(doJoin).toHaveBeenCalledTimes(1));
192
+ await call.leave({ reject: false });
193
+ await vi.advanceTimersByTimeAsync(30_000);
194
+ await joining;
195
+
196
+ expect(doJoin).toHaveBeenCalledTimes(1);
197
+ } finally {
198
+ vi.useRealTimers();
199
+ }
200
+ });
201
+
202
+ it('prepares an outgoing call that this join makes ringing', async () => {
203
+ const { beforeJoin } = install();
204
+ // a fresh instance, not ringing until its first join says so
205
+ const call = createCall(false);
206
+ vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined);
207
+
208
+ await call.join({ ring: true });
209
+
210
+ // reading `call.ringing` before applying `options.ring` would skip
211
+ // preparation entirely on this path
212
+ expect(beforeJoin).toHaveBeenCalledTimes(1);
213
+ });
214
+
215
+ it('stops a join that a leave overtook during preparation', async () => {
216
+ let releaseHook: () => void = () => {};
217
+ const { beforeJoin, onLeave, onJoinFailed } = install();
218
+ beforeJoin.mockImplementation(
219
+ () => new Promise<void>((resolve) => (releaseHook = resolve)),
220
+ );
221
+ const call = createCall(true);
222
+ const doJoin = vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined);
223
+
224
+ const joining = call.join();
225
+ await vi.waitFor(() => expect(beforeJoin).toHaveBeenCalled());
226
+ await call.leave({ reject: false });
227
+ releaseHook();
228
+
229
+ await expect(joining).resolves.toBeUndefined();
230
+ expect(onJoinFailed).not.toHaveBeenCalled();
231
+ // Whatever `beforeJoin` installed is released exactly once, by the `leave()`
232
+ // that overtook the join rather than by the join itself.
233
+ expect(onLeave).toHaveBeenCalledTimes(1);
234
+ expect(doJoin).not.toHaveBeenCalled();
235
+ expect(call.state.callingState).toBe(CallingState.LEFT);
236
+ });
237
+
238
+ it('is inert when no React Native globals are registered', async () => {
239
+ const call = createCall(true);
240
+ await expect(call.leave()).resolves.not.toThrow();
241
+ });
242
+ });
@@ -150,7 +150,9 @@ export class Publisher extends BasePeerConnection {
150
150
  const sendEncodings = isSvcCodec(publishOption.codec?.name)
151
151
  ? toSvcEncodings(encodings)
152
152
  : encodings;
153
- const transceiver = this.pc.addTransceiver(track, {
153
+ // An E2EE sender must never have media before its transform is attached,
154
+ // including when another track or an ICE restart negotiates this PC.
155
+ const transceiver = this.pc.addTransceiver(this.e2ee ? track.kind : track, {
154
156
  direction: 'sendonly',
155
157
  sendEncodings,
156
158
  });
@@ -161,23 +163,75 @@ export class Publisher extends BasePeerConnection {
161
163
  this.trackIdToTrackType.set(track.id, trackType);
162
164
  this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
163
165
 
164
- const params = transceiver.sender.getParameters();
165
- params.degradationPreference =
166
- toRTCDegradationPreference(publishOption.degradationPreference) ??
167
- 'maintain-framerate';
168
- await transceiver.sender.setParameters(params);
169
166
  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');
167
+ await this.initEncryptedSender(transceiver, track, publishOption);
168
+ } else {
169
+ await this.setDegradationPreference(transceiver.sender, publishOption);
176
170
  }
177
171
 
178
172
  await this.negotiate();
179
173
  };
180
174
 
175
+ /**
176
+ * Attaches the encryptor, applies sender parameters, then attaches the track.
177
+ *
178
+ * `encrypt` must run before the first await: Chrome short-circuits a sender
179
+ * whose encoded streams aren't created in the task that created it, and the
180
+ * track goes on last so the sender has no media until both steps succeed.
181
+ */
182
+ private initEncryptedSender = async (
183
+ transceiver: RTCRtpTransceiver,
184
+ track: MediaStreamTrack,
185
+ publishOption: PublishOption,
186
+ ) => {
187
+ try {
188
+ const { sender } = transceiver;
189
+ const codec = publishOption.codec?.name.toLowerCase();
190
+ this.e2ee!.encrypt(sender, codec, TrackType[publishOption.trackType]);
191
+ this.logger.debug('E2EE encryptor attached to sender');
192
+ await this.setDegradationPreference(sender, publishOption);
193
+ await sender.replaceTrack(track);
194
+ } catch (err) {
195
+ this.retireTransceiver(transceiver, track);
196
+ throw err;
197
+ }
198
+ };
199
+
200
+ private setDegradationPreference = async (
201
+ sender: RTCRtpSender,
202
+ publishOption: PublishOption,
203
+ ) => {
204
+ const params = sender.getParameters();
205
+ params.degradationPreference =
206
+ toRTCDegradationPreference(publishOption.degradationPreference) ??
207
+ 'maintain-framerate';
208
+ await sender.setParameters(params);
209
+ };
210
+
211
+ /**
212
+ * Discards a sender whose E2EE initialization failed.
213
+ *
214
+ * A partially initialized sender may carry no transform, so publish() must
215
+ * not reuse it: retiring it from the cache is what keeps a later publish or
216
+ * ICE restart from putting cleartext on the wire. Cleanup cannot throw, so
217
+ * the initialization failure is what reaches the caller.
218
+ */
219
+ private retireTransceiver = (
220
+ transceiver: RTCRtpTransceiver,
221
+ track: MediaStreamTrack,
222
+ ) => {
223
+ this.transceiverCache.remove(transceiver);
224
+ this.trackIdToTrackType.delete(track.id);
225
+ // on React Native a clone shares its native source, so stopping it here
226
+ // would stop the track the caller passed in. dispose() releases it.
227
+ if (!isReactNative()) this.stopTrack(track);
228
+ try {
229
+ transceiver.stop();
230
+ } catch (err) {
231
+ this.logger.debug('Failed to stop a retired transceiver', err);
232
+ }
233
+ };
234
+
181
235
  /**
182
236
  * Updates the transceiver with the given track and track type.
183
237
  */
@@ -21,6 +21,23 @@ export class TransceiverCache {
21
21
  this.transceiverOrder.push(bundle.transceiver);
22
22
  };
23
23
 
24
+ /**
25
+ * Retires a failed sender from the reusable cache.
26
+ *
27
+ * A transceiver that never reached an m-section also leaves the creation
28
+ * history: once stopped, it is left out of every later offer, so counting
29
+ * it would shift the `mid` index of every transceiver added after it. One
30
+ * that is already associated keeps its slot, since its m-section survives
31
+ * as a rejected one.
32
+ */
33
+ remove = (transceiver: RTCRtpTransceiver) => {
34
+ const index = this.cache.findIndex((b) => b.transceiver === transceiver);
35
+ if (index !== -1) this.cache.splice(index, 1);
36
+ if (transceiver.mid) return;
37
+ const orderIndex = this.transceiverOrder.indexOf(transceiver);
38
+ if (orderIndex !== -1) this.transceiverOrder.splice(orderIndex, 1);
39
+ };
40
+
24
41
  /**
25
42
  * Gets the transceiver for the given publish option.
26
43
  */
@@ -26,6 +26,7 @@ import { TransceiverCache } from '../TransceiverCache';
26
26
  import { promiseWithResolvers } from '../../helpers/promise';
27
27
  import { settled } from '../../helpers/concurrency';
28
28
  import { isFirefox } from '../../helpers/browsers';
29
+ import { isReactNative } from '../../helpers/platforms';
29
30
 
30
31
  vi.mock('../../StreamSfuClient', () => {
31
32
  console.log('MOCKING StreamSfuClient');
@@ -40,6 +41,12 @@ vi.mock('../../helpers/browsers', async (importOriginal) => {
40
41
  return { ...actual, isFirefox: vi.fn().mockReturnValue(false) };
41
42
  });
42
43
 
44
+ vi.mock('../../helpers/platforms', async (importOriginal) => {
45
+ const actual =
46
+ await importOriginal<typeof import('../../helpers/platforms')>();
47
+ return { ...actual, isReactNative: vi.fn().mockReturnValue(false) };
48
+ });
49
+
43
50
  describe('Publisher', () => {
44
51
  const sessionId = 'session-id-test';
45
52
  let publisher: Publisher;
@@ -106,6 +113,208 @@ describe('Publisher', () => {
106
113
  });
107
114
 
108
115
  describe('Publishing', () => {
116
+ describe('E2EE initialization failures', () => {
117
+ afterEach(() => {
118
+ vi.mocked(isReactNative).mockReturnValue(false);
119
+ });
120
+
121
+ const setup = async () => {
122
+ await publisher.dispose();
123
+ const e2ee = { encrypt: vi.fn(), decrypt: vi.fn() };
124
+ publisher = new Publisher(
125
+ {
126
+ sfuClient,
127
+ dispatcher,
128
+ state,
129
+ tag: 'test',
130
+ enableTracing: false,
131
+ e2ee,
132
+ },
133
+ [
134
+ fromPartial<PublishOption>({
135
+ id: 1,
136
+ trackType: TrackType.VIDEO,
137
+ codec: { name: 'vp9' },
138
+ }),
139
+ ],
140
+ );
141
+ const transceivers: RTCRtpTransceiver[] = [];
142
+ vi.mocked(publisher['pc'].addTransceiver).mockImplementation((kind) => {
143
+ expect(kind).toBe('video');
144
+ const transceiver = new RTCRtpTransceiver();
145
+ vi.mocked(transceiver.sender.replaceTrack).mockImplementation(
146
+ async (track) => {
147
+ expect(e2ee.encrypt).toHaveBeenCalledWith(
148
+ transceiver.sender,
149
+ 'vp9',
150
+ 'VIDEO',
151
+ );
152
+ Object.assign(transceiver.sender, { track });
153
+ },
154
+ );
155
+ transceivers.push(transceiver);
156
+ return transceiver;
157
+ });
158
+ const track = new MediaStreamTrack();
159
+ vi.mocked(track.clone).mockImplementation(() => new MediaStreamTrack());
160
+ const realNegotiate = publisher['negotiate'];
161
+ const negotiate = vi.fn<Publisher['negotiate']>().mockResolvedValue();
162
+ publisher['negotiate'] = negotiate;
163
+ return { e2ee, transceivers, track, negotiate, realNegotiate };
164
+ };
165
+
166
+ it.each([
167
+ 'encrypt',
168
+ 'getParameters',
169
+ 'setParameters',
170
+ 'replaceTrack',
171
+ ] as const)(
172
+ 'retires the sender after %s fails and attaches encryption on retry',
173
+ async (failurePoint) => {
174
+ const { e2ee, transceivers, track, negotiate } = await setup();
175
+ const error = new Error('initialization failed');
176
+ e2ee.encrypt.mockImplementationOnce((sender: RTCRtpSender) => {
177
+ expect(sender.track).toBeNull();
178
+ if (failurePoint === 'encrypt') throw error;
179
+ if (failurePoint === 'getParameters') {
180
+ vi.mocked(sender.getParameters).mockImplementationOnce(() => {
181
+ throw error;
182
+ });
183
+ } else {
184
+ vi.mocked(sender[failurePoint]).mockRejectedValueOnce(error);
185
+ }
186
+ });
187
+ await expect(publisher.publish(track, TrackType.VIDEO)).rejects.toBe(
188
+ error,
189
+ );
190
+ const failed = transceivers[0];
191
+ expect(failed.sender.track).toBeNull();
192
+ expect(failed.stop).toHaveBeenCalledOnce();
193
+ expect(publisher['transceiverCache'].items()).toHaveLength(0);
194
+ expect(publisher['clonedTracks'].size).toBe(0);
195
+ expect(negotiate).not.toHaveBeenCalled();
196
+
197
+ await publisher.publish(track, TrackType.VIDEO);
198
+ expect(transceivers).toHaveLength(2);
199
+ expect(e2ee.encrypt).toHaveBeenLastCalledWith(
200
+ transceivers[1].sender,
201
+ 'vp9',
202
+ 'VIDEO',
203
+ );
204
+ expect(transceivers[1].sender.track).not.toBeNull();
205
+ expect(publisher['transceiverCache'].indexOf(failed)).toBe(-1);
206
+ expect(publisher['transceiverCache'].indexOf(transceivers[1])).toBe(
207
+ 0,
208
+ );
209
+ expect(negotiate).toHaveBeenCalledOnce();
210
+ },
211
+ );
212
+
213
+ it('keeps the mid index of a retired sender that reached an m-section', async () => {
214
+ const { e2ee, transceivers, track } = await setup();
215
+ const error = new Error('initialization failed');
216
+ e2ee.encrypt.mockImplementationOnce((sender: RTCRtpSender) => {
217
+ Object.assign(transceivers[0], { mid: '0' });
218
+ vi.mocked(sender.replaceTrack).mockRejectedValueOnce(error);
219
+ });
220
+ await expect(publisher.publish(track, TrackType.VIDEO)).rejects.toBe(
221
+ error,
222
+ );
223
+ expect(publisher['transceiverCache'].items()).toHaveLength(0);
224
+ expect(publisher['transceiverCache'].indexOf(transceivers[0])).toBe(0);
225
+
226
+ await publisher.publish(track, TrackType.VIDEO);
227
+ expect(publisher['transceiverCache'].indexOf(transceivers[1])).toBe(1);
228
+ });
229
+
230
+ it('reports the initialization failure even when cleanup throws', async () => {
231
+ const { e2ee, transceivers, track } = await setup();
232
+ const error = new Error('initialization failed');
233
+ e2ee.encrypt.mockImplementationOnce(() => {
234
+ vi.mocked(transceivers[0].stop).mockImplementationOnce(() => {
235
+ throw new Error('peer connection is closed');
236
+ });
237
+ throw error;
238
+ });
239
+ await expect(publisher.publish(track, TrackType.VIDEO)).rejects.toBe(
240
+ error,
241
+ );
242
+ expect(publisher['transceiverCache'].items()).toHaveLength(0);
243
+ expect(publisher['clonedTracks'].size).toBe(0);
244
+ });
245
+
246
+ it('leaves the cloned track alone on React Native', async () => {
247
+ vi.mocked(isReactNative).mockReturnValue(true);
248
+ const { e2ee, transceivers, track } = await setup();
249
+ const error = new Error('initialization failed');
250
+ e2ee.encrypt.mockImplementationOnce(() => {
251
+ throw error;
252
+ });
253
+ await expect(publisher.publish(track, TrackType.VIDEO)).rejects.toBe(
254
+ error,
255
+ );
256
+ const [clone] = publisher['clonedTracks'];
257
+ expect(clone.stop).not.toHaveBeenCalled();
258
+ expect(transceivers[0].stop).toHaveBeenCalledOnce();
259
+ expect(publisher['transceiverCache'].items()).toHaveLength(0);
260
+ });
261
+
262
+ it('reuses an encrypted sender when only negotiation failed', async () => {
263
+ const { e2ee, transceivers, track, negotiate } = await setup();
264
+ const error = new Error('negotiation failed');
265
+ negotiate.mockRejectedValueOnce(error);
266
+ await expect(publisher.publish(track, TrackType.VIDEO)).rejects.toBe(
267
+ error,
268
+ );
269
+ expect(transceivers[0].sender.track).not.toBeNull();
270
+ expect(transceivers[0].stop).not.toHaveBeenCalled();
271
+ await publisher.publish(track, TrackType.VIDEO);
272
+ expect(transceivers).toHaveLength(1);
273
+ expect(e2ee.encrypt).toHaveBeenCalledOnce();
274
+ expect(negotiate).toHaveBeenCalledTimes(2);
275
+ });
276
+
277
+ it('has no media to expose if ICE restart negotiates during parameter setup', async () => {
278
+ const { e2ee, transceivers, track, negotiate, realNegotiate } =
279
+ await setup();
280
+ negotiate.mockImplementation(realNegotiate);
281
+ publisher['publishOptions'].push(
282
+ fromPartial<PublishOption>({
283
+ id: 2,
284
+ trackType: TrackType.VIDEO,
285
+ codec: { name: 'vp9' },
286
+ }),
287
+ );
288
+ sfuClient.setPublisher = vi
289
+ .fn()
290
+ .mockResolvedValue({ response: { sdp: 'answer-sdp' } });
291
+ const parameters = promiseWithResolvers<void>();
292
+ e2ee.encrypt
293
+ .mockImplementationOnce(() => {})
294
+ .mockImplementationOnce((sender: RTCRtpSender) => {
295
+ vi.mocked(sender.setParameters).mockReturnValueOnce(
296
+ parameters.promise,
297
+ );
298
+ });
299
+ const publish = publisher.publish(track, TrackType.VIDEO);
300
+ await vi.waitFor(() => expect(e2ee.encrypt).toHaveBeenCalledTimes(2));
301
+ const [published, pending] = transceivers;
302
+ expect(pending.sender.track).toBeNull();
303
+ expect(pending.sender.replaceTrack).not.toHaveBeenCalled();
304
+
305
+ await publisher.restartIce();
306
+ const restart = vi.mocked(sfuClient.setPublisher).mock.calls[1][0];
307
+ expect(restart.tracks.map((t) => t.trackId)).toEqual([
308
+ published.sender.track!.id,
309
+ ]);
310
+
311
+ parameters.resolve();
312
+ await publish;
313
+ expect(pending.sender.track).not.toBeNull();
314
+ expect(sfuClient.setPublisher).toHaveBeenCalledTimes(3);
315
+ });
316
+ });
317
+
109
318
  it('should throw when publishing ended tracks', async () => {
110
319
  const track = new MediaStreamTrack();
111
320
  // @ts-expect-error readonly field
@@ -60,6 +60,7 @@ const RTCRtpTransceiverMock = vi.fn(function (): Partial<RTCRtpTransceiver> {
60
60
  transform: null,
61
61
  },
62
62
  setCodecPreferences: vi.fn(),
63
+ stop: vi.fn(),
63
64
  mid: '',
64
65
  };
65
66
  });
@@ -5,7 +5,16 @@
5
5
  * attaching their own encoded transform in these two methods.
6
6
  */
7
7
  export interface E2EEManager {
8
- /** `trackType` only groups perf stats, keeping a camera and screen share apart. */
8
+ /**
9
+ * Synchronously attach a transform that encrypts the sender's outgoing frames.
10
+ * Must support a sender without a track, and throw if attachment fails.
11
+ *
12
+ * `sender.track` is always null here: the track is attached only after this
13
+ * returns, so implementations must not read it. Use `codec` and `trackType`
14
+ * instead.
15
+ *
16
+ * `trackType` only groups perf stats, keeping a camera and screen share apart.
17
+ */
9
18
  encrypt(sender: RTCRtpSender, codec?: string, trackType?: string): void;
10
19
  /** `trackType` only groups perf stats, keeping a peer's audio and video apart. */
11
20
  decrypt(receiver: RTCRtpReceiver, userId: string, trackType?: string): void;
@@ -263,9 +263,9 @@ export class EncryptionManager
263
263
  }
264
264
 
265
265
  if ((this.piped ??= new WeakSet()).has(target)) return;
266
- this.piped.add(target);
267
266
  // @ts-expect-error createEncodedStreams is not in the standard typedefs
268
267
  const { readable, writable } = target.createEncodedStreams();
268
+ this.piped.add(target);
269
269
  this.worker.postMessage(
270
270
  { type: 'cmd.setup_transform', ...options, readable, writable },
271
271
  [readable, writable],
@@ -79,7 +79,7 @@ await call.join();
79
79
  - **Removal comes in per-epoch and per-user forms, and the names must stay distinct.** `removeKey(userId, keyIndex)` retires one epoch; `removeAllKeys(userId)` revokes the participant entirely. Do not render these as overloads of one name: dropping the index argument would then silently mean "revoke everything", which is the opposite of the caller's intent and impossible to catch in review.
80
80
  - **Every key operation's outcome is tabulated in Appendix B.** Implement the edge cases (removing the latest or active epoch, a failed import, an absent index) from that table rather than from prose.
81
81
  - **Join request carries `e2ee: true`** so the backend knows the call is encrypted.
82
- - The internal attach points (`encrypt(sender, codec, trackType)` / `decrypt(receiver, userId, trackType)`) are called by the RTC layer, not by apps. Keeping them behind a small interface lets an integrator plug in a different scheme (e.g. SFrame).
82
+ - The internal attach points (`encrypt(sender, codec, trackType)` / `decrypt(receiver, userId, trackType)`) are called by the RTC layer, not by apps. The publisher creates an E2EE sender without a track and attaches media only after `encrypt` returns and sender parameters succeed. `encrypt` must synchronously attach the transform or throw, and must support a sender whose `track` is null. A failed initialization retires that transceiver so retries cannot bypass attachment. Keeping these points behind a small interface lets an integrator plug in a different scheme (e.g. SFrame).
83
83
 
84
84
  ---
85
85