livekit-client 2.22.1 → 2.22.3

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 (88) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +663 -476
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +480 -157
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js +1 -1
  8. package/dist/livekit-client.fm.worker.js.map +1 -1
  9. package/dist/livekit-client.fm.worker.mjs +8 -1
  10. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  11. package/dist/livekit-client.umd.js +1 -1
  12. package/dist/livekit-client.umd.js.map +1 -1
  13. package/dist/src/api/SignalClient.d.ts.map +1 -1
  14. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  15. package/dist/src/api/utils.d.ts +1 -0
  16. package/dist/src/api/utils.d.ts.map +1 -1
  17. package/dist/src/e2ee/E2eeManager.d.ts +33 -0
  18. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  19. package/dist/src/e2ee/constants.d.ts +5 -0
  20. package/dist/src/e2ee/constants.d.ts.map +1 -1
  21. package/dist/src/e2ee/types.d.ts +24 -3
  22. package/dist/src/e2ee/types.d.ts.map +1 -1
  23. package/dist/src/e2ee/worker/DataCryptor.d.ts.map +1 -1
  24. package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
  25. package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts.map +1 -0
  26. package/dist/src/e2ee/worker/FrameCryptor.d.ts +43 -19
  27. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  28. package/dist/src/logger.d.ts +4 -0
  29. package/dist/src/logger.d.ts.map +1 -1
  30. package/dist/src/room/PCTransportManager.d.ts +12 -0
  31. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  32. package/dist/src/room/RTCEngine.d.ts +1 -0
  33. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  34. package/dist/src/room/data-stream/incoming/StreamReader.d.ts +17 -17
  35. package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
  36. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  37. package/dist/src/room/participant/publishUtils.d.ts +16 -0
  38. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  39. package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
  40. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  41. package/dist/src/room/track/options.d.ts +1 -1
  42. package/dist/src/room/utils.d.ts +34 -0
  43. package/dist/src/room/utils.d.ts.map +1 -1
  44. package/dist/ts4.2/api/utils.d.ts +1 -0
  45. package/dist/ts4.2/e2ee/E2eeManager.d.ts +33 -0
  46. package/dist/ts4.2/e2ee/constants.d.ts +5 -0
  47. package/dist/ts4.2/e2ee/types.d.ts +24 -3
  48. package/dist/ts4.2/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
  49. package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +43 -19
  50. package/dist/ts4.2/logger.d.ts +4 -0
  51. package/dist/ts4.2/room/PCTransportManager.d.ts +12 -0
  52. package/dist/ts4.2/room/RTCEngine.d.ts +1 -0
  53. package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +17 -17
  54. package/dist/ts4.2/room/participant/publishUtils.d.ts +16 -0
  55. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +7 -0
  56. package/dist/ts4.2/room/track/options.d.ts +1 -1
  57. package/dist/ts4.2/room/utils.d.ts +34 -0
  58. package/package.json +1 -1
  59. package/src/api/SignalClient.ts +2 -1
  60. package/src/api/WebSocketStream.ts +3 -8
  61. package/src/api/utils.ts +10 -0
  62. package/src/e2ee/E2eeManager.test.ts +196 -0
  63. package/src/e2ee/E2eeManager.ts +150 -31
  64. package/src/e2ee/constants.ts +6 -0
  65. package/src/e2ee/subscriberBlackScreen.test.ts +544 -0
  66. package/src/e2ee/types.ts +28 -3
  67. package/src/e2ee/worker/DataCryptor.ts +2 -1
  68. package/src/e2ee/worker/ErrorRateLimiter.test.ts +53 -0
  69. package/src/e2ee/worker/ErrorRateLimiter.ts +52 -0
  70. package/src/e2ee/worker/FrameCryptor.race.test.ts +9 -26
  71. package/src/e2ee/worker/FrameCryptor.test.ts +0 -1
  72. package/src/e2ee/worker/FrameCryptor.ts +202 -119
  73. package/src/e2ee/worker/e2ee.worker.ts +72 -17
  74. package/src/logger.ts +22 -0
  75. package/src/room/PCTransportManager.test.ts +35 -0
  76. package/src/room/PCTransportManager.ts +12 -4
  77. package/src/room/RTCEngine.ts +28 -7
  78. package/src/room/Room.ts +1 -1
  79. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +171 -0
  80. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +17 -18
  81. package/src/room/data-stream/incoming/StreamReader.ts +20 -50
  82. package/src/room/participant/LocalParticipant.ts +30 -14
  83. package/src/room/participant/publishUtils.test.ts +133 -0
  84. package/src/room/participant/publishUtils.ts +54 -19
  85. package/src/room/track/LocalVideoTrack.ts +15 -5
  86. package/src/room/track/options.ts +1 -1
  87. package/src/room/utils.test.ts +87 -0
  88. package/src/room/utils.ts +59 -0
