livekit-client 2.21.0 → 2.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/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 +189 -2
  5. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  6. package/dist/livekit-client.esm.mjs +2243 -270
  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 +189 -2
  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/api/SignalClient.d.ts +25 -1
  15. package/dist/src/api/SignalClient.d.ts.map +1 -1
  16. package/dist/src/api/SignalClientStateMachine.d.ts +85 -0
  17. package/dist/src/api/SignalClientStateMachine.d.ts.map +1 -0
  18. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  19. package/dist/src/api/utils.d.ts.map +1 -1
  20. package/dist/src/index.d.ts +3 -2
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/logger.d.ts +2 -1
  23. package/dist/src/logger.d.ts.map +1 -1
  24. package/dist/src/options.d.ts +22 -0
  25. package/dist/src/options.d.ts.map +1 -1
  26. package/dist/src/room/PCTransport.d.ts +51 -3
  27. package/dist/src/room/PCTransport.d.ts.map +1 -1
  28. package/dist/src/room/RTCEngine.d.ts +27 -3
  29. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  30. package/dist/src/room/Room.d.ts +10 -1
  31. package/dist/src/room/Room.d.ts.map +1 -1
  32. package/dist/src/room/errors.d.ts.map +1 -1
  33. package/dist/src/room/events.d.ts +1 -1
  34. package/dist/src/room/participant/LocalParticipant.d.ts +1 -1
  35. package/dist/src/room/statsSummary.d.ts +13 -0
  36. package/dist/src/room/statsSummary.d.ts.map +1 -0
  37. package/dist/src/room/token-source/TokenSource.d.ts +19 -10
  38. package/dist/src/room/token-source/TokenSource.d.ts.map +1 -1
  39. package/dist/src/room/token-source/utils.d.ts.map +1 -1
  40. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  41. package/dist/src/room/track/LocalVideoTrack.d.ts +12 -1
  42. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  43. package/dist/src/room/track/RemoteTrack.d.ts +3 -0
  44. package/dist/src/room/track/RemoteTrack.d.ts.map +1 -1
  45. package/dist/src/room/track/Track.d.ts +10 -0
  46. package/dist/src/room/track/Track.d.ts.map +1 -1
  47. package/dist/src/room/utils.d.ts +15 -0
  48. package/dist/src/room/utils.d.ts.map +1 -1
  49. package/dist/src/test/promiseState.d.ts +12 -0
  50. package/dist/src/test/promiseState.d.ts.map +1 -0
  51. package/dist/src/test/signalToken.d.ts.map +1 -1
  52. package/dist/src/utils/AsyncQueue.d.ts +3 -3
  53. package/dist/src/utils/AsyncQueue.d.ts.map +1 -1
  54. package/dist/src/utils/machineInspector.d.ts +54 -0
  55. package/dist/src/utils/machineInspector.d.ts.map +1 -0
  56. package/dist/ts4.2/api/SignalClient.d.ts +25 -1
  57. package/dist/ts4.2/api/SignalClientStateMachine.d.ts +85 -0
  58. package/dist/ts4.2/index.d.ts +3 -2
  59. package/dist/ts4.2/logger.d.ts +2 -1
  60. package/dist/ts4.2/options.d.ts +22 -0
  61. package/dist/ts4.2/room/PCTransport.d.ts +51 -3
  62. package/dist/ts4.2/room/RTCEngine.d.ts +27 -3
  63. package/dist/ts4.2/room/Room.d.ts +10 -1
  64. package/dist/ts4.2/room/events.d.ts +1 -1
  65. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +1 -1
  66. package/dist/ts4.2/room/statsSummary.d.ts +13 -0
  67. package/dist/ts4.2/room/token-source/TokenSource.d.ts +17 -8
  68. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +12 -1
  69. package/dist/ts4.2/room/track/RemoteTrack.d.ts +3 -0
  70. package/dist/ts4.2/room/track/Track.d.ts +10 -0
  71. package/dist/ts4.2/room/utils.d.ts +15 -0
  72. package/dist/ts4.2/test/promiseState.d.ts +12 -0
  73. package/dist/ts4.2/utils/AsyncQueue.d.ts +3 -3
  74. package/dist/ts4.2/utils/machineInspector.d.ts +54 -0
  75. package/package.json +19 -12
  76. package/src/api/SignalClient.e2e.test.ts +16 -9
  77. package/src/api/SignalClient.test.ts +320 -8
  78. package/src/api/SignalClient.ts +260 -82
  79. package/src/api/SignalClientStateMachine.test.ts +472 -0
  80. package/src/api/SignalClientStateMachine.ts +180 -0
  81. package/src/api/WebSocketStream.ts +19 -3
  82. package/src/api/utils.test.ts +20 -1
  83. package/src/api/utils.ts +5 -0
  84. package/src/e2ee/utils.ts +1 -1
  85. package/src/index.ts +5 -0
  86. package/src/logger.ts +1 -0
  87. package/src/options.ts +24 -0
  88. package/src/room/PCTransport.test.ts +243 -1
  89. package/src/room/PCTransport.ts +183 -81
  90. package/src/room/RTCEngine.test.ts +339 -2
  91. package/src/room/RTCEngine.ts +168 -18
  92. package/src/room/Room.test.ts +134 -3
  93. package/src/room/Room.ts +122 -22
  94. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +1 -1
  95. package/src/room/data-stream/incoming/StreamReader.ts +1 -1
  96. package/src/room/errors.ts +1 -2
  97. package/src/room/events.ts +1 -1
  98. package/src/room/statsSummary.ts +187 -0
  99. package/src/room/token-source/TokenSource.ts +25 -12
  100. package/src/room/token-source/test-tokens.ts +20 -0
  101. package/src/room/token-source/utils.test.ts +27 -0
  102. package/src/room/token-source/utils.ts +12 -5
  103. package/src/room/track/LocalAudioTrack.ts +9 -3
  104. package/src/room/track/LocalVideoTrack.test.ts +105 -2
  105. package/src/room/track/LocalVideoTrack.ts +36 -10
  106. package/src/room/track/RemoteTrack.test.ts +144 -0
  107. package/src/room/track/RemoteTrack.ts +39 -12
  108. package/src/room/track/Track.ts +29 -1
  109. package/src/room/utils.test.ts +94 -2
  110. package/src/room/utils.ts +53 -1
  111. package/src/test/promiseState.ts +23 -0
  112. package/src/test/signalServerSetup.ts +2 -1
  113. package/src/test/signalToken.ts +17 -13
  114. package/src/type-polyfills/header-extensions.d.ts +13 -0
  115. package/src/utils/AsyncQueue.ts +3 -3
  116. package/src/utils/machineInspector.ts +90 -0
