livekit-client 2.21.0 → 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 (70) 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 +2 -1
  5. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  6. package/dist/livekit-client.esm.mjs +480 -150
  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 +2 -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/room/PCTransport.d.ts +49 -2
  15. package/dist/src/room/PCTransport.d.ts.map +1 -1
  16. package/dist/src/room/RTCEngine.d.ts +20 -1
  17. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  18. package/dist/src/room/Room.d.ts +2 -0
  19. package/dist/src/room/Room.d.ts.map +1 -1
  20. package/dist/src/room/errors.d.ts.map +1 -1
  21. package/dist/src/room/participant/LocalParticipant.d.ts +1 -1
  22. package/dist/src/room/token-source/TokenSource.d.ts +19 -10
  23. package/dist/src/room/token-source/TokenSource.d.ts.map +1 -1
  24. package/dist/src/room/track/LocalVideoTrack.d.ts +12 -1
  25. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  26. package/dist/src/room/track/RemoteTrack.d.ts +3 -0
  27. package/dist/src/room/track/RemoteTrack.d.ts.map +1 -1
  28. package/dist/src/room/track/Track.d.ts.map +1 -1
  29. package/dist/src/room/utils.d.ts +15 -0
  30. package/dist/src/room/utils.d.ts.map +1 -1
  31. package/dist/src/test/promiseState.d.ts +12 -0
  32. package/dist/src/test/promiseState.d.ts.map +1 -0
  33. package/dist/src/test/signalToken.d.ts.map +1 -1
  34. package/dist/src/utils/AsyncQueue.d.ts +3 -3
  35. package/dist/src/utils/AsyncQueue.d.ts.map +1 -1
  36. package/dist/ts4.2/room/PCTransport.d.ts +49 -2
  37. package/dist/ts4.2/room/RTCEngine.d.ts +20 -1
  38. package/dist/ts4.2/room/Room.d.ts +2 -0
  39. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +1 -1
  40. package/dist/ts4.2/room/token-source/TokenSource.d.ts +17 -8
  41. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +12 -1
  42. package/dist/ts4.2/room/track/RemoteTrack.d.ts +3 -0
  43. package/dist/ts4.2/room/utils.d.ts +15 -0
  44. package/dist/ts4.2/test/promiseState.d.ts +12 -0
  45. package/dist/ts4.2/utils/AsyncQueue.d.ts +3 -3
  46. package/package.json +13 -12
  47. package/src/api/SignalClient.e2e.test.ts +16 -9
  48. package/src/e2ee/utils.ts +1 -1
  49. package/src/room/PCTransport.test.ts +243 -1
  50. package/src/room/PCTransport.ts +181 -80
  51. package/src/room/RTCEngine.test.ts +339 -2
  52. package/src/room/RTCEngine.ts +152 -11
  53. package/src/room/Room.test.ts +134 -3
  54. package/src/room/Room.ts +52 -17
  55. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +1 -1
  56. package/src/room/data-stream/incoming/StreamReader.ts +1 -1
  57. package/src/room/errors.ts +1 -2
  58. package/src/room/token-source/TokenSource.ts +25 -12
  59. package/src/room/track/LocalVideoTrack.test.ts +105 -2
  60. package/src/room/track/LocalVideoTrack.ts +36 -10
  61. package/src/room/track/RemoteTrack.test.ts +144 -0
  62. package/src/room/track/RemoteTrack.ts +39 -12
  63. package/src/room/track/Track.ts +2 -1
  64. package/src/room/utils.test.ts +71 -2
  65. package/src/room/utils.ts +52 -0
  66. package/src/test/promiseState.ts +23 -0
  67. package/src/test/signalServerSetup.ts +2 -1
  68. package/src/test/signalToken.ts +17 -13
  69. package/src/type-polyfills/header-extensions.d.ts +13 -0
  70. package/src/utils/AsyncQueue.ts +3 -3
@@ -213,29 +213,34 @@ class TokenSourceEndpoint extends TokenSourceCached {
213
213
  const body = await response.json();
214
214
  return TokenSourceResponse.fromJson(body, {
215
215
  // NOTE: it could be possible that the response body could contain more fields than just
216
- // what's in TokenSourceResponse depending on the implementation (ie, SandboxTokenServer)
216
+ // what's in TokenSourceResponse depending on the implementation (ie, DevelopmentTokenServer)
217
217
  ignoreUnknownFields: true,
218
218
  });
219
219
  }
220
220
  }
221
221
 
