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.
- package/dist/livekit-client.e2ee.worker.js +1 -1
- package/dist/livekit-client.e2ee.worker.js.map +1 -1
- package/dist/livekit-client.e2ee.worker.mjs +486 -437
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +398 -100
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.fm.worker.js +1 -1
- package/dist/livekit-client.fm.worker.js.map +1 -1
- package/dist/livekit-client.fm.worker.mjs +8 -1
- package/dist/livekit-client.fm.worker.mjs.map +1 -1
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/api/WebSocketStream.d.ts.map +1 -1
- package/dist/src/api/utils.d.ts +1 -0
- package/dist/src/api/utils.d.ts.map +1 -1
- package/dist/src/e2ee/E2eeManager.d.ts +26 -0
- package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
- package/dist/src/e2ee/types.d.ts +15 -1
- package/dist/src/e2ee/types.d.ts.map +1 -1
- package/dist/src/e2ee/worker/DataCryptor.d.ts.map +1 -1
- package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
- package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts.map +1 -0
- package/dist/src/e2ee/worker/FrameCryptor.d.ts +1 -18
- package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
- package/dist/src/logger.d.ts +4 -0
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +1 -0
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/publishUtils.d.ts +16 -0
- package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
- package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
- package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
- package/dist/src/room/track/options.d.ts +1 -1
- package/dist/src/room/utils.d.ts +34 -0
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/ts4.2/api/utils.d.ts +1 -0
- package/dist/ts4.2/e2ee/E2eeManager.d.ts +26 -0
- package/dist/ts4.2/e2ee/types.d.ts +15 -1
- package/dist/ts4.2/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
- package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +1 -18
- package/dist/ts4.2/logger.d.ts +4 -0
- package/dist/ts4.2/room/RTCEngine.d.ts +1 -0
- package/dist/ts4.2/room/participant/publishUtils.d.ts +16 -0
- package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +7 -0
- package/dist/ts4.2/room/track/options.d.ts +1 -1
- package/dist/ts4.2/room/utils.d.ts +34 -0
- package/package.json +1 -1
- package/src/api/WebSocketStream.ts +3 -8
- package/src/api/utils.ts +10 -0
- package/src/e2ee/E2eeManager.test.ts +196 -0
- package/src/e2ee/E2eeManager.ts +114 -19
- package/src/e2ee/types.ts +19 -1
- package/src/e2ee/worker/DataCryptor.ts +2 -1
- package/src/e2ee/worker/ErrorRateLimiter.test.ts +53 -0
- package/src/e2ee/worker/ErrorRateLimiter.ts +52 -0
- package/src/e2ee/worker/FrameCryptor.ts +20 -70
- package/src/e2ee/worker/e2ee.worker.ts +34 -11
- package/src/logger.ts +22 -0
- package/src/room/RTCEngine.ts +28 -7
- package/src/room/Room.ts +1 -1
- package/src/room/participant/LocalParticipant.ts +30 -14
- package/src/room/participant/publishUtils.test.ts +133 -0
- package/src/room/participant/publishUtils.ts +54 -19
- package/src/room/track/LocalVideoTrack.ts +15 -5
- package/src/room/track/options.ts +1 -1
- package/src/room/utils.test.ts +87 -0
- package/src/room/utils.ts +59 -0
package/dist/ts4.2/logger.d.ts
CHANGED
|
@@ -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
|
@@ -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
|
+
});
|
package/src/e2ee/E2eeManager.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
//
|
|
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;
|
|
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;
|
|
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
|
|
139
|
+
`DataCryptor: Decryption failed: ${getErrorDescription(error, 'decryption')}`,
|
|
139
140
|
CryptorErrorReason.InvalidKey,
|
|
140
141
|
keys.participantIdentity,
|
|
141
142
|
);
|