@@ -31,6 +31,33 @@ describe('isResponseTokenValid', () => {
31
31
  );
32
32
  expect(isValid).toBe(false);
33
33
  });
34
+ it('should treat a jwt without exp as expired', () => {
35
+ const isValid = isResponseTokenValid(
36
+ TokenSourceResponse.fromJson({
37
+ serverUrl: 'ws://localhost:7800',
38
+ participantToken: TOKENS.NO_EXP,
39
+ }),
40
+ );
41
+ expect(isValid).toBe(false);
42
+ });
43
+ it('should honor exp when nbf is absent', () => {
44
+ const isValid = isResponseTokenValid(
45
+ TokenSourceResponse.fromJson({
46
+ serverUrl: 'ws://localhost:7800',
47
+ participantToken: TOKENS.EXP_IN_PAST_NO_NBF,
48
+ }),
49
+ );
50
+ expect(isValid).toBe(false);
51
+ });
52
+ it('should accept a non-expired jwt that omits nbf', () => {
53
+ const isValid = isResponseTokenValid(
54
+ TokenSourceResponse.fromJson({
55
+ serverUrl: 'ws://localhost:7800',
56
+ participantToken: TOKENS.VALID_NO_NBF,
57
+ }),
58
+ );
59
+ expect(isValid).toBe(true);
60
+ });
34
61
  });
35
62
 