@@ -0,0 +1,196 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { LogLevel, getWorkerLogLevelListenerCount, setLogLevel, workerLogger } from '../logger';
3
+ import Room from '../room/Room';
4
+ import { E2EEManager } from './E2eeManager';
5
+ import { BaseKeyProvider } from './KeyProvider';
6
+
7
+ /**
8
+ * Install just enough of the DOM to let isE2EESupported() return true so
9
+ * setup() doesn't throw.
10
+ */
11
+ function installE2EEShims() {
12
+ const w = window as unknown as Record<string, any>;
13
+ if (typeof w.RTCRtpSender === 'undefined') {
14
+ w.RTCRtpSender = class {};
15
+ }
16
+ w.RTCRtpSender.prototype.createEncodedStreams = () => {};
17
+ }
18
+
19
+ class FakeWorker {
20
+ postMessage = vi.fn();
21
+
22
+ onmessage: unknown = null;
23
+
24
+ onerror: unknown = null;
25
+
26
+ levelMessages(): LogLevel[] {
27
+ return this.postMessage.mock.calls
28
+ .map(([m]) => m)
29
+ .filter((m: any) => m?.kind === 'setLogLevel')
30
+ .map((m: any) => m.data.level);
31
+ }
32
+ }
33
+
34
+ function makeManager() {
35
+ installE2EEShims();
36
+ const room = new Room();
37
+ const worker = new FakeWorker();
38
+ const manager = new E2EEManager(
39
+ { keyProvider: new BaseKeyProvider({ sharedKey: true }), worker: worker as unknown as Worker },
40
+ false,
41
+ );
42
+ return { room, worker, manager };
43
+ }
44
+
45
+ describe('E2EEManager log-level listener lifecycle', () => {
46
+ const startingLevel = workerLogger.getLevel();
47
+ const startingCount = getWorkerLogLevelListenerCount();
48
+
49
+ afterEach(() => {
50
+ setLogLevel(startingLevel);
51
+ });
52
+
53
+ it('forwards level changes to the worker while subscribed', () => {
54
+ const { room, worker, manager } = makeManager();
55
+ manager.setup(room);
56
+ worker.postMessage.mockClear();
57
+
58
+ setLogLevel(LogLevel.debug);
59
+
60
+ expect(worker.levelMessages()).toEqual([LogLevel.debug]);
61
+ manager.dispose();
62
+ });
63
+
64
+ it('dispose() removes the listener and stops forwarding', () => {
65
+ const { room, worker, manager } = makeManager();
66
+ manager.setup(room);
67
+ manager.dispose();
68
+ worker.postMessage.mockClear();
69
+
70
+ setLogLevel(LogLevel.warn);
71
+
72
+ expect(worker.postMessage).not.toHaveBeenCalled();
73
+ expect(getWorkerLogLevelListenerCount()).toBe(startingCount);
74
+ });
75
+
76
+ it('re-setup with a new room does not stack listeners', () => {
77
+ const { worker, manager } = makeManager();
78
+ const roomA = new Room();
79
+ const roomB = new Room();
80
+ manager.setup(roomA);
81
+ const countAfterFirst = getWorkerLogLevelListenerCount();
82
+ manager.setup(roomB);
83
+ expect(getWorkerLogLevelListenerCount()).toBe(countAfterFirst);
84
+
85
+ worker.postMessage.mockClear();
86
+ setLogLevel(LogLevel.debug);
87
+ expect(worker.levelMessages()).toEqual([LogLevel.debug]); // exactly one delivery
88
+
89
+ manager.dispose();
90
+ });
91
+
92
+ it('dispose() is idempotent', () => {
93
+ const { room, manager } = makeManager();
94
+ manager.setup(room);
95
+ manager.dispose();
96
+ manager.dispose();
97
+ expect(getWorkerLogLevelListenerCount()).toBe(startingCount);
98
+ });
99
+
100
+ it('dispose() rejects pending encrypt/decrypt futures and clears both maps', async () => {
101
+ const { room, manager } = makeManager();
102
+ manager.setup(room);
103
+
104
+ const encrypting = manager.encryptData(new Uint8Array([1, 2, 3]) as any);
105
+ const decrypting = manager.handleEncryptedData(
106
+ new Uint8Array([4, 5, 6]) as any,
107
+ new Uint8Array([7, 8, 9]) as any,
108
+ 'peer',
109
+ 0,
110
+ );
111
+
112
+ const priv = manager as unknown as {
113
+ encryptDataRequests: Map<string, unknown>;
114
+ decryptDataRequests: Map<string, unknown>;
115
+ };
116
+ expect(priv.encryptDataRequests.size).toBe(1);
117
+ expect(priv.decryptDataRequests.size).toBe(1);
118
+
119
+ manager.dispose();
120
+
121
+ await expect(encrypting).rejects.toThrow(/disposed/);
122
+ await expect(decrypting).rejects.toThrow(/disposed/);
123
+ expect(priv.encryptDataRequests.size).toBe(0);
124
+ expect(priv.decryptDataRequests.size).toBe(0);
125
+
126
+ // Second dispose while maps are empty must not throw.
127
+ expect(() => manager.dispose()).not.toThrow();
128
+ });
129
+ });
130
+
131
+ /**
132
+ * GC-path test. Flaky by construction — FinalizationRegistry callbacks are
133
+ * best-effort. Skipped unless vitest is run with `--expose-gc`:
134
+ *
135
+ * NODE_OPTIONS="--expose-gc" pnpm exec vitest run src/e2ee/E2eeManager.test.ts
136
+ *
137
+ * Deliberately bypasses `manager.setup(room)`. `new Room()` on its own is not
138
+ * collectable in this test environment (device-change listeners, timers), and
139
+ * that leak is not what this test is about — it would only mask what we
140
+ * actually want to verify: that the log-level listener wiring holds nothing
141
+ * strongly.
142
+ */
143
+ describe('E2EEManager GC cleanup', () => {
144
+ const startingLevel = workerLogger.getLevel();
145
+
146
+ beforeEach(() => {
147
+ installE2EEShims();
148
+ });
149
+
150
+ afterEach(() => {
151
+ setLogLevel(startingLevel);
152
+ });
153
+
154
+ it.skipIf(!(globalThis as any).gc)(
155
+ 'releases the log-level listener when the manager is garbage collected',
156
+ async () => {
157
+ const before = getWorkerLogLevelListenerCount();
158
+
159
+ // Construct + subscribe in an IIFE so nothing lives on the test's stack.
160
+ // Direct call to the private subscription — no Room, no leaky graph.
161
+ const managerRef = ((): WeakRef<E2EEManager> => {
162
+ const worker = new FakeWorker();
163
+ const manager = new E2EEManager(
164
+ {
165
+ keyProvider: new BaseKeyProvider({ sharedKey: true }),
166
+ worker: worker as unknown as Worker,
167
+ },
168
+ false,
169
+ );
170
+ (manager as unknown as { subscribeToLogLevelChanges(): void }).subscribeToLogLevelChanges();
171
+ expect(getWorkerLogLevelListenerCount()).toBe(before + 1);
172
+ return new WeakRef(manager);
173
+ })();
174
+
175
+ // Full major GC + macrotask yield in a loop, with allocation pressure to
176
+ // force the major sweep FinalizationRegistry needs.
177
+ //
178
+ // Crucial: do NOT call `managerRef.deref()` inside the loop. Per spec,
179
+ // `WeakRef.prototype.deref` keeps the referent alive until the end of the
180
+ // current job — calling it in the check would pin the manager forever.
181
+ // Read the listener count (which does not touch the referent) instead.
182
+ const gc = (globalThis as any).gc as (opts?: { type?: 'major'; execution?: 'sync' }) => void;
183
+ for (let i = 0; i < 50; i++) {
184
+ // eslint-disable-next-line no-void
185
+ void new Array(100_000).fill({ i });
186
+ gc({ type: 'major', execution: 'sync' });
187
+ await new Promise((r) => setImmediate(r));
188
+ if (getWorkerLogLevelListenerCount() === before) break;
189
+ }
190
+
191
+ // Diagnostic: separate "manager wasn't collected" from "FR didn't fire".
192
+ expect(managerRef.deref(), 'manager was not collected — strong ref leaked').toBeUndefined();
193
+ expect(getWorkerLogLevelListenerCount()).toBe(before);
194
+ },
195
+ );
196
+ });
@@ -3,7 +3,7 @@ import { EventEmitter } from 'events';
3
3
  import type TypedEventEmitter from 'typed-emitter';
