livekit-client 2.22.2 → 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 (68) 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 +486 -437
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +398 -100
  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/WebSocketStream.d.ts.map +1 -1
  14. package/dist/src/api/utils.d.ts +1 -0
  15. package/dist/src/api/utils.d.ts.map +1 -1
  16. package/dist/src/e2ee/E2eeManager.d.ts +26 -0
  17. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  18. package/dist/src/e2ee/types.d.ts +15 -1
  19. package/dist/src/e2ee/types.d.ts.map +1 -1
  20. package/dist/src/e2ee/worker/DataCryptor.d.ts.map +1 -1
  21. package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
  22. package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts.map +1 -0
  23. package/dist/src/e2ee/worker/FrameCryptor.d.ts +1 -18
  24. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  25. package/dist/src/logger.d.ts +4 -0
  26. package/dist/src/logger.d.ts.map +1 -1
  27. package/dist/src/room/RTCEngine.d.ts +1 -0
  28. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  29. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  30. package/dist/src/room/participant/publishUtils.d.ts +16 -0
  31. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  32. package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
  33. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  34. package/dist/src/room/track/options.d.ts +1 -1
  35. package/dist/src/room/utils.d.ts +34 -0
  36. package/dist/src/room/utils.d.ts.map +1 -1
  37. package/dist/ts4.2/api/utils.d.ts +1 -0
  38. package/dist/ts4.2/e2ee/E2eeManager.d.ts +26 -0
  39. package/dist/ts4.2/e2ee/types.d.ts +15 -1
  40. package/dist/ts4.2/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
  41. package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +1 -18
  42. package/dist/ts4.2/logger.d.ts +4 -0
  43. package/dist/ts4.2/room/RTCEngine.d.ts +1 -0
  44. package/dist/ts4.2/room/participant/publishUtils.d.ts +16 -0
  45. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +7 -0
  46. package/dist/ts4.2/room/track/options.d.ts +1 -1
  47. package/dist/ts4.2/room/utils.d.ts +34 -0
  48. package/package.json +1 -1
  49. package/src/api/WebSocketStream.ts +3 -8
  50. package/src/api/utils.ts +10 -0
  51. package/src/e2ee/E2eeManager.test.ts +196 -0
  52. package/src/e2ee/E2eeManager.ts +114 -19
  53. package/src/e2ee/types.ts +19 -1
  54. package/src/e2ee/worker/DataCryptor.ts +2 -1
  55. package/src/e2ee/worker/ErrorRateLimiter.test.ts +53 -0
  56. package/src/e2ee/worker/ErrorRateLimiter.ts +52 -0
  57. package/src/e2ee/worker/FrameCryptor.ts +20 -70
  58. package/src/e2ee/worker/e2ee.worker.ts +34 -11
  59. package/src/logger.ts +22 -0
  60. package/src/room/RTCEngine.ts +28 -7
  61. package/src/room/Room.ts +1 -1
  62. package/src/room/participant/LocalParticipant.ts +30 -14
  63. package/src/room/participant/publishUtils.test.ts +133 -0
  64. package/src/room/participant/publishUtils.ts +54 -19
  65. package/src/room/track/LocalVideoTrack.ts +15 -5
  66. package/src/room/track/options.ts +1 -1
  67. package/src/room/utils.test.ts +87 -0
  68. package/src/room/utils.ts +59 -0
@@ -59,4 +59,8 @@ export type LogExtension = (level: LogLevel, msg: string, context?: object) => v
59
59
  */
60
60
  export declare function setLogExtension(extension: LogExtension, logger?: StructuredLogger): void;
61
61
  export declare const workerLogger: StructuredLogger;
62
+ /** @internal Subscribe to workerLogger level changes (so E2EE workers can be kept in sync). */
63
+ export declare function onWorkerLogLevelChanged(cb: (level: LogLevel) => void): () => void;
64
+ /** @internal Test-only accessor: current number of workerLogger level listeners. */
65
+ export declare function getWorkerLogLevelListenerCount(): number;
62
66
  //# sourceMappingURL=logger.d.ts.map