36
63
  describe('decodeTokenPayload', () => {
@@ -7,19 +7,26 @@ const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS;
7
7
 
8
8
  export function isResponseTokenValid(response: TokenSourceResponse) {
9
9
  const jwtPayload = decodeTokenPayload(response.participantToken);
10
- if (!jwtPayload?.nbf || !jwtPayload?.exp) {
11
- return true;
10
+ // Missing exp: TokenSourceCached would otherwise return this response forever.
11
+ // nbf is optional (RFC 7519); do not skip the exp check when it is absent.
12
+ if (!jwtPayload?.exp) {
13
+ return false;
12
14
  }
13
15
 
14
16
  const now = new Date();
15
17
 
16
- const nbfInMilliseconds = jwtPayload.nbf * ONE_SECOND_IN_MILLISECONDS;
17
- const nbfDate = new Date(nbfInMilliseconds);
18
+ if (jwtPayload.nbf) {
19
+ const nbfInMilliseconds = jwtPayload.nbf * ONE_SECOND_IN_MILLISECONDS;
20
+ const nbfDate = new Date(nbfInMilliseconds);
21
+ if (nbfDate > now) {
22
+ return false;
23
+ }
24
+ }
18
25
 
19
26
  const expInMilliseconds = jwtPayload.exp * ONE_SECOND_IN_MILLISECONDS;
20
27
  const expDate = new Date(expInMilliseconds - ONE_MINUTE_IN_MILLISECONDS);
21
28
 
22
- return nbfDate <= now && expDate > now;
29
+ return expDate > now;
23
30
  }
24
31
 
25
32
  /** Given a LiveKit generated participant token, decodes and returns the associated {@link TokenPayload} data. */
@@ -245,12 +245,18 @@ export default class LocalAudioTrack extends LocalTrack<Track.Kind.Audio> {
245
245
  type: 'audio',
246
246
  streamId: v.id,
247
247
  packetsSent: v.packetsSent,
248
- packetsLost: v.packetsLost,
249
248
  bytesSent: v.bytesSent,
250
249
  timestamp: v.timestamp,
251
- roundTripTime: v.roundTripTime,
252
- jitter: v.jitter,
253
250
  };
251
+
252
+ // loss, jitter and RTT are only known from what the remote reports back,
253
+ // the same way the video sender picks them up
254
+ const remote = stats.get(v.remoteId);
255
+ if (remote) {
256
+ audioStats.packetsLost = remote.packetsLost;
257
+ audioStats.jitter = remote.jitter;
258
+ audioStats.roundTripTime = remote.roundTripTime;
259
+ }
254
260
  }
255
261
  });
256
262
 
@@ -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
  }
@@ -11,6 +11,7 @@ import type { SignalClient } from '../../api/SignalClient';
11
11
  import log, { LoggerNames, type StructuredLogger, getLogger } from '../../logger';
