livekit-client 2.22.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 (75) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +187 -1
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +1764 -121
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js +1 -1
  8. package/dist/livekit-client.fm.worker.js.map +1 -1
  9. package/dist/livekit-client.fm.worker.mjs +187 -1
  10. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  11. package/dist/livekit-client.umd.js +1 -1
  12. package/dist/livekit-client.umd.js.map +1 -1
  13. package/dist/src/api/SignalClient.d.ts +25 -1
  14. package/dist/src/api/SignalClient.d.ts.map +1 -1
  15. package/dist/src/api/SignalClientStateMachine.d.ts +85 -0
  16. package/dist/src/api/SignalClientStateMachine.d.ts.map +1 -0
  17. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  18. package/dist/src/api/utils.d.ts.map +1 -1
  19. package/dist/src/index.d.ts +3 -2
  20. package/dist/src/index.d.ts.map +1 -1
  21. package/dist/src/logger.d.ts +2 -1
  22. package/dist/src/logger.d.ts.map +1 -1
  23. package/dist/src/options.d.ts +22 -0
  24. package/dist/src/options.d.ts.map +1 -1
  25. package/dist/src/room/PCTransport.d.ts +2 -1
  26. package/dist/src/room/PCTransport.d.ts.map +1 -1
  27. package/dist/src/room/RTCEngine.d.ts +7 -2
  28. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  29. package/dist/src/room/Room.d.ts +8 -1
  30. package/dist/src/room/Room.d.ts.map +1 -1
  31. package/dist/src/room/events.d.ts +1 -1
  32. package/dist/src/room/statsSummary.d.ts +13 -0
  33. package/dist/src/room/statsSummary.d.ts.map +1 -0
  34. package/dist/src/room/token-source/utils.d.ts.map +1 -1
  35. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  36. package/dist/src/room/track/Track.d.ts +10 -0
  37. package/dist/src/room/track/Track.d.ts.map +1 -1
  38. package/dist/src/utils/machineInspector.d.ts +54 -0
  39. package/dist/src/utils/machineInspector.d.ts.map +1 -0
  40. package/dist/ts4.2/api/SignalClient.d.ts +25 -1
  41. package/dist/ts4.2/api/SignalClientStateMachine.d.ts +85 -0
  42. package/dist/ts4.2/index.d.ts +3 -2
  43. package/dist/ts4.2/logger.d.ts +2 -1
  44. package/dist/ts4.2/options.d.ts +22 -0
  45. package/dist/ts4.2/room/PCTransport.d.ts +2 -1
  46. package/dist/ts4.2/room/RTCEngine.d.ts +7 -2
  47. package/dist/ts4.2/room/Room.d.ts +8 -1
  48. package/dist/ts4.2/room/events.d.ts +1 -1
  49. package/dist/ts4.2/room/statsSummary.d.ts +13 -0
  50. package/dist/ts4.2/room/track/Track.d.ts +10 -0
  51. package/dist/ts4.2/utils/machineInspector.d.ts +54 -0
  52. package/package.json +7 -1
  53. package/src/api/SignalClient.test.ts +320 -8
  54. package/src/api/SignalClient.ts +260 -82
  55. package/src/api/SignalClientStateMachine.test.ts +472 -0
  56. package/src/api/SignalClientStateMachine.ts +180 -0
  57. package/src/api/WebSocketStream.ts +19 -3
  58. package/src/api/utils.test.ts +20 -1
  59. package/src/api/utils.ts +5 -0
  60. package/src/index.ts +5 -0
  61. package/src/logger.ts +1 -0
  62. package/src/options.ts +24 -0
  63. package/src/room/PCTransport.ts +2 -1
  64. package/src/room/RTCEngine.ts +16 -7
  65. package/src/room/Room.ts +70 -5
  66. package/src/room/events.ts +1 -1
  67. package/src/room/statsSummary.ts +187 -0
  68. package/src/room/token-source/test-tokens.ts +20 -0
  69. package/src/room/token-source/utils.test.ts +27 -0
  70. package/src/room/token-source/utils.ts +12 -5
  71. package/src/room/track/LocalAudioTrack.ts +9 -3
  72. package/src/room/track/Track.ts +27 -0
  73. package/src/room/utils.test.ts +24 -1
  74. package/src/room/utils.ts +1 -1
  75. package/src/utils/machineInspector.ts +90 -0
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { createRtcUrl, createValidateUrl } from './utils';
2
+ import { createRtcUrl, createValidateUrl, getAbortReasonAsString } from './utils';
3
3
 