@@ -40,6 +40,7 @@ export default class RTCEngine extends RTCEngine_base {
40
40
  get isClosed(): boolean;
41
41
  get isNewlyCreated(): boolean;
42
42
  get pendingReconnect(): boolean;
43
+ get serverVersion(): string | undefined;
43
44
  /**
44
45
  * Owns the data channels: the three flow-controlled publisher wrappers (engine-lifetime; the
45
46
  * RTCDataChannel handles underneath are attached/detached as peer connections come and go, with
@@ -12,6 +12,22 @@ export declare const defaultSimulcastPresets169: VideoPreset[];
12
12
  export declare const defaultSimulcastPresets43: VideoPreset[];
13
13
  export declare const computeDefaultScreenShareSimulcastPresets: (fromPreset: VideoPreset) => VideoPreset[];
14
14
  export declare function computeVideoEncodings(isScreenShare: boolean, width?: number, height?: number, options?: TrackPublishOptions): RTCRtpEncodingParameters[];
15
+ /**
16
+ * Bitrate to hint to the bandwidth estimator through `x-google-start-bitrate`, so that
17
+ * a publish does not spend its first seconds ramping up from a very low rate.
18
+ *
19
+ * It has to be the total the encoder will put on the wire, which means picking the
20
+ * encoding that carries the inclusive bitrate:
21
+ * - SVC publishes a single stream with the layers built in. `encodings[0]` holds the
22
+ * full bitrate — the legacy SVC shape orders its encodings `f`..`q`, so that holds
23
+ * for both SVC shapes.
24
+ * - Simulcast publishes independent streams ordered `q`..`f`, so the total is the sum.
25
+ * This includes VP9/AV1 published as rid based simulcast, where `encodings[0]` is
26
+ * the *smallest* layer even though the codec is SVC capable.
27
+ *
28
+ * @internal
29
+ */
30
+ export declare function computeStartTargetBitrate(codec: string, options: TrackPublishOptions | undefined, encodings: RTCRtpEncodingParameters[]): number;
15
31
  export declare function computeTrackBackupEncodings(track: LocalVideoTrack, videoCodec: BackupVideoCodec, opts: TrackPublishOptions): RTCRtpEncodingParameters[] | undefined;
16
32
  export declare function determineAppropriateEncoding(isScreenShare: boolean, width: number, height: number, codec?: VideoCodec): VideoEncoding;
17
33
  export declare function presetsForResolution(isScreenShare: boolean, width: number, height: number): VideoPreset[];
@@ -43,6 +43,13 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
43
43
  unmute(): Promise<typeof this>;
44
44
  protected setTrackMuted(muted: boolean): void;
45
45
  getSenderStats(): Promise<VideoSenderStats[]>;
46
+ /**
47
+ * Whether `codec` is being published as SVC (a single stream carrying all spatial
48
+ * layers) as opposed to rid based simulcast. VP9/AV1 are SVC unless the publisher
49
+ * opted into simulcast, in which case each rid is an independent stream and the
50
+ * layers can be enabled/disabled individually.
51
+ */
52
+ private isSvcPublish;
46
53
  setPublishingQuality(maxQuality: VideoQuality): void;
47
54
  restartTrack(options?: VideoCaptureOptions): Promise<void>;
48
55
  protected onSenderTrackSwapped(): Promise<void>;
@@ -67,7 +67,7 @@ export interface TrackPublishDefaults {
67
67
  simulcast?: boolean;
68
68
  /**
69
69
  * scalability mode for svc codecs, defaults to 'L3T3_KEY'.
70
- * for svc codecs, simulcast is disabled.
70
+ * for svc codecs, simulcast is disabled if more than one spatial layer is used ('L2Tx' or 'L3Tx').
71
71
  */
72
72
  scalabilityMode?: ScalabilityMode;
73
73
  /**
@@ -46,6 +46,40 @@ export declare function isSVCCodec(codec?: string): boolean;
46
46
  * @internal
47
47
  */
48
48
  export declare function negotiateDependencyDescriptor(transceiver: RTCRtpTransceiver): boolean;
49
+ /**
50
+ * VP9 and AV1 are published as SVC (a single RTP stream carrying every spatial layer)
51
+ * by default. They can instead be published as real, rid based simulcast — one
52
+ * independent stream per rid, each carrying a single spatial layer — when the caller
53
+ * opts in with `simulcast: true` and a single spatial layer scalability mode (`L1Tx`).
54
+ *
55
+ * The SFU has to be told about this: without an explicit
56
+ * `SimulcastCodec.videoLayerMode` it assumes `MULTIPLE_SPATIAL_LAYERS_PER_STREAM` for
57
+ * any SVC capable codec.
58
+ */
59
+ export declare function isSVCSimulcast(codec?: string, options?: {
60
+ simulcast?: boolean;
61
+ scalabilityMode?: string;
62
+ }): boolean;
63
+ /**
64
+ * Whether the browser reads multiple encodings on an SVC capable codec as *legacy SVC*
65
+ * rather than as real simulcast.
66
+ *
67
+ * Before Chrome M113, supplying more than one encoding for VP9/AV1 selected SVC mode;
68
+ * only from M113 does libwebrtc treat such encodings as simulcast, and only when each
69
+ * one carries its own scalabilityMode. Safari (and anything WebKit based, i. e. every
70
+ * browser on iOS) still uses the old interpretation, as does React Native's libwebrtc.
71
+ * Announced at https://groups.google.com/g/discuss-webrtc/c/-QQ3pxrl-fw
72
+ *
73
+ * Where this is true the rids would not exist on the wire, so VP9/AV1 must be published
74
+ * as SVC no matter what the caller asked for.
75
+ */
76
+ export declare function usesLegacySVCEncodings(): boolean;
77
+ /**
78
+ * Whether the connected server honours `SimulcastCodec.videoLayerMode`, i. e. whether
79
+ * VP9/AV1 can be published as rid based simulcast. An unknown version is treated as
80
+ * unsupported so the publish falls back to SVC.
81
+ */
82
+ export declare function isSVCSimulcastSupportedByServer(serverVersion?: string): boolean;
49
83
  export declare function supportsSetSinkId(elm?: HTMLMediaElement): boolean;
50
84
  /**
51
85
  * Checks whether or not setting an audio output via {@link Room#setActiveDevice}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livekit-client",
3
- "version": "2.22.2",
3
+ "version": "2.22.3",
4
4
  "description": "JavaScript/TypeScript client SDK for LiveKit",
5
5
  "main": "./dist/livekit-client.umd.js",
6
6
  "unpkg": "./dist/livekit-client.umd.js",
@@ -2,6 +2,7 @@
2
2
  import { ConnectionError } from '../room/errors';
3
3
  import { sleep } from '../room/utils';
4
4
  import TypedPromise from '../utils/TypedPromise';
5
+ import { getErrorDescription } from './utils';
5
6
 
6
7
  export interface WebSocketConnection<T extends ArrayBuffer | string = ArrayBuffer | string> {
7
8
  readable: ReadableStream<T>;
@@ -68,15 +69,9 @@ export class WebSocketStream<T extends ArrayBuffer | string = ArrayBuffer | stri
68
69
  start(controller) {
69
70
  ws.onmessage = ({ data }) => controller.enqueue(data);
70
71
  ws.onerror = (e) =>
71
- controller.error(
72
- ConnectionError.websocket(
73
- e instanceof Error
74
- ? `${e.name}: ${e.message}`
75
- : `Encountered unknown websocket error: ${String(e)}`,
76
- ),
77
- );
72
+ controller.error(ConnectionError.websocket(getErrorDescription(e, 'websocket')));
78
73
  ws.onclose = (ev) => {
79
- if (ev.wasClean) {
74
+ if (ev.wasClean || ev.code === 1000) {
80
75
  controller.close();
81
76
  } else {
82
77
  controller.error(
package/src/api/utils.ts CHANGED
@@ -63,3 +63,13 @@ export function getAbortReasonAsString(
63
63
  return 'toString' in reason ? reason.toString() : defaultMessage;
64
64
  }
65
65
  }
66
+
67
+ export function getErrorDescription(error: unknown, errorCategory: string): string {
68
+ if (error instanceof Error) {
69
+ if (error.name && error.message) {
70
+ return `${error.name}: ${error.message}`;
71
+ }
72
+ return error.name;
73
+ }
74
+ return `Encountered unknown ${errorCategory} error: ${String(error)}`;
75
+ }
@@ -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';
@@ -25,6 +25,7 @@ import {
25
25
  import type { NonSharedUint8Array } from '../type-polyfills/non-shared-typed-arrays';
26
26
  import type { BaseKeyProvider } from './KeyProvider';
27
27
  import { E2EE_FLAG, E2EE_TRACK_ID } from './constants';
28
+ import { CryptorError, CryptorErrorReason } from './errors';
28
29
  import { type E2EEManagerCallbacks, EncryptionEvent, KeyProviderEvent } from './events';
29
30
  import type {
30
31
  DecryptDataRequestMessage,
@@ -62,6 +63,7 @@ export interface BaseE2EEManager {
62
63
  keyIndex: number,
63
64
  ): Promise<DecryptDataResponseMessage['data']>;
64
65
  on<E extends keyof E2EEManagerCallbacks>(event: E, listener: E2EEManagerCallbacks[E]): this;
66
+ dispose?(): void;
65
67
  }
66
68
 
67
69
  /**
@@ -87,6 +89,29 @@ export class E2EEManager
87
89
 
88
90
  private dataChannelEncryptionEnabled: boolean;
89
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
+
90
115
  constructor(options: E2EEManagerOptions, dcEncryptionEnabled: boolean) {
91
116
  super();
92
117
  this.keyProvider = options.keyProvider;
@@ -112,7 +137,7 @@ export class E2EEManager
112
137
  'tried to setup end-to-end encryption on an unsupported browser',
113
138
  );
114
139
  }
115
- log.info('setting up e2ee');
140
+ this.log.info('setting up e2ee');
116
141
  if (room !== this.room) {
117
142
  this.room = room;
118
143
  this.setupEventListeners(room, this.keyProvider);
@@ -125,19 +150,89 @@ export class E2EEManager
125
150
  },
126
151
  };
127
152
  if (this.worker) {
128
- log.info(`initializing worker`, { worker: this.worker });
153
+ this.log.info(`initializing worker`, { worker: this.worker });
129
154
  this.worker.onmessage = this.onWorkerMessage;
130
155
  this.worker.onerror = this.onWorkerError;
131
156
  this.worker.postMessage(msg);
157
+ this.subscribeToLogLevelChanges();
132
158
  }
133
159
  }
134
160
  }
135
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
+
136
231
  /**
137
232
  * @internal
138
233
  */
139
234
  setParticipantCryptorEnabled(enabled: boolean, participantIdentity: string) {
140
- log.debug(`set e2ee to ${enabled} for participant ${participantIdentity}`);
235
+ this.log.debug(`set e2ee to ${enabled} for participant ${participantIdentity}`);
141
236
  this.postEnable(enabled, participantIdentity);
142
237
  }
143
238
 
@@ -146,7 +241,7 @@ export class E2EEManager
146
241
  */
147
242
  setSifTrailer(trailer: NonSharedUint8Array) {
148
243
  if (!trailer || trailer.length === 0) {
149
- log.warn("ignoring server sent trailer as it's empty");
244
+ this.log.warn("ignoring server sent trailer as it's empty");
150
245
  } else {
151
246
  this.postSifTrailer(trailer);
152
247
  }
@@ -156,25 +251,23 @@ export class E2EEManager
156
251
  const { kind, data } = ev.data;
157
252
  switch (kind) {
158
253
  case 'error':
159
- log.error(data.error.message);
160
-
161
- // If error has uuid, it's from an async operation (encrypt/decrypt)
162
- // 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.
163
257
  if (data.uuid) {
164
258
  const decryptFuture = this.decryptDataRequests.get(data.uuid);
165
259
  if (decryptFuture?.reject) {
166
260
  decryptFuture.reject(data.error);
167
- break; // Don't emit general error if it's handled by future
261
+ break;
168
262
  }
169
263
 
170
264
  const encryptFuture = this.encryptDataRequests.get(data.uuid);
171
265
  if (encryptFuture?.reject) {
172
266
  encryptFuture.reject(data.error);
173
- break; // Don't emit general error if it's handled by future
267
+ break;
174
268
  }
175
269
  }
176
-
177
- // Emit general error event for unhandled errors
270
+ this.log.error(data.error.message);
178
271
  this.emit(EncryptionEvent.EncryptionError, data.error, data.participantIdentity);
179
272
  break;
180
273
  case 'initAck':
@@ -235,13 +328,16 @@ export class E2EEManager
235
328
  case 'packetTrailerMetadata':
236
329
  this.handleFrameMetadata(data.trackId, data.rtpTimestamp, data.ssrc, data.metadata);
237
330
  break;
331
+ case 'log':
332
+ workerLogger[data.level](data.msg, data.context);
333
+ break;
238
334
  default:
239
335
  break;
240
336
  }
241
337
  };
242
338
 
243
339
  private onWorkerError = (ev: ErrorEvent) => {
244
- log.error('e2ee worker encountered an error:', { error: ev.error });
340
+ this.log.error('e2ee worker encountered an error:', { error: ev.error });
245
341
  this.emit(EncryptionEvent.EncryptionError, ev.error, undefined);
246
342
  };
247
343
 
@@ -488,8 +584,7 @@ export class E2EEManager
488
584
  participantIdentity: string,
489
585
  ) {
490
586
  if (!pub.trackInfo) {
491
- log.warn('skipping e2ee enabled update for publication without trackInfo', {
492
- participant: participantIdentity,
587
+ this.log.warn('skipping e2ee enabled update for publication without trackInfo', {
493
588
  trackSid: pub.trackSid,
494
589
  });
495
590
  return;
@@ -522,7 +617,7 @@ export class E2EEManager
522
617
 
523
618
  private setupE2EESender(track: Track, sender: RTCRtpSender) {
524
619
  if (!isLocalTrack(track) || !sender) {
525
- if (!sender) log.warn('early return because sender is not ready');
620
+ if (!sender) this.log.warn('early return because sender is not ready');
526
621
  return;
527
622
  }
528
623
  this.handleSender(
@@ -638,7 +733,7 @@ export class E2EEManager
638
733
  }
639
734
 
640
735
  if (isScriptTransformSupportedForWorker()) {
641
- log.info('initialize script transform');
736
+ this.log.info('initialize script transform');
642
737
  const options: ScriptTransformOptions = {
643
738
  kind: 'encode',
644
739
  participantIdentity: this.room.localParticipant.identity,
@@ -650,7 +745,7 @@ export class E2EEManager
650
745
  // @ts-ignore
651
746
  sender.transform = new RTCRtpScriptTransform(this.worker, options);
652
747
  } else {
653
- log.info('initialize encoded streams');
748
+ this.log.info('initialize encoded streams');
654
749
  // @ts-ignore
655
750
  const senderStreams = sender.createEncodedStreams();
656
751
  const msg: EncodeMessage = {
package/src/e2ee/types.ts CHANGED
@@ -176,6 +176,22 @@ export interface PTMetadataFromE2EEMessage extends BaseMessage {
176
176
  data: FrameMetadataPayload;
177
177
  }
178
178
 
179
+ export interface LogMessage extends BaseMessage {
180
+ kind: 'log';
181
+ data: {
182
+ level: 'trace' | 'debug' | 'info' | 'warn' | 'error';
183
+ msg: string;
184
+ context?: object;
185
+ };
186
+ }
187
+
188
+ export interface SetLogLevelMessage extends BaseMessage {
189
+ kind: 'setLogLevel';
190
+ data: {
191
+ level: LogLevel;
192
+ };
193
+ }
194
+
179
195
  export type E2EEWorkerMessage =
180
196
  | InitMessage
181
197
  | SetKeyMessage
@@ -193,7 +209,9 @@ export type E2EEWorkerMessage =
193
209
  | DecryptDataResponseMessage
194
210
  | EncryptDataRequestMessage
195
211
  | EncryptDataResponseMessage
196
- | PTMetadataFromE2EEMessage;
212
+ | PTMetadataFromE2EEMessage
213
+ | LogMessage
214
+ | SetLogLevelMessage;
197
215
 
198
216
  export type KeySet = { material: CryptoKey; encryptionKey: CryptoKey };
199
217
 
@@ -1,3 +1,4 @@
1
+ import { getErrorDescription } from '../../api/utils';
1
2
  import { workerLogger } from '../../logger';
2
3
  import type { NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays';
3
4
  import { ENCRYPTION_ALGORITHM } from '../constants';
@@ -135,7 +136,7 @@ export class DataCryptor {
135
136
  }
136
137
  } else {
137
138
  throw new CryptorError(
138
- `DataCryptor: Decryption failed: ${error.message}`,
139
+ `DataCryptor: Decryption failed: ${getErrorDescription(error, 'decryption')}`,
139
140
  CryptorErrorReason.InvalidKey,
140
141
  keys.participantIdentity,
141
142
  );