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.
Files changed (116) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +69 -7
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +1133 -289
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js.map +1 -1
  8. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  9. package/dist/livekit-client.umd.js +1 -1
  10. package/dist/livekit-client.umd.js.map +1 -1
  11. package/dist/src/api/SignalClient.d.ts.map +1 -1
  12. package/dist/src/api/WebSocketStream.d.ts +1 -0
  13. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  14. package/dist/src/api/utils.d.ts +1 -1
  15. package/dist/src/api/utils.d.ts.map +1 -1
  16. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  17. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  18. package/dist/src/e2ee/worker/naluUtils.d.ts +12 -1
  19. package/dist/src/e2ee/worker/naluUtils.d.ts.map +1 -1
  20. package/dist/src/index.d.ts +2 -2
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/room/DefaultReconnectPolicy.d.ts.map +1 -1
  23. package/dist/src/room/PCTransport.d.ts +26 -4
  24. package/dist/src/room/PCTransport.d.ts.map +1 -1
  25. package/dist/src/room/RTCEngine.d.ts +8 -0
  26. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  27. package/dist/src/room/Room.d.ts +53 -0
  28. package/dist/src/room/Room.d.ts.map +1 -1
  29. package/dist/src/room/data-channel/DataChannelManager.d.ts +9 -8
  30. package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -1
  31. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
  32. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -1
  33. package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -1
  34. package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -1
  35. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
  36. package/dist/src/room/participant/LocalParticipant.d.ts +4 -0
  37. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  38. package/dist/src/room/participant/Participant.d.ts +1 -1
  39. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  40. package/dist/src/room/rpc/utils.d.ts.map +1 -1
  41. package/dist/src/room/track/LocalAudioTrack.d.ts +24 -1
  42. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  43. package/dist/src/room/track/LocalTrack.d.ts +18 -0
  44. package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
  45. package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
  46. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  47. package/dist/src/room/track/RemoteAudioTrack.d.ts +6 -0
  48. package/dist/src/room/track/RemoteAudioTrack.d.ts.map +1 -1
  49. package/dist/src/room/track/processor/types.d.ts +11 -0
  50. package/dist/src/room/track/processor/types.d.ts.map +1 -1
  51. package/dist/src/room/track/utils.d.ts.map +1 -1
  52. package/dist/src/room/utils.d.ts +28 -1
  53. package/dist/src/room/utils.d.ts.map +1 -1
  54. package/dist/ts4.2/api/WebSocketStream.d.ts +1 -0
  55. package/dist/ts4.2/api/utils.d.ts +1 -1
  56. package/dist/ts4.2/e2ee/worker/naluUtils.d.ts +12 -1
  57. package/dist/ts4.2/index.d.ts +2 -2
  58. package/dist/ts4.2/room/PCTransport.d.ts +26 -4
  59. package/dist/ts4.2/room/RTCEngine.d.ts +8 -0
  60. package/dist/ts4.2/room/Room.d.ts +53 -0
  61. package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +9 -8
  62. package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
  63. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +4 -0
  64. package/dist/ts4.2/room/participant/Participant.d.ts +1 -1
  65. package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +24 -1
  66. package/dist/ts4.2/room/track/LocalTrack.d.ts +18 -0
  67. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +7 -0
  68. package/dist/ts4.2/room/track/RemoteAudioTrack.d.ts +6 -0
  69. package/dist/ts4.2/room/track/processor/types.d.ts +11 -0
  70. package/dist/ts4.2/room/utils.d.ts +28 -1
  71. package/package.json +1 -1
  72. package/src/api/SignalClient.test.ts +234 -0
  73. package/src/api/SignalClient.ts +58 -23
  74. package/src/api/WebSocketStream.test.ts +11 -0
  75. package/src/api/WebSocketStream.ts +22 -1
  76. package/src/api/utils.ts +1 -1
  77. package/src/e2ee/E2eeManager.test.ts +43 -0
  78. package/src/e2ee/E2eeManager.ts +6 -2
  79. package/src/e2ee/worker/FrameCryptor.test.ts +212 -90
  80. package/src/e2ee/worker/FrameCryptor.ts +21 -4
  81. package/src/e2ee/worker/naluUtils.test.ts +123 -0
  82. package/src/e2ee/worker/naluUtils.ts +57 -2
  83. package/src/index.ts +2 -0
  84. package/src/room/DefaultReconnectPolicy.test.ts +58 -0
  85. package/src/room/DefaultReconnectPolicy.ts +14 -2
  86. package/src/room/PCTransport.test.ts +144 -2
  87. package/src/room/PCTransport.ts +151 -9
  88. package/src/room/RTCEngine.test.ts +234 -2
  89. package/src/room/RTCEngine.ts +102 -7
  90. package/src/room/Room.test.ts +116 -0
  91. package/src/room/Room.ts +286 -74
  92. package/src/room/data-channel/DataChannelManager.test.ts +24 -7
  93. package/src/room/data-channel/DataChannelManager.ts +43 -38
  94. package/src/room/data-channel/FlowControlledDataChannel.test.ts +60 -18
  95. package/src/room/data-channel/FlowControlledDataChannel.ts +40 -22
  96. package/src/room/data-channel/LossyDataChannel.test.ts +38 -0
  97. package/src/room/data-channel/LossyDataChannel.ts +10 -1
  98. package/src/room/data-channel/ReliableDataChannel.test.ts +72 -0
  99. package/src/room/data-channel/ReliableDataChannel.ts +16 -5
  100. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +31 -0
  101. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +11 -0
  102. package/src/room/participant/LocalParticipant.test.ts +136 -1
  103. package/src/room/participant/LocalParticipant.ts +66 -17
  104. package/src/room/participant/Participant.ts +6 -3
  105. package/src/room/participant/restartOnDefaultAudioDevice.test.ts +177 -0
  106. package/src/room/rpc/utils.test.ts +32 -0
  107. package/src/room/rpc/utils.ts +7 -0
  108. package/src/room/track/LocalAudioTrack.ts +101 -10
  109. package/src/room/track/LocalTrack.ts +70 -13
  110. package/src/room/track/LocalVideoTrack.ts +7 -0
  111. package/src/room/track/RemoteAudioTrack.ts +56 -11
  112. package/src/room/track/processor/types.ts +11 -0
  113. package/src/room/track/utils.test.ts +5 -0
  114. package/src/room/track/utils.ts +29 -5
  115. package/src/room/utils.test.ts +126 -0
  116. package/src/room/utils.ts +84 -21
