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.
- package/dist/livekit-client.e2ee.worker.js +1 -1
- package/dist/livekit-client.e2ee.worker.js.map +1 -1
- package/dist/livekit-client.e2ee.worker.mjs +187 -1
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +1764 -121
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.fm.worker.js +1 -1
- package/dist/livekit-client.fm.worker.js.map +1 -1
- package/dist/livekit-client.fm.worker.mjs +187 -1
- package/dist/livekit-client.fm.worker.mjs.map +1 -1
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/api/SignalClient.d.ts +25 -1
- package/dist/src/api/SignalClient.d.ts.map +1 -1
- package/dist/src/api/SignalClientStateMachine.d.ts +85 -0
- package/dist/src/api/SignalClientStateMachine.d.ts.map +1 -0
- package/dist/src/api/WebSocketStream.d.ts.map +1 -1
- package/dist/src/api/utils.d.ts.map +1 -1
- package/dist/src/index.d.ts +3 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/logger.d.ts +2 -1
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/options.d.ts +22 -0
- package/dist/src/options.d.ts.map +1 -1
- package/dist/src/room/PCTransport.d.ts +2 -1
- package/dist/src/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +7 -2
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +8 -1
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/events.d.ts +1 -1
- package/dist/src/room/statsSummary.d.ts +13 -0
- package/dist/src/room/statsSummary.d.ts.map +1 -0
- package/dist/src/room/token-source/utils.d.ts.map +1 -1
- package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
- package/dist/src/room/track/Track.d.ts +10 -0
- package/dist/src/room/track/Track.d.ts.map +1 -1
- package/dist/src/utils/machineInspector.d.ts +54 -0
- package/dist/src/utils/machineInspector.d.ts.map +1 -0
- package/dist/ts4.2/api/SignalClient.d.ts +25 -1
- package/dist/ts4.2/api/SignalClientStateMachine.d.ts +85 -0
- package/dist/ts4.2/index.d.ts +3 -2
- package/dist/ts4.2/logger.d.ts +2 -1
- package/dist/ts4.2/options.d.ts +22 -0
- package/dist/ts4.2/room/PCTransport.d.ts +2 -1
- package/dist/ts4.2/room/RTCEngine.d.ts +7 -2
- package/dist/ts4.2/room/Room.d.ts +8 -1
- package/dist/ts4.2/room/events.d.ts +1 -1
- package/dist/ts4.2/room/statsSummary.d.ts +13 -0
- package/dist/ts4.2/room/track/Track.d.ts +10 -0
- package/dist/ts4.2/utils/machineInspector.d.ts +54 -0
- package/package.json +7 -1
- package/src/api/SignalClient.test.ts +320 -8
- package/src/api/SignalClient.ts +260 -82
- package/src/api/SignalClientStateMachine.test.ts +472 -0
- package/src/api/SignalClientStateMachine.ts +180 -0
- package/src/api/WebSocketStream.ts +19 -3
- package/src/api/utils.test.ts +20 -1
- package/src/api/utils.ts +5 -0
- package/src/index.ts +5 -0
- package/src/logger.ts +1 -0
- package/src/options.ts +24 -0
- package/src/room/PCTransport.ts +2 -1
- package/src/room/RTCEngine.ts +16 -7
- package/src/room/Room.ts +70 -5
- package/src/room/events.ts +1 -1
- package/src/room/statsSummary.ts +187 -0
- package/src/room/token-source/test-tokens.ts +20 -0
- package/src/room/token-source/utils.test.ts +27 -0
- package/src/room/token-source/utils.ts +12 -5
- package/src/room/track/LocalAudioTrack.ts +9 -3
- package/src/room/track/Track.ts +27 -0
- package/src/room/utils.test.ts +24 -1
- package/src/room/utils.ts +1 -1
- package/src/utils/machineInspector.ts +90 -0
package/src/api/utils.test.ts
CHANGED
|
@@ -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
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
|
/**
|
package/src/room/PCTransport.ts
CHANGED
|
@@ -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.
|
|
602
|
+
return this._pc?.getStats();
|
|
602
603
|
}
|
|
603
604
|
|
|
604
605
|
getMaxMessageSize() {
|
package/src/room/RTCEngine.ts
CHANGED
|
@@ -432,7 +432,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
432
432
|
}
|
|
433
433
|
}
|
|
434
434
|
|
|
435
|
-
|
|
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(
|
|
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(
|
|
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: (
|
|
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,
|
package/src/room/events.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
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
|
|