4
4
  import type { FrameMetadata } from '../frameMetadata/types';
5
5
  import { hasFrameMetadataPublishOptions } from '../frameMetadata/utils';
6
- import log, { LogLevel, workerLogger } from '../logger';
6
+ import { LogLevel, LoggerNames, getLogger, onWorkerLogLevelChanged, workerLogger } from '../logger';
7
7
  import type RTCEngine from '../room/RTCEngine';
8
8
  import type Room from '../room/Room';
9
9
  import { ConnectionState } from '../room/Room';
@@ -12,6 +12,7 @@ import { EngineEvent, ParticipantEvent, RoomEvent } from '../room/events';
12
12
  import type RemoteTrack from '../room/track/RemoteTrack';
13
13
  import RemoteVideoTrack from '../room/track/RemoteVideoTrack';
14
14
  import type { Track } from '../room/track/Track';
15
+ import type { TrackPublication } from '../room/track/TrackPublication';
15
16
  import type { TrackPublishOptions, VideoCodec } from '../room/track/options';
16
17
  import { mimeTypeToVideoCodecString } from '../room/track/utils';
17
18
  import {
@@ -23,7 +24,8 @@ import {
23
24
  } from '../room/utils';
24
25
  import type { NonSharedUint8Array } from '../type-polyfills/non-shared-typed-arrays';
25
26
  import type { BaseKeyProvider } from './KeyProvider';
26
- import { E2EE_FLAG } from './constants';
27
+ import { E2EE_FLAG, E2EE_TRACK_ID } from './constants';
28
+ import { CryptorError, CryptorErrorReason } from './errors';
27
29
  import { type E2EEManagerCallbacks, EncryptionEvent, KeyProviderEvent } from './events';
28
30
  import type {
29
31
  DecryptDataRequestMessage,
@@ -61,6 +63,7 @@ export interface BaseE2EEManager {
61
63
  keyIndex: number,
62
64
  ): Promise<DecryptDataResponseMessage['data']>;
63
65
  on<E extends keyof E2EEManagerCallbacks>(event: E, listener: E2EEManagerCallbacks[E]): this;
66
+ dispose?(): void;
64
67
  }
65
68
 
66
69
  /**
@@ -86,6 +89,29 @@ export class E2EEManager
86
89
 
87
90
  private dataChannelEncryptionEnabled: boolean;
88
91
 
92
+ private unsubscribeLogLevel?: () => void;
93
+
94
+ private log = getLogger(LoggerNames.E2EE, () => this.logContext);
95
+
96
+ get logContext() {
97
+ return {
98
+ room: this.room?.name,
99
+ participant: this.room?.localParticipant.identity,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Runs a cleanup callback once this manager is garbage collected. Lets the
105
+ * log-level listener (held in a module-global Set on the main-thread logger)
106
+ * fall out of scope even when the consumer forgets to call `dispose()`.
107
+ */
108
+ private static disposeRegistry =
109
+ typeof FinalizationRegistry !== 'undefined' &&
110
+ typeof WeakRef !== 'undefined' &&
111
+ new FinalizationRegistry((cleanup: () => void) => {
112
+ cleanup();
113
+ });
114
+
89
115
  constructor(options: E2EEManagerOptions, dcEncryptionEnabled: boolean) {
90
116
  super();
91
117
  this.keyProvider = options.keyProvider;
@@ -111,7 +137,7 @@ export class E2EEManager
111
137
  'tried to setup end-to-end encryption on an unsupported browser',
112
138
  );
113
139
  }
