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
@@ -25,7 +25,7 @@ import type {
25
25
  import { deriveKeys, isVideoFrame, needsRbspUnescaping, parseRbsp, writeRbsp } from '../utils';
26
26
  import { ErrorRateLimiter } from './ErrorRateLimiter';
27
27
  import type { ParticipantKeyHandler } from './ParticipantKeyHandler';
28
- import { processNALUsForEncryption } from './naluUtils';
28
+ import { normalizeAnnexBStartCodes, processNALUsForEncryption } from './naluUtils';
29
29
  import { identifySifPayload } from './sifPayload';
30
30
 
31
31
  export const encryptionEnabledMap: Map<string, boolean> = new Map();
@@ -490,7 +490,16 @@ export class FrameCryptor extends BaseFrameCryptor {
490
490
  let frameInfo = this.getUnencryptedBytes(encodedFrame);
491
491
 
492
492
  // Thіs is not encrypted and contains the VP8 payload descriptor or the Opus TOC byte.
493
- const frameHeader = new Uint8Array(encodedFrame.data, 0, frameInfo.unencryptedBytes);
493
+ let frameHeader: NonSharedUint8Array = new Uint8Array(
494
+ encodedFrame.data,
495
+ 0,
496
+ frameInfo.unencryptedBytes,
497
+ );
498
+ if (frameInfo.requiresNALUProcessing) {
499
+ // The receiver's depacketizer rebuilds NALUs with 4-byte start codes, so authenticate (and
500
+ // send) the header in that form or decryption fails for encoders emitting 3-byte ones.
501
+ frameHeader = normalizeAnnexBStartCodes(frameHeader);
502
+ }
494
503
 
495
504
  // Frame trailer contains the R|IV_LENGTH and key index
496
505
  const frameTrailer = new Uint8Array(2);
@@ -510,7 +519,7 @@ export class FrameCryptor extends BaseFrameCryptor {
510
519
  {
511
520
  name: ENCRYPTION_ALGORITHM,
512
521
  iv,
513
- additionalData: new Uint8Array(encodedFrame.data, 0, frameHeader.byteLength),
522
+ additionalData: frameHeader,
514
523
  },
515
524
  encryptionKey,
516
525
  new Uint8Array(encodedFrame.data, frameInfo.unencryptedBytes),
@@ -906,7 +915,7 @@ export class FrameCryptor extends BaseFrameCryptor {
906
915
  };
907
916
  }
908
917
  } catch (e) {
909
- this.logNALUFallbackOnce(fallbackKey, payloadType, e);
918
+ this.logNALUFallbackOnce(fallbackKey, frame, payloadType, detectedCodec, e);
910
919
  }
911
920
 
912
921
  // Fallback to VP8 handling
@@ -919,7 +928,9 @@ export class FrameCryptor extends BaseFrameCryptor {
919
928
  */
920
929
  private logNALUFallbackOnce(
921
930
  fallbackKey: string,
931
+ frame: RTCEncodedVideoFrame,
922
932
  payloadType: number | undefined,
933
+ detectedCodec: VideoCodec | undefined,
923
934
  error: unknown,
924
935
  ) {
925
936
  if (this.loggedNALUFallbacks.has(fallbackKey)) {
@@ -929,6 +940,12 @@ export class FrameCryptor extends BaseFrameCryptor {
929
940
  workerLogger.warn('NALU processing failed, falling back to VP8 handling', {
930
941
  error,
931
942
  payloadType,
943
+ detectedCodec,
944
+ frameType: frame.type,
945
+ byteLength: frame.data.byteLength,
946
+ firstBytes: Array.from(new Uint8Array(frame.data, 0, Math.min(16, frame.data.byteLength)))
947
+ .map((b) => b.toString(16).padStart(2, '0'))
948
+ .join(' '),
932
949
  ...this.logContext,
933
950
  });
934
951
  }
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { normalizeAnnexBStartCodes, processNALUsForEncryption } from './naluUtils';
3
+
4
+ /** Annex-B frame: a 4-byte start code before every NALU. */
5
+ function annexB(...nalus: number[][]) {
6
+ return Uint8Array.from(nalus.flatMap((nalu) => [0x00, 0x00, 0x00, 0x01, ...nalu]));
7
+ }
8
+
9
+ // h264: 0x06 SEI, 0x65 IDR slice, 0x67 SPS. h265: 0x26 IDR_W_RADL slice.
10
+ // An h264 SEI byte (0x06) reads as h265 TSA_R, which is why the SEI leads here.
11
+ // findNALUIndices stops at stream.length - 3. A trailing NALU with fewer than 4 payload
12
+ // bytes is never indexed, so its fixture tests nothing. Interior NALUs are fine at any
13
+ // length, because the next start code ends them.
14
+ const h264KeyFrame = annexB([0x06, 0x11, 0x22], [0x65, 0x33, 0x44, 0x55, 0x66]);
15
+ const h265KeyFrame = annexB([0x26, 0x11, 0x22], [0x02, 0x33, 0x44, 0x55, 0x66]);
16
+ const noSliceFrame = annexB([0x67, 0x11, 0x22, 0x33, 0x44, 0x55]);
17
+ // 0x25 is a slice under both: h264 &0x1f = 5 IDR, h265 (b >> 1) & 0x3f = 18 BLA_W_RADL.
18
+ const ambiguousFrame = annexB([0x25, 0x11, 0x22, 0x33, 0x44, 0x55]);
19
+
20
+ describe('normalizeAnnexBStartCodes', () => {
21
+ it('rewrites 3-byte start codes to 4-byte start codes', () => {
22
+ // prettier-ignore
23
+ const input = new Uint8Array([
24
+ 0x00, 0x00, 0x01, 0x67, 0x64, // SPS
25
+ 0x00, 0x00, 0x01, 0x68, 0xee, // PPS
26
+ 0x00, 0x00, 0x01, 0x65, 0x88, // IDR slice
27
+ ]);
28
+ // prettier-ignore
29
+ const expected = new Uint8Array([
30
+ 0x00, 0x00, 0x00, 0x01, 0x67, 0x64,
31
+ 0x00, 0x00, 0x00, 0x01, 0x68, 0xee,
32
+ 0x00, 0x00, 0x00, 0x01, 0x65, 0x88,
33
+ ]);
34
+
35
+ expect(normalizeAnnexBStartCodes(input)).toEqual(expected);
36
+ });
37
+
38
+ it('rewrites only the 3-byte start codes when mixed with 4-byte ones', () => {
39
+ // prettier-ignore
40
+ const input = new Uint8Array([
41
+ 0x00, 0x00, 0x00, 0x01, 0x67, 0x64,
42
+ 0x00, 0x00, 0x01, 0x68, 0xee,
43
+ 0x00, 0x00, 0x00, 0x01, 0x06, 0x05,
44
+ 0x00, 0x00, 0x01, 0x65, 0x88,
45
+ ]);
46
+ // prettier-ignore
47
+ const expected = new Uint8Array([
48
+ 0x00, 0x00, 0x00, 0x01, 0x67, 0x64,
49
+ 0x00, 0x00, 0x00, 0x01, 0x68, 0xee,
50
+ 0x00, 0x00, 0x00, 0x01, 0x06, 0x05,
51
+ 0x00, 0x00, 0x00, 0x01, 0x65, 0x88,
52
+ ]);
53
+
54
+ expect(normalizeAnnexBStartCodes(input)).toEqual(expected);
55
+ });
56
+
57
+ it('returns the same instance when all start codes are already 4-byte', () => {
58
+ // prettier-ignore
59
+ const input = new Uint8Array([
60
+ 0x00, 0x00, 0x00, 0x01, 0x67, 0x64,
61
+ 0x00, 0x00, 0x00, 0x01, 0x65, 0x88,
62
+ ]);
63
+
64
+ expect(normalizeAnnexBStartCodes(input)).toBe(input);
65
+ });
66
+
67
+ it('does not add a zero when a start code already has extra leading zeros', () => {
68
+ const input = new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x01, 0x65, 0x88]);
69
+
70
+ expect(normalizeAnnexBStartCodes(input)).toBe(input);
71
+ });
72
+ });
73
+
74
+ describe('processNALUsForEncryption', () => {
75
+ it('slices at the h264 slice NALU even when seeded with h265', () => {
76
+ // The whole point: the h265 seed must not win, or the offset lands at 6 (inside the
77
+ // SEI) and the slice header gets encrypted with no error raised.
78
+ expect(processNALUsForEncryption(h264KeyFrame, 'h265')).toEqual({
79
+ unencryptedBytes: 13,
80
+ detectedCodec: 'h264',
81
+ requiresNALUProcessing: true,
82
+ });
83
+ });
84
+
85
+ it('reads an h264 frame whose SEI precedes the slice as h264', () => {
86
+ expect(processNALUsForEncryption(h264KeyFrame)).toEqual({
87
+ unencryptedBytes: 13,
88
+ detectedCodec: 'h264',
89
+ requiresNALUProcessing: true,
90
+ });
91
+ });
92
+
93
+ it('still reads an h265 frame as h265', () => {
94
+ expect(processNALUsForEncryption(h265KeyFrame)).toEqual({
95
+ unencryptedBytes: 6,
96
+ detectedCodec: 'h265',
97
+ requiresNALUProcessing: true,
98
+ });
99
+ expect(processNALUsForEncryption(h265KeyFrame, 'h264')).toEqual({
100
+ unencryptedBytes: 6,
101
+ detectedCodec: 'h265',
102
+ requiresNALUProcessing: true,
103
+ });
104
+ });
105
+
106
+ it('resolves a byte that is a slice under both codecs as h264', () => {
107
+ // Only the h264-first ordering decides this one. Swap the two loops back and it reads h265.
108
+ expect(processNALUsForEncryption(ambiguousFrame)).toEqual({
109
+ unencryptedBytes: 6,
110
+ detectedCodec: 'h264',
111
+ requiresNALUProcessing: true,
112
+ });
113
+ });
114
+
115
+ it('throws with a seed, and returns quietly without one, when no slice NALU is present', () => {
116
+ expect(() => processNALUsForEncryption(noSliceFrame, 'h264')).toThrow('Could not find NALU');
117
+ expect(processNALUsForEncryption(noSliceFrame)).toEqual({
118
+ unencryptedBytes: 0,
119
+ detectedCodec: 'unknown',
120
+ requiresNALUProcessing: false,
121
+ });
122
+ });
123
+ });
@@ -202,8 +202,14 @@ export interface NALUProcessingResult {
202
202
  * @returns Detected codec type
203
203
  */
204
204
  function detectCodecFromNALUs(data: NonSharedUint8Array, naluIndices: number[]): DetectedCodec {
205
+ // h264 runs to completion first. Several non-slice h264 NALUs read as h265 slice types
206
+ // (AUD 0x09 -> STSA_N, SEI 0x06 -> TSA_R), so interleaving the two checks lets one of
207
+ // those outrank the real h264 slice that follows it. The reverse cannot happen: h265
208
+ // header bytes are even at nuh_layer_id 0, so they never mask to an h264 slice type.
205
209
  for (const naluIndex of naluIndices) {
206
210
  if (isH264SliceNALU(parseH264NALUType(data[naluIndex]))) return 'h264';
211
+ }
212
+ for (const naluIndex of naluIndices) {
207
213
  if (isH265SliceNALU(parseH265NALUType(data[naluIndex]))) return 'h265';
208
214
  }
209
215
  return 'unknown';
@@ -306,7 +312,7 @@ function findNALUIndices(stream: NonSharedUint8Array): number[] {
306
312
  /**
307
313
  * Process NALU data for frame encryption, detecting codec and finding unencrypted bytes
308
314
  * @param data Frame data
309
- * @param knownCodec Known codec from other sources (optional)
315
+ * @param knownCodec Codec from other sources, used only when the bytes are inconclusive (optional)
310
316
  * @returns NALU processing result
311
317
  */
312
318
  export function processNALUsForEncryption(
@@ -314,7 +320,16 @@ export function processNALUsForEncryption(
314
320
  knownCodec?: 'h264' | 'h265',
315
321
  ): NALUProcessingResult {
316
322
  const naluIndices = findNALUIndices(data);
317
- const detectedCodec = knownCodec ?? detectCodecFromNALUs(data, naluIndices);
323
+ // The bytes outrank knownCodec, which can be the codec the client asked for rather than the
324
+ // one that was negotiated. Parsing the wrong one mis-slices without raising anything: the
325
+ // offset lands before the real slice header, so the header itself gets encrypted.
326
+ // That holds in one direction only. An h264 access unit carrying no slice NALU but carrying
327
+ // an AUD or SEI that reads as an h265 slice now slices there instead of throwing. No real
328
+ // WebRTC access unit can reach it, because every one of them carries a type 1 or 5 slice.
329
+ let detectedCodec = detectCodecFromNALUs(data, naluIndices);
330
+ if (detectedCodec === 'unknown') {
331
+ detectedCodec = knownCodec ?? 'unknown';
332
+ }
318
333
 
319
334
  if (detectedCodec === 'unknown') {
320
335
  return { unencryptedBytes: 0, detectedCodec, requiresNALUProcessing: false };
@@ -327,3 +342,43 @@ export function processNALUsForEncryption(
327
342
 
328
343
  return { unencryptedBytes, detectedCodec, requiresNALUProcessing: true };
329
344
  }
345
+
346
+ /**
347
+ * Rewrite every 3-byte Annex B start code (00 00 01) in `data` to the 4-byte form (00 00 00 01).
348
+ *
349
+ * WebRTC's H.264/H.265 depacketizer always reconstructs NALUs with 4-byte start codes, regardless
350
+ * of what the encoder emitted. Some encoders (e.g. Intel Quick Sync) emit 3-byte start codes, so
351
+ * the unencrypted header a receiver sees would differ from what the sender authenticated as
352
+ * AES-GCM additionalData. Normalizing on the sender makes both sides agree.
353
+ *
354
+ * Returns `data` unchanged (same instance) when it contains no 3-byte start codes.
355
+ */
356
+ export function normalizeAnnexBStartCodes(data: NonSharedUint8Array): NonSharedUint8Array {
357
+ const shortStartCodes: number[] = [];
358
+ for (let i = 0; i + 2 < data.length;) {
359
+ if (data[i + 2] === 0) {
360
+ i += 1;
361
+ continue;
362
+ }
363
+ if (data[i + 2] === 1 && data[i] === 0 && data[i + 1] === 0 && (i === 0 || data[i - 1] !== 0)) {
364
+ shortStartCodes.push(i);
365
+ }
366
+ // data[i + 2] is non-zero, so no start code can begin at i + 1 or i + 2 either.
367
+ i += 3;
368
+ }
369
+ if (shortStartCodes.length === 0) {
370
+ return data;
371
+ }
372
+
373
+ // The new array is zero-filled, so the one-byte gap left before each short start code becomes
374
+ // the inserted leading 00.
375
+ const result = new Uint8Array(data.length + shortStartCodes.length);
376
+ let from = 0;
377
+ for (let inserted = 0; inserted < shortStartCodes.length; inserted++) {
378
+ const to = shortStartCodes[inserted];
379
+ result.set(data.subarray(from, to), from + inserted);
380
+ from = to;
381
+ }
382
+ result.set(data.subarray(from), from + shortStartCodes.length);
383
+ return result;
384
+ }
package/src/index.ts CHANGED
@@ -56,6 +56,7 @@ import {
56
56
  isSVCCodec,
57
57
  isVideoCodec,
58
58
  isVideoTrack,
59
+ releaseEmptyAudioStreamTrack,
59
60
  supportsAV1,
60
61
  supportsAdaptiveStream,
61
62
  supportsAudioOutputSelection,
@@ -145,6 +146,7 @@ export {
145
146
  getEmptyVideoStreamTrack,
146
147
  getLogger,
147
148
  isBrowserSupported,
149
+ releaseEmptyAudioStreamTrack,
148
150
  setLogExtension,
149
151
  setLogLevel,
150
152
  supportsAV1,
@@ -0,0 +1,58 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import DefaultReconnectPolicy from './DefaultReconnectPolicy';
3
+
4
+ const delayFor = (policy: DefaultReconnectPolicy, retryCount: number, random: number) => {
5
+ vi.spyOn(Math, 'random').mockReturnValue(random);
6
+ return policy.nextRetryDelayInMs({ retryCount, elapsedMs: 0 });
7
+ };
8
+
9
+ describe('DefaultReconnectPolicy', () => {
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ it('spreads the first retry over a window instead of retrying immediately', () => {
15
+ const policy = new DefaultReconnectPolicy();
16
+ expect(delayFor(policy, 0, 0)).toBe(0);
17
+ expect(delayFor(policy, 0, 0.5)).toBe(250);
18
+ expect(delayFor(policy, 0, 0.999)).toBeGreaterThan(450);
19
+ expect(delayFor(policy, 0, 0.999)).toBeLessThan(500);
20
+ });
21
+
22
+ it('scales the jitter of later retries with the delay', () => {
23
+ const policy = new DefaultReconnectPolicy();
24
+ expect(delayFor(policy, 1, 0)).toBe(150);
25
+ expect(delayFor(policy, 1, 0.5)).toBe(300);
26
+ expect(delayFor(policy, 2, 0)).toBe(600);
27
+ expect(delayFor(policy, 2, 0.5)).toBe(1200);
28
+ expect(delayFor(policy, 9, 0)).toBe(3500);
29
+ expect(delayFor(policy, 9, 0.999)).toBeLessThan(10500);
30
+ });
31
+
32
+ it('returns a different delay for each client on every retry', () => {
33
+ const policy = new DefaultReconnectPolicy();
34
+ for (let retryCount = 0; retryCount < 10; retryCount++) {
35
+ expect(delayFor(policy, retryCount, 0.1)).not.toBe(delayFor(policy, retryCount, 0.9));
36
+ }
37
+ });
38
+
39
+ it('returns null once the retries are exhausted', () => {
40
+ const policy = new DefaultReconnectPolicy([0, 100]);
41
+ expect(policy.nextRetryDelayInMs({ retryCount: 2, elapsedMs: 0 })).toBeNull();
42
+ expect(
43
+ new DefaultReconnectPolicy().nextRetryDelayInMs({ retryCount: 10, elapsedMs: 0 }),
44
+ ).toBeNull();
45
+ });
46
+
47
+ it('applies jitter to custom delays', () => {
48
+ const policy = new DefaultReconnectPolicy([1000]);
49
+ expect(delayFor(policy, 0, 0.5)).toBe(1000);
50
+ expect(delayFor(policy, 0, 0)).toBe(500);
51
+ });
52
+
53
+ it('keeps a zero delay at a later index immediate', () => {
54
+ const policy = new DefaultReconnectPolicy([100, 0]);
55
+ expect(delayFor(policy, 1, 0.5)).toBe(0);
56
+ expect(delayFor(policy, 1, 0.999)).toBe(0);
57
+ });
58
+ });
@@ -2,6 +2,12 @@ import type { ReconnectContext, ReconnectPolicy } from './ReconnectPolicy';
2
2
 
3
3
  const maxRetryDelay = 7000;
4
4
 
5
+ /**
6
+ * Upper bound of the random delay added to the first retry, whose base delay is 0. It keeps a
7
+ * brief network blip recovering quickly while spreading out clients that disconnect together.
8
+ */
9
+ const maxFirstRetryJitterInMs = 500;
10
+
5
11
  const DEFAULT_RETRY_DELAYS_IN_MS = [
6
12
  0,
7
13
  300,
@@ -26,9 +32,15 @@ class DefaultReconnectPolicy implements ReconnectPolicy {
26
32
  if (context.retryCount >= this._retryDelays.length) return null;
27
33
 
28
34
  const retryDelay = this._retryDelays[context.retryCount];
29
- if (context.retryCount <= 1) return retryDelay;
30
35
 
31
- return retryDelay + Math.random() * 1_000;
36
+ // The first retry has no base delay to scale, so it gets a fixed window instead. A zero at a
37
+ // later index is an explicit request for an immediate retry and passes through the formula as zero.
38
+ if (context.retryCount === 0 && retryDelay === 0) {
39
+ return Math.random() * maxFirstRetryJitterInMs;
40
+ }
41
+
42
+ // Jitter scales with the delay (+/-50%) so clients spread out further on later retries.
43
+ return Math.round(retryDelay * (0.5 + Math.random()));
32
44
  }
33
45
  }
34
46
 
@@ -1,6 +1,6 @@
1
1
  import { type MediaDescription, parse } from 'sdp-transform';
2
- import { describe, expect, it } from 'vitest';
3
- import {
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import PCTransport, {
4
4
  applyVideoStartBitrate,
5
5
  computeConnectionStartBitrate,
6
6
  computeTrackStartBitrate,
@@ -156,6 +156,148 @@ describe('video start bitrate', () => {
156
156
  expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 300 })).toBe(270);
157
157
  });
158
158
 
159
+ it('ramps the cap down with connection setup time', () => {
160
+ // A 3 Mbps camera target, so only the cap moves.
161
+ const camera = (ms: number) =>
162
+ computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 3_000 }, ms);
163
+
164
+ expect(camera(0)).toBe(1_000);
165
+ expect(camera(471)).toBe(1_000); // unshaped baseline
166
+ expect(camera(1_273)).toBe(1_000); // 1 Mbps link median
167
+ expect(camera(1_500)).toBe(1_000); // the fast anchor
168
+ expect(camera(1_724)).toBe(922); // 1 Mbps link slow attempt
169
+ expect(camera(2_334)).toBe(708); // 500 kbps link median
170
+ expect(camera(2_500)).toBe(650); // midpoint of the ramp
171
+ expect(camera(3_093)).toBe(442); // 300 kbps link fastest attempt
172
+ expect(camera(3_500)).toBe(300); // the slow anchor
173
+ expect(camera(18_131)).toBe(300); // anything slower stays at the floor
174
+ });
175
+
176
+ it('keeps the 1 Mbps ceiling without a setup time', () => {
177
+ expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 3_000 }, undefined)).toBe(
178
+ 1_000,
179
+ );
180
+ });
181
+
182
+ it('still applies 90% of the target under the ramp, and hints at the floor', () => {
183
+ expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 500 }, 2_500)).toBe(450);
184
+ expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 300 }, 3_500)).toBe(270);
185
+ });
186
+
187
+ it('leaves screen share uncapped however slow the setup', () => {
188
+ const screenShare = (ms: number) =>
189
+ computeTrackStartBitrate({ cid: 's', codec: 'VP8', maxbr: 3_000, isScreenShare: true }, ms);
190
+
191
+ expect(screenShare(1_273)).toBe(2_700);
192
+ expect(screenShare(2_500)).toBe(2_700);
193
+ expect(screenShare(4_061)).toBe(2_700);
194
+ });
195
+
196
+ it('applies the setup time to the connection-level value', () => {
197
+ const { media } = parse(TWO_VIDEO_SECTIONS);
198
+ const camera = { cid: 'camera-cid', codec: 'VP8', maxbr: 3_000 };
199
+ const screenShare = { cid: 'other-track', codec: 'VP8', maxbr: 3_000, isScreenShare: true };
200
+
201
+ expect(computeConnectionStartBitrate(media, [camera], 1_000)).toBe(1_000);
202
+ expect(computeConnectionStartBitrate(media, [camera], 2_500)).toBe(650);
203
+ // The uncapped screen share still wins the connection-level max on a slow setup.
204
+ expect(computeConnectionStartBitrate(media, [camera, screenShare], 2_500)).toBe(2_700);
205
+ });
206
+
207
+ describe('browser effective connection type', () => {
208
+ const withEffectiveType = (effectiveType: string | undefined) =>
209
+ vi.stubGlobal('navigator', { connection: effectiveType ? { effectiveType } : undefined });
210
+
211
+ afterEach(() => {
212
+ vi.unstubAllGlobals();
213
+ });
214
+
215
+ // A 3 Mbps camera target, so only the cap moves.
216
+ const camera = (setupTimeMs?: number) =>
217
+ computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 3_000 }, setupTimeMs);
218
+
219
+ it('caps 2g and slow-2g at 300 kbps and 3g at 500 kbps', () => {
220
+ withEffectiveType('slow-2g');
221
+ expect(camera(500)).toBe(300);
222
+ withEffectiveType('2g');
223
+ expect(camera(500)).toBe(300);
224
+ withEffectiveType('3g');
225
+ expect(camera(500)).toBe(500);
226
+ });
227
+
228
+ it('gives no cap for 4g or without the API', () => {
229
+ withEffectiveType('4g');
230
+ expect(camera(500)).toBe(1_000);
231
+ withEffectiveType(undefined);
232
+ expect(camera(500)).toBe(1_000);
233
+ vi.stubGlobal('navigator', undefined);
234
+ expect(camera(500)).toBe(1_000);
235
+ });
236
+
237
+ it('takes the lower of the setup time cap and the effective type cap', () => {
238
+ withEffectiveType('3g');
239
+ expect(camera(2_500)).toBe(500); // setup time alone allows 650
240
+ expect(camera(3_093)).toBe(442); // setup time is lower
241
+ expect(camera()).toBe(500);
242
+ });
243
+
244
+ it('still applies 90% of a target below the cap', () => {
245
+ withEffectiveType('3g');
246
+ expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 450 }, 500)).toBe(405);
247
+ });
248
+
249
+ it('leaves screen share uncapped', () => {
250
+ withEffectiveType('slow-2g');
251
+ expect(
252
+ computeTrackStartBitrate(
253
+ { cid: 's', codec: 'VP8', maxbr: 3_000, isScreenShare: true },
254
+ 500,
255
+ ),
256
+ ).toBe(2_700);
257
+ });
258
+ });
259
+
260
+ describe('connection setup time while still connecting', () => {
261
+ beforeEach(() => {
262
+ // The constructor creates its peer connection eagerly; only its event handlers are set.
263
+ vi.stubGlobal('RTCPeerConnection', class {});
264
+ });
265
+
266
+ afterEach(() => {
267
+ vi.restoreAllMocks();
268
+ vi.unstubAllGlobals();
269
+ });
270
+
271
+ const estimate = (transport: PCTransport) =>
272
+ (
273
+ transport as unknown as { connectionSetupTimeEstimateMs(): number | undefined }
274
+ ).connectionSetupTimeEstimateMs();
275
+
276
+ it('uses the time elapsed since the join started before the setup time is measured', () => {
277
+ const transport = new PCTransport();
278
+ vi.spyOn(performance, 'now').mockReturnValue(10_000);
279
+ transport.setConnectionStartedAt(7_500);
280
+
281
+ expect(estimate(transport)).toBe(2_500);
282
+ expect(
283
+ computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 3_000 }, estimate(transport)),
284
+ ).toBe(650);
285
+ });
286
+
287
+ it('prefers the measured setup time once known', () => {
288
+ const transport = new PCTransport();
289
+ vi.spyOn(performance, 'now').mockReturnValue(10_000);
290
+ transport.setConnectionStartedAt(7_500);
291
+ transport.setConnectionSetupTime(3_600);
292
+
293
+ expect(estimate(transport)).toBe(3_600);
294
+ });
295
+
296
+ it('has no estimate without any timing', () => {
297
+ expect(estimate(new PCTransport())).toBeUndefined();
298
+ });
299
+ });
300
+
159
301
  it('uses one connection-level value: the largest hint across video sections', () => {
160
302
  const { media } = parse(TWO_VIDEO_SECTIONS);
161
303