@@ -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
- this.connectOptions = opts;
438
- this.useV0SignalPath = useV0Path;
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
- const clientInfo = getClientInfo(opts.clientInfoCapabilities);
441
- const params = useV0Path
442
- ? createConnectionParams(token, clientInfo, opts)
443
- : await createJoinRequestConnectionParams(token, clientInfo, opts, publisherOffer);
444
- const rtcUrl = createRtcUrl(url, params, useV0Path).toString();
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
- if (this.ws) {
745
- this.ws.close({ closeCode: 1000, reason });
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
- await Promise.race([closePromise, sleep(MAX_WS_CLOSE_TIME)]);
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
- return e instanceof ConnectionError
1327
- ? e
1328
- : ConnectionError.serverUnreachable(
1329
- e instanceof Error ? e.message : 'server was not reachable',
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
+ });
@@ -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
- isVideoTrack(track)
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
- undefined,
118
+ codec,
114
119
  packetTrailer,
115
120
  );
116
121
 
@@ -233,81 +238,224 @@ describe('FrameCryptor', () => {
233
238
  }
234
239
  });
235
240
 
236
- it('appends packet trailer after encryption', async () => {
237
- vitest.useFakeTimers();
238
- const now = new Date('2025-04-10T12:00:00.123Z');
239
- vitest.setSystemTime(now);
240
- try {
241
- const { keys, input, output } = prepareParticipantTestEncoder(
242
- participantIdentity,
243
- {},
244
- { timestamp: true, frameId: true },
245
- );
246
-
247
- await keys.setKey(await createKeyMaterialFromString('key1'), 1);
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
- const frame = mockRTCEncodedVideoFrame(
250
- new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
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
- input.write(frame);
254
- await vitest.waitFor(() => expect(output.chunks).toHaveLength(1));
255
-
256
- const extracted = extractPacketTrailer(frame.data);
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(extracted.metadata?.frameId).toBe(1);
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
- const frameTrailer = new Uint8Array(
263
- extracted.data.buffer.slice(extracted.data.byteLength - 2),
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
- it('does not append packet trailer after encryption when no trailer features are enabled', async () => {
273
- vitest.useFakeTimers();
274
- try {
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
- await keys.setKey(await createKeyMaterialFromString('key1'), 1);
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 frame = mockRTCEncodedVideoFrame(
284
- new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
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
- input.write(frame);
288
- await vitest.waitFor(() => expect(output.chunks).toHaveLength(1));
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
- const extracted = extractPacketTrailer(frame.data);
291
- expect(extracted.metadata).toBeUndefined();
292
- expect(extracted.data.byteLength).toBe(frame.data.byteLength);
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
- const frameTrailer = new Uint8Array(frame.data.slice(frame.data.byteLength - 2));
295
- expect(frameTrailer[0]).toEqual(IV_LENGTH);
296
- expect(frameTrailer[1]).toEqual(1);
297
- } finally {
298
- vitest.useRealTimers();
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
- it('appends only timestamp packet trailer metadata after encryption', async () => {
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
- { timestamp: true },
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(15);
324
- expect(extracted.metadata?.userTimestamp).toBeGreaterThanOrEqual(
325
- BigInt(now.getTime()) * BigInt(1000),
326
- );
327
- expect(extracted.metadata?.frameId).toBe(0);
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
  });