114
- log.info('setting up e2ee');
140
+ this.log.info('setting up e2ee');
115
141
  if (room !== this.room) {
116
142
  this.room = room;
117
143
  this.setupEventListeners(room, this.keyProvider);
@@ -124,19 +150,89 @@ export class E2EEManager
124
150
  },
125
151
  };
126
152
  if (this.worker) {
127
- log.info(`initializing worker`, { worker: this.worker });
153
+ this.log.info(`initializing worker`, { worker: this.worker });
128
154
  this.worker.onmessage = this.onWorkerMessage;
129
155
  this.worker.onerror = this.onWorkerError;
130
156
  this.worker.postMessage(msg);
157
+ this.subscribeToLogLevelChanges();
131
158
  }
132
159
  }
133
160
  }
134
161
 
162
+ /**
163
+ * Subscribe the current worker to main-thread `workerLogger` level changes,
164
+ * without strongly retaining `this` or `this.worker` from the module-global
165
+ * listener Set on the logger. See {@link disposeRegistry}.
166
+ */
167
+ private subscribeToLogLevelChanges() {
168
+ // Guard against duplicate registration on re-setup.
169
+ this.unsubscribeLogLevel?.();
170
+
171
+ let unsub: (() => void) | undefined;
172
+ if (E2EEManager.disposeRegistry) {
173
+ // Modern engines: hold the worker weakly so the module-global listener Set
174
+ // on the logger can't retain this manager, and clean up the entry on GC.
175
+ const workerRef = new WeakRef(this.worker);
176
+ unsub = onWorkerLogLevelChanged((level) => {
177
+ const worker = workerRef.deref();
178
+ if (!worker) {
179
+ unsub?.();
180
+ return;
181
+ }
182
+ worker.postMessage({ kind: 'setLogLevel', data: { level } });
183
+ });
184
+ E2EEManager.disposeRegistry.register(this, unsub, this);
185
+ } else {
186
+ // Safari <14.1 and similar: no WeakRef. Fall back to a strong reference;
187
+ // the leak lives until the consumer calls `dispose()`.
188
+ const worker = this.worker;
189
+ unsub = onWorkerLogLevelChanged((level) => {
190
+ worker.postMessage({ kind: 'setLogLevel', data: { level } });
191
+ });
192
+ }
193
+ this.unsubscribeLogLevel = unsub;
194
+ }
195
+
196
+ /**
197
+ * @internal
198
+ * Release the log-level subscription, reject any pending encrypt/decrypt
199
+ * futures, and detach the worker message handlers. The worker itself is
200
+ * caller-owned and is not terminated. Idempotent.
201
+ */
202
+ dispose() {
203
+ this.unsubscribeLogLevel?.();
204
+ this.unsubscribeLogLevel = undefined;
205
+ if (E2EEManager.disposeRegistry) {
206
+ E2EEManager.disposeRegistry.unregister(this);
207
+ }
208
+
209
+ // Reject pending futures BEFORE detaching worker handlers, so any late
210
+ // response can't resolve one after we've cut the pipe. Each future's
211
+ // `onFinally` deletes its own map entry, so both maps drain themselves.
212
+ // Snapshot before iterating in case a rejection handler mutates the map.
213
+ const disposalError = new CryptorError(
214
+ 'E2EEManager disposed',
215
+ CryptorErrorReason.InternalError,
216
+ );
217
+ for (const future of [...this.encryptDataRequests.values()]) {
218
+ future.reject?.(disposalError);
219
+ }
220
+ for (const future of [...this.decryptDataRequests.values()]) {
221
+ future.reject?.(disposalError);
222
+ }
223
+
224
+ if (this.worker) {
225
+ this.worker.onmessage = null;
226
+ this.worker.onerror = null;
227
+ }
228
+ this.removeAllListeners();
229
+ }
230
+
135
231
  /**
136
232
  * @internal
137
233
  */