4
4
  describe('createRtcUrl', () => {
5
5
  it('should create a basic RTC URL', () => {
@@ -125,3 +125,22 @@ describe('createValidateUrl', () => {
125
125
  expect(parsedResult.pathname).toBe('/sub/path/rtc/validate');
126
126
  });
127
127
  });
128
+
129
+ describe('getAbortReasonAsString', () => {
130
+ it('uses an error message, which is how the connect timeout describes itself', () => {
131
+ // the timeout hands the abort handler an Error, not a signal
132
+ expect(
133
+ getAbortReasonAsString(new Error('room connection has timed out (signal)'), 'fallback'),
134
+ ).toBe('room connection has timed out (signal)');
135
+ });
136
+
137
+ it("reads an abort signal's reason", () => {
138
+ const controller = new AbortController();
139
+ controller.abort('user navigated away');
140
+ expect(getAbortReasonAsString(controller.signal, 'fallback')).toBe('user navigated away');
141
+ });
142
+
143
+ it('falls back when there is nothing to describe', () => {
144
+ expect(getAbortReasonAsString(undefined, 'fallback')).toBe('fallback');
145
+ });
146
+ });
package/src/api/utils.ts CHANGED
@@ -45,6 +45,11 @@ export function getAbortReasonAsString(
45
45
  signal: AbortSignal | Error | unknown,
46
46
  defaultMessage = 'Unknown reason',
47
47
  ) {
48
+ // the connect timeout hands this an Error rather than a signal, and its message is the only
49
+ // description of what went wrong — without this a timeout reports itself as a generic abort
50
+ if (signal instanceof Error) {
51
+ return signal.message;
52
+ }
48
53
  if (!(signal instanceof AbortSignal)) {
49
54
  return defaultMessage;
50
55
  }
package/src/index.ts CHANGED
@@ -53,12 +53,14 @@ import {
53
53
  isLocalTrack,
54
54
  isRemoteParticipant,
55
55
  isRemoteTrack,
56
+ isSVCCodec,
56
57
  isVideoCodec,
57
58
  isVideoTrack,
58
59
  supportsAV1,
59
60
  supportsAdaptiveStream,
60
61
  supportsAudioOutputSelection,
61
62
  supportsDynacast,
63
+ supportsH265,
62
64
  supportsVP9,
63
65
  } from './room/utils';
64
66
  import { getBrowser } from './utils/browserParser';
@@ -149,12 +151,14 @@ export {
149
151
  supportsAdaptiveStream,
150
152
  supportsAudioOutputSelection,
151
153
  supportsDynacast,
154
+ supportsH265,
152
155
  supportsVP9,
153
156
  Mutex,
154
157
  isAudioCodec,
155
158
  isAudioTrack,
156
159
  isLocalTrack,
157
160
  isRemoteTrack,
161
+ isSVCCodec,
158
162
  isVideoCodec,
159
163
  isVideoTrack,
160
164
  isLocalParticipant,
@@ -180,6 +184,7 @@ export type {
180
184
  DataTrackSubscribeOptions,
181
185
  RemoteDataTrackPipelineOptions,
182
186
  };
187
+ export { type DataTrackFrame } from './room/data-track/frame';
183
188
  export { DataTrackPacket, type DataTrackPacketHeader } from './room/data-track/packet';
184
189
  export {
185
190
  type DataTrackExtensions,
package/src/logger.ts CHANGED
@@ -24,6 +24,7 @@ export enum LoggerNames {
24
24
  DataTracks = 'livekit-data-tracks',
25
25
  Region = 'livekit-region',
26
26
  ICE = 'livekit-ice',
27
+ Stats = 'livekit-stats',
27
28
  }
28
29
 
29
30
  type LogLevelString = keyof typeof LogLevel;
package/src/options.ts CHANGED
@@ -120,6 +120,30 @@ export interface InternalRoomOptions {
120
120
  * @default true
121
121
  */
122
122
  singlePeerConnection: boolean;
123
+
124
+ /**
125
+ * Options controlling data stream behavior for this room.
126
+ */
127
+ dataStream?: RoomDataStreamOptions;
128
+ }
129
+
130
+ /**
131
+ * Options controlling data stream behavior for a room.
132
+ */
133
+ export interface RoomDataStreamOptions {
134
+ /**
135
+ * Maximum size, in bytes, of the payload this client accepts from a single incoming data stream.
136
+ *
137
+ * A compressed stream can inflate to an arbitrarily large payload, so the decompressed output is
138
+ * bounded rather than trusting the size declared on the wire. An incoming stream that exceeds the
139
+ * cap fails with a `DataStreamErrorReason.PayloadTooLarge` error on the next read instead of
140
+ * buffering without bound.
141
+ *
142
+ * This is enforced on the receiving side only: raising it on a sender has no effect.
143
+ *
144
+ * @default 5_000_000_000 (5 GB)
145
+ */
146
+ maxPayloadByteLength?: number;
123
147
  }
124
148
 
125
149
  /**
@@ -597,8 +597,9 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
597
597
  return this.pc?.remoteDescription;
598
598
  }
599
599
 
600
+ /** stats of the underlying connection, `undefined` when there is none */
600
601
  getStats() {
601
- return this.pc.getStats();
602
+ return this._pc?.getStats();
602
603
  }
603
604
 
604
605
  getMaxMessageSize() {
@@ -432,7 +432,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
432
432
  }
433
433
  }
434
434
 
435
- async close() {
435
+ /**
436
+ * @param reason why the session is ending, recorded by the signal lifecycle. Worth passing
437
+ * wherever the caller knows more than "someone called close" — the server's leave reason, or
438
+ * having given up on reconnecting.
439
+ */
440
+ async close(reason?: string) {
436
441
  const unlock = await this.closingLock.lock();
437
442
  if (this.isClosed) {
438
443
  unlock();
@@ -448,7 +453,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
448
453
  this.clearLostQualityTimeout();
449
454
  this.cleanupLossyDataStats();
450
455
  await this.cleanupPeerConnections();
451
- await this.cleanupClient();
456
+ await this.cleanupClient(reason);
452
457
  } finally {
453
458
  unlock();
454
459
  }
@@ -469,8 +474,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
469
474
  this.lossyChannel.stopThresholdTuning();
470
475
  }
471
476
 
472
- async cleanupClient() {
473
- await this.client.close();
477
+ async cleanupClient(reason?: string) {
478
+ await this.client.close(true, reason);
474
479
  this.client.resetCallbacks();
475
480
  // Any in-flight addTrack requests are orphaned by the signal reconnect — the new session
476
481
  // won't deliver `trackPublishedResponse` for them, so reject the pending resolvers and
@@ -751,7 +756,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
751
756
  switch (leave.action) {
752
757
  case LeaveRequest_Action.DISCONNECT:
753
758
  this.emit(EngineEvent.Disconnected, leave?.reason);
754
- this.close();
759
+ this.close(`server leave: ${DisconnectReason[leave.reason] ?? leave.reason}`);
755
760
  break;
756
761
  case LeaveRequest_Action.RECONNECT:
757
762
  this.fullReconnectOnNext = true;
@@ -1161,7 +1166,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1161
1166
  `could not recover connection after ${this.reconnectAttempts} attempts, ${duration}ms. giving up`,
1162
1167
  );
1163
1168
  this.emit(EngineEvent.Disconnected);
1164
- this.close();
1169
+ this.close(`gave up reconnecting after ${this.reconnectAttempts} attempts, ${duration}ms`);
1165
1170
  };
1166
1171
 
1167
1172
  const duration = Date.now() - this.reconnectStart;
@@ -1323,7 +1328,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1323
1328
  }ms. giving up`,
1324
1329
  );
1325
1330
  this.emit(EngineEvent.Disconnected);
1326
- await this.close();
1331
+ await this.close(
1332
+ `gave up reconnecting after ${this.reconnectAttempts} attempts, ${
1333
+ Date.now() - this.reconnectStart
1334
+ }ms`,
1335
+ );
1327
1336
  }
1328
1337
  } finally {
1329
1338
  this.attemptingReconnect = false;
package/src/room/Room.ts CHANGED
@@ -91,6 +91,7 @@ import {
91
91
  type RpcInvocationData,
92
92
  RpcServerManager,
93
93
  } from './rpc';
94
+ import { summarizeStatsReport } from './statsSummary';
94
95
  import CriticalTimers from './timers';
95
96
  import LocalAudioTrack from './track/LocalAudioTrack';
96
97
  import type LocalTrack from './track/LocalTrack';
@@ -143,6 +144,7 @@ export enum ConnectionState {
143
144
  }
144
145
 
145
146
  const CONNECTION_RECONCILE_FREQUENCY_MS = 4 * 1000;
147
+ const STATS_LOG_FREQUENCY_MS = 30 * 1000;
146
148
 
147
149
  /**
148
150
  * In LiveKit, a room is the logical grouping for a list of participants.
@@ -207,6 +209,8 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
207
209
 
208
210
  private connectionReconcileInterval?: ReturnType<typeof setInterval>;
209
211
 
212
+ private statsLogInterval?: ReturnType<typeof setInterval>;
213
+
210
214
  private regionUrlProvider?: RegionUrlProvider;
211
215
 
212
216
  private regionUrl?: string;
@@ -215,6 +219,8 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
215
219
 
216
220
  private log = log;
217
221
 
222
+ private statsLog = log;
223
+
218
224
  private bufferedEvents: Array<any> = [];
219
225
 
220
226
  private isResuming: boolean = false;
@@ -254,6 +260,9 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
254
260
  this.options = { ...roomOptionDefaults, ...options };
255
261
 
256
262
  this.log = getLogger(this.options.loggerName ?? LoggerNames.Room, () => this.logContext);
263
+ // its own logger name, so the stats dumps can be silenced or routed
264
+ // separately from the rest of the room's logs
265
+ this.statsLog = getLogger(LoggerNames.Stats, () => this.logContext);
257
266
  this.transcriptionReceivedTimes = new Map();
258
267
 
259
268
  this.options.audioCaptureDefaults = {
@@ -271,7 +280,9 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
271
280
 
272
281
  this.maybeCreateEngine();
273
282
 
274
- this.incomingDataStreamManager = new IncomingDataStreamManager();
283
+ this.incomingDataStreamManager = new IncomingDataStreamManager(
284
+ this.options.dataStream?.maxPayloadByteLength,
285
+ );
275
286
  this.outgoingDataStreamManager = new OutgoingDataStreamManager(
276
287
  this.engine,
277
288
  this.log,
@@ -1913,7 +1924,13 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1913
1924
 
1914
1925
  // when it's disconnected, send updates
1915
1926
  if (info.state === ParticipantInfo_State.DISCONNECTED) {
1916
- this.handleParticipantDisconnected(info.identity, remoteParticipant);
1927
+ this.handleParticipantDisconnected(
1928
+ info.identity,
1929
+ remoteParticipant,
1930
+ info.disconnectReason === DisconnectReason.UNKNOWN_REASON
1931
+ ? undefined
1932
+ : info.disconnectReason,
1933
+ );
1917
1934
  } else {
1918
1935
  // create participant if doesn't exist
1919
1936
  this.getOrCreateParticipant(info.identity, info);
@@ -1931,7 +1948,11 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1931
1948
  this.incomingDataTrackManager.receiveSfuPublicationUpdates(mapped);
1932
1949
  };
1933
1950
 
1934
- private handleParticipantDisconnected(identity: string, participant?: RemoteParticipant) {
1951
+ private handleParticipantDisconnected(
1952
+ identity: string,
1953
+ participant?: RemoteParticipant,
1954
+ disconnectReason?: DisconnectReason,
1955
+ ) {
1935
1956
  // remove and send event
1936
1957
  this.remoteParticipants.delete(identity);
1937
1958
  if (!participant) {
@@ -1944,7 +1965,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1944
1965
  participant.trackPublications.forEach((publication) => {
1945
1966
  participant.unpublishTrack(publication.trackSid, true);
1946
1967
  });
1947
- this.emit(RoomEvent.ParticipantDisconnected, participant);
1968
+ this.emit(RoomEvent.ParticipantDisconnected, participant, disconnectReason);
1948
1969
  participant.setDisconnected();
1949
1970
  this.rpcClientManager.handleParticipantDisconnected(participant.identity);
1950
1971
  }
@@ -2582,6 +2603,46 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
2582
2603
  );
2583
2604
  }
2584
2605
 
2606
+ private setStatsLogging(enabled: boolean) {
2607
+ if (enabled) {
2608
+ if (!this.statsLogInterval) {
2609
+ this.statsLogInterval = CriticalTimers.setInterval(() => {
2610
+ // logWebRTCStats handles its own errors, nothing to await here
2611
+ this.logWebRTCStats();
2612
+ }, STATS_LOG_FREQUENCY_MS);
2613
+ }
2614
+ } else if (this.statsLogInterval) {
2615
+ CriticalTimers.clearInterval(this.statsLogInterval);
2616
+ this.statsLogInterval = undefined;
2617
+ }
2618
+ }
2619
+
2620
+ /**
2621
+ * Dumps stats of both peer connections.
2622
+ */
2623
+ private logWebRTCStats = async () => {
2624
+ const pcManager = this.engine?.pcManager;
2625
+ if (!pcManager) {
2626
+ return;
2627
+ }
2628
+ try {
2629
+ const [publisher, subscriber] = await Promise.all([
2630
+ pcManager.publisher.getStats(),
2631
+ pcManager.subscriber?.getStats(),
2632
+ ]);
2633
+ const publisherStats = publisher && summarizeStatsReport(publisher);
2634
+ const subscriberStats = subscriber && summarizeStatsReport(subscriber);
2635
+ this.statsLog.info(`webrtc stats`, {
2636
+ publisher: publisherStats?.connection,
2637
+ subscriber: subscriberStats?.connection,
2638
+ inbound: [...(publisherStats?.inbound ?? []), ...(subscriberStats?.inbound ?? [])],
2639
+ outbound: publisherStats?.outbound,
2640
+ });
2641
+ } catch (error) {
2642
+ this.statsLog.debug('could not collect webrtc stats', { error });
2643
+ }
2644
+ };
2645
+
2585
2646
  private registerConnectionReconcile() {
2586
2647
  this.clearConnectionReconcile();
2587
2648
  let consecutiveFailures = 0;
@@ -2640,6 +2701,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
2640
2701
  this.log.info(`connection state changed: ${this.state} -> ${state}`);
2641
2702
  this.state = state;
2642
2703
  this.incomingDataStreamManager.setConnected(state === ConnectionState.Connected);
2704
+ this.setStatsLogging(state === ConnectionState.Connected);
2643
2705
 
2644
2706
  this.emit(RoomEvent.ConnectionStateChanged, this.state);
2645
2707
 
@@ -2948,7 +3010,10 @@ export type RoomEventCallbacks = {
2948
3010
  moved: (name: string) => void;
2949
3011
  mediaDevicesChanged: () => void;
2950
3012
  participantConnected: (participant: RemoteParticipant) => void;
2951
- participantDisconnected: (participant: RemoteParticipant) => void;
3013
+ participantDisconnected: (
3014
+ participant: RemoteParticipant,
3015
+ disconnectReason?: DisconnectReason,
3016
+ ) => void;
2952
3017
  trackPublished: (publication: RemoteTrackPublication, participant: RemoteParticipant) => void;
2953
3018
  trackSubscribed: (
2954
3019
  track: RemoteTrack,
@@ -80,7 +80,7 @@ export enum RoomEvent {
80
80
  * When a [[RemoteParticipant]] leaves *after* the local
81
81
  * participant has joined.
82
82
  *
83
- * args: ([[RemoteParticipant]])
83
+ * args: ([[RemoteParticipant]], [[DisconnectReason]] | undefined)
84
84
  */
85
85
  ParticipantDisconnected = 'participantDisconnected',
86
86
 
@@ -0,0 +1,187 @@
1
+ /** one summarised stats entry; keys without a value are dropped */
2
+ type Summary = Record<string, unknown>;
3
+
4
+ function compact<T extends Summary>(summary: T): Summary {
5
+ const compacted: Summary = {};
6
+ for (const [key, value] of Object.entries(summary)) {
7
+ if (value !== undefined) {
8
+ compacted[key] = value;
9
+ }
10
+ }
11
+ return compacted;
12
+ }
13
+
14
+ function resolution(width?: number, height?: number): string | undefined {
15
+ return width && height ? `${width}x${height}` : undefined;
16
+ }
17
+
18
+ /** keeps the derived durations readable; reported values are logged as they are */
19
+ function round(seconds: number): number {
20
+ return Math.round(seconds * 10_000) / 10_000;
21
+ }
22
+
23
+ /**
24
+ * Average time media spent in the jitter buffer, in s. Both counters are
25
+ * cumulative, so this is the average over the lifetime of the stream.
26
+ */
27
+ function jitterBuffer(stat: Summary): number | undefined {
28
+ const delay = stat.jitterBufferDelay as number | undefined;
29
+ const emitted = stat.jitterBufferEmittedCount as number | undefined;
30
+ return delay !== undefined && emitted ? round(delay / emitted) : undefined;
31
+ }
32
+
33
+ /**
34
+ * Playout delay in s. Video receive reports it directly, the audio path keeps it
35
+ * in the linked `media-playout` stats summed over the samples played out. Pairs
36
+ * with `RemoteTrack.setPlayoutDelay`.
37
+ */
38
+ function playoutDelay(stat: Summary, playout?: Summary): number | undefined {
39
+ if (stat.playoutDelay !== undefined) {
40
+ return round(stat.playoutDelay as number);
41
+ }
42
+ const total = playout?.totalPlayoutDelay as number | undefined;
43
+ const samples = playout?.totalSamplesCount as number | undefined;
44
+ return total !== undefined && samples ? round(total / samples) : undefined;
45
+ }
46
+
47
+ /**
48
+ * Picks the interesting fields out of a `getStats()` report and groups them by
49
+ * RTP stream, so a stats dump can be read without unfolding the raw report.
50
+ */
51
+ export function summarizeStatsReport(report: RTCStatsReport) {
52
+ const byId = new Map<string, Summary>();
53
+ const candidatePairs: Summary[] = [];
54
+ const inbound: Summary[] = [];
55
+ const outbound: Summary[] = [];
56
+ let transport: Summary | undefined;
57
+
58
+ report.forEach((stat) => byId.set(stat.id, stat));
59
+
60
+ const codecOf = (stat: Summary) =>
61
+ stat.codecId ? byId.get(stat.codecId as string)?.mimeType : undefined;
62
+ const relatedOf = (stat: Summary, key: 'remoteId' | 'mediaSourceId' | 'playoutId') =>
63
+ stat[key] ? byId.get(stat[key] as string) : undefined;
64
+
65
+ report.forEach((stat) => {
66
+ switch (stat.type) {
67
+ case 'inbound-rtp': {
68
+ const playout = relatedOf(stat, 'playoutId');
69
+ inbound.push(
70
+ compact({
71
+ kind: stat.kind,
72
+ ssrc: stat.ssrc,
73
+ mid: stat.mid,
74
+ // matches `streamTrackID` in the track's own log context
75
+ trackId: stat.trackIdentifier,
76
+ codec: codecOf(stat),
77
+ decoder: stat.decoderImplementation,
78
+ resolution: resolution(stat.frameWidth, stat.frameHeight),
79
+ fps: stat.framesPerSecond,
80
+ bytesReceived: stat.bytesReceived,
81
+ packetsReceived: stat.packetsReceived,
82
+ packetsLost: stat.packetsLost,
83
+ packetsDiscarded: stat.packetsDiscarded,
84
+ // frames received without frames decoded is a decode failure
85
+ framesReceived: stat.framesReceived,
86
+ framesDecoded: stat.framesDecoded,
87
+ framesDropped: stat.framesDropped,
88
+ keyFramesDecoded: stat.keyFramesDecoded,
89
+ freezeCount: stat.freezeCount,
90
+ totalFreezesDuration: stat.totalFreezesDuration,
91
+ pauseCount: stat.pauseCount,
92
+ nackCount: stat.nackCount,
93
+ pliCount: stat.pliCount,
94
+ firCount: stat.firCount,
95
+ jitter: stat.jitter,
96
+ jitterBuffer: jitterBuffer(stat),
97
+ playoutDelay: playoutDelay(stat, playout),
98
+ audioLevel: stat.audioLevel,
99
+ totalSamplesReceived: stat.totalSamplesReceived,
100
+ concealedSamples: stat.concealedSamples,
101
+ }),
102
+ );
103
+ break;
104
+ }
105
+ case 'outbound-rtp': {
106
+ const remote = relatedOf(stat, 'remoteId');
107
+ const source = relatedOf(stat, 'mediaSourceId');
108
+ outbound.push(
109
+ compact({
110
+ kind: stat.kind,
111
+ ssrc: stat.ssrc,
112
+ mid: stat.mid,
113
+ rid: stat.rid,
114
+ trackId: source?.trackIdentifier,
115
+ active: stat.active,
116
+ codec: codecOf(stat),
117
+ encoder: stat.encoderImplementation,
118
+ resolution: resolution(stat.frameWidth, stat.frameHeight),
119
+ fps: stat.framesPerSecond,
120
+ // what the source produces, to tell a stalled capture from a stalled encoder
121
+ captureResolution: resolution(
122
+ source?.width as number | undefined,
123
+ source?.height as number | undefined,
124
+ ),
125
+ captureFps: source?.framesPerSecond,
126
+ audioLevel: source?.audioLevel,
127
+ targetBitrate: stat.targetBitrate,
128
+ bytesSent: stat.bytesSent,
129
+ packetsSent: stat.packetsSent,
130
+ retransmittedPacketsSent: stat.retransmittedPacketsSent,
131
+ framesEncoded: stat.framesEncoded,
132
+ keyFramesEncoded: stat.keyFramesEncoded,
133
+ limitedBy:
134
+ stat.qualityLimitationReason === 'none' ? undefined : stat.qualityLimitationReason,
135
+ nackCount: stat.nackCount,
136
+ pliCount: stat.pliCount,
137
+ firCount: stat.firCount,
138
+ // loss, jitter and RTT are only known from what the remote reports
139
+ remotePacketsLost: remote?.packetsLost,
140
+ remoteFractionLost: remote?.fractionLost,
141
+ remoteJitter: remote?.jitter,
142
+ remoteRoundTripTime: remote?.roundTripTime,
143
+ }),
144
+ );
145
+ break;
146
+ }
147
+ case 'transport':
148
+ transport = stat;
149
+ break;
150
+ case 'candidate-pair':
151
+ candidatePairs.push(stat);
152
+ break;
153
+ default:
154
+ }
155
+ });
156
+
157
+ const selectedPairId = transport?.selectedCandidatePairId as string | undefined;
158
+ const pair =
159
+ (selectedPairId ? byId.get(selectedPairId) : undefined) ??
160
+ candidatePairs.find((candidate) => candidate.selected) ??
161
+ candidatePairs.find((candidate) => candidate.nominated);
162
+ const local = pair?.localCandidateId ? byId.get(pair.localCandidateId as string) : undefined;
163
+ const remote = pair?.remoteCandidateId ? byId.get(pair.remoteCandidateId as string) : undefined;
164
+
165
+ const connection = compact({
166
+ ice: transport?.iceState,
167
+ dtls: transport?.dtlsState,
168
+ route:
169
+ local && remote
170
+ ? `${local.candidateType}/${local.protocol} -> ${remote.candidateType}`
171
+ : undefined,
172
+ network: local?.networkType,
173
+ currentRoundTripTime: pair?.currentRoundTripTime,
174
+ // the send bandwidth estimate; no RTP stream reports it
175
+ availableOutgoingBitrate: pair?.availableOutgoingBitrate,
176
+ availableIncomingBitrate: pair?.availableIncomingBitrate,
177
+ bytesSent: pair?.bytesSent,
178
+ bytesReceived: pair?.bytesReceived,
179
+ candidatePairChanges: transport?.selectedCandidatePairChanges,
180
+ });
181
+
182
+ return {
183
+ connection: Object.keys(connection).length > 0 ? connection : undefined,
184
+ outbound: outbound.length > 0 ? outbound : undefined,
185
+ inbound: inbound.length > 0 ? inbound : undefined,
186
+ };
187
+ }
@@ -1,3 +1,11 @@
1
+ // Builds an unsigned (`alg: none`) JWT. These aren't signed at all, so they can only be used in
2
+ // tests which don't care about the signature.
3
+ function unsignedToken(payload: Record<string, unknown>) {
4
+ const encode = (value: Record<string, unknown>) =>
5
+ btoa(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
6
+ return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.`;
7
+ }
8
+
1
9
  // Test JWTs created for test purposes only.
2
10
  // None of these actually auth against anything.
3
11
  export const TOKENS = {
@@ -25,4 +33,16 @@ export const TOKENS = {
25
33
  // A dummy roomConfig value is also set, with room_config.name = "test room name", room_config.extraField = "extra field value", and room_config.agents = [{"agentName": "test agent name","metadata":"test agent metadata","extraField":"extra field value"}]
26
34
  EXTRA_FIELDS:
27
35
  'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjEwLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MCwicm9vbUNvbmZpZyI6eyJuYW1lIjoidGVzdCByb29tIG5hbWUiLCJlbXB0eVRpbWVvdXQiOjAsImRlcGFydHVyZVRpbWVvdXQiOjAsIm1heFBhcnRpY2lwYW50cyI6MCwibWluUGxheW91dERlbGF5IjowLCJtYXhQbGF5b3V0RGVsYXkiOjAsInN5bmNTdHJlYW1zIjpmYWxzZSwiYWdlbnRzIjpbeyJhZ2VudE5hbWUiOiJ0ZXN0IGFnZW50IG5hbWUiLCJtZXRhZGF0YSI6InRlc3QgYWdlbnQgbWV0YWRhdGEiLCJleHRyYUZpZWxkIjoiZXh0cmEgZmllbGQgdmFsdWUifV0sIm1ldGFkYXRhIjoiIiwiZXh0cmFGaWVsZCI6ImV4dHJhIGZpZWxkIHZhbHVlIn19Cg.EDetpHG8cSubaApzgWJaQrpCiSy9KDBlfCfVdIydbQ-_CHiNnXOK_f_mCJbTf9A-duT1jmvPOkLrkkWFT60XPQ',
36
+
37
+ // Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
38
+ // No exp date set at all
39
+ NO_EXP: unsignedToken({ sub: '1234567890', nbf: 1234567890, iat: 1234567890 }),
40
+
41
+ // No nbf date set at all
42
+ // Exp date set at 1234567891 seconds (Fri Feb 13 2009 23:31:31 GMT+0000)
43
+ EXP_IN_PAST_NO_NBF: unsignedToken({ sub: '1234567890', exp: 1234567891, iat: 1234567890 }),
44
+
45
+ // No nbf date set at all
46
+ // Exp date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
47
+ VALID_NO_NBF: unsignedToken({ sub: '1234567890', exp: 9876543210, iat: 1234567890 }),
28
48
  };
@@ -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