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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ErrorRateLimiter } from './ErrorRateLimiter';
|
|
3
|
+
|
|
4
|
+
describe('ErrorRateLimiter', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.useFakeTimers();
|
|
7
|
+
vi.setSystemTime(1_000_000);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.useRealTimers();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('emits first call, throttles the immediate next, then allows after throttle window', () => {
|
|
15
|
+
const l = new ErrorRateLimiter(1000, 60_000, 5);
|
|
16
|
+
expect(l.shouldEmit('k')).toBe(true);
|
|
17
|
+
vi.setSystemTime(1_000_500);
|
|
18
|
+
expect(l.shouldEmit('k')).toBe(false);
|
|
19
|
+
vi.setSystemTime(1_001_600);
|
|
20
|
+
expect(l.shouldEmit('k')).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('caps at maxPerWindow and invokes onSuppress once', () => {
|
|
24
|
+
const l = new ErrorRateLimiter(0, 60_000, 3);
|
|
25
|
+
const onSuppress = vi.fn();
|
|
26
|
+
// one free emit on window reset (count stays 0), then increments to 3
|
|
27
|
+
expect(l.shouldEmit('k', onSuppress)).toBe(true);
|
|
28
|
+
expect(l.shouldEmit('k', onSuppress)).toBe(true);
|
|
29
|
+
expect(l.shouldEmit('k', onSuppress)).toBe(true);
|
|
30
|
+
expect(l.shouldEmit('k', onSuppress)).toBe(true);
|
|
31
|
+
// now count == 3 == max → suppressed, callback fires once
|
|
32
|
+
expect(l.shouldEmit('k', onSuppress)).toBe(false);
|
|
33
|
+
expect(l.shouldEmit('k', onSuppress)).toBe(false);
|
|
34
|
+
expect(onSuppress).toHaveBeenCalledTimes(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('resets count after windowMs', () => {
|
|
38
|
+
const l = new ErrorRateLimiter(0, 1000, 2);
|
|
39
|
+
l.shouldEmit('k');
|
|
40
|
+
l.shouldEmit('k');
|
|
41
|
+
l.shouldEmit('k');
|
|
42
|
+
expect(l.shouldEmit('k')).toBe(false);
|
|
43
|
+
vi.setSystemTime(1_003_000);
|
|
44
|
+
expect(l.shouldEmit('k')).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('tracks keys independently', () => {
|
|
48
|
+
const l = new ErrorRateLimiter(1000);
|
|
49
|
+
expect(l.shouldEmit('a')).toBe(true);
|
|
50
|
+
expect(l.shouldEmit('b')).toBe(true);
|
|
51
|
+
expect(l.shouldEmit('a')).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-key rate limiter for repeated errors. Prevents log/emit floods and the
|
|
3
|
+
* unbounded map growth that a per-event log would cause when a broken key
|
|
4
|
+
* keeps producing failures.
|
|
5
|
+
*/
|
|
6
|
+
export class ErrorRateLimiter {
|
|
7
|
+
private lastAt: Map<string, number> = new Map();
|
|
8
|
+
|
|
9
|
+
private counts: Map<string, number> = new Map();
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly throttleMs: number = 1000,
|
|
13
|
+
private readonly windowMs: number = 60_000,
|
|
14
|
+
private readonly maxPerWindow: number = 5,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
reset() {
|
|
18
|
+
this.lastAt.clear();
|
|
19
|
+
this.counts.clear();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
countFor(key: string): number {
|
|
23
|
+
return this.counts.get(key) ?? 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns true if the caller should emit for this key. Invokes `onSuppress`
|
|
28
|
+
* exactly once per window when the per-window limit is first crossed.
|
|
29
|
+
*/
|
|
30
|
+
shouldEmit(key: string, onSuppress?: () => void): boolean {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const last = this.lastAt.get(key) ?? 0;
|
|
33
|
+
const count = this.counts.get(key) ?? 0;
|
|
34
|
+
|
|
35
|
+
if (now - last > this.windowMs) {
|
|
36
|
+
this.counts.set(key, 0);
|
|
37
|
+
this.lastAt.set(key, now);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
if (now - last < this.throttleMs) return false;
|
|
41
|
+
if (count >= this.maxPerWindow) {
|
|
42
|
+
if (count === this.maxPerWindow) {
|
|
43
|
+
onSuppress?.();
|
|
44
|
+
this.counts.set(key, count + 1);
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
this.lastAt.set(key, now);
|
|
49
|
+
this.counts.set(key, count + 1);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// TODO code inspired by https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption/js/worker.js
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
3
|
import type TypedEventEmitter from 'typed-emitter';
|
|
4
|
+
import { getErrorDescription } from '../../api/utils';
|
|
4
5
|
import {
|
|
5
6
|
appendPacketTrailerToEncodedFrame,
|
|
6
7
|
processPacketTrailer,
|
|
@@ -22,6 +23,7 @@ import type {
|
|
|
22
23
|
RatchetResult,
|
|
23
24
|
} from '../types';
|
|
24
25
|
import { deriveKeys, isVideoFrame, needsRbspUnescaping, parseRbsp, writeRbsp } from '../utils';
|
|
26
|
+
import { ErrorRateLimiter } from './ErrorRateLimiter';
|
|
25
27
|
import type { ParticipantKeyHandler } from './ParticipantKeyHandler';
|
|
26
28
|
import { processNALUsForEncryption } from './naluUtils';
|
|
27
29
|
import { identifySifPayload } from './sifPayload';
|
|
@@ -111,18 +113,7 @@ export class FrameCryptor extends BaseFrameCryptor {
|
|
|
111
113
|
|
|
112
114
|
private frameMetadataFrameId = 0;
|
|
113
115
|
|
|
114
|
-
|
|
115
|
-
* Throttling mechanism for decryption errors to prevent memory leaks
|
|
116
|
-
*/
|
|
117
|
-
private lastErrorTimestamp: Map<string, number> = new Map();
|
|
118
|
-
|
|
119
|
-
private errorCounts: Map<string, number> = new Map();
|
|
120
|
-
|
|
121
|
-
private readonly ERROR_THROTTLE_MS = 1000; // Emit error at most once per second
|
|
122
|
-
|
|
123
|
-
private readonly MAX_ERRORS_PER_MINUTE = 5; // Maximum errors to emit per minute per key
|
|
124
|
-
|
|
125
|
-
private readonly ERROR_WINDOW_MS = 60000; // 1 minute window
|
|
116
|
+
private errorLimiter = new ErrorRateLimiter();
|
|
126
117
|
|
|
127
118
|
private undecryptedTrackTimeout?: ReturnType<typeof setTimeout>;
|
|
128
119
|
|
|
@@ -196,8 +187,7 @@ export class FrameCryptor extends BaseFrameCryptor {
|
|
|
196
187
|
clearTimeout(this.undecryptedTrackTimeout);
|
|
197
188
|
this.undecryptedTrackTimeout = undefined;
|
|
198
189
|
this.participantIdentity = undefined;
|
|
199
|
-
this.
|
|
200
|
-
this.errorCounts = new Map();
|
|
190
|
+
this.errorLimiter.reset();
|
|
201
191
|
}
|
|
202
192
|
|
|
203
193
|
isEnabled() {
|
|
@@ -421,64 +411,24 @@ export class FrameCryptor extends BaseFrameCryptor {
|
|
|
421
411
|
this.sifTrailer = trailer;
|
|
422
412
|
}
|
|
423
413
|
|
|
424
|
-
/**
|
|
425
|
-
* Checks if we should emit an error based on throttling rules to prevent memory leaks
|
|
426
|
-
* @param errorKey - unique key identifying the error context
|
|
427
|
-
* @returns true if the error should be emitted, false otherwise
|
|
428
|
-
*/
|
|
429
|
-
private shouldEmitError(errorKey: string): boolean {
|
|
430
|
-
const now = Date.now();
|
|
431
|
-
const lastErrorTime = this.lastErrorTimestamp.get(errorKey) ?? 0;
|
|
432
|
-
const errorCount = this.errorCounts.get(errorKey) ?? 0;
|
|
433
|
-
|
|
434
|
-
// Reset count if we're in a new time window
|
|
435
|
-
if (now - lastErrorTime > this.ERROR_WINDOW_MS) {
|
|
436
|
-
this.errorCounts.set(errorKey, 0);
|
|
437
|
-
this.lastErrorTimestamp.set(errorKey, now);
|
|
438
|
-
return true;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// Check if we've exceeded the throttle time
|
|
442
|
-
if (now - lastErrorTime < this.ERROR_THROTTLE_MS) {
|
|
443
|
-
return false;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// Check if we've exceeded the max errors per window
|
|
447
|
-
if (errorCount >= this.MAX_ERRORS_PER_MINUTE) {
|
|
448
|
-
// Only log a warning once when hitting the limit
|
|
449
|
-
if (errorCount === this.MAX_ERRORS_PER_MINUTE) {
|
|
450
|
-
workerLogger.warn(`Suppressing further decryption errors for ${this.participantIdentity}`, {
|
|
451
|
-
...this.logContext,
|
|
452
|
-
errorKey,
|
|
453
|
-
});
|
|
454
|
-
this.errorCounts.set(errorKey, errorCount + 1);
|
|
455
|
-
}
|
|
456
|
-
return false;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// Update tracking
|
|
460
|
-
this.lastErrorTimestamp.set(errorKey, now);
|
|
461
|
-
this.errorCounts.set(errorKey, errorCount + 1);
|
|
462
|
-
return true;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* Emits a throttled error to prevent memory leaks from repeated decryption failures
|
|
467
|
-
* @param error - the CryptorError to emit
|
|
468
|
-
*/
|
|
469
414
|
private emitThrottledError(error: CryptorError) {
|
|
470
415
|
const errorKey = `${this.participantIdentity}-${error.reason}-decrypt`;
|
|
416
|
+
const emit = this.errorLimiter.shouldEmit(errorKey, () => {
|
|
417
|
+
workerLogger.warn(`Suppressing further decryption errors for ${this.participantIdentity}`, {
|
|
418
|
+
...this.logContext,
|
|
419
|
+
errorKey,
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
if (!emit) return;
|
|
471
423
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
this.emit(CryptorEvent.Error, error);
|
|
424
|
+
const count = this.errorLimiter.countFor(errorKey);
|
|
425
|
+
if (count > 1) {
|
|
426
|
+
workerLogger.debug(`Decryption error (${count} occurrences in window)`, {
|
|
427
|
+
...this.logContext,
|
|
428
|
+
reason: CryptorErrorReason[error.reason],
|
|
429
|
+
});
|
|
481
430
|
}
|
|
431
|
+
this.emit(CryptorEvent.Error, error);
|
|
482
432
|
}
|
|
483
433
|
|
|
484
434
|
/**
|
|
@@ -587,7 +537,7 @@ export class FrameCryptor extends BaseFrameCryptor {
|
|
|
587
537
|
return controller.enqueue(encodedFrame);
|
|
588
538
|
} catch (e: any) {
|
|
589
539
|
// TODO: surface this to the app.
|
|
590
|
-
workerLogger.error(e);
|
|
540
|
+
workerLogger.error(`error while encrypting`, { ...this.logContext, error: e });
|
|
591
541
|
}
|
|
592
542
|
} else {
|
|
593
543
|
workerLogger.debug('failed to encrypt, emitting error', this.logContext);
|
|
@@ -858,7 +808,7 @@ export class FrameCryptor extends BaseFrameCryptor {
|
|
|
858
808
|
}
|
|
859
809
|
} else {
|
|
860
810
|
throw new CryptorError(
|
|
861
|
-
`Decryption failed: ${error
|
|
811
|
+
`Decryption failed: ${getErrorDescription(error, 'decryption')}`,
|
|
862
812
|
CryptorErrorReason.InvalidKey,
|
|
863
813
|
this.participantIdentity,
|
|
864
814
|
);
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
ScriptTransformOptions,
|
|
19
19
|
} from '../types';
|
|
20
20
|
import { DataCryptor } from './DataCryptor';
|
|
21
|
+
import { ErrorRateLimiter } from './ErrorRateLimiter';
|
|
21
22
|
import { FrameCryptor, encryptionEnabledMap } from './FrameCryptor';
|
|
22
23
|
import { ParticipantKeyHandler } from './ParticipantKeyHandler';
|
|
23
24
|
|
|
@@ -36,8 +37,21 @@ let keyProviderOptions: KeyProviderOptions = KEY_PROVIDER_DEFAULTS;
|
|
|
36
37
|
|
|
37
38
|
let rtpMap: Map<number, VideoCodec> = new Map();
|
|
38
39
|
|
|
40
|
+
const dataDecryptErrorLimiter = new ErrorRateLimiter();
|
|
41
|
+
|
|
39
42
|
workerLogger.setDefaultLevel('info');
|
|
40
43
|
|
|
44
|
+
// Forward worker log calls to the main thread so they reach any
|
|
45
|
+
// setLogExtension consumer installed there. The main-thread workerLogger
|
|
46
|
+
// re-emits them, which invokes both the console and the extension.
|
|
47
|
+
workerLogger.methodFactory = (methodName) => (msg, context) => {
|
|
48
|
+
postMessage({
|
|
49
|
+
kind: 'log',
|
|
50
|
+
data: { level: methodName, msg, context },
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
workerLogger.setLevel(workerLogger.getLevel());
|
|
54
|
+
|
|
41
55
|
onmessage = (ev) => {
|
|
42
56
|
messageQueue.run(async () => {
|
|
43
57
|
const { kind, data }: E2EEWorkerMessage = ev.data;
|
|
@@ -45,7 +59,7 @@ onmessage = (ev) => {
|
|
|
45
59
|
switch (kind) {
|
|
46
60
|
case 'init':
|
|
47
61
|
workerLogger.setLevel(data.loglevel);
|
|
48
|
-
workerLogger.info('worker initialized');
|
|
62
|
+
workerLogger.info('e2ee worker initialized');
|
|
49
63
|
keyProviderOptions = data.keyProviderOptions;
|
|
50
64
|
useSharedKey = !!data.keyProviderOptions.sharedKey;
|
|
51
65
|
// acknowledge init successful
|
|
@@ -55,6 +69,9 @@ onmessage = (ev) => {
|
|
|
55
69
|
};
|
|
56
70
|
postMessage(ackMsg);
|
|
57
71
|
break;
|
|
72
|
+
case 'setLogLevel':
|
|
73
|
+
workerLogger.setLevel(data.level);
|
|
74
|
+
break;
|
|
58
75
|
case 'enable':
|
|
59
76
|
setEncryptionEnabled(data.enabled, data.participantIdentity);
|
|
60
77
|
workerLogger.info(
|
|
@@ -96,11 +113,6 @@ onmessage = (ev) => {
|
|
|
96
113
|
data.payload,
|
|
97
114
|
getParticipantKeyHandler(data.participantIdentity),
|
|
98
115
|
);
|
|
99
|
-
console.log('encrypted payload', {
|
|
100
|
-
original: data.payload,
|
|
101
|
-
encrypted: encryptedPayload,
|
|
102
|
-
iv,
|
|
103
|
-
});
|
|
104
116
|
postMessage({
|
|
105
117
|
kind: 'encryptDataResponse',
|
|
106
118
|
data: {
|
|
@@ -125,12 +137,23 @@ onmessage = (ev) => {
|
|
|
125
137
|
data: { payload: decryptedPayload, uuid: data.uuid },
|
|
126
138
|
} satisfies DecryptDataResponseMessage);
|
|
127
139
|
} catch (error) {
|
|
128
|
-
// Send error back to main thread with uuid so it can reject the corresponding promise
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
140
|
+
// Send error back to main thread with uuid so it can reject the corresponding promise.
|
|
141
|
+
// The error response must always be posted so the awaiting future resolves; only the
|
|
142
|
+
// log is throttled to avoid flooding when a broken key keeps producing failures.
|
|
143
|
+
const errorKey = `${data.participantIdentity}-datadecrypt`;
|
|
144
|
+
const shouldLog = dataDecryptErrorLimiter.shouldEmit(errorKey, () => {
|
|
145
|
+
workerLogger.warn(
|
|
146
|
+
`Suppressing further data decryption errors for ${data.participantIdentity}`,
|
|
147
|
+
{ errorKey },
|
|
148
|
+
);
|
|
133
149
|
});
|
|
150
|
+
if (shouldLog) {
|
|
151
|
+
workerLogger.error('DataCryptor decryption failed', {
|
|
152
|
+
error,
|
|
153
|
+
participantIdentity: data.participantIdentity,
|
|
154
|
+
uuid: data.uuid,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
134
157
|
postMessage({
|
|
135
158
|
kind: 'error',
|
|
136
159
|
data: {
|
package/src/logger.ts
CHANGED
|
@@ -131,3 +131,25 @@ export function setLogExtension(extension: LogExtension, logger?: StructuredLogg
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
export const workerLogger = log.getLogger(LoggerNames.E2EE) as StructuredLogger;
|
|
134
|
+
|
|
135
|
+
const workerLogLevelListeners = new Set<(level: LogLevel) => void>();
|
|
136
|
+
|
|
137
|
+
const originalWorkerSetLevel = workerLogger.setLevel.bind(workerLogger);
|
|
138
|
+
workerLogger.setLevel = ((level: log.LogLevelDesc, persist?: boolean) => {
|
|
139
|
+
originalWorkerSetLevel(level, persist);
|
|
140
|
+
const numeric = workerLogger.getLevel() as LogLevel;
|
|
141
|
+
workerLogLevelListeners.forEach((cb) => cb(numeric));
|
|
142
|
+
}) as typeof workerLogger.setLevel;
|
|
143
|
+
|
|
144
|
+
/** @internal Subscribe to workerLogger level changes (so E2EE workers can be kept in sync). */
|
|
145
|
+
export function onWorkerLogLevelChanged(cb: (level: LogLevel) => void): () => void {
|
|
146
|
+
workerLogLevelListeners.add(cb);
|
|
147
|
+
return () => {
|
|
148
|
+
workerLogLevelListeners.delete(cb);
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @internal Test-only accessor: current number of workerLogger level listeners. */
|
|
153
|
+
export function getWorkerLogLevelListenerCount(): number {
|
|
154
|
+
return workerLogLevelListeners.size;
|
|
155
|
+
}
|
package/src/room/RTCEngine.ts
CHANGED
|
@@ -173,6 +173,14 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
173
173
|
return !!this.reconnectTimeout;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
get serverVersion(): string | undefined {
|
|
177
|
+
return (
|
|
178
|
+
this.latestJoinResponse?.serverInfo?.version ||
|
|
179
|
+
this.latestJoinResponse?.serverVersion ||
|
|
180
|
+
undefined
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
176
184
|
/**
|
|
177
185
|
* Owns the data channels: the three flow-controlled publisher wrappers (engine-lifetime; the
|
|
178
186
|
* RTCDataChannel handles underneath are attached/detached as peer connections come and go, with
|
|
@@ -655,7 +663,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
655
663
|
sdp: sd.sdp,
|
|
656
664
|
midToTrackId,
|
|
657
665
|
});
|
|
658
|
-
|
|
666
|
+
// in dual PC mode the publisher answer carries no mapping (the server's publisher
|
|
667
|
+
// transport has no sending tracks) and must not clobber the subscriber offer mapping
|
|
668
|
+
if (this.pcManager.mode === 'publisher-only') {
|
|
669
|
+
this.midToTrackId = midToTrackId;
|
|
670
|
+
}
|
|
659
671
|
await this.pcManager.setPublisherAnswer(sd, offerId);
|
|
660
672
|
};
|
|
661
673
|
|
|
@@ -918,12 +930,21 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
918
930
|
this.log.error('Received encrypted packet but E2EE not set up');
|
|
919
931
|
return;
|
|
920
932
|
}
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
933
|
+
let decryptedData;
|
|
934
|
+
try {
|
|
935
|
+
decryptedData = await this.e2eeManager.handleEncryptedData(
|
|
936
|
+
dp.value.value.encryptedValue as NonSharedUint8Array,
|
|
937
|
+
dp.value.value.iv as NonSharedUint8Array,
|
|
938
|
+
dp.participantIdentity,
|
|
939
|
+
dp.value.value.keyIndex,
|
|
940
|
+
);
|
|
941
|
+
} catch (err) {
|
|
942
|
+
this.log.debug('failed to decrypt data packet', {
|
|
943
|
+
error: err,
|
|
944
|
+
participantIdentity: dp.participantIdentity,
|
|
945
|
+
});
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
927
948
|
const decryptedPacket = EncryptedPacketPayload.fromBinary(decryptedData.payload);
|
|
928
949
|
const newDp = new DataPacket({
|
|
929
950
|
value: decryptedPacket.value,
|
package/src/room/Room.ts
CHANGED
|
@@ -339,7 +339,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
339
339
|
this.log,
|
|
340
340
|
this.outgoingDataStreamManager,
|
|
341
341
|
this.getRemoteParticipantClientProtocol,
|
|
342
|
-
() => this.engine
|
|
342
|
+
() => this.engine?.serverVersion,
|
|
343
343
|
);
|
|
344
344
|
this.rpcClientManager.on('sendDataPacket', ({ packet }) => {
|
|
345
345
|
this.engine?.sendDataPacket(packet, DataChannelKind.RELIABLE);
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
TrackInfo,
|
|
20
20
|
TrackUnpublishedResponse,
|
|
21
21
|
UserPacket,
|
|
22
|
+
VideoLayer_Mode,
|
|
22
23
|
protoInt64,
|
|
23
24
|
} from '@livekit/protocol';
|
|
24
25
|
import { SignalConnectionState } from '../../api/SignalClient';
|
|
@@ -101,6 +102,8 @@ import {
|
|
|
101
102
|
isLocalTrack,
|
|
102
103
|
isLocalVideoTrack,
|
|
103
104
|
isSVCCodec,
|
|
105
|
+
isSVCSimulcast,
|
|
106
|
+
isSVCSimulcastSupportedByServer,
|
|
104
107
|
isSafari17Based,
|
|
105
108
|
isVideoCodec,
|
|
106
109
|
isVideoTrack,
|
|
@@ -108,12 +111,14 @@ import {
|
|
|
108
111
|
sleep,
|
|
109
112
|
supportsAV1,
|
|
110
113
|
supportsVP9,
|
|
114
|
+
usesLegacySVCEncodings,
|
|
111
115
|
} from '../utils';
|
|
112
116
|
import Participant from './Participant';
|
|
113
117
|
import type { ParticipantTrackPermission } from './ParticipantTrackPermission';
|
|
114
118
|
import { trackPermissionToProto } from './ParticipantTrackPermission';
|
|
115
119
|
import type RemoteParticipant from './RemoteParticipant';
|
|
116
120
|
import {
|
|
121
|
+
computeStartTargetBitrate,
|
|
117
122
|
computeTrackBackupEncodings,
|
|
118
123
|
computeVideoEncodings,
|
|
119
124
|
getDefaultDegradationPreference,
|
|
@@ -1135,7 +1140,19 @@ export default class LocalParticipant extends Participant {
|
|
|
1135
1140
|
req.height = dims.height;
|
|
1136
1141
|
// for svc codecs, disable simulcast and use vp8 for backup codec
|
|
1137
1142
|
if (isLocalVideoTrack(track)) {
|
|
1138
|
-
if (
|
|
1143
|
+
if (
|
|
1144
|
+
isSVCSimulcast(videoCodec, opts) &&
|
|
1145
|
+
(usesLegacySVCEncodings() || !isSVCSimulcastSupportedByServer(this.engine?.serverVersion))
|
|
1146
|
+
) {
|
|
1147
|
+
opts.simulcast = false;
|
|
1148
|
+
this.log.info(
|
|
1149
|
+
'SVC simulcast is not supported, disabling simulcast.',
|
|
1150
|
+
getLogContextFromTrack(track),
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const svcSimulcast = isSVCSimulcast(videoCodec, opts);
|
|
1155
|
+
if (isSVCCodec(videoCodec) && !svcSimulcast) {
|
|
1139
1156
|
if (track.source === Track.Source.ScreenShare) {
|
|
1140
1157
|
// vp9 svc with screenshare cannot encode multiple spatial layers
|
|
1141
1158
|
// doing so reduces publish resolution to minimal resolution
|
|
@@ -1157,12 +1174,14 @@ export default class LocalParticipant extends Participant {
|
|
|
1157
1174
|
opts.scalabilityMode = opts.scalabilityMode ?? 'L3T3_KEY';
|
|
1158
1175
|
}
|
|
1159
1176
|
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1177
|
+
const primaryCodec = new SimulcastCodec({
|
|
1178
|
+
codec: videoCodec,
|
|
1179
|
+
cid: track.mediaStreamTrack.id,
|
|
1180
|
+
});
|
|
1181
|
+
if (svcSimulcast) {
|
|
1182
|
+
primaryCodec.videoLayerMode = VideoLayer_Mode.ONE_SPATIAL_LAYER_PER_STREAM;
|
|
1183
|
+
}
|
|
1184
|
+
req.simulcastCodecs = [primaryCodec];
|
|
1166
1185
|
|
|
1167
1186
|
// set up backup
|
|
1168
1187
|
if (opts.backupCodec === true) {
|
|
@@ -1197,7 +1216,7 @@ export default class LocalParticipant extends Participant {
|
|
|
1197
1216
|
req.width,
|
|
1198
1217
|
req.height,
|
|
1199
1218
|
encodings,
|
|
1200
|
-
isSVCCodec(opts.videoCodec),
|
|
1219
|
+
isSVCCodec(opts.videoCodec) && !isSVCSimulcast(opts.videoCodec, opts),
|
|
1201
1220
|
);
|
|
1202
1221
|
} else if (track.kind === Track.Kind.Audio) {
|
|
1203
1222
|
encodings = [
|
|
@@ -1253,12 +1272,9 @@ export default class LocalParticipant extends Participant {
|
|
|
1253
1272
|
});
|
|
1254
1273
|
}
|
|
1255
1274
|
} else if (track.codec && isVideoCodec(track.codec)) {
|
|
1256
|
-
// Apply start bitrate for all video codecs to prevent initial blurriness
|
|
1257
|
-
//
|
|
1258
|
-
|
|
1259
|
-
const targetBitrate = isSVCCodec(track.codec)
|
|
1260
|
-
? (encodings[0]?.maxBitrate ?? 0)
|
|
1261
|
-
: encodings.reduce((sum, enc) => sum + (enc.maxBitrate ?? 0), 0);
|
|
1275
|
+
// Apply start bitrate for all video codecs to prevent initial blurriness,
|
|
1276
|
+
// see computeStartTargetBitrate
|
|
1277
|
+
const targetBitrate = computeStartTargetBitrate(track.codec, opts, encodings);
|
|
1262
1278
|
if (targetBitrate > 0) {
|
|
1263
1279
|
this.engine.pcManager.publisher.setTrackCodecBitrate({
|
|
1264
1280
|
cid: req.cid,
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import { ScreenSharePresets, VideoPreset, VideoPresets, VideoPresets43 } from '../track/options';
|
|
3
3
|
import {
|
|
4
4
|
computeDefaultScreenShareSimulcastPresets,
|
|
5
|
+
computeStartTargetBitrate,
|
|
5
6
|
computeVideoEncodings,
|
|
6
7
|
determineAppropriateEncoding,
|
|
7
8
|
presets43,
|
|
@@ -116,6 +117,78 @@ describe('computeVideoEncodings', () => {
|
|
|
116
117
|
expect(encodings![0].scaleResolutionDownBy).toBe(1);
|
|
117
118
|
});
|
|
118
119
|
|
|
120
|
+
// svc carries the scalabilityMode on the first encoding only (whether it emits a
|
|
121
|
+
// single encoding or the legacy multi-encoding shape), simulcast carries it on all
|
|
122
|
+
const countScalabilityModes = (encodings?: RTCRtpEncodingParameters[]) =>
|
|
123
|
+
/* @ts-ignore */
|
|
124
|
+
encodings!.filter((encoding) => encoding.scalabilityMode !== undefined).length;
|
|
125
|
+
|
|
126
|
+
it('keeps svc for an svc codec without simulcast', () => {
|
|
127
|
+
const encodings = computeVideoEncodings(false, 960, 540, {
|
|
128
|
+
simulcast: false,
|
|
129
|
+
videoCodec: 'vp9',
|
|
130
|
+
scalabilityMode: 'L3T3_KEY',
|
|
131
|
+
});
|
|
132
|
+
/* @ts-ignore */
|
|
133
|
+
expect(encodings![0].scalabilityMode).toBe('L3T3_KEY');
|
|
134
|
+
expect(countScalabilityModes(encodings)).toBe(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('keeps svc for an svc codec with a multi spatial layer mode even if simulcast is set', () => {
|
|
138
|
+
const encodings = computeVideoEncodings(false, 960, 540, {
|
|
139
|
+
simulcast: true,
|
|
140
|
+
videoCodec: 'vp9',
|
|
141
|
+
scalabilityMode: 'L3T3_KEY',
|
|
142
|
+
});
|
|
143
|
+
/* @ts-ignore */
|
|
144
|
+
expect(encodings![0].scalabilityMode).toBe('L3T3_KEY');
|
|
145
|
+
expect(countScalabilityModes(encodings)).toBe(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('returns a simulcast ladder for an svc codec with simulcast and an L1Tx mode', () => {
|
|
149
|
+
for (const videoCodec of ['vp9', 'av1'] as const) {
|
|
150
|
+
const encodings = computeVideoEncodings(false, 960, 540, {
|
|
151
|
+
simulcast: true,
|
|
152
|
+
videoCodec,
|
|
153
|
+
scalabilityMode: 'L1T2',
|
|
154
|
+
});
|
|
155
|
+
expect(encodings).toHaveLength(3);
|
|
156
|
+
expect(encodings!.map((e) => e.rid)).toEqual(['q', 'h', 'f']);
|
|
157
|
+
// every encoding needs both scalabilityMode and scaleResolutionDownBy for chrome
|
|
158
|
+
// M113+ to treat them as real simulcast rather than legacy svc
|
|
159
|
+
encodings!.forEach((encoding) => {
|
|
160
|
+
/* @ts-ignore */
|
|
161
|
+
expect(encoding.scalabilityMode).toBe('L1T2');
|
|
162
|
+
expect(encoding.scaleResolutionDownBy).toBeGreaterThanOrEqual(1);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('sets the scalability mode on a single encoding svc simulcast ladder', () => {
|
|
168
|
+
const encodings = computeVideoEncodings(false, 100, 120, {
|
|
169
|
+
simulcast: true,
|
|
170
|
+
videoCodec: 'vp9',
|
|
171
|
+
scalabilityMode: 'L1T3',
|
|
172
|
+
});
|
|
173
|
+
expect(encodings).toHaveLength(1);
|
|
174
|
+
expect(encodings![0].rid).toBe('q');
|
|
175
|
+
/* @ts-ignore */
|
|
176
|
+
expect(encodings![0].scalabilityMode).toBe('L1T3');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('does not set a scalability mode for non-svc simulcast', () => {
|
|
180
|
+
const encodings = computeVideoEncodings(false, 960, 540, {
|
|
181
|
+
simulcast: true,
|
|
182
|
+
videoCodec: 'vp8',
|
|
183
|
+
scalabilityMode: 'L1T2',
|
|
184
|
+
});
|
|
185
|
+
expect(encodings).toHaveLength(3);
|
|
186
|
+
encodings!.forEach((encoding) => {
|
|
187
|
+
/* @ts-ignore */
|
|
188
|
+
expect(encoding.scalabilityMode).toBeUndefined();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
119
192
|
// it('respects default backup codec encoding', () => {
|
|
120
193
|
// const vp8Encodings = computeTrackBackupEncodings(false, 100, 120, { simulcast: true });
|
|
121
194
|
// const h264Encodings = computeVideoEncodings(false, 100, 120, {
|
|
@@ -193,3 +266,63 @@ describe('screenShareSimulcastDefaults', () => {
|
|
|
193
266
|
expect(defaultSimulcastLayers[0].encoding.maxBitrate).toBe(375000);
|
|
194
267
|
});
|
|
195
268
|
});
|
|
269
|
+
|
|
270
|
+
describe('computeStartTargetBitrate', () => {
|
|
271
|
+
// ordered q..f, as encodingsFromPresets builds them
|
|
272
|
+
const simulcastLadder: RTCRtpEncodingParameters[] = [
|
|
273
|
+
{ rid: 'q', maxBitrate: 160_000 },
|
|
274
|
+
{ rid: 'h', maxBitrate: 450_000 },
|
|
275
|
+
{ rid: 'f', maxBitrate: 680_000 },
|
|
276
|
+
];
|
|
277
|
+
|
|
278
|
+
it('sums the ladder for plain simulcast', () => {
|
|
279
|
+
expect(computeStartTargetBitrate('vp8', { simulcast: true }, simulcastLadder)).toBe(1_290_000);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('sums the ladder for vp9/av1 published as rid simulcast', () => {
|
|
283
|
+
// encodings[0] is the *smallest* layer here, so taking it would under-hint BWE
|
|
284
|
+
for (const codec of ['vp9', 'av1'] as const) {
|
|
285
|
+
expect(
|
|
286
|
+
computeStartTargetBitrate(
|
|
287
|
+
codec,
|
|
288
|
+
{ simulcast: true, videoCodec: codec, scalabilityMode: 'L1T2' },
|
|
289
|
+
simulcastLadder,
|
|
290
|
+
),
|
|
291
|
+
).toBe(1_290_000);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('uses the single encoding for vp9/av1 svc', () => {
|
|
296
|
+
for (const codec of ['vp9', 'av1'] as const) {
|
|
297
|
+
expect(
|
|
298
|
+
computeStartTargetBitrate(
|
|
299
|
+
codec,
|
|
300
|
+
{ simulcast: false, videoCodec: codec, scalabilityMode: 'L3T3_KEY' },
|
|
301
|
+
[{ maxBitrate: 680_000 }],
|
|
302
|
+
),
|
|
303
|
+
).toBe(680_000);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('uses the first encoding for the legacy svc shape, which is ordered f..q', () => {
|
|
308
|
+
// legacy SVC pushes videoRids[2 - i], so encodings[0] carries the full bitrate
|
|
309
|
+
const legacySvc: RTCRtpEncodingParameters[] = [
|
|
310
|
+
{ rid: 'f', maxBitrate: 680_000 },
|
|
311
|
+
{ rid: 'h', maxBitrate: 226_667 },
|
|
312
|
+
{ rid: 'q', maxBitrate: 75_556 },
|
|
313
|
+
];
|
|
314
|
+
expect(
|
|
315
|
+
computeStartTargetBitrate(
|
|
316
|
+
'vp9',
|
|
317
|
+
{ simulcast: false, videoCodec: 'vp9', scalabilityMode: 'L3T3_KEY' },
|
|
318
|
+
legacySvc,
|
|
319
|
+
),
|
|
320
|
+
).toBe(680_000);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('handles missing bitrates and empty encodings', () => {
|
|
324
|
+
expect(computeStartTargetBitrate('vp8', { simulcast: true }, [])).toBe(0);
|
|
325
|
+
expect(computeStartTargetBitrate('vp9', undefined, [])).toBe(0);
|
|
326
|
+
expect(computeStartTargetBitrate('vp8', { simulcast: true }, [{ rid: 'q' }])).toBe(0);
|
|
327
|
+
});
|
|
328
|
+
});
|