138
234
  setParticipantCryptorEnabled(enabled: boolean, participantIdentity: string) {
139
- log.debug(`set e2ee to ${enabled} for participant ${participantIdentity}`);
235
+ this.log.debug(`set e2ee to ${enabled} for participant ${participantIdentity}`);
140
236
  this.postEnable(enabled, participantIdentity);
141
237
  }
142
238
 
@@ -145,7 +241,7 @@ export class E2EEManager
145
241
  */
146
242
  setSifTrailer(trailer: NonSharedUint8Array) {
147
243
  if (!trailer || trailer.length === 0) {
148
- log.warn("ignoring server sent trailer as it's empty");
244
+ this.log.warn("ignoring server sent trailer as it's empty");
149
245
  } else {
150
246
  this.postSifTrailer(trailer);
151
247
  }
@@ -155,25 +251,23 @@ export class E2EEManager
155
251
  const { kind, data } = ev.data;
156
252
  switch (kind) {
157
253
  case 'error':
158
- log.error(data.error.message);
159
-
160
- // If error has uuid, it's from an async operation (encrypt/decrypt)
161
- // Reject the corresponding future
254
+ // If error has uuid, it's from an async operation (encrypt/decrypt).
255
+ // Reject the corresponding future and let the caller decide how to log/handle;
256
+ // logging here would duplicate whatever the caller does.
162
257
  if (data.uuid) {
163
258
  const decryptFuture = this.decryptDataRequests.get(data.uuid);
164
259
  if (decryptFuture?.reject) {
165
260
  decryptFuture.reject(data.error);
166
- break; // Don't emit general error if it's handled by future
261
+ break;
167
262
  }
168
263
 
169
264
  const encryptFuture = this.encryptDataRequests.get(data.uuid);
170
265
  if (encryptFuture?.reject) {
171
266
  encryptFuture.reject(data.error);
172
- break; // Don't emit general error if it's handled by future
267
+ break;
173
268
  }
174
269
  }
175
-
176
- // Emit general error event for unhandled errors
270
+ this.log.error(data.error.message);
177
271
  this.emit(EncryptionEvent.EncryptionError, data.error, data.participantIdentity);
178
272
  break;
179
273
  case 'initAck':
@@ -234,13 +328,16 @@ export class E2EEManager
234
328
  case 'packetTrailerMetadata':
235
329
  this.handleFrameMetadata(data.trackId, data.rtpTimestamp, data.ssrc, data.metadata);
236
330
  break;
331
+ case 'log':
332
+ workerLogger[data.level](data.msg, data.context);
333
+ break;
237
334
  default:
238
335
  break;
239
336
  }
240
337
  };
