livekit-client 2.20.2 → 2.22.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 (142) hide show
  1. package/README.md +10 -10
  2. package/dist/livekit-client.e2ee.worker.js +1 -1
  3. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  4. package/dist/livekit-client.e2ee.worker.mjs +6 -1
  5. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  6. package/dist/livekit-client.esm.mjs +2588 -1056
  7. package/dist/livekit-client.esm.mjs.map +1 -1
  8. package/dist/livekit-client.fm.worker.js +1 -1
  9. package/dist/livekit-client.fm.worker.js.map +1 -1
  10. package/dist/livekit-client.fm.worker.mjs +6 -1
  11. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  12. package/dist/livekit-client.umd.js +1 -1
  13. package/dist/livekit-client.umd.js.map +1 -1
  14. package/dist/src/index.d.ts +1 -1
  15. package/dist/src/index.d.ts.map +1 -1
  16. package/dist/src/room/PCTransport.d.ts +49 -2
  17. package/dist/src/room/PCTransport.d.ts.map +1 -1
  18. package/dist/src/room/RTCEngine.d.ts +48 -65
  19. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  20. package/dist/src/room/Room.d.ts +6 -0
  21. package/dist/src/room/Room.d.ts.map +1 -1
  22. package/dist/src/room/data-channel/DataChannelManager.d.ts +62 -0
  23. package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -0
  24. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
  25. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -0
  26. package/dist/src/room/data-channel/LossyDataChannel.d.ts +40 -0
  27. package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -0
  28. package/dist/src/room/data-channel/ReliableDataChannel.d.ts +56 -0
  29. package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -0
  30. package/dist/src/room/data-channel/types.d.ts +12 -0
  31. package/dist/src/room/data-channel/types.d.ts.map +1 -0
  32. package/dist/src/room/data-stream/compression.d.ts +49 -0
  33. package/dist/src/room/data-stream/compression.d.ts.map +1 -0
  34. package/dist/src/room/data-stream/constants.d.ts +18 -0
  35. package/dist/src/room/data-stream/constants.d.ts.map +1 -0
  36. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
  37. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
  38. package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
  39. package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
  40. package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts.map +1 -1
  41. package/dist/src/room/data-stream/outgoing/header-utils.d.ts +18 -0
  42. package/dist/src/room/data-stream/outgoing/header-utils.d.ts.map +1 -0
  43. package/dist/src/room/errors.d.ts +3 -1
  44. package/dist/src/room/errors.d.ts.map +1 -1
  45. package/dist/src/room/participant/LocalParticipant.d.ts +10 -2
  46. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  47. package/dist/src/room/participant/RemoteParticipant.d.ts +11 -2
  48. package/dist/src/room/participant/RemoteParticipant.d.ts.map +1 -1
  49. package/dist/src/room/rpc/client/RpcClientManager.d.ts.map +1 -1
  50. package/dist/src/room/rpc/server/RpcServerManager.d.ts.map +1 -1
  51. package/dist/src/room/token-source/TokenSource.d.ts +19 -10
  52. package/dist/src/room/token-source/TokenSource.d.ts.map +1 -1
  53. package/dist/src/room/track/LocalVideoTrack.d.ts +12 -1
  54. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  55. package/dist/src/room/track/RemoteTrack.d.ts +3 -0
  56. package/dist/src/room/track/RemoteTrack.d.ts.map +1 -1
  57. package/dist/src/room/track/Track.d.ts.map +1 -1
  58. package/dist/src/room/types.d.ts +17 -0
  59. package/dist/src/room/types.d.ts.map +1 -1
  60. package/dist/src/room/utils.d.ts +28 -0
  61. package/dist/src/room/utils.d.ts.map +1 -1
  62. package/dist/src/test/promiseState.d.ts +12 -0
  63. package/dist/src/test/promiseState.d.ts.map +1 -0
  64. package/dist/src/test/signalToken.d.ts.map +1 -1
  65. package/dist/src/utils/AsyncQueue.d.ts +3 -3
  66. package/dist/src/utils/AsyncQueue.d.ts.map +1 -1
  67. package/dist/src/version.d.ts +5 -1
  68. package/dist/src/version.d.ts.map +1 -1
  69. package/dist/ts4.2/index.d.ts +1 -1
  70. package/dist/ts4.2/room/PCTransport.d.ts +49 -2
  71. package/dist/ts4.2/room/RTCEngine.d.ts +48 -65
  72. package/dist/ts4.2/room/Room.d.ts +6 -0
  73. package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +62 -0
  74. package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
  75. package/dist/ts4.2/room/data-channel/LossyDataChannel.d.ts +41 -0
  76. package/dist/ts4.2/room/data-channel/ReliableDataChannel.d.ts +57 -0
  77. package/dist/ts4.2/room/data-channel/types.d.ts +12 -0
  78. package/dist/ts4.2/room/data-stream/compression.d.ts +49 -0
  79. package/dist/ts4.2/room/data-stream/constants.d.ts +18 -0
  80. package/dist/ts4.2/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
  81. package/dist/ts4.2/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
  82. package/dist/ts4.2/room/data-stream/outgoing/header-utils.d.ts +18 -0
  83. package/dist/ts4.2/room/errors.d.ts +3 -1
  84. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +10 -2
  85. package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +11 -1
  86. package/dist/ts4.2/room/token-source/TokenSource.d.ts +17 -8
  87. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +12 -1
  88. package/dist/ts4.2/room/track/RemoteTrack.d.ts +3 -0
  89. package/dist/ts4.2/room/types.d.ts +17 -0
  90. package/dist/ts4.2/room/utils.d.ts +28 -0
  91. package/dist/ts4.2/test/promiseState.d.ts +12 -0
  92. package/dist/ts4.2/utils/AsyncQueue.d.ts +3 -3
  93. package/dist/ts4.2/version.d.ts +5 -1
  94. package/package.json +14 -13
  95. package/src/api/SignalClient.e2e.test.ts +16 -9
  96. package/src/e2ee/utils.ts +1 -1
  97. package/src/index.ts +2 -0
  98. package/src/room/PCTransport.test.ts +243 -1
  99. package/src/room/PCTransport.ts +181 -80
  100. package/src/room/RTCEngine.test.ts +397 -101
  101. package/src/room/RTCEngine.ts +244 -545
  102. package/src/room/Room.test.ts +138 -4
  103. package/src/room/Room.ts +92 -25
  104. package/src/room/data-channel/DataChannelManager.test.ts +152 -0
  105. package/src/room/data-channel/DataChannelManager.ts +237 -0
  106. package/src/room/data-channel/FlowControlledDataChannel.test.ts +154 -0
  107. package/src/room/data-channel/FlowControlledDataChannel.ts +216 -0
  108. package/src/room/data-channel/LossyDataChannel.test.ts +118 -0
  109. package/src/room/data-channel/LossyDataChannel.ts +125 -0
  110. package/src/room/data-channel/ReliableDataChannel.test.ts +158 -0
  111. package/src/room/data-channel/ReliableDataChannel.ts +153 -0
  112. package/src/room/data-channel/types.ts +30 -0
  113. package/src/room/data-stream/compression.ts +117 -0
  114. package/src/room/data-stream/constants.ts +18 -0
  115. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +1601 -0
  116. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +438 -86
  117. package/src/room/data-stream/incoming/StreamReader.ts +9 -3
  118. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.test.ts +1292 -0
  119. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +489 -116
  120. package/src/room/data-stream/outgoing/header-utils.ts +87 -0
  121. package/src/room/errors.ts +7 -2
  122. package/src/room/participant/LocalParticipant.ts +13 -0
  123. package/src/room/participant/RemoteParticipant.ts +19 -5
  124. package/src/room/rpc/client/RpcClientManager.test.ts +20 -27
  125. package/src/room/rpc/client/RpcClientManager.ts +1 -4
  126. package/src/room/rpc/server/RpcServerManager.test.ts +24 -23
  127. package/src/room/rpc/server/RpcServerManager.ts +1 -3
  128. package/src/room/token-source/TokenSource.ts +25 -12
  129. package/src/room/track/LocalVideoTrack.test.ts +105 -2
  130. package/src/room/track/LocalVideoTrack.ts +36 -10
  131. package/src/room/track/RemoteTrack.test.ts +144 -0
  132. package/src/room/track/RemoteTrack.ts +39 -12
  133. package/src/room/track/Track.ts +2 -1
  134. package/src/room/types.ts +18 -0
  135. package/src/room/utils.test.ts +71 -2
  136. package/src/room/utils.ts +119 -0
  137. package/src/test/promiseState.ts +23 -0
  138. package/src/test/signalServerSetup.ts +2 -1
  139. package/src/test/signalToken.ts +17 -13
  140. package/src/type-polyfills/header-extensions.d.ts +13 -0
  141. package/src/utils/AsyncQueue.ts +3 -3
  142. package/src/version.ts +5 -1
