livekit-client 2.22.4 → 2.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +69 -7
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +1133 -289
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.fm.worker.js.map +1 -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/SignalClient.d.ts.map +1 -1
- package/dist/src/api/WebSocketStream.d.ts +1 -0
- package/dist/src/api/WebSocketStream.d.ts.map +1 -1
- package/dist/src/api/utils.d.ts +1 -1
- package/dist/src/api/utils.d.ts.map +1 -1
- package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
- package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
- package/dist/src/e2ee/worker/naluUtils.d.ts +12 -1
- package/dist/src/e2ee/worker/naluUtils.d.ts.map +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/room/DefaultReconnectPolicy.d.ts.map +1 -1
- package/dist/src/room/PCTransport.d.ts +26 -4
- package/dist/src/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +8 -0
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +53 -0
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/data-channel/DataChannelManager.d.ts +9 -8
- package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -1
- package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
- package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -1
- package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -1
- package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -1
- package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts +4 -0
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/Participant.d.ts +1 -1
- package/dist/src/room/participant/Participant.d.ts.map +1 -1
- package/dist/src/room/rpc/utils.d.ts.map +1 -1
- package/dist/src/room/track/LocalAudioTrack.d.ts +24 -1
- package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
- package/dist/src/room/track/LocalTrack.d.ts +18 -0
- package/dist/src/room/track/LocalTrack.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/RemoteAudioTrack.d.ts +6 -0
- package/dist/src/room/track/RemoteAudioTrack.d.ts.map +1 -1
- package/dist/src/room/track/processor/types.d.ts +11 -0
- package/dist/src/room/track/processor/types.d.ts.map +1 -1
- package/dist/src/room/track/utils.d.ts.map +1 -1
- package/dist/src/room/utils.d.ts +28 -1
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/ts4.2/api/WebSocketStream.d.ts +1 -0
- package/dist/ts4.2/api/utils.d.ts +1 -1
- package/dist/ts4.2/e2ee/worker/naluUtils.d.ts +12 -1
- package/dist/ts4.2/index.d.ts +2 -2
- package/dist/ts4.2/room/PCTransport.d.ts +26 -4
- package/dist/ts4.2/room/RTCEngine.d.ts +8 -0
- package/dist/ts4.2/room/Room.d.ts +53 -0
- package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +9 -8
- package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
- package/dist/ts4.2/room/participant/LocalParticipant.d.ts +4 -0
- package/dist/ts4.2/room/participant/Participant.d.ts +1 -1
- package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +24 -1
- package/dist/ts4.2/room/track/LocalTrack.d.ts +18 -0
- package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +7 -0
- package/dist/ts4.2/room/track/RemoteAudioTrack.d.ts +6 -0
- package/dist/ts4.2/room/track/processor/types.d.ts +11 -0
- package/dist/ts4.2/room/utils.d.ts +28 -1
- package/package.json +1 -1
- package/src/api/SignalClient.test.ts +234 -0
- package/src/api/SignalClient.ts +58 -23
- package/src/api/WebSocketStream.test.ts +11 -0
- package/src/api/WebSocketStream.ts +22 -1
- package/src/api/utils.ts +1 -1
- package/src/e2ee/E2eeManager.test.ts +43 -0
- package/src/e2ee/E2eeManager.ts +6 -2
- package/src/e2ee/worker/FrameCryptor.test.ts +212 -90
- package/src/e2ee/worker/FrameCryptor.ts +21 -4
- package/src/e2ee/worker/naluUtils.test.ts +123 -0
- package/src/e2ee/worker/naluUtils.ts +57 -2
- package/src/index.ts +2 -0
- package/src/room/DefaultReconnectPolicy.test.ts +58 -0
- package/src/room/DefaultReconnectPolicy.ts +14 -2
- package/src/room/PCTransport.test.ts +144 -2
- package/src/room/PCTransport.ts +151 -9
- package/src/room/RTCEngine.test.ts +234 -2
- package/src/room/RTCEngine.ts +102 -7
- package/src/room/Room.test.ts +116 -0
- package/src/room/Room.ts +286 -74
- package/src/room/data-channel/DataChannelManager.test.ts +24 -7
- package/src/room/data-channel/DataChannelManager.ts +43 -38
- package/src/room/data-channel/FlowControlledDataChannel.test.ts +60 -18
- package/src/room/data-channel/FlowControlledDataChannel.ts +40 -22
- package/src/room/data-channel/LossyDataChannel.test.ts +38 -0
- package/src/room/data-channel/LossyDataChannel.ts +10 -1
- package/src/room/data-channel/ReliableDataChannel.test.ts +72 -0
- package/src/room/data-channel/ReliableDataChannel.ts +16 -5
- package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +31 -0
- package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +11 -0
- package/src/room/participant/LocalParticipant.test.ts +136 -1
- package/src/room/participant/LocalParticipant.ts +66 -17
- package/src/room/participant/Participant.ts +6 -3
- package/src/room/participant/restartOnDefaultAudioDevice.test.ts +177 -0
- package/src/room/rpc/utils.test.ts +32 -0
- package/src/room/rpc/utils.ts +7 -0
- package/src/room/track/LocalAudioTrack.ts +101 -10
- package/src/room/track/LocalTrack.ts +70 -13
- package/src/room/track/LocalVideoTrack.ts +7 -0
- package/src/room/track/RemoteAudioTrack.ts +56 -11
- package/src/room/track/processor/types.ts +11 -0
- package/src/room/track/utils.test.ts +5 -0
- package/src/room/track/utils.ts +29 -5
- package/src/room/utils.test.ts +126 -0
- package/src/room/utils.ts +84 -21
package/src/api/SignalClient.ts
CHANGED
|
@@ -63,6 +63,7 @@ import type { LoggerOptions } from '../room/types';
|
|
|
63
63
|
import { getClientInfo, isCompressionStreamSupported, isReactNative, sleep } from '../room/utils';
|
|
64
64
|
import type { NonSharedUint8Array } from '../type-polyfills/non-shared-typed-arrays';
|
|
65
65
|
import { AsyncQueue } from '../utils/AsyncQueue';
|
|
66
|
+
import { abortSignalTimeout } from '../utils/abort-signal-polyfill';
|
|
66
67
|
import { announceMachine } from '../utils/machineInspector';
|
|
67
68
|
import {
|
|
68
69
|
type SignalLifecycleState,
|
|
@@ -434,15 +435,29 @@ export class SignalClient {
|
|
|
434
435
|
): Promise<JoinResponse | ReconnectResponse | undefined> {
|
|
435
436
|
const unlock = await this.connectionLock.lock();
|
|
436
437
|
|
|
437
|
-
|
|
438
|
-
|
|
438
|
+
let rtcUrl: string;
|
|
439
|
+
let validateUrl: string;
|
|
440
|
+
try {
|
|
441
|
+
this.connectOptions = opts;
|
|
442
|
+
this.useV0SignalPath = useV0Path;
|
|
443
|
+
|
|
444
|
+
const clientInfo = getClientInfo(opts.clientInfoCapabilities);
|
|
445
|
+
const params = useV0Path
|
|
446
|
+
? createConnectionParams(token, clientInfo, opts)
|
|
447
|
+
: await createJoinRequestConnectionParams(token, clientInfo, opts, publisherOffer);
|
|
448
|
+
rtcUrl = createRtcUrl(url, params, useV0Path).toString();
|
|
449
|
+
validateUrl = createValidateUrl(rtcUrl).toString();
|
|
450
|
+
} catch (e) {
|
|
451
|
+
// the executor below owns the lock from here on
|
|
452
|
+
unlock();
|
|
453
|
+
throw e;
|
|
454
|
+
}
|
|
439
455
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const validateUrl = createValidateUrl(rtcUrl).toString();
|
|
456
|
+
if (abortSignal?.aborted) {
|
|
457
|
+
unlock();
|
|
458
|
+
this.log.debug('signal connect abandoned before opening a transport');
|
|
459
|
+
throw ConnectionError.cancelled('Connection aborted');
|
|
460
|
+
}
|
|
446
461
|
|
|
447
462
|
return new Promise<JoinResponse | ReconnectResponse | undefined>(async (resolve, reject) => {
|
|
448
463
|
try {
|
|
@@ -457,13 +472,13 @@ export class SignalClient {
|
|
|
457
472
|
// send leave if we have an active stream writer (connection is open)
|
|
458
473
|
if (this.streamWriter && !this.isDisconnected) {
|
|
459
474
|
this.sendLeave()
|
|
460
|
-
.then(() => this.close(reason))
|
|
475
|
+
.then(() => this.close(true, reason))
|
|
461
476
|
.catch((e) => {
|
|
462
477
|
this.log.error(e);
|
|
463
|
-
this.close();
|
|
478
|
+
this.close(true, reason);
|
|
464
479
|
});
|
|
465
480
|
} else {
|
|
466
|
-
this.close();
|
|
481
|
+
this.close(true, reason);
|
|
467
482
|
}
|
|
468
483
|
cleanupAbortHandlers();
|
|
469
484
|
// The caller may already have classified this failure: the connect timeout hands us a
|
|
@@ -498,6 +513,10 @@ export class SignalClient {
|
|
|
498
513
|
await this.teardownTransport('replaced by a new connection attempt');
|
|
499
514
|
this.log.debug(`closed previous ws connection in ${performance.now() - startClose}ms`);
|
|
500
515
|
}
|
|
516
|
+
if (alreadyAborted) {
|
|
517
|
+
this.log.debug('signal connect abandoned after tearing down the previous transport');
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
501
520
|
|
|
502
521
|
// the transport created below belongs to this attempt; events arriving from it after a
|
|
503
522
|
// newer attempt has started are dropped by the machine
|
|
@@ -562,6 +581,12 @@ export class SignalClient {
|
|
|
562
581
|
}
|
|
563
582
|
});
|
|
564
583
|
const connection = await this.ws.opened.catch(async (reason: unknown) => {
|
|
584
|
+
if (alreadyAborted) {
|
|
585
|
+
// the abort handler has already rejected this attempt; don't probe the validate
|
|
586
|
+
// endpoint for a failure nobody is waiting on
|
|
587
|
+
this.log.debug('signal connect abandoned while the websocket was opening');
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
565
590
|
if (this.lifecycleState !== 'connected') {
|
|
566
591
|
// claimed synchronously, before the await below: the socket's close event is already
|
|
567
592
|
// on its way and would otherwise reject first with a less useful error
|
|
@@ -638,6 +663,13 @@ export class SignalClient {
|
|
|
638
663
|
return;
|
|
639
664
|
}
|
|
640
665
|
signalReader.releaseLock();
|
|
666
|
+
if (alreadyAborted) {
|
|
667
|
+
// The abort handler has already rejected this attempt and is closing the transport.
|
|
668
|
+
// Don't act on a join that raced it: `onJoined` would publish an abandoned session's
|
|
669
|
+
// state to the room.
|
|
670
|
+
this.log.debug('signal connect abandoned while waiting for the first message');
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
641
673
|
|
|
642
674
|
// Validate the first message
|
|
643
675
|
const validation = this.validateFirstMessage(
|
|
@@ -741,14 +773,15 @@ export class SignalClient {
|
|
|
741
773
|
private async teardownTransport(reason: string) {
|
|
742
774
|
try {
|
|
743
775
|
this.clearPingInterval();
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
// calling `ws.close()` only starts the closing handshake (CLOSING state), prefer to wait until state is actually CLOSED
|
|
748
|
-
const closePromise = this.ws.closed;
|
|
776
|
+
const ws = this.ws;
|
|
777
|
+
if (ws) {
|
|
778
|
+
// released before closing, so a close that throws cannot leave the transport attached
|
|
749
779
|
this.ws = undefined;
|
|
750
780
|
this.streamWriter = undefined;
|
|
751
|
-
|
|
781
|
+
ws.close({ closeCode: 1000, reason });
|
|
782
|
+
|
|
783
|
+
// calling `ws.close()` only starts the closing handshake (CLOSING state), prefer to wait until state is actually CLOSED
|
|
784
|
+
await Promise.race([ws.closed, sleep(MAX_WS_CLOSE_TIME)]);
|
|
752
785
|
}
|
|
753
786
|
} catch (e) {
|
|
754
787
|
this.log.debug('websocket error while closing', { error: e });
|
|
@@ -1294,7 +1327,7 @@ export class SignalClient {
|
|
|
1294
1327
|
validateUrl: string,
|
|
1295
1328
|
): Promise<ConnectionError> {
|
|
1296
1329
|
try {
|
|
1297
|
-
const resp = await fetch(validateUrl);
|
|
1330
|
+
const resp = await fetch(validateUrl, { signal: abortSignalTimeout(3000) });
|
|
1298
1331
|
|
|
1299
1332
|
switch (resp.status) {
|
|
1300
1333
|
case 404:
|
|
@@ -1323,11 +1356,13 @@ export class SignalClient {
|
|
|
1323
1356
|
);
|
|
1324
1357
|
}
|
|
1325
1358
|
} catch (e) {
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1359
|
+
if (e instanceof ConnectionError) {
|
|
1360
|
+
return e;
|
|
1361
|
+
} else {
|
|
1362
|
+
return ConnectionError.serverUnreachable(
|
|
1363
|
+
e instanceof Error ? e.message : 'server was not reachable',
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1331
1366
|
}
|
|
1332
1367
|
}
|
|
1333
1368
|
}
|
|
@@ -407,6 +407,17 @@ describe('WebSocketStream', () => {
|
|
|
407
407
|
wsStream.close();
|
|
408
408
|
expect(closeSpy).toHaveBeenCalledWith(undefined, undefined);
|
|
409
409
|
});
|
|
410
|
+
|
|
411
|
+
it('truncates a reason longer than WebSocket.close accepts', () => {
|
|
412
|
+
const wsStream = new WebSocketStream('wss://test.example.com');
|
|
413
|
+
|
|
414
|
+
const closeSpy = vi.spyOn(mockWebSocket, 'close');
|
|
415
|
+
|
|
416
|
+
// 'é' is two UTF-8 bytes, so the 123-byte limit falls in the middle of a character
|
|
417
|
+
wsStream.close({ closeCode: 1000, reason: 'é'.repeat(100) });
|
|
418
|
+
|
|
419
|
+
expect(closeSpy).toHaveBeenCalledWith(1000, 'é'.repeat(61));
|
|
420
|
+
});
|
|
410
421
|
});
|
|
411
422
|
|
|
412
423
|
describe('AbortSignal integration', () => {
|
|
@@ -55,7 +55,7 @@ export class WebSocketStream<T extends ArrayBuffer | string = ArrayBuffer | stri
|
|
|
55
55
|
this.ws = ws;
|
|
56
56
|
|
|
57
57
|
const closeWithInfo = ({ closeCode: code, reason }: WebSocketCloseInfo = {}) =>
|
|
58
|
-
ws.close(code, reason);
|
|
58
|
+
ws.close(code, reason === undefined ? undefined : truncateCloseReason(reason));
|
|
59
59
|
|
|
60
60
|
this.opened = new TypedPromise<WebSocketConnection<T>, WebsocketError>((resolve, reject) => {
|
|
61
61
|
const rejectHandler = () => {
|
|
@@ -140,3 +140,24 @@ export class WebSocketStream<T extends ArrayBuffer | string = ArrayBuffer | stri
|
|
|
140
140
|
this.close = closeWithInfo;
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
|
+
|
|
144
|
+
/** `WebSocket.close` throws a `SyntaxError` for a reason longer than this many UTF-8 bytes. */
|
|
145
|
+
const MAX_CLOSE_REASON_BYTES = 123;
|
|
146
|
+
|
|
147
|
+
export function truncateCloseReason(reason: string): string {
|
|
148
|
+
const encoder = new TextEncoder();
|
|
149
|
+
if (encoder.encode(reason).length <= MAX_CLOSE_REASON_BYTES) {
|
|
150
|
+
return reason;
|
|
151
|
+
}
|
|
152
|
+
// cut on code point boundaries, so a multi-byte character is never split
|
|
153
|
+
let truncated = '';
|
|
154
|
+
let bytes = 0;
|
|
155
|
+
for (const char of reason) {
|
|
156
|
+
bytes += encoder.encode(char).length;
|
|
157
|
+
if (bytes > MAX_CLOSE_REASON_BYTES) {
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
truncated += char;
|
|
161
|
+
}
|
|
162
|
+
return truncated;
|
|
163
|
+
}
|
package/src/api/utils.ts
CHANGED
|
@@ -44,7 +44,7 @@ export function parseSignalResponse(value: ArrayBuffer | string) {
|
|
|
44
44
|
export function getAbortReasonAsString(
|
|
45
45
|
signal: AbortSignal | Error | unknown,
|
|
46
46
|
defaultMessage = 'Unknown reason',
|
|
47
|
-
) {
|
|
47
|
+
): string {
|
|
48
48
|
// the connect timeout hands this an Error rather than a signal, and its message is the only
|
|
49
49
|
// description of what went wrong — without this a timeout reports itself as a generic abort
|
|
50
50
|
if (signal instanceof Error) {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { LogLevel, getWorkerLogLevelListenerCount, setLogLevel, workerLogger } from '../logger';
|
|
3
3
|
import Room from '../room/Room';
|
|
4
|
+
import { ParticipantEvent } from '../room/events';
|
|
5
|
+
import { Track } from '../room/track/Track';
|
|
6
|
+
import type { VideoCodec } from '../room/track/options';
|
|
4
7
|
import { E2EEManager } from './E2eeManager';
|
|
5
8
|
import { BaseKeyProvider } from './KeyProvider';
|
|
6
9
|
|
|
@@ -194,3 +197,43 @@ describe('E2EEManager GC cleanup', () => {
|
|
|
194
197
|
},
|
|
195
198
|
);
|
|
196
199
|
});
|
|
200
|
+
|
|
201
|
+
describe('E2EEManager sender codec seeding', () => {
|
|
202
|
+
function publishVideo(videoCodec: VideoCodec | undefined) {
|
|
203
|
+
const { room, worker, manager } = makeManager();
|
|
204
|
+
room.localParticipant.identity = 'me';
|
|
205
|
+
manager.setup(room);
|
|
206
|
+
|
|
207
|
+
const sender = {
|
|
208
|
+
createEncodedStreams: () => ({
|
|
209
|
+
readable: new ReadableStream(),
|
|
210
|
+
writable: new WritableStream(),
|
|
211
|
+
}),
|
|
212
|
+
} as unknown as RTCRtpSender;
|
|
213
|
+
const track = {
|
|
214
|
+
isLocal: true,
|
|
215
|
+
kind: Track.Kind.Video,
|
|
216
|
+
mediaStreamID: 'ms1',
|
|
217
|
+
publishOptions: { videoCodec },
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
room.localParticipant.emit(ParticipantEvent.LocalSenderCreated, sender, track as any);
|
|
221
|
+
manager.dispose();
|
|
222
|
+
|
|
223
|
+
return worker.postMessage.mock.calls.map(([m]) => m).find((m: any) => m?.kind === 'encode');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
it('seeds the publish codec for h264', () => {
|
|
227
|
+
expect(publishVideo('h264')?.data.codec).toBe('h264');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('seeds the publish codec for h265', () => {
|
|
231
|
+
expect(publishVideo('h265')?.data.codec).toBe('h265');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('leaves vp8 unseeded so byte detection still decides', () => {
|
|
235
|
+
const encode = publishVideo('vp8');
|
|
236
|
+
expect(encode).toBeDefined();
|
|
237
|
+
expect(encode.data.codec).toBeUndefined();
|
|
238
|
+
});
|
|
239
|
+
});
|
package/src/e2ee/E2eeManager.ts
CHANGED
|
@@ -620,11 +620,15 @@ export class E2EEManager
|
|
|
620
620
|
if (!sender) this.log.warn('early return because sender is not ready');
|
|
621
621
|
return;
|
|
622
622
|
}
|
|
623
|
+
const isVideo = isVideoTrack(track);
|
|
624
|
+
// Safari exposes no per-frame codec info until trackInfo arrives. Seed only h264/h265:
|
|
625
|
+
// a wrong vp8 seed would skip the NALU path, a wrong NALU seed only costs a logged fallback.
|
|
626
|
+
const publishedCodec = isVideo ? track.publishOptions?.videoCodec : undefined;
|
|
623
627
|
this.handleSender(
|
|
624
628
|
sender,
|
|
625
629
|
track.mediaStreamID,
|
|
626
|
-
undefined,
|
|
627
|
-
|
|
630
|
+
publishedCodec === 'h264' || publishedCodec === 'h265' ? publishedCodec : undefined,
|
|
631
|
+
isVideo
|
|
628
632
|
? (track.publishOptions?.frameMetadata ?? track.publishOptions?.packetTrailer)
|
|
629
633
|
: undefined,
|
|
630
634
|
);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vitest } from 'vitest';
|
|
2
2
|
import { appendPacketTrailer, extractPacketTrailer } from '../../frameMetadata/frameMetadata';
|
|
3
3
|
import type { FrameMetadataPublishOptions } from '../../frameMetadata/types';
|
|
4
|
+
import { workerLogger } from '../../logger';
|
|
5
|
+
import type { VideoCodec } from '../../room/track/options';
|
|
4
6
|
import type { NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays';
|
|
5
7
|
import { IV_LENGTH, KEY_PROVIDER_DEFAULTS } from '../constants';
|
|
6
8
|
import { CryptorEvent } from '../events';
|
|
@@ -71,12 +73,14 @@ function prepareParticipantTestEncoder(
|
|
|
71
73
|
participantIdentity: string,
|
|
72
74
|
partialKeyProviderOptions: Partial<KeyProviderOptions>,
|
|
73
75
|
packetTrailer?: FrameMetadataPublishOptions,
|
|
76
|
+
codec?: VideoCodec,
|
|
74
77
|
) {
|
|
75
78
|
return prepareParticipantTest(
|
|
76
79
|
'encode',
|
|
77
80
|
participantIdentity,
|
|
78
81
|
partialKeyProviderOptions,
|
|
79
82
|
packetTrailer,
|
|
83
|
+
codec,
|
|
80
84
|
);
|
|
81
85
|
}
|
|
82
86
|
|
|
@@ -85,6 +89,7 @@ function prepareParticipantTest(
|
|
|
85
89
|
participantIdentity: string,
|
|
86
90
|
partialKeyProviderOptions: Partial<KeyProviderOptions>,
|
|
87
91
|
packetTrailer?: FrameMetadataPublishOptions,
|
|
92
|
+
codec?: VideoCodec,
|
|
88
93
|
): {
|
|
89
94
|
keys: ParticipantKeyHandler;
|
|
90
95
|
cryptor: FrameCryptor;
|
|
@@ -110,7 +115,7 @@ function prepareParticipantTest(
|
|
|
110
115
|
new ReadableStream(input),
|
|
111
116
|
new WritableStream(output),
|
|
112
117
|
'testTrack',
|
|
113
|
-
|
|
118
|
+
codec,
|
|
114
119
|
packetTrailer,
|
|
115
120
|
);
|
|
116
121
|
|
|
@@ -233,81 +238,224 @@ describe('FrameCryptor', () => {
|
|
|
233
238
|
}
|
|
234
239
|
});
|
|
235
240
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
);
|
|
246
|
-
|
|
247
|
-
|
|
241
|
+
describe('H.264 start codes (#2140)', () => {
|
|
242
|
+
const sps = [0x67, 0x64, 0x00, 0x1f, 0xac, 0xd9];
|
|
243
|
+
const pps = [0x68, 0xee, 0x3c, 0xb0];
|
|
244
|
+
const idr = [0x65, 0x88, 0x84, 0x21, 0x43, 0x55, 0x66, 0x77];
|
|
245
|
+
|
|
246
|
+
async function encryptAndDecrypt(plainTextData: NonSharedUint8Array) {
|
|
247
|
+
const encoder = prepareParticipantTestEncoder(participantIdentity, {});
|
|
248
|
+
await encoder.keys.setKey(await createKeyMaterialFromString('key1'), 1);
|
|
249
|
+
const encrypted = mockRTCEncodedVideoFrame(plainTextData);
|
|
250
|
+
encoder.input.write(encrypted);
|
|
251
|
+
await vitest.waitFor(() => expect(encoder.output.chunks).toHaveLength(1));
|
|
252
|
+
const encryptedData = new Uint8Array(encrypted.data.slice(0));
|
|
253
|
+
|
|
254
|
+
const decoder = prepareParticipantTestDecoder(participantIdentity, {});
|
|
255
|
+
await decoder.keys.setKey(await createKeyMaterialFromString('key1'), 1);
|
|
256
|
+
const decrypted = mockRTCEncodedVideoFrame(new Uint8Array(encryptedData));
|
|
257
|
+
decoder.input.write(decrypted);
|
|
258
|
+
await vitest.waitFor(() => expect(decoder.output.chunks).toHaveLength(1));
|
|
259
|
+
|
|
260
|
+
return { encryptedData, decryptedData: new Uint8Array(decrypted.data) };
|
|
261
|
+
}
|
|
248
262
|
|
|
249
|
-
|
|
250
|
-
|
|
263
|
+
it('rewrites 3-byte start codes in the unencrypted header to 4-byte start codes', async () => {
|
|
264
|
+
const { encryptedData, decryptedData } = await encryptAndDecrypt(
|
|
265
|
+
new Uint8Array([0, 0, 1, ...sps, 0, 0, 1, ...pps, 0, 0, 1, ...idr]),
|
|
251
266
|
);
|
|
252
267
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
expect(extracted.metadata?.userTimestamp).toBeGreaterThanOrEqual(
|
|
258
|
-
BigInt(now.getTime()) * BigInt(1000),
|
|
268
|
+
// The unencrypted header runs through the first 2 bytes of the slice NALU.
|
|
269
|
+
const expectedHeader = [0, 0, 0, 1, ...sps, 0, 0, 0, 1, ...pps, 0, 0, 0, 1, 0x65, 0x88];
|
|
270
|
+
expect(Array.from(encryptedData.subarray(0, expectedHeader.length))).toEqual(
|
|
271
|
+
expectedHeader,
|
|
259
272
|
);
|
|
260
|
-
expect(
|
|
273
|
+
expect(Array.from(decryptedData)).toEqual([
|
|
274
|
+
0,
|
|
275
|
+
0,
|
|
276
|
+
0,
|
|
277
|
+
1,
|
|
278
|
+
...sps,
|
|
279
|
+
0,
|
|
280
|
+
0,
|
|
281
|
+
0,
|
|
282
|
+
1,
|
|
283
|
+
...pps,
|
|
284
|
+
0,
|
|
285
|
+
0,
|
|
286
|
+
0,
|
|
287
|
+
1,
|
|
288
|
+
...idr,
|
|
289
|
+
]);
|
|
290
|
+
});
|
|
261
291
|
|
|
262
|
-
|
|
263
|
-
|
|
292
|
+
it('rewrites a 3-byte start code on a delta frame', async () => {
|
|
293
|
+
const pSlice = [0x41, 0x9a, 0x21, 0x43, 0x55, 0x66];
|
|
294
|
+
const { encryptedData, decryptedData } = await encryptAndDecrypt(
|
|
295
|
+
new Uint8Array([0, 0, 1, ...pSlice]),
|
|
264
296
|
);
|
|
265
|
-
expect(frameTrailer[0]).toEqual(IV_LENGTH);
|
|
266
|
-
expect(frameTrailer[1]).toEqual(1);
|
|
267
|
-
} finally {
|
|
268
|
-
vitest.useRealTimers();
|
|
269
|
-
}
|
|
270
|
-
});
|
|
271
297
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const { keys, input, output } = prepareParticipantTestEncoder(
|
|
276
|
-
participantIdentity,
|
|
277
|
-
{},
|
|
278
|
-
{ timestamp: false, frameId: false },
|
|
279
|
-
);
|
|
298
|
+
expect(Array.from(encryptedData.subarray(0, 6))).toEqual([0, 0, 0, 1, 0x41, 0x9a]);
|
|
299
|
+
expect(Array.from(decryptedData)).toEqual([0, 0, 0, 1, ...pSlice]);
|
|
300
|
+
});
|
|
280
301
|
|
|
281
|
-
|
|
302
|
+
it('leaves 4-byte start codes in the unencrypted header unchanged', async () => {
|
|
303
|
+
const plainTextData = [0, 0, 0, 1, ...sps, 0, 0, 0, 1, ...pps, 0, 0, 0, 1, ...idr];
|
|
304
|
+
const { encryptedData, decryptedData } = await encryptAndDecrypt(
|
|
305
|
+
new Uint8Array(plainTextData),
|
|
306
|
+
);
|
|
282
307
|
|
|
283
|
-
const
|
|
284
|
-
|
|
308
|
+
const expectedHeader = [0, 0, 0, 1, ...sps, 0, 0, 0, 1, ...pps, 0, 0, 0, 1, 0x65, 0x88];
|
|
309
|
+
expect(Array.from(encryptedData.subarray(0, expectedHeader.length))).toEqual(
|
|
310
|
+
expectedHeader,
|
|
285
311
|
);
|
|
312
|
+
expect(Array.from(decryptedData)).toEqual(plainTextData);
|
|
313
|
+
});
|
|
314
|
+
it('logs the frame bytes once when NALU processing fails', async () => {
|
|
315
|
+
vitest.useFakeTimers();
|
|
316
|
+
const warn = vitest.spyOn(workerLogger, 'warn');
|
|
317
|
+
try {
|
|
318
|
+
const { keys, input, output } = prepareParticipantTestEncoder(
|
|
319
|
+
participantIdentity,
|
|
320
|
+
{},
|
|
321
|
+
undefined,
|
|
322
|
+
'h264',
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
await keys.setKey(await createKeyMaterialFromString('key1'), 1);
|
|
326
|
+
|
|
327
|
+
// No start code at offset 0, so findNALUIndices throws 'byte stream contains
|
|
328
|
+
// leading data' and the cryptor falls back to VP8 handling.
|
|
329
|
+
input.write(
|
|
330
|
+
mockRTCEncodedVideoFrame(new Uint8Array([9, 9, 9, 9, 1, 2, 3, 4, 5, 6, 7, 8])),
|
|
331
|
+
);
|
|
332
|
+
await vitest.waitFor(() => expect(output.chunks).toHaveLength(1));
|
|
333
|
+
|
|
334
|
+
expect(warn).toHaveBeenCalledWith(
|
|
335
|
+
'NALU processing failed, falling back to VP8 handling',
|
|
336
|
+
expect.objectContaining({
|
|
337
|
+
detectedCodec: 'h264',
|
|
338
|
+
frameType: 'key',
|
|
339
|
+
byteLength: 12,
|
|
340
|
+
firstBytes: expect.stringMatching(/^09 09 09 09 /),
|
|
341
|
+
}),
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// same payload type, so the once-per-tuple guard must suppress the second log
|
|
345
|
+
input.write(
|
|
346
|
+
mockRTCEncodedVideoFrame(new Uint8Array([8, 8, 8, 8, 1, 2, 3, 4, 5, 6, 7, 8])),
|
|
347
|
+
);
|
|
348
|
+
await vitest.waitFor(() => expect(output.chunks).toHaveLength(2));
|
|
349
|
+
|
|
350
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
351
|
+
} finally {
|
|
352
|
+
warn.mockRestore();
|
|
353
|
+
vitest.useRealTimers();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
286
356
|
|
|
287
|
-
|
|
288
|
-
|
|
357
|
+
it('appends packet trailer after encryption', async () => {
|
|
358
|
+
vitest.useFakeTimers();
|
|
359
|
+
const now = new Date('2025-04-10T12:00:00.123Z');
|
|
360
|
+
vitest.setSystemTime(now);
|
|
361
|
+
try {
|
|
362
|
+
const { keys, input, output } = prepareParticipantTestEncoder(
|
|
363
|
+
participantIdentity,
|
|
364
|
+
{},
|
|
365
|
+
{ timestamp: true, frameId: true },
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
await keys.setKey(await createKeyMaterialFromString('key1'), 1);
|
|
369
|
+
|
|
370
|
+
const frame = mockRTCEncodedVideoFrame(
|
|
371
|
+
new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
input.write(frame);
|
|
375
|
+
await vitest.waitFor(() => expect(output.chunks).toHaveLength(1));
|
|
376
|
+
|
|
377
|
+
const extracted = extractPacketTrailer(frame.data);
|
|
378
|
+
expect(extracted.metadata?.userTimestamp).toBeGreaterThanOrEqual(
|
|
379
|
+
BigInt(now.getTime()) * BigInt(1000),
|
|
380
|
+
);
|
|
381
|
+
expect(extracted.metadata?.frameId).toBe(1);
|
|
382
|
+
|
|
383
|
+
const frameTrailer = new Uint8Array(
|
|
384
|
+
extracted.data.buffer.slice(extracted.data.byteLength - 2),
|
|
385
|
+
);
|
|
386
|
+
expect(frameTrailer[0]).toEqual(IV_LENGTH);
|
|
387
|
+
expect(frameTrailer[1]).toEqual(1);
|
|
388
|
+
} finally {
|
|
389
|
+
vitest.useRealTimers();
|
|
390
|
+
}
|
|
391
|
+
});
|
|
289
392
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
393
|
+
it('does not append packet trailer after encryption when no trailer features are enabled', async () => {
|
|
394
|
+
vitest.useFakeTimers();
|
|
395
|
+
try {
|
|
396
|
+
const { keys, input, output } = prepareParticipantTestEncoder(
|
|
397
|
+
participantIdentity,
|
|
398
|
+
{},
|
|
399
|
+
{ timestamp: false, frameId: false },
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
await keys.setKey(await createKeyMaterialFromString('key1'), 1);
|
|
403
|
+
|
|
404
|
+
const frame = mockRTCEncodedVideoFrame(
|
|
405
|
+
new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
input.write(frame);
|
|
409
|
+
await vitest.waitFor(() => expect(output.chunks).toHaveLength(1));
|
|
410
|
+
|
|
411
|
+
const extracted = extractPacketTrailer(frame.data);
|
|
412
|
+
expect(extracted.metadata).toBeUndefined();
|
|
413
|
+
expect(extracted.data.byteLength).toBe(frame.data.byteLength);
|
|
414
|
+
|
|
415
|
+
const frameTrailer = new Uint8Array(frame.data.slice(frame.data.byteLength - 2));
|
|
416
|
+
expect(frameTrailer[0]).toEqual(IV_LENGTH);
|
|
417
|
+
expect(frameTrailer[1]).toEqual(1);
|
|
418
|
+
} finally {
|
|
419
|
+
vitest.useRealTimers();
|
|
420
|
+
}
|
|
421
|
+
});
|
|
293
422
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
423
|
+
it('appends only timestamp packet trailer metadata after encryption', async () => {
|
|
424
|
+
vitest.useFakeTimers();
|
|
425
|
+
const now = new Date('2025-04-10T12:00:00.123Z');
|
|
426
|
+
vitest.setSystemTime(now);
|
|
427
|
+
try {
|
|
428
|
+
const { keys, input, output } = prepareParticipantTestEncoder(
|
|
429
|
+
participantIdentity,
|
|
430
|
+
{},
|
|
431
|
+
{ timestamp: true },
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
await keys.setKey(await createKeyMaterialFromString('key1'), 1);
|
|
435
|
+
|
|
436
|
+
const frame = mockRTCEncodedVideoFrame(
|
|
437
|
+
new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
input.write(frame);
|
|
441
|
+
await vitest.waitFor(() => expect(output.chunks).toHaveLength(1));
|
|
442
|
+
|
|
443
|
+
const extracted = extractPacketTrailer(frame.data);
|
|
444
|
+
expect(frame.data.byteLength - extracted.data.byteLength).toBe(15);
|
|
445
|
+
expect(extracted.metadata?.userTimestamp).toBeGreaterThanOrEqual(
|
|
446
|
+
BigInt(now.getTime()) * BigInt(1000),
|
|
447
|
+
);
|
|
448
|
+
expect(extracted.metadata?.frameId).toBe(0);
|
|
449
|
+
} finally {
|
|
450
|
+
vitest.useRealTimers();
|
|
451
|
+
}
|
|
452
|
+
});
|
|
301
453
|
|
|
302
|
-
|
|
303
|
-
vitest.useFakeTimers();
|
|
304
|
-
const now = new Date('2025-04-10T12:00:00.123Z');
|
|
305
|
-
vitest.setSystemTime(now);
|
|
306
|
-
try {
|
|
454
|
+
it('appends only frame id packet trailer metadata after encryption', async () => {
|
|
307
455
|
const { keys, input, output } = prepareParticipantTestEncoder(
|
|
308
456
|
participantIdentity,
|
|
309
457
|
{},
|
|
310
|
-
{
|
|
458
|
+
{ frameId: true },
|
|
311
459
|
);
|
|
312
460
|
|
|
313
461
|
await keys.setKey(await createKeyMaterialFromString('key1'), 1);
|
|
@@ -320,37 +468,11 @@ describe('FrameCryptor', () => {
|
|
|
320
468
|
await vitest.waitFor(() => expect(output.chunks).toHaveLength(1));
|
|
321
469
|
|
|
322
470
|
const extracted = extractPacketTrailer(frame.data);
|
|
323
|
-
expect(frame.data.byteLength - extracted.data.byteLength).toBe(
|
|
324
|
-
expect(extracted.metadata
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
} finally {
|
|
329
|
-
vitest.useRealTimers();
|
|
330
|
-
}
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
it('appends only frame id packet trailer metadata after encryption', async () => {
|
|
334
|
-
const { keys, input, output } = prepareParticipantTestEncoder(
|
|
335
|
-
participantIdentity,
|
|
336
|
-
{},
|
|
337
|
-
{ frameId: true },
|
|
338
|
-
);
|
|
339
|
-
|
|
340
|
-
await keys.setKey(await createKeyMaterialFromString('key1'), 1);
|
|
341
|
-
|
|
342
|
-
const frame = mockRTCEncodedVideoFrame(
|
|
343
|
-
new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
|
|
344
|
-
);
|
|
345
|
-
|
|
346
|
-
input.write(frame);
|
|
347
|
-
await vitest.waitFor(() => expect(output.chunks).toHaveLength(1));
|
|
348
|
-
|
|
349
|
-
const extracted = extractPacketTrailer(frame.data);
|
|
350
|
-
expect(frame.data.byteLength - extracted.data.byteLength).toBe(11);
|
|
351
|
-
expect(extracted.metadata).toEqual({
|
|
352
|
-
userTimestamp: BigInt(0),
|
|
353
|
-
frameId: 1,
|
|
471
|
+
expect(frame.data.byteLength - extracted.data.byteLength).toBe(11);
|
|
472
|
+
expect(extracted.metadata).toEqual({
|
|
473
|
+
userTimestamp: BigInt(0),
|
|
474
|
+
frameId: 1,
|
|
475
|
+
});
|
|
354
476
|
});
|
|
355
477
|
});
|
|
356
478
|
});
|