241
338
 
242
339
  private onWorkerError = (ev: ErrorEvent) => {
243
- log.error('e2ee worker encountered an error:', { error: ev.error });
340
+ this.log.error('e2ee worker encountered an error:', { error: ev.error });
244
341
  this.emit(EncryptionEvent.EncryptionError, ev.error, undefined);
245
342
  };
246
343
 
@@ -279,20 +376,14 @@ export class E2EEManager
279
376
 
280
377
  private setupEventListeners(room: Room, keyProvider: BaseKeyProvider) {
281
378
  room.on(RoomEvent.TrackPublished, (pub, participant) =>
282
- this.setParticipantCryptorEnabled(
283
- pub.trackInfo!.encryption !== Encryption_Type.NONE,
284
- participant.identity,
285
- ),
379
+ this.setParticipantCryptorEnabledForPublication(pub, participant.identity),
286
380
  );
287
381
  room
288
382
  .on(RoomEvent.ConnectionStateChanged, (state) => {
289
383
  if (state === ConnectionState.Connected) {
290
384
  room.remoteParticipants.forEach((participant) => {
291
385
  participant.trackPublications.forEach((pub) => {
292
- this.setParticipantCryptorEnabled(
293
- pub.trackInfo!.encryption !== Encryption_Type.NONE,
294
- participant.identity,
295
- );
386
+ this.setParticipantCryptorEnabledForPublication(pub, participant.identity);
296
387
  });
297
388
  });
298
389
  }
@@ -482,6 +573,28 @@ export class E2EEManager
482
573
  this.worker.postMessage(msg);
483
574
  }
484
575
 
576
+ /**
577
+ * Derive the participant's encryption state from one of its publications.
578
+ * Publications without trackInfo are skipped rather than thrown on: throwing
579
+ * inside the forEach above would leave every remaining participant without an
580
+ * encryption state, and a cryptor with unknown state can no longer decrypt.
581
+ */
582
+ private setParticipantCryptorEnabledForPublication(
583
+ pub: TrackPublication,
584
+ participantIdentity: string,
585
+ ) {
586
+ if (!pub.trackInfo) {
587
+ this.log.warn('skipping e2ee enabled update for publication without trackInfo', {
588
+ trackSid: pub.trackSid,
589
+ });
590
+ return;
591
+ }
592
+ this.setParticipantCryptorEnabled(
593
+ pub.trackInfo.encryption !== Encryption_Type.NONE,
594
+ participantIdentity,
595
+ );
596
+ }
597
+
485
598
  private setupE2EEReceiver(track: RemoteTrack, remoteId: string, trackInfo?: TrackInfo) {
486
599
  if (!track.receiver) {
487
600
  return;
@@ -504,7 +617,7 @@ export class E2EEManager
504
617
 
505
618
  private setupE2EESender(track: Track, sender: RTCRtpSender) {
506
619
  if (!isLocalTrack(track) || !sender) {
507
- if (!sender) log.warn('early return because sender is not ready');
620
+ if (!sender) this.log.warn('early return because sender is not ready');
508
621
  return;
509
622
  }
510
623
  this.handleSender(
@@ -541,21 +654,27 @@ export class E2EEManager
541
654
  codec,
542
655
  hasPacketTrailer,
543
656
  };
544
- // @ts-ignore
545
657
  receiver.transform = new RTCRtpScriptTransform(this.worker, options);
546
658
  } else {
547
- if (E2EE_FLAG in receiver && codec) {
548
- // update track-specific state when the transceiver is reused
659
+ if (E2EE_FLAG in receiver && E2EE_TRACK_ID in receiver) {
660
+ // The transceiver is being reused for a new track. We cannot set up a new
661
+ // pipeline from here: this receiver's encoded streams were transferred to
662
+ // the worker on first setup and a transferred stream can't be transferred
663
+ // again, while createEncodedStreams() may only be called once per receiver.
664
+ // So hand the worker the new track/participant and let it re-point (and if
665
+ // necessary rebuild) the cryptor that still owns those streams.
549
666
  const msg: UpdateCodecMessage = {
550
667
  kind: 'updateCodec',
551
668
  data: {
552
669
  trackId,
670
+ previousTrackId: receiver[E2EE_TRACK_ID] as string,
553
671
  codec,
554
672
  participantIdentity,
555
673
  hasPacketTrailer,
556
674
  },
557
675
  };
558
676
  this.worker.postMessage(msg);
677
+ receiver[E2EE_TRACK_ID] = trackId;
559
678
  return;
560
679
  }
561
680
  // @ts-ignore
@@ -582,7 +701,6 @@ export class E2EEManager
582
701
  trackId: trackId,
583
702
  codec,
584
703
  participantIdentity: participantIdentity,
585
- isReuse: E2EE_FLAG in receiver,
586
704
  hasPacketTrailer,
587
705
  },
588
706
  };
@@ -591,6 +709,8 @@ export class E2EEManager
591
709
 
592
710
  // @ts-ignore
593
711
  receiver[E2EE_FLAG] = true;
712
+ // @ts-ignore
713
+ receiver[E2EE_TRACK_ID] = trackId;
594
714
  }
595
715
 
596
716
  /**
@@ -613,7 +733,7 @@ export class E2EEManager
613
733
  }
614
734
 
615
735
  if (isScriptTransformSupportedForWorker()) {
616
- log.info('initialize script transform');
736
+ this.log.info('initialize script transform');
617
737
  const options: ScriptTransformOptions = {
618
738
  kind: 'encode',
619
739
  participantIdentity: this.room.localParticipant.identity,
@@ -625,7 +745,7 @@ export class E2EEManager
625
745
  // @ts-ignore
626
746
  sender.transform = new RTCRtpScriptTransform(this.worker, options);
627
747
  } else {
628
- log.info('initialize encoded streams');
748
+ this.log.info('initialize encoded streams');
629
749
  // @ts-ignore
630
750
  const senderStreams = sender.createEncodedStreams();
631
751
  const msg: EncodeMessage = {
@@ -636,7 +756,6 @@ export class E2EEManager
636
756
  codec,
637
757
  trackId,
638
758
  participantIdentity: this.room.localParticipant.identity,
639
- isReuse: false,
640
759
  hasPacketTrailer: hasFrameMetadataPublishOptions(frameMetadata),
641
760
  packetTrailer: frameMetadata,
642
761
  },
@@ -29,6 +29,12 @@ export const IV_LENGTH = 12;
29
29
  // flag set to indicate that e2ee has been setup for sender/receiver;
30
30
  export const E2EE_FLAG = 'lk_e2ee';
31
31
 
32
+ /**
33
+ * trackId a receiver's/sender's encoded stream pipeline was last set up for.
34
+ * Needed to recover the right cryptor when a transceiver gets reused.
35
+ */
36
+ export const E2EE_TRACK_ID = 'lk_e2ee_track_id';
37
+
32
38
  export const SALT = 'LKFrameEncryptionKey';
33
39
 
34
40
  export const KEY_PROVIDER_DEFAULTS: KeyProviderOptions = {