222
- export type SandboxTokenServerOptions = {
222
+ export type DevelopmentTokenServerOptions = {
223
223
  baseUrl?: string;
224
224
  };
225
-
226
- class TokenSourceSandboxTokenServer extends TokenSourceEndpoint {
227
- constructor(sandboxId: string, options: SandboxTokenServerOptions) {
225
+ class TokenSourceDevelopmentTokenServer extends TokenSourceEndpoint {
226
+ constructor(tokenServerId: string, options: DevelopmentTokenServerOptions) {
228
227
  const { baseUrl = 'https://cloud-api.livekit.io', ...rest } = options;
229
228
 
230
229
  super(`${baseUrl}/api/v2/sandbox/connection-details`, {
231
230
  ...rest,
232
231
  headers: {
233
- 'X-Sandbox-ID': sandboxId,
232
+ 'X-Sandbox-ID': tokenServerId,
234
233
  },
235
234
  });
236
235
  }
237
236
  }
238
237
 
238
+ /** @deprecated use {@link DevelopmentTokenServerOptions} instead */
239
+ export type SandboxTokenServerOptions = DevelopmentTokenServerOptions;
240
+
241
+ /** @deprecated Use {@link TokenSourceDevelopmentTokenServer} instead */
242
+ class TokenSourceSandboxTokenServer extends TokenSourceDevelopmentTokenServer {}
243
+
239
244
  export {
240
245
  /** The return type of {@link TokenSource.literal} */
241
246
  type TokenSourceLiteral,
@@ -243,8 +248,9 @@ export {
243
248
  type TokenSourceCustom,
244
249
  /** The return type of {@link TokenSource.endpoint} */
245
250
  type TokenSourceEndpoint,
246
- /** The return type of {@link TokenSource.sandboxTokenServer} */
247
251
  type TokenSourceSandboxTokenServer,
252
+ /** The return type of {@link TokenSource.developmentTokenServer} */
253
+ type TokenSourceDevelopmentTokenServer,
248
254
  decodeTokenPayload,
249
255
  areTokenSourceFetchOptionsEqual,
250
256
  };
@@ -269,22 +275,29 @@ export const TokenSource = {
269
275
  /**
270
276
  * TokenSource.endpoint creates a token source that fetches credentials from a given URL using
271
277
  * the standard endpoint format:
272
- * @see https://cloud.livekit.io/projects/p_/sandbox/templates/token-server
278
+ * @see https://docs.livekit.io/frontends/build/authentication/endpoint/
273
279
  */
274
280
  endpoint(url: string, options: EndpointOptions = {}) {
275
281
  return new TokenSourceEndpoint(url, options);
276
282
  },
277
283
 
278
284
  /**
279
- * TokenSource.sandboxTokenServer queries a sandbox token server for credentials,
285
+ * @deprecated Use {@link TokenSource.developmentTokenServer} instead
286
+ */
287
+ sandboxTokenServer(sandboxId: string, options: SandboxTokenServerOptions = {}) {
288
+ return new TokenSourceSandboxTokenServer(sandboxId, options);
289
+ },
290
+
291
+ /**
292
+ * TokenSource.developmentTokenServer queries a development token server for credentials,
280
293
  * which supports quick prototyping / getting started types of use cases.
281
294
  *
282
295
  * This token provider is INSECURE and should NOT be used in production.
283
296
  *
284
297
  * For more info:
285
- * @see https://cloud.livekit.io/projects/p_/sandbox/templates/token-server
298
+ * @see https://docs.livekit.io/frontends/build/authentication/sandbox-token-server/
286
299
  */
287
- sandboxTokenServer(sandboxId: string, options: SandboxTokenServerOptions = {}) {
288
- return new TokenSourceSandboxTokenServer(sandboxId, options);
300
+ developmentTokenServer(tokenServerId: string, options: DevelopmentTokenServerOptions = {}) {
301
+ return new TokenSourceDevelopmentTokenServer(tokenServerId, options);
289
302
  },
290
303
  };
@@ -1,6 +1,8 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { videoLayersFromEncodings } from './LocalVideoTrack';
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import LocalVideoTrack, { videoLayersFromEncodings } from './LocalVideoTrack';
3
+ import type { SimulcastTrackInfo } from './LocalVideoTrack';
3
4
  import { VideoQuality } from './Track';
5
+ import type { VideoCodec } from './options';
4
6
 
5
7
  describe('videoLayersFromEncodings', () => {
6
8
  it('returns single layer for no encoding', () => {
@@ -131,3 +133,104 @@ describe('videoLayersFromEncodings', () => {
131
133
  expect(layers[2].width).toBe(720);
132
134
  });
133
135
  });
136
+
137
+ function makeSender(label = 'sender', events: string[] = []) {
138
+ let params: RTCRtpSendParameters = {
139
+ encodings: [],
140
+ transactionId: '',
141
+ codecs: [],
142
+ headerExtensions: [],
143
+ rtcp: {},
144
+ };
145
+ return {
146
+ getParameters: () => params,
147
+ // resolves on a later task, like the real setParameters, so that a caller
148
+ // which fails to await it never observes the update
149
+ setParameters: vi.fn(async (next: RTCRtpSendParameters) => {
150
+ events.push(`${label}:start`);
151
+ await new Promise((resolve) => setTimeout(resolve, 0));
152
+ params = next;
153
+ events.push(`${label}:done`);
154
+ }),
155
+ get degradationPreference() {
156
+ return params.degradationPreference;
157
+ },
158
+ };
159
+ }
160
+
161
+ function makeTrack() {
162
+ const track = Object.create(LocalVideoTrack.prototype) as LocalVideoTrack;
163
+ Object.assign(track, {
164
+ log: { debug: vi.fn(), warn: vi.fn() },
165
+ simulcastCodecs: new Map<VideoCodec, SimulcastTrackInfo>(),
166
+ subscribedCodecs: undefined,
167
+ });
168
+ // logContext and mediaStreamTrack are getters we don't set up state for here
169
+ Object.defineProperty(track, 'logContext', { get: () => ({}) });
170
+ Object.defineProperty(track, 'mediaStreamTrack', { get: () => ({ clone: () => ({}) }) });
171
+ return track;
172
+ }
173
+
174
+ describe('setDegradationPreference', () => {
175
+ it('applies the preference to the primary sender', async () => {
176
+ const track = makeTrack();
177
+ const sender = makeSender();
178
+ Object.assign(track, { _sender: sender });
179
+
180
+ await track.setDegradationPreference('maintain-resolution');
181
+
182
+ expect(sender.degradationPreference).toBe('maintain-resolution');
183
+ });
184
+
185
+ it('applies the resolved preference to a backup codec sender', async () => {
186
+ const track = makeTrack();
187
+ const primary = makeSender();
188
+ Object.assign(track, { _sender: primary });
189
+
190
+ await track.setDegradationPreference('maintain-resolution');
191
+
192
+ // the backup codec transceiver is created later, when the server asks for it
193
+ const backupInfo = track.addSimulcastTrack('vp8', [])!;
194
+ const backup = makeSender();
195
+ await track.setSimulcastTrackSender('vp8', backup as unknown as RTCRtpSender);
196
+
197
+ expect(backupInfo.sender).toBe(backup);
198
+ expect(backup.degradationPreference).toBe('maintain-resolution');
199
+ expect(primary.degradationPreference).toBe('maintain-resolution');
200
+ });
201
+
202
+ it('updates every sender when the preference changes after the backup is published', async () => {
203
+ const track = makeTrack();
204
+ const primary = makeSender();
205
+ Object.assign(track, { _sender: primary });
206
+ await track.setDegradationPreference('maintain-framerate');
207
+
208
+ track.addSimulcastTrack('vp8', []);
209
+ const backup = makeSender();
210
+ await track.setSimulcastTrackSender('vp8', backup as unknown as RTCRtpSender);
211
+
212
+ await track.setDegradationPreference('balanced');
213
+
214
+ expect(primary.degradationPreference).toBe('balanced');
215
+ expect(backup.degradationPreference).toBe('balanced');
216
+ });
217
+
218
+ it('applies to senders one at a time', async () => {
219
+ const events: string[] = [];
220
+ const track = makeTrack();
221
+ const primary = makeSender('primary', events);
222
+ Object.assign(track, { _sender: primary });
223
+ track.addSimulcastTrack('vp8', []);
224
+ await track.setSimulcastTrackSender(
225
+ 'vp8',
226
+ makeSender('backup', events) as unknown as RTCRtpSender,
227
+ );
228
+ events.length = 0;
229
+
230
+ await track.setDegradationPreference('balanced');
231
+
232
+ // setParameters is only valid against the most recent getParameters, so the
233
+ // writes must not overlap
234
+ expect(events).toEqual(['primary:start', 'primary:done', 'backup:start', 'backup:done']);
235
+ });
236
+ });
@@ -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
 
@@ -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;
@@ -867,3 +906,16 @@ export function isPublisherOfferWithJoinSupported() {
867
906
  // we have connectivity issue about publisher offer with join on firefox #1919
868
907
  return isCompressionStreamSupported() && !isFireFox();
869
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
  }