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
@@ -406,15 +406,37 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
406
406
 
407
407
  async setDegradationPreference(preference: RTCDegradationPreference) {
408
408
  this.degradationPreference = preference;
409
- if (this.sender) {
410
- try {
411
- this.log.debug(`setting degradationPreference to ${preference}`, this.logContext);
412
- const params = this.sender.getParameters();
413
- params.degradationPreference = preference;
414
- this.sender.setParameters(params);
415
- } catch (e: any) {
416
- this.log.warn(`failed to set degradationPreference`, { error: e, ...this.logContext });
417
- }
409
+ // applied one sender at a time on purpose, see applyDegradationPreference
410
+ await this.applyDegradationPreference(this.sender);
411
+ for (const sc of this.simulcastCodecs.values()) {
412
+ await this.applyDegradationPreference(sc.sender);
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Degradation preference is a property of the sender, not of the track, so every sender
418
+ * publishing this track needs it applied separately. A backup codec publishes over its
419
+ * own sender, which would otherwise let the browser resolve a preference implicitly and
420
+ * diverge from the primary encoder.
421
+ *
422
+ * Callers apply this sequentially rather than concurrently: `setParameters` is only valid
423
+ * against the parameters most recently returned by `getParameters`, which is why this file
424
+ * serializes other sender parameter updates through `senderLock`.
425
+ */
426
+ private async applyDegradationPreference(sender?: RTCRtpSender) {
427
+ if (!sender) {
428
+ return;
429
+ }
430
+ try {
431
+ this.log.debug(
432
+ `setting degradationPreference to ${this.degradationPreference}`,
433
+ this.logContext,
434
+ );
435
+ const params = sender.getParameters();
436
+ params.degradationPreference = this.degradationPreference;
437
+ await sender.setParameters(params);
438
+ } catch (e: any) {
439
+ this.log.warn(`failed to set degradationPreference`, { error: e, ...this.logContext });
418
440
  }
419
441
  }
420
442
 
@@ -436,13 +458,17 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
436
458
  return simulcastCodecInfo;
437
459
  }
438
460
 
439
- setSimulcastTrackSender(codec: VideoCodec, sender: RTCRtpSender) {
461
+ async setSimulcastTrackSender(codec: VideoCodec, sender: RTCRtpSender) {
440
462
  const simulcastCodecInfo = this.simulcastCodecs.get(codec);
441
463
  if (!simulcastCodecInfo) {
442
464
  return;
443
465
  }
444
466
  simulcastCodecInfo.sender = sender;
445
467
 
468
+ // the backup codec publishes over its own sender, so it needs the same degradation
469
+ // preference the primary sender resolved to.
470
+ await this.applyDegradationPreference(sender);
471
+
446
472
  // browser will reenable disabled codec/layers after new codec has been published,
447
473
  // so refresh subscribedCodecs after publish a new codec
448
474
  setTimeout(() => {
@@ -0,0 +1,144 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import MockMediaStreamTrack from '../../test/MockMediaStreamTrack';
3
+ import { TrackEvent } from '../events';
4
+ import RemoteVideoTrack from './RemoteVideoTrack';
5
+
6
+ describe('RemoteTrack time sync loop', () => {
7
+ let track: RemoteVideoTrack;
8
+ let getSynchronizationSources: ReturnType<typeof vi.fn>;
9
+
10
+ beforeEach(() => {
11
+ // fake timers back `requestAnimationFrame`, so cancellation behaves like it does in a browser
12
+ vi.useFakeTimers();
13
+ // `supportsSynchronizationSources()` gates the loop on this being implemented
14
+ vi.stubGlobal(
15
+ 'RTCRtpReceiver',
16
+ class {
17
+ getSynchronizationSources() {}
18
+ },
19
+ );
20
+
21
+ let rtpTimestamp = 0;
22
+ getSynchronizationSources = vi.fn(() => [{ timestamp: 1, rtpTimestamp: ++rtpTimestamp }]);
23
+ const receiver = { getSynchronizationSources } as unknown as RTCRtpReceiver;
24
+ track = new RemoteVideoTrack(new MockMediaStreamTrack(), 'sid', receiver, {});
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.unstubAllGlobals();
29
+ vi.useRealTimers();
30
+ });
31
+
32
+ it('does not sample the receiver when nobody listens', () => {
33
+ track.startMonitor();
34
+ vi.advanceTimersToNextFrame();
35
+
36
+ expect(getSynchronizationSources).not.toHaveBeenCalled();
37
+ });
38
+
39
+ it('starts the loop when a listener subscribes', () => {
40
+ track.startMonitor();
41
+ const listener = vi.fn();
42
+ track.on(TrackEvent.TimeSyncUpdate, listener);
43
+ vi.advanceTimersToNextFrame();
44
+
45
+ expect(listener).toHaveBeenCalledWith({ timestamp: 1, rtpTimestamp: 1 });
46
+ });
47
+
48
+ it('starts the loop for listeners that subscribed before the monitor started', () => {
49
+ const listener = vi.fn();
50
+ track.on(TrackEvent.TimeSyncUpdate, listener);
51
+ track.startMonitor();
52
+ vi.advanceTimersToNextFrame();
53
+
54
+ expect(listener).toHaveBeenCalled();
55
+ });
56
+
57
+ it('does not start a second loop when a second listener subscribes', () => {
58
+ track.startMonitor();
59
+ track.on(TrackEvent.TimeSyncUpdate, vi.fn());
60
+ track.on(TrackEvent.TimeSyncUpdate, vi.fn());
61
+
62
+ vi.advanceTimersToNextFrame();
63
+
64
+ // a second chain would sample twice per frame
65
+ expect(getSynchronizationSources).toHaveBeenCalledTimes(1);
66
+ });
67
+
68
+ it('does not start a second loop when the monitor is started again', () => {
69
+ track.on(TrackEvent.TimeSyncUpdate, vi.fn());
70
+ track.startMonitor();
71
+ track.startMonitor();
72
+ getSynchronizationSources.mockClear();
73
+
74
+ vi.advanceTimersToNextFrame();
75
+
76
+ expect(getSynchronizationSources).toHaveBeenCalledTimes(1);
77
+ });
78
+
79
+ it('stops the loop once the last listener unsubscribes', () => {
80
+ const listener = vi.fn();
81
+ track.startMonitor();
82
+ track.on(TrackEvent.TimeSyncUpdate, listener);
83
+ vi.advanceTimersToNextFrame();
84
+
85
+ track.off(TrackEvent.TimeSyncUpdate, listener);
86
+ vi.advanceTimersToNextFrame();
87
+ getSynchronizationSources.mockClear();
88
+ vi.advanceTimersToNextFrame();
89
+
90
+ expect(getSynchronizationSources).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it('restarts the loop when a listener subscribes again', () => {
94
+ const listener = vi.fn();
95
+ track.startMonitor();
96
+ track.on(TrackEvent.TimeSyncUpdate, listener);
97
+ track.off(TrackEvent.TimeSyncUpdate, listener);
98
+ vi.advanceTimersToNextFrame();
99
+ listener.mockClear();
100
+
101
+ track.on(TrackEvent.TimeSyncUpdate, listener);
102
+ vi.advanceTimersToNextFrame();
103
+
104
+ expect(listener).toHaveBeenCalled();
105
+ });
106
+
107
+ it('cancels a running loop when the monitor is stopped', () => {
108
+ track.startMonitor();
109
+ // the listener stays subscribed, so only the cancellation can stop the loop
110
+ track.on(TrackEvent.TimeSyncUpdate, vi.fn());
111
+ vi.advanceTimersToNextFrame();
112
+ expect(getSynchronizationSources).toHaveBeenCalled();
113
+
114
+ track.stopMonitor();
115
+ getSynchronizationSources.mockClear();
116
+ vi.advanceTimersToNextFrame();
117
+ vi.advanceTimersToNextFrame();
118
+
119
+ expect(getSynchronizationSources).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it('resumes the loop when the monitor is started again after a stop', () => {
123
+ track.startMonitor();
124
+ track.on(TrackEvent.TimeSyncUpdate, vi.fn());
125
+ vi.advanceTimersToNextFrame();
126
+ track.stopMonitor();
127
+ getSynchronizationSources.mockClear();
128
+
129
+ track.startMonitor();
130
+ vi.advanceTimersToNextFrame();
131
+
132
+ expect(getSynchronizationSources).toHaveBeenCalled();
133
+ });
134
+
135
+ it('does not restart the loop after the monitor has been stopped', () => {
136
+ track.startMonitor();
137
+ track.stopMonitor();
138
+
139
+ track.on(TrackEvent.TimeSyncUpdate, vi.fn());
140
+ vi.advanceTimersToNextFrame();
141
+
142
+ expect(getSynchronizationSources).not.toHaveBeenCalled();
143
+ });
144
+ });
@@ -1,3 +1,4 @@
1
+ import type { EventEmitter } from 'events';
1
2
  import { TrackEvent } from '../events';
2
3
  import { monitorFrequency } from '../stats';
3
4
  import type { LoggerOptions } from '../types';
@@ -123,20 +124,46 @@ export default abstract class RemoteTrack<
123
124
  }
124
125
  }
125
126
 
127
+ /* @internal */
128
+ stopMonitor() {
129
+ super.stopMonitor();
130
+ (this as unknown as EventEmitter).off('newListener', this.onTimeSyncListenerAdded);
131
+ }
132
+
126
133
  protected abstract monitorReceiver(): void;
127
134
 
128
- registerTimeSyncUpdate() {
129
- const loop = () => {
130
- this.timeSyncHandle = requestAnimationFrame(() => loop());
131
- const sources = this.receiver?.getSynchronizationSources()[0];
132
- if (sources) {
133
- const { timestamp, rtpTimestamp } = sources;
134
- if (rtpTimestamp && this.rtpTimestamp !== rtpTimestamp) {
135
- this.emit(TrackEvent.TimeSyncUpdate, { timestamp, rtpTimestamp });
136
- this.rtpTimestamp = rtpTimestamp;
137
- }
135
+ private timeSyncLoop = () => {
136
+ if (this.listenerCount(TrackEvent.TimeSyncUpdate) === 0) {
137
+ // nobody is listening anymore, pause the loop until a new listener subscribes
138
+ this.timeSyncHandle = undefined;
139
+ return;
140
+ }
141
+ this.timeSyncHandle = requestAnimationFrame(this.timeSyncLoop);
142
+ const sources = this.receiver?.getSynchronizationSources()[0];
143
+ if (sources) {
144
+ const { timestamp, rtpTimestamp } = sources;
145
+ if (rtpTimestamp && this.rtpTimestamp !== rtpTimestamp) {
146
+ this.emit(TrackEvent.TimeSyncUpdate, { timestamp, rtpTimestamp });
147
+ this.rtpTimestamp = rtpTimestamp;
138
148
  }
139
- };
140
- loop();
149
+ }
150
+ };
151
+
152
+ private onTimeSyncListenerAdded = (event: string) => {
153
+ if (event === TrackEvent.TimeSyncUpdate && this.timeSyncHandle === undefined) {
154
+ // `newListener` fires before the listener is registered, so schedule the
155
+ // next frame instead of entering the loop (which would see a count of 0)
156
+ this.timeSyncHandle = requestAnimationFrame(this.timeSyncLoop);
157
+ }
158
+ };
159
+
160
+ registerTimeSyncUpdate() {
161
+ // `newListener` isn't part of the typed event map, hence the cast
162
+ const emitter = this as unknown as EventEmitter;
163
+ emitter.off('newListener', this.onTimeSyncListenerAdded);
164
+ emitter.on('newListener', this.onTimeSyncListenerAdded);
165
+ if (this.timeSyncHandle === undefined) {
166
+ this.timeSyncLoop();
167
+ }
141
168
  }
142
169
  }
@@ -276,8 +276,9 @@ export abstract class Track<
276
276
  if (this.monitorInterval) {
277
277
  clearInterval(this.monitorInterval);
278
278
  }
279
- if (this.timeSyncHandle) {
279
+ if (this.timeSyncHandle !== undefined) {
280
280
  cancelAnimationFrame(this.timeSyncHandle);
281
+ this.timeSyncHandle = undefined;
281
282
  }
282
283
  }
283
284
 
package/src/room/types.ts CHANGED
@@ -21,6 +21,21 @@ export interface SendTextOptions {
21
21
  attachments?: Array<File>;
22
22
  onProgress?: (progress: number) => void;
23
23
  attributes?: Record<string, string>;
24
+ /** Whether to compress the payload (deflate-raw). Defaults to true. Compression is only applied
25
+ * when every recipient supports data streams v2 and the runtime can compress. */
26
+ compress?: boolean;
27
+ }
28
+
29
+ export interface SendBytesOptions {
30
+ topic?: string;
31
+ destinationIdentities?: Array<string>;
32
+ attributes?: Record<string, string>;
33
+ onProgress?: (progress: number) => void;
34
+ /** Whether to compress the payload (deflate-raw). Defaults to true. Compression is only applied
35
+ * when every recipient supports data streams v2 and the runtime can compress. */
36
+ compress?: boolean;
37
+ name?: string;
38
+ mimeType?: string;
24
39
  }
25
40
 
26
41
  export interface StreamTextOptions {
@@ -51,6 +66,9 @@ export type SendFileOptions = Pick<
51
66
  > & {
52
67
  onProgress?: (progress: number) => void;
53
68
  encryptionType?: Encryption_Type.NONE;
69
+ /** Whether to compress the payload (deflate-raw). Defaults to true. Compression is only applied
70
+ * when every recipient supports data streams v2 and the runtime can compress. */
71
+ compress?: boolean;
54
72
  };
55
73
 
56
74
  export type DataPublishOptions = {
@@ -1,6 +1,13 @@
1
1
  import { ClientInfo_Capability } from '@livekit/protocol';
2
- import { describe, expect, it } from 'vitest';
3
- import { extractMaxAgeFromRequestHeaders, getClientInfo, splitUtf8, toWebsocketUrl } from './utils';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ ddExtensionURI,
5
+ extractMaxAgeFromRequestHeaders,
6
+ getClientInfo,
7
+ negotiateDependencyDescriptor,
8
+ splitUtf8,
9
+ toWebsocketUrl,
10
+ } from './utils';
4
11
 
5
12
  describe('toWebsocketUrl', () => {
6
13
  it('leaves wss urls alone', () => {
@@ -188,3 +195,65 @@ describe('extractMaxAgeFromRequestHeaders', () => {
188
195
  expect(extractMaxAgeFromRequestHeaders(headers)).toBe(3600);
189
196
  });
190
197
  });
198
+
199
+ describe('negotiateDependencyDescriptor', () => {
200
+ /** A transceiver whose header extension control offers `extensions`, or none at all. */
201
+ const transceiverWith = (extensions?: RTCRtpHeaderExtensionCapability[], throws = false) => {
202
+ const set = vi.fn((updated: RTCRtpHeaderExtensionCapability[]) => {
203
+ if (throws) {
204
+ throw new Error('InvalidModificationError');
205
+ }
206
+ extensions = updated;
207
+ });
208
+ return {
209
+ transceiver: (extensions
210
+ ? {
211
+ getHeaderExtensionsToNegotiate: () => extensions!,
212
+ setHeaderExtensionsToNegotiate: set,
213
+ }
214
+ : {}) as unknown as RTCRtpTransceiver,
215
+ set,
216
+ current: () => extensions,
217
+ };
218
+ };
219
+
220
+ it('turns the extension on for a transceiver that has it stopped', () => {
221
+ const { transceiver, set, current } = transceiverWith([
222
+ { uri: 'urn:ietf:params:rtp-hdrext:sdes:mid', direction: 'sendrecv' },
223
+ { uri: ddExtensionURI, direction: 'stopped' },
224
+ ]);
225
+
226
+ expect(negotiateDependencyDescriptor(transceiver)).toBe(true);
227
+ expect(set).toHaveBeenCalledOnce();
228
+ // sendrecv keeps it a plain a=extmap line, with no direction suffix for the server to parse
229
+ expect(current()?.find((ext) => ext.uri === ddExtensionURI)?.direction).toBe('sendrecv');
230
+ });
231
+
232
+ it('leaves an extension the browser already negotiates alone', () => {
233
+ const { transceiver, set } = transceiverWith([{ uri: ddExtensionURI, direction: 'recvonly' }]);
234
+
235
+ expect(negotiateDependencyDescriptor(transceiver)).toBe(true);
236
+ expect(set).not.toHaveBeenCalled();
237
+ });
238
+
239
+ it('reports no negotiation where the browser does not know the extension', () => {
240
+ const { transceiver, set } = transceiverWith([
241
+ { uri: 'urn:ietf:params:rtp-hdrext:sdes:mid', direction: 'sendrecv' },
242
+ ]);
243
+
244
+ expect(negotiateDependencyDescriptor(transceiver)).toBe(false);
245
+ expect(set).not.toHaveBeenCalled();
246
+ });
247
+
248
+ it('reports no negotiation where the browser has no such control', () => {
249
+ const { transceiver } = transceiverWith();
250
+
251
+ expect(negotiateDependencyDescriptor(transceiver)).toBe(false);
252
+ });
253
+
254
+ it('swallows a rejected direction rather than failing the connection', () => {
255
+ const { transceiver } = transceiverWith([{ uri: ddExtensionURI, direction: 'stopped' }], true);
256
+
257
+ expect(negotiateDependencyDescriptor(transceiver)).toBe(false);
258
+ });
259
+ });
package/src/room/utils.ts CHANGED
@@ -146,6 +146,45 @@ export function isSVCCodec(codec?: string): boolean {
146
146
  return codec === 'av1' || codec === 'vp9';
147
147
  }
148
148
 
149
+ /**
150
+ * Opts `transceiver` into negotiating the AV1 dependency descriptor, reporting whether it will be.
151
+ *
152
+ * Chrome only offers the extension on transceivers that can send, so one we create to receive on
153
+ * never negotiates it — and Chrome 152 stopped decoding AV1 that arrives without it: frames get
154
+ * assembled, none ever decode, and the receiver asks for a keyframe forever. Asking through the
155
+ * transceiver rather than munging the extension into the SDP leaves the browser owning the
156
+ * extension id, which is what keeps that id consistent across the bundle and across
157
+ * renegotiations.
158
+ *
159
+ * A no-op where the browser offers no such control, or does not know the extension at all.
160
+ * @internal
161
+ */
162
+ export function negotiateDependencyDescriptor(transceiver: RTCRtpTransceiver): boolean {
163
+ const extensions = transceiver.getHeaderExtensionsToNegotiate?.();
164
+ if (!extensions || !transceiver.setHeaderExtensionsToNegotiate) {
165
+ return false;
166
+ }
167
+ const dd = extensions.find((ext) => ext.uri === ddExtensionURI);
168
+ if (!dd) {
169
+ return false;
170
+ }
171
+ if (dd.direction !== 'stopped') {
172
+ return true;
173
+ }
174
+ // sendrecv rather than recvonly, so that the extension is written as a plain `a=extmap` line —
175
+ // the form the server already emits where it is the one offering — rather than one carrying a
176
+ // `/recvonly` suffix that its parser may not expect
177
+ dd.direction = 'sendrecv';
178
+ try {
179
+ transceiver.setHeaderExtensionsToNegotiate(extensions);
180
+ return true;
181
+ } catch (e) {
182
+ // a rejected direction throws. Negotiating without the extension is what happened before this
183
+ // existed, so it is not worth failing the connection over
184
+ return false;
185
+ }
186
+ }
187
+
149
188
  export function supportsSetSinkId(elm?: HTMLMediaElement): boolean {
150
189
  if (!document || isSafariBased()) {
151
190
  return false;
@@ -781,6 +820,73 @@ export function splitUtf8(s: string, n: number): NonSharedUint8Array[] {
781
820
  return result;
782
821
  }
783
822
 
823
+ /** Wraps a byte array in a `ReadableStream` that yields it as a single chunk and then closes. */
824
+ export function readableFromBytes(bytes: NonSharedUint8Array): ReadableStream<NonSharedUint8Array> {
825
+ return new ReadableStream<NonSharedUint8Array>({
826
+ start(controller) {
827
+ controller.enqueue(bytes);
828
+ controller.close();
829
+ },
830
+ });
831
+ }
832
+
833
+ /**
834
+ * Re-chunks a byte stream into pieces of exactly `chunkSize` bytes (the final piece may be
835
+ * smaller), coalescing or splitting the source's pieces as needed. Memory use is bounded to roughly
836
+ * `chunkSize` plus one source read, so it never buffers the whole stream — used to pack
837
+ * `CompressionStream`/`file.stream()` output into MTU-sized data-stream chunks.
838
+ */
839
+ export async function* readBytesInChunks(
840
+ source: ReadableStream<NonSharedUint8Array>,
841
+ chunkSize: number,
842
+ ): AsyncGenerator<NonSharedUint8Array> {
843
+ const reader = source.getReader();
844
+ let buffer = new Uint8Array(0);
845
+ try {
846
+ while (true) {
847
+ const { done, value } = await reader.read();
848
+ if (done) {
849
+ break;
850
+ }
851
+ if (value.byteLength === 0) {
852
+ continue;
853
+ }
854
+ const merged = new Uint8Array(buffer.byteLength + value.byteLength);
855
+ merged.set(buffer);
856
+ merged.set(value, buffer.byteLength);
857
+ buffer = merged;
858
+ while (buffer.byteLength >= chunkSize) {
859
+ yield buffer.slice(0, chunkSize);
860
+ buffer = buffer.slice(chunkSize);
861
+ }
862
+ }
863
+ if (buffer.byteLength > 0) {
864
+ yield buffer;
865
+ }
866
+ } finally {
867
+ reader.releaseLock();
868
+ }
869
+ }
870
+
871
+ /** Encodes a byte array as a base64 string (suitable for embedding binary data in a string field). */
872
+ export function encodeBase64(bytes: Uint8Array): string {
873
+ let binary = '';
874
+ for (let i = 0; i < bytes.byteLength; i++) {
875
+ binary += String.fromCharCode(bytes[i]!);
876
+ }
877
+ return btoa(binary);
878
+ }
879
+
880
+ /** Decodes a base64 string (as produced by {@link encodeBase64}) back into a byte array. */
881
+ export function decodeBase64(base64: string): Uint8Array {
882
+ const binary = atob(base64);
883
+ const bytes = new Uint8Array(binary.length);
884
+ for (let i = 0; i < binary.length; i++) {
885
+ bytes[i] = binary.charCodeAt(i);
886
+ }
887
+ return bytes;
888
+ }
889
+
784
890
  export function extractMaxAgeFromRequestHeaders(headers: Headers): number | undefined {
785
891
  const cacheControl = headers.get('Cache-Control');
786
892
  if (cacheControl) {
@@ -800,3 +906,16 @@ export function isPublisherOfferWithJoinSupported() {
800
906
  // we have connectivity issue about publisher offer with join on firefox #1919
801
907
  return isCompressionStreamSupported() && !isFireFox();
802
908
  }
909
+
910
+ export function extractTrackSid(
911
+ mediaTrack: MediaStreamTrack,
912
+ stream: MediaStream,
913
+ ): Track.SID | undefined {
914
+ const [, streamId] = unpackStreamId(stream.id);
915
+ if (streamId?.startsWith('TR')) {
916
+ return streamId;
917
+ }
918
+ if (mediaTrack.id.startsWith('TR')) {
919
+ return mediaTrack.id;
920
+ }
921
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Test helper: resolves `true` if `promise` is still pending after `ms`
3
+ * (default: one macrotask), `false` if it has already settled — regardless of
4
+ * whether it resolved or rejected.
5
+ *
6
+ * Use to assert that something has *not* happened yet (e.g. an onClose callback
7
+ * that must stay silent), optionally within a timeout window. Preferable to
8
+ * `Promise.race([p, Promise.resolve(sentinel)])` when the "not yet" needs to
9
+ * hold for a duration rather than just the current microtask.
10
+ */
11
+ export async function isPending(promise: Promise<unknown>, ms = 0): Promise<boolean> {
12
+ const pending = Symbol('pending');
13
+ // Swallow settlement (incl. rejection) so racing it can't raise unhandled
14
+ // rejections when the timer wins.
15
+ const settled = promise.then(
16
+ () => 'settled' as const,
17
+ () => 'settled' as const,
18
+ );
19
+ const timer = new Promise<typeof pending>((resolve) => {
20
+ setTimeout(() => resolve(pending), ms);
21
+ });
22
+ return (await Promise.race([settled, timer])) === pending;
23
+ }
@@ -2,6 +2,7 @@ import { type ChildProcess, execFileSync, spawn } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { Socket, createServer } from 'node:net';
4
4
  import { join } from 'node:path';
5
+ import { sleep } from '../room/utils';
5
6
  import { createToken } from './signalToken';
6
7
 
7
8
  /**
@@ -47,7 +48,7 @@ async function waitReady(serverUrl: string): Promise<boolean> {
47
48
  } catch {
48
49
  // not up yet
49
50
  }
50
- await new Promise((r) => setTimeout(r, 200));
51
+ await sleep(200);
51
52
  }
52
53
  return false;
53
54
  }
@@ -1,4 +1,4 @@
1
- import { SignJWT } from 'jose';
1
+ import { AccessToken } from 'livekit-server-sdk';
2
2
 
3
3
  /**
4
4
  * Mint a LiveKit access token for the mock test-server (HS256, dev secret).
@@ -33,24 +33,28 @@ export async function createToken(opts: TokenOptions = {}): Promise<string> {
33
33
  secret = 'secret',
34
34
  ttlSeconds = 600,
35
35
  } = opts;
36
- const key = new TextEncoder().encode(secret);
37
- const payload: Record<string, unknown> = {
38
- video: { room, roomJoin: true, canPublish: true, canSubscribe: true, canPublishData: true },
39
- };
36
+ const attributes: Record<string, string> = {};
40
37
  if (signal) {
41
38
  const control: Record<string, unknown> = { signal };
42
39
  if (leaveAction !== undefined) {
43
40
  control.leaveAction = leaveAction;
44
41
  }
45
- payload.attributes = { 'lk.mock': JSON.stringify(control) };
42
+ attributes['lk.mock'] = JSON.stringify(control);
46
43
  }
47
- return new SignJWT(payload)
48
- .setProtectedHeader({ alg: 'HS256' })
49
- .setIssuer(apiKey)
50
- .setSubject(identity)
51
- .setIssuedAt()
52
- .setExpirationTime(`${ttlSeconds}s`)
53
- .sign(key);
44
+ const token = new AccessToken(apiKey, secret, {
45
+ ttl: ttlSeconds,
46
+ identity,
47
+ attributes,
48
+ });
49
+ token.addGrant({
50
+ room,
51
+ roomJoin: true,
52
+ canPublish: true,
53
+ canSubscribe: true,
54
+ canPublishData: true,
55
+ });
56
+
57
+ return token.toJwt();
54
58
  }
55
59
 
56
60
  /** A syntactically-valid token signed with the WRONG secret — for 401 tests. */
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Per-transceiver control over which RTP header extensions get negotiated. Declared as optional
3
+ * because it is not in lib.dom yet and not every browser implements it.
4
+ * https://w3c.github.io/webrtc-extensions/#rtcrtptransceiver-interface-extensions
5
+ */
6
+ interface RTCRtpTransceiver {
7
+ getHeaderExtensionsToNegotiate?(): RTCRtpHeaderExtensionCapability[];
8
+ setHeaderExtensionsToNegotiate?(extensions: RTCRtpHeaderExtensionCapability[]): void;
9
+ }
10
+
11
+ interface RTCRtpHeaderExtensionCapability {
12
+ direction?: 'sendrecv' | 'sendonly' | 'recvonly' | 'stopped';
13
+ }
@@ -3,9 +3,9 @@ import { Mutex } from '@livekit/mutex';
3
3
  type QueueTask<T> = () => PromiseLike<T>;
4
4
 
5
5
  enum QueueTaskStatus {
6
- 'WAITING',
7
- 'RUNNING',
8
- 'COMPLETED',
6
+ WAITING,
7
+ RUNNING,
8
+ COMPLETED,
9
9
  }
10
10
 
11
11
  type QueueTaskInfo = {
package/src/version.ts CHANGED
@@ -8,7 +8,11 @@ export const CLIENT_PROTOCOL_DEFAULT = 0;
8
8
  /** Replaces RPC v1 protocol with a v2 data streams based one to support unlimited request /
9
9
  * response payload length. */
10
10
  export const CLIENT_PROTOCOL_DATA_STREAM_RPC = 1;
11
+ /** "Data streams v2": the client knows how to receive a single-packet data stream (a stream whose
12
+ * entire payload is smuggled into the header packet, with no chunk/trailer packets). Senders only
13
+ * use the single-packet optimization when the recipient advertises at least this protocol. */
14
+ export const CLIENT_PROTOCOL_DATA_STREAM_V2 = 2;
11
15
 
12
16
  /** The client protocol version indicates what level of support that the client has for
13
17
  * client <-> client api interactions. */
14
- export const clientProtocol = CLIENT_PROTOCOL_DATA_STREAM_RPC;
18
+ export const clientProtocol = CLIENT_PROTOCOL_DATA_STREAM_V2;