12
12
  import type { NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays';
13
13
  import { TrackEvent } from '../events';
14
+ import { summarizeStatsReport } from '../statsSummary';
14
15
  import type { LoggerOptions } from '../types';
15
16
  import { isFireFox, isSafari, isWeb } from '../utils';
16
17
  import type { TrackProcessor } from './processor/types';
@@ -85,6 +86,8 @@ export abstract class Track<
85
86
 
86
87
  protected monitorInterval?: ReturnType<typeof setInterval>;
87
88
 
89
+ private finalStatsLogged = false;
90
+
88
91
  protected log: StructuredLogger = log;
89
92
 
90
93
  protected constructor(
@@ -271,14 +274,39 @@ export abstract class Track<
271
274
  /* @internal */
272
275
  abstract startMonitor(signalClient?: SignalClient): void;
273
276
 
277
+ /**
278
+ * Raw stats of the sender or receiver this track is attached to.
279
+ */
280
+ abstract getRTCStatsReport(): Promise<RTCStatsReport | undefined>;
281
+
274
282
  /* @internal */
275
283
  stopMonitor() {
276
284
  if (this.monitorInterval) {
277
285
  clearInterval(this.monitorInterval);
278
286
  }
279
- if (this.timeSyncHandle) {
287
+ if (this.timeSyncHandle !== undefined) {
280
288
  cancelAnimationFrame(this.timeSyncHandle);
289
+ this.timeSyncHandle = undefined;
281
290
  }
291
+ this.logFinalStats();
292
+ }
293
+
294
+ /**
295
+ * Dumps the raw stats of the track as it ends, once: a track that stops
296
+ * between two of the room's stats dumps is gone by the time the next one runs.
297
+ */
298
+ private logFinalStats() {
299
+ if (this.finalStatsLogged) {
300
+ return;
301
+ }
302
+ this.finalStatsLogged = true;
303
+ this.getRTCStatsReport()
304
+ .then((report) => {
305
+ if (report) {
306
+ this.log.info('final track stats', summarizeStatsReport(report));
307
+ }
308
+ })
309
+ .catch((error) => this.log.debug('could not collect final track stats', { error }));
282
310
  }
283
311
 
284
312
  /** @internal */
@@ -1,6 +1,14 @@
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 { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ ddExtensionURI,
5
+ extractMaxAgeFromRequestHeaders,
6
+ getClientInfo,
7
+ negotiateDependencyDescriptor,
8
+ splitUtf8,
9
+ supportsAdaptiveStream,
10
+ toWebsocketUrl,
11
+ } from './utils';
4
12
 
5
13
  describe('toWebsocketUrl', () => {
6
14
  it('leaves wss urls alone', () => {
@@ -188,3 +196,87 @@ describe('extractMaxAgeFromRequestHeaders', () => {
188
196
  expect(extractMaxAgeFromRequestHeaders(headers)).toBe(3600);
189
197
  });
190
198
  });
199
+
200
+ describe('negotiateDependencyDescriptor', () => {
201
+ /** A transceiver whose header extension control offers `extensions`, or none at all. */
202
+ const transceiverWith = (extensions?: RTCRtpHeaderExtensionCapability[], throws = false) => {
203
+ const set = vi.fn((updated: RTCRtpHeaderExtensionCapability[]) => {
204
+ if (throws) {
205
+ throw new Error('InvalidModificationError');
206
+ }
207
+ extensions = updated;
208
+ });
209
+ return {
210
+ transceiver: (extensions
211
+ ? {
212
+ getHeaderExtensionsToNegotiate: () => extensions!,
213
+ setHeaderExtensionsToNegotiate: set,
214
+ }
215
+ : {}) as unknown as RTCRtpTransceiver,
216
+ set,
217
+ current: () => extensions,
218
+ };
219
+ };
220
+
221
+ it('turns the extension on for a transceiver that has it stopped', () => {
222
+ const { transceiver, set, current } = transceiverWith([
223
+ { uri: 'urn:ietf:params:rtp-hdrext:sdes:mid', direction: 'sendrecv' },
224
+ { uri: ddExtensionURI, direction: 'stopped' },
225
+ ]);
226
+
227
+ expect(negotiateDependencyDescriptor(transceiver)).toBe(true);
228
+ expect(set).toHaveBeenCalledOnce();
229
+ // sendrecv keeps it a plain a=extmap line, with no direction suffix for the server to parse
230
+ expect(current()?.find((ext) => ext.uri === ddExtensionURI)?.direction).toBe('sendrecv');
231
+ });
232
+
233
+ it('leaves an extension the browser already negotiates alone', () => {
234
+ const { transceiver, set } = transceiverWith([{ uri: ddExtensionURI, direction: 'recvonly' }]);
235
+
236
+ expect(negotiateDependencyDescriptor(transceiver)).toBe(true);
237
+ expect(set).not.toHaveBeenCalled();
238
+ });
239
+
240
+ it('reports no negotiation where the browser does not know the extension', () => {
241
+ const { transceiver, set } = transceiverWith([
242
+ { uri: 'urn:ietf:params:rtp-hdrext:sdes:mid', direction: 'sendrecv' },
243
+ ]);
244
+
245
+ expect(negotiateDependencyDescriptor(transceiver)).toBe(false);
246
+ expect(set).not.toHaveBeenCalled();
247
+ });
248
+
249
+ it('reports no negotiation where the browser has no such control', () => {
250
+ const { transceiver } = transceiverWith();
251
+
252
+ expect(negotiateDependencyDescriptor(transceiver)).toBe(false);
253
+ });
254
+
255
+ it('swallows a rejected direction rather than failing the connection', () => {
256
+ const { transceiver } = transceiverWith([{ uri: ddExtensionURI, direction: 'stopped' }], true);
257
+
258
+ expect(negotiateDependencyDescriptor(transceiver)).toBe(false);
259
+ });
260
+ });
261
+
262
+ describe('supportsAdaptiveStream', () => {
263
+ afterEach(() => {
264
+ vi.unstubAllGlobals();
265
+ });
266
+
267
+ it('reports support where both observers exist', () => {
268
+ expect(supportsAdaptiveStream()).toBe(true);
269
+ });
270
+
271
+ it('reports no support where ResizeObserver is missing', () => {
272
+ vi.stubGlobal('ResizeObserver', undefined);
273
+
274
+ expect(supportsAdaptiveStream()).toBe(false);
275
+ });
276
+
277
+ it('reports no support where IntersectionObserver is missing', () => {
278
+ vi.stubGlobal('IntersectionObserver', undefined);
279
+
280
+ expect(supportsAdaptiveStream()).toBe(false);
281
+ });
282
+ });