@@ -1,8 +1,8 @@
1
1
  type QueueTask<T> = () => PromiseLike<T>;
2
2
  declare enum QueueTaskStatus {
3
- 'WAITING' = 0,
4
- 'RUNNING' = 1,
5
- 'COMPLETED' = 2
3
+ WAITING = 0,
4
+ RUNNING = 1,
5
+ COMPLETED = 2
6
6
  }
7
7
  type QueueTaskInfo = {
8
8
  id: number;
@@ -5,7 +5,11 @@ export declare const CLIENT_PROTOCOL_DEFAULT = 0;
5
5
  /** Replaces RPC v1 protocol with a v2 data streams based one to support unlimited request /
6
6
  * response payload length. */
7
7
  export declare const CLIENT_PROTOCOL_DATA_STREAM_RPC = 1;
8
+ /** "Data streams v2": the client knows how to receive a single-packet data stream (a stream whose
9
+ * entire payload is smuggled into the header packet, with no chunk/trailer packets). Senders only
10
+ * use the single-packet optimization when the recipient advertises at least this protocol. */
11
+ export declare const CLIENT_PROTOCOL_DATA_STREAM_V2 = 2;
8
12
  /** The client protocol version indicates what level of support that the client has for
9
13
  * client <-> client api interactions. */
10
- export declare const clientProtocol = 1;
14
+ export declare const clientProtocol = 2;
11
15
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livekit-client",
3
- "version": "2.20.2",
3
+ "version": "2.22.0",
4
4
  "description": "JavaScript/TypeScript client SDK for LiveKit",
5
5
  "main": "./dist/livekit-client.umd.js",
6
6
  "unpkg": "./dist/livekit-client.umd.js",
@@ -50,7 +50,7 @@
50
50
  "license": "Apache-2.0",
51
51
  "dependencies": {
52
52
  "@livekit/mutex": "1.1.1",
53
- "@livekit/protocol": "1.46.6",
53
+ "@livekit/protocol": "1.50.4",
54
54
  "events": "^3.3.0",
55
55
  "jose": "^6.1.0",
56
56
  "loglevel": "^1.9.2",
@@ -66,12 +66,12 @@
66
66
  "@babel/core": "7.29.7",
67
67
  "@babel/preset-env": "7.29.7",
68
68
  "@bufbuild/protoc-gen-es": "^1.10.0",
69
- "@changesets/cli": "2.31.0",
69
+ "@changesets/cli": "2.31.1",
70
70
  "@eslint/js": "10.0.1",
71
- "@livekit/changesets-changelog-github": "^0.0.4",
71
+ "@livekit/changesets-changelog-github": "^0.2.0",
72
72
  "@livekit/throws-transformer": "^0.1.3",
73
- "@rollup/plugin-babel": "7.0.0",
74
- "@rollup/plugin-commonjs": "29.0.2",
73
+ "@rollup/plugin-babel": "7.1.0",
74
+ "@rollup/plugin-commonjs": "29.0.3",
75
75
  "@rollup/plugin-json": "6.1.0",
76
76
  "@rollup/plugin-node-resolve": "16.0.3",
77
77
  "@rollup/plugin-terser": "^1.0.0",
@@ -82,12 +82,12 @@
82
82
  "@types/events": "^3.0.3",
83
83
  "@types/sdp-transform": "2.15.0",
84
84
  "@types/ua-parser-js": "0.7.39",
85
- "@typescript-eslint/eslint-plugin": "8.59.4",
86
- "@typescript-eslint/parser": "8.59.4",
85
+ "@typescript-eslint/eslint-plugin": "8.64.0",
86
+ "@typescript-eslint/parser": "8.64.0",
87
87
  "@vitest/browser": "^4.1.10",
88
88
  "@vitest/browser-playwright": "^4.1.10",
89
89
  "downlevel-dts": "^0.11.0",
90
- "eslint": "10.4.0",
90
+ "eslint": "10.7.0",
91
91
  "eslint-config-airbnb-extended": "^2.3.2",
92
92
  "eslint-config-prettier": "10.1.8",
93
93
  "eslint-plugin-compat": "^6.0.2",
@@ -97,19 +97,20 @@
97
97
  "glob": "^13.0.6",
98
98
  "happy-dom": "^20.0.0",
99
99
  "jsdom": "^26.1.0",
100
+ "livekit-server-sdk": "^2.17.0",
100
101
  "playwright": "^1.61.1",
101
102
  "prettier": "^3.4.2",
102
103
  "publint": "^0.3.21",
103
- "rollup": "4.60.4",
104
+ "rollup": "4.62.2",
104
105
  "rollup-plugin-delete": "^2.1.0",
105
106
  "rollup-plugin-typescript2": "0.37.0",
106
- "size-limit": "^11.2.0",
107
+ "size-limit": "^12.0.0",
107
108
  "tsx": "^4.21.0",
108
- "typedoc": "0.28.19",
109
+ "typedoc": "0.28.20",
109
110
  "typedoc-plugin-no-inherit": "1.6.1",
110
111
  "typescript": "5.9.3",
111
112
  "typescript-eslint": "^8.47.0",
112
- "vite": "7.3.5",
113
+ "vite": "8.1.5",
113
114
  "vitest": "^4.1.10"
114
115
  },
115
116
  "scripts": {
@@ -1,6 +1,8 @@
1
1
  import { type LeaveRequest, LeaveRequest_Action } from '@livekit/protocol';
2
- import { afterEach, beforeEach, describe, expect, inject, it, vi } from 'vitest';
2
+ import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest';
3
3
  import { ConnectionErrorReason } from '../room/errors';
4
+ import { sleep } from '../room/utils';
5
+ import { isPending } from '../test/promiseState';
4
6
  import { createInvalidToken, createToken } from '../test/signalToken';
5
7
  import { SignalClient, SignalConnectionState, type SignalOptions } from './SignalClient';
6
8
 
@@ -15,8 +17,6 @@ const defaultOpts = (): SignalOptions => ({
15
17
  websocketTimeout: 5_000,
16
18
  });
17
19
 
18
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
19
-
20
20
  function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
21
21
  return Promise.race([
22
22
  p,
@@ -70,12 +70,11 @@ describe.skipIf(!!unavailable)('SignalClient e2e', () => {
70
70
  });
71
71
 
72
72
  it('stays connected past the ping timeout while the server pongs', async () => {
73
- const onClose = vi.fn();
74
73
  await join('happy');
75
- client.onClose = onClose;
74
+ const closed = captureClose(client);
76
75
  // Server ping timeout is 3s; a healthy pong loop must keep us alive.
77
76
  await sleep(4_000);
78
- expect(onClose).not.toHaveBeenCalled();
77
+ expect(await isPending(closed)).toBe(true);
79
78
  expect(client.currentState).toBe(SignalConnectionState.CONNECTED);
80
79
  });
81
80
 
@@ -151,15 +150,19 @@ describe.skipIf(!!unavailable)('SignalClient e2e', () => {
151
150
  (e) => e as Error,
152
151
  );
153
152
  expect(err).toBeInstanceOf(Error);
153
+ expect((err as { reason?: ConnectionErrorReason }).reason).toBe(
154
+ ConnectionErrorReason.LeaveRequest,
155
+ );
156
+ expect((err as Error).message).toContain('Received leave request');
154
157
  });
155
158
 
156
159
  it('closes gracefully via close() without firing onClose', async () => {
157
- const onClose = vi.fn();
158
160
  await join('happy');
159
- client.onClose = onClose;
161
+ const closed = captureClose(client);
160
162
  await client.close();
161
163
  expect(client.isDisconnected).toBe(true);
162
- expect(onClose).not.toHaveBeenCalled();
164
+ // Give any stray onClose a macrotask to land before asserting it stayed silent.
165
+ expect(await isPending(closed, 100)).toBe(true);
163
166
  });
164
167
 
165
168
  it('reconnects (resume) back to CONNECTED', async () => {
@@ -181,6 +184,10 @@ describe.skipIf(!!unavailable)('SignalClient e2e', () => {
181
184
  (e) => e as Error,
182
185
  );
183
186
  expect(err).toBeInstanceOf(Error);
187
+ expect((err as { reason?: ConnectionErrorReason }).reason).toBe(
188
+ ConnectionErrorReason.LeaveRequest,
189
+ );
190
+ expect((err as Error).message).toContain('Received leave request');
184
191
  });
185
192
 
186
193
  // --- validate-endpoint classification -------------------------------
package/src/e2ee/utils.ts CHANGED
@@ -139,7 +139,7 @@ export function needsRbspUnescaping(frameData: NonSharedUint8Array) {
139
139
  export function parseRbsp(stream: NonSharedUint8Array): NonSharedUint8Array {
140
140
  const dataOut: number[] = [];
141
141
  var length = stream.length;
142
- for (var i = 0; i < stream.length; ) {
142
+ for (var i = 0; i < stream.length;) {
143
143
  // Be careful about over/underflow here. byte_length_ - 3 can underflow, and
144
144
  // i + 3 can overflow, but byte_length_ - i can't, because i < byte_length_
145
145
  // above, and that expression will produce the number of bytes left in
package/src/index.ts CHANGED
@@ -104,6 +104,8 @@ export type {
104
104
  TranscriptionSegment,
105
105
  ChatMessage,
106
106
  SendTextOptions,
107
+ SendBytesOptions,
108
+ ByteStreamInfo,
107
109
  } from './room/types';
108
110
  export * from './version';
109
111
  export {
@@ -1,6 +1,15 @@
1
1
  import { type MediaDescription, parse } from 'sdp-transform';
2
2
  import { describe, expect, it } from 'vitest';
3
- import { conformBundledCodecFmtp, placeholderMidsFromTransceivers } from './PCTransport';
3
+ import {
4
+ applyVideoStartBitrate,
5
+ conformBundledCodecFmtp,
6
+ ensureAudioNackAndStereo,
7
+ ensureVideoDDExtension,
8
+ extractStereoAndNackAudioFromOffer,
9
+ fmtpConfigHasParam,
10
+ placeholderMidsFromTransceivers,
11
+ } from './PCTransport';
12
+ import { ddExtensionURI } from './utils';
4
13
 
5
14
  /** Parse the `key[=value]` pairs of an fmtp config into a comparable set. */
6
15
  const paramSet = (config: string) => new Set(config.split(';').filter(Boolean));
@@ -52,6 +61,35 @@ a=recvonly
52
61
  a=rtpmap:49 H265/90000
53
62
  a=fmtp:49 level-id=180;profile-id=1;tier-flag=0;tx-mode=SRST`;
54
63
 
64
+ describe('video start bitrate', () => {
65
+ it('applies the bitrate only to the section whose msid track ID matches the cid', () => {
66
+ const { media } = parse(`v=0
67
+ o=- 0 0 IN IP4 127.0.0.1
68
+ s=-
69
+ t=0 0
70
+ a=group:BUNDLE 0 1
71
+ m=video 9 UDP/TLS/RTP/SAVPF 96
72
+ c=IN IP4 0.0.0.0
73
+ a=mid:0
74
+ a=sendonly
75
+ a=msid:PA_remote|camera other-track
76
+ a=rtpmap:96 VP8/90000
77
+ m=video 9 UDP/TLS/RTP/SAVPF 96
78
+ c=IN IP4 0.0.0.0
79
+ a=mid:1
80
+ a=sendonly
81
+ a=msid:PA_remote|camera camera-cid
82
+ a=rtpmap:96 VP8/90000`);
83
+
84
+ for (const section of media) {
85
+ applyVideoStartBitrate(section, 'camera-cid', 'VP8', 1_000);
86
+ }
87
+
88
+ expect(fmtpOf(media, '0', 96)).toBeUndefined();
89
+ expect(paramSet(fmtpOf(media, '1', 96)!)).toContain('x-google-start-bitrate=900');
90
+ });
91
+ });
92
+
55
93
  describe('placeholderMidsFromTransceivers', () => {
56
94
  const tr = (mid: string | null, track: unknown) =>
57
95
  ({ mid, sender: { track } }) as unknown as RTCRtpTransceiver;
@@ -148,3 +186,207 @@ a=fmtp:49 level-id=180;profile-id=1;tier-flag=0;tx-mode=SRST`);
148
186
  expect(fmtpOf(media, '5', 49)).toBe(fmtpOf(media, '3', 49)); // placeholder conformed
149
187
  });
150
188
  });
189
+
190
+ describe('fmtpConfigHasParam', () => {
191
+ it('matches a whole `;`-delimited parameter, not a substring', () => {
192
+ // `stereo=1` is a substring of `sprop-stereo=1` — an exact-token match must
193
+ // not treat the latter as declaring the former.
194
+ expect(fmtpConfigHasParam('minptime=10;stereo=1', 'stereo=1')).toBe(true);
195
+ expect(fmtpConfigHasParam('minptime=10;sprop-stereo=1', 'stereo=1')).toBe(false);
196
+ expect(fmtpConfigHasParam('minptime=10;sprop-stereo=1', 'sprop-stereo=1')).toBe(true);
197
+ });
198
+
199
+ it('tolerates surrounding whitespace between parameters', () => {
200
+ expect(fmtpConfigHasParam('minptime=10; stereo=1', 'stereo=1')).toBe(true);
201
+ });
202
+ });
203
+
204
+ /** Build a parsed audio media section from its `a=` lines for the munge helpers. */
205
+ const audioSection = (rtpmap: string, fmtp: string, mid = '0') =>
206
+ parse(`v=0
207
+ o=- 0 0 IN IP4 127.0.0.1
208
+ s=-
209
+ t=0 0
210
+ m=audio 9 UDP/TLS/RTP/SAVPF 111
211
+ a=mid:${mid}
212
+ ${rtpmap}
213
+ ${fmtp}`).media[0];
214
+
215
+ describe('ensureAudioNackAndStereo', () => {
216
+ it('adds stereo=1 even when sprop-stereo=1 is already present', () => {
217
+ const media = audioSection(
218
+ 'a=rtpmap:111 opus/48000/2',
219
+ 'a=fmtp:111 minptime=10;sprop-stereo=1',
220
+ );
221
+
222
+ ensureAudioNackAndStereo(media as any, ['all'], []);
223
+
224
+ const config = media.fmtp.find((f) => f.payload === 111)!.config;
225
+ expect(paramSet(config)).toContain('stereo=1');
226
+ expect(paramSet(config)).toContain('sprop-stereo=1');
227
+ });
228
+
229
+ it('does not add a duplicate stereo=1 when it is already present', () => {
230
+ const media = audioSection('a=rtpmap:111 opus/48000/2', 'a=fmtp:111 minptime=10;stereo=1');
231
+
232
+ ensureAudioNackAndStereo(media as any, ['all'], []);
233
+
234
+ const config = media.fmtp.find((f) => f.payload === 111)!.config;
235
+ expect(config.match(/(?:^|;)stereo=1(?:;|$)/g)).toHaveLength(1);
236
+ });
237
+
238
+ it('matches the opus codec case-insensitively (RFC 4855)', () => {
239
+ const media = audioSection('a=rtpmap:111 OPUS/48000/2', 'a=fmtp:111 minptime=10');
240
+
241
+ ensureAudioNackAndStereo(media as any, ['all'], []);
242
+
243
+ expect(paramSet(media.fmtp.find((f) => f.payload === 111)!.config)).toContain('stereo=1');
244
+ });
245
+ });
246
+
247
+ describe('extractStereoAndNackAudioFromOffer', () => {
248
+ const offerWith = (rtpmap: string, fmtp: string) => ({
249
+ type: 'offer' as const,
250
+ sdp: `v=0
251
+ o=- 0 0 IN IP4 127.0.0.1
252
+ s=-
253
+ t=0 0
254
+ m=audio 9 UDP/TLS/RTP/SAVPF 111
255
+ a=mid:0
256
+ ${rtpmap}
257
+ ${fmtp}`,
258
+ });
259
+
260
+ it('detects sprop-stereo=1 as a whole parameter', () => {
261
+ const { stereoMids } = extractStereoAndNackAudioFromOffer(
262
+ offerWith('a=rtpmap:111 opus/48000/2', 'a=fmtp:111 minptime=10;sprop-stereo=1'),
263
+ );
264
+ expect(stereoMids).toEqual(['0']);
265
+ });
266
+
267
+ it('does not treat plain stereo=1 as sprop-stereo=1', () => {
268
+ const { stereoMids } = extractStereoAndNackAudioFromOffer(
269
+ offerWith('a=rtpmap:111 opus/48000/2', 'a=fmtp:111 minptime=10;stereo=1'),
270
+ );
271
+ expect(stereoMids).toEqual([]);
272
+ });
273
+
274
+ it('matches the opus codec case-insensitively (RFC 4855)', () => {
275
+ const { stereoMids } = extractStereoAndNackAudioFromOffer(
276
+ offerWith('a=rtpmap:111 OPUS/48000/2', 'a=fmtp:111 minptime=10;sprop-stereo=1'),
277
+ );
278
+ expect(stereoMids).toEqual(['0']);
279
+ });
280
+ });
281
+
282
+ // A single peer connection bundle as Chrome offers it: the section our camera sends on, the
283
+ // recvonly sections subscribed tracks arrive on, and one that lost AV1 in negotiation.
284
+ const SINGLE_PC_OFFER = `v=0
285
+ o=- 0 0 IN IP4 127.0.0.1
286
+ s=-
287
+ t=0 0
288
+ a=group:BUNDLE 0 1 2
289
+ m=video 9 UDP/TLS/RTP/SAVPF 96 45
290
+ c=IN IP4 0.0.0.0
291
+ a=mid:0
292
+ a=sendonly
293
+ a=msid:s cam
294
+ a=rtpmap:96 VP8/90000
295
+ a=rtpmap:45 AV1/90000
296
+ a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
297
+ a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id
298
+ m=video 9 UDP/TLS/RTP/SAVPF 96 45
299
+ c=IN IP4 0.0.0.0
300
+ a=mid:1
301
+ a=recvonly
302
+ a=rtpmap:96 VP8/90000
303
+ a=rtpmap:45 AV1/90000
304
+ a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
305
+ m=video 9 UDP/TLS/RTP/SAVPF 96
306
+ c=IN IP4 0.0.0.0
307
+ a=mid:2
308
+ a=recvonly
309
+ a=rtpmap:96 VP8/90000
310
+ a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time`;
311
+
312
+ const sectionOf = (media: MediaDescription[], mid: string) =>
313
+ media.find((section) => `${section.mid}` === mid)!;
314
+
315
+ const ddOf = (media: MediaDescription[], mid: string) =>
316
+ sectionOf(media, mid).ext?.find((ext) => ext.uri === ddExtensionURI)?.value;
317
+
318
+ describe('ensureVideoDDExtension', () => {
319
+ it('assigns an id above every extension in the bundle', () => {
320
+ const sdp = parse(SINGLE_PC_OFFER);
321
+ // 11 is the highest in use, across all sections rather than just this one
322
+ expect(ensureVideoDDExtension(sectionOf(sdp.media, '1'), sdp, 0)).toBe(12);
323
+ expect(ddOf(sdp.media, '1')).toBe(12);
324
+ });
325
+
326
+ it('reuses the id already chosen for the connection', () => {
327
+ const sdp = parse(SINGLE_PC_OFFER);
328
+ expect(ensureVideoDDExtension(sectionOf(sdp.media, '1'), sdp, 7)).toBe(7);
329
+ expect(ensureVideoDDExtension(sectionOf(sdp.media, '2'), sdp, 7)).toBe(7);
330
+ expect(ddOf(sdp.media, '1')).toBe(7);
331
+ expect(ddOf(sdp.media, '2')).toBe(7);
332
+ });
333
+
334
+ it('leaves a section that already carries the extension alone, and reports its id', () => {
335
+ const sdp = parse(`${SINGLE_PC_OFFER}
336
+ a=extmap:3 ${ddExtensionURI}`);
337
+ expect(ensureVideoDDExtension(sectionOf(sdp.media, '2'), sdp, 0)).toBe(3);
338
+ expect(sectionOf(sdp.media, '2').ext).toHaveLength(2);
339
+ expect(ddOf(sdp.media, '2')).toBe(3);
340
+ });
341
+
342
+ it('adopts the id the browser already advertises rather than inventing a second one', () => {
343
+ // Chrome maps the extension itself on the section it sends AV1 on, but not on recvonly
344
+ // ones. A bundle has to map the URI to one id, so the send section's id has to win over
345
+ // both a fresh id and the cached one.
346
+ const sdp = parse(SINGLE_PC_OFFER);
347
+ sectionOf(sdp.media, '0').ext!.push({ value: 13, uri: ddExtensionURI });
348
+
349
+ expect(ensureVideoDDExtension(sectionOf(sdp.media, '1'), sdp, 0)).toBe(13);
350
+ expect(ensureVideoDDExtension(sectionOf(sdp.media, '2'), sdp, 7)).toBe(13);
351
+ expect(ddOf(sdp.media, '1')).toBe(13);
352
+ expect(ddOf(sdp.media, '2')).toBe(13);
353
+ });
354
+
355
+ it('steps over the id RFC 8285 reserves', () => {
356
+ const sdp = parse(SINGLE_PC_OFFER);
357
+ sectionOf(sdp.media, '0').ext!.push({ value: 14, uri: 'urn:3gpp:video-orientation' });
358
+
359
+ expect(ensureVideoDDExtension(sectionOf(sdp.media, '1'), sdp, 0)).toBe(16);
360
+ });
361
+
362
+ it('abandons the cached id once something else stands for it', () => {
363
+ // The id was free when it was picked, then the first section we send on brought the fuller
364
+ // extension set along and claimed it. Reusing it anyway is what makes the browser reject
365
+ // the offer with "a BUNDLE group contains a codec collision for header extension id".
366
+ const sdp = parse(SINGLE_PC_OFFER);
367
+ sectionOf(sdp.media, '0').ext!.push({ value: 12, uri: 'urn:3gpp:video-orientation' });
368
+
369
+ expect(ensureVideoDDExtension(sectionOf(sdp.media, '1'), sdp, 12)).toBe(13);
370
+ expect(ddOf(sdp.media, '1')).toBe(13);
371
+ });
372
+
373
+ it('leaves the offer alone when the mapped id is contested', () => {
374
+ // Nothing consistent for the whole bundle is available: the extension is mapped to 12 on one
375
+ // section and 12 means something else on another, and the browser's half of the map is not
376
+ // ours to renumber. Losing AV1 beats an offer that cannot be applied at all.
377
+ const sdp = parse(SINGLE_PC_OFFER);
378
+ sectionOf(sdp.media, '0').ext!.push({ value: 12, uri: 'urn:3gpp:video-orientation' });
379
+ sectionOf(sdp.media, '2').ext!.push({ value: 12, uri: ddExtensionURI });
380
+
381
+ expect(ensureVideoDDExtension(sectionOf(sdp.media, '1'), sdp, 0)).toBe(0);
382
+ expect(ddOf(sdp.media, '1')).toBeUndefined();
383
+ });
384
+
385
+ it('adds the extension to a section that has none', () => {
386
+ const sdp = parse(SINGLE_PC_OFFER);
387
+ const section = sectionOf(sdp.media, '1');
388
+ delete section.ext;
389
+ expect(ensureVideoDDExtension(section, sdp, 0)).toBe(12);
390
+ expect(ddOf(sdp.media, '1')).toBe(12);
391
+ });
392
+ });