livekit-client 2.22.0 → 2.22.2
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 +364 -40
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +1847 -179
- 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/e2ee/E2eeManager.d.ts +7 -0
- package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
- package/dist/src/e2ee/constants.d.ts +5 -0
- package/dist/src/e2ee/constants.d.ts.map +1 -1
- package/dist/src/e2ee/types.d.ts +9 -2
- package/dist/src/e2ee/types.d.ts.map +1 -1
- package/dist/src/e2ee/worker/FrameCryptor.d.ts +42 -1
- package/dist/src/e2ee/worker/FrameCryptor.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/PCTransportManager.d.ts +12 -0
- package/dist/src/room/PCTransportManager.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/data-stream/incoming/StreamReader.d.ts +17 -17
- package/dist/src/room/data-stream/incoming/StreamReader.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/e2ee/E2eeManager.d.ts +7 -0
- package/dist/ts4.2/e2ee/constants.d.ts +5 -0
- package/dist/ts4.2/e2ee/types.d.ts +9 -2
- package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +42 -1
- 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/PCTransportManager.d.ts +12 -0
- 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/data-stream/incoming/StreamReader.d.ts +17 -17
- 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 +261 -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/e2ee/E2eeManager.ts +38 -14
- package/src/e2ee/constants.ts +6 -0
- package/src/e2ee/subscriberBlackScreen.test.ts +544 -0
- package/src/e2ee/types.ts +9 -2
- package/src/e2ee/worker/FrameCryptor.race.test.ts +9 -26
- package/src/e2ee/worker/FrameCryptor.test.ts +0 -1
- package/src/e2ee/worker/FrameCryptor.ts +185 -52
- package/src/e2ee/worker/e2ee.worker.ts +38 -6
- 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/PCTransportManager.test.ts +35 -0
- package/src/room/PCTransportManager.ts +12 -4
- package/src/room/RTCEngine.ts +16 -7
- package/src/room/Room.ts +70 -5
- package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +171 -0
- package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +17 -18
- package/src/room/data-stream/incoming/StreamReader.ts +20 -50
- 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
|
@@ -279,3 +279,38 @@ describe('PCTransportManager.negotiate', () => {
|
|
|
279
279
|
await expect(p).resolves.toBeUndefined();
|
|
280
280
|
});
|
|
281
281
|
});
|
|
282
|
+
|
|
283
|
+
describe('PCTransportManager.triggerIceRestart', () => {
|
|
284
|
+
let originalRTCPeerConnection: unknown;
|
|
285
|
+
|
|
286
|
+
beforeEach(() => {
|
|
287
|
+
originalRTCPeerConnection = (globalThis as unknown as { RTCPeerConnection?: unknown })
|
|
288
|
+
.RTCPeerConnection;
|
|
289
|
+
(globalThis as unknown as { RTCPeerConnection: unknown }).RTCPeerConnection = StubPC;
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
afterEach(() => {
|
|
293
|
+
(globalThis as unknown as { RTCPeerConnection: unknown }).RTCPeerConnection =
|
|
294
|
+
originalRTCPeerConnection;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The subscriber must keep applying remote candidates across a reconnect.
|
|
299
|
+
*
|
|
300
|
+
* Putting it into `restartingIce` would queue them until a new remote description arrives —
|
|
301
|
+
* but the server only re-offers the subscriber when the reconnect moved us to another node,
|
|
302
|
+
* so on an ordinary signal-only resume nothing would ever flush that queue, and the
|
|
303
|
+
* transport would stop adopting new network paths for the rest of the session. The server
|
|
304
|
+
* also never sends candidates ahead of the offer that introduces them, so queueing buys
|
|
305
|
+
* nothing in exchange.
|
|
306
|
+
*/
|
|
307
|
+
it('does not stop the subscriber applying remote candidates', async () => {
|
|
308
|
+
const manager = new PCTransportManager('subscriber-primary', {});
|
|
309
|
+
const publisher = new FakePublisher();
|
|
310
|
+
(manager as unknown as { publisher: FakePublisher }).publisher = publisher;
|
|
311
|
+
|
|
312
|
+
await manager.triggerIceRestart();
|
|
313
|
+
|
|
314
|
+
expect(manager.subscriber?.restartingIce).toBe(false);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
@@ -162,12 +162,20 @@ export class PCTransportManager {
|
|
|
162
162
|
this.updateState();
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Restarts ICE on the transports that need it. Only the publisher: the server restarts the
|
|
167
|
+
* subscriber's ICE itself and follows with a fresh offer.
|
|
168
|
+
*
|
|
169
|
+
* The subscriber deliberately does NOT enter `restartingIce` here. Queueing its remote
|
|
170
|
+
* candidates would guard against candidates for a new generation arriving before the offer
|
|
171
|
+
* that introduces it, but the server does not send them in that order -- on a same-node
|
|
172
|
+
* resume it buffers them until the offer has gone out, and on a reconnect that lands on
|
|
173
|
+
* another node it withholds subscriber candidates until immediately before creating the
|
|
174
|
+
* offer. Setting the flag only risks withholding candidates during the window that decides
|
|
175
|
+
* whether the reconnect succeeded.
|
|
176
|
+
*/
|
|
165
177
|
async triggerIceRestart() {
|
|
166
178
|
this.iceLog.warn('triggering ICE restart');
|
|
167
|
-
if (this.subscriber) {
|
|
168
|
-
this.subscriber.restartingIce = true;
|
|
169
|
-
}
|
|
170
|
-
// only restart publisher if it's needed
|
|
171
179
|
if (this.needsPublisher) {
|
|
172
180
|
await this.createAndSendPublisherOffer({ iceRestart: true });
|
|
173
181
|
}
|
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,
|
|
@@ -1237,6 +1237,177 @@ describe('IncomingDataStreamManager', () => {
|
|
|
1237
1237
|
await expect(reader.readAll()).rejects.toThrow('Missing chunk(s)');
|
|
1238
1238
|
});
|
|
1239
1239
|
|
|
1240
|
+
it('should error on a gap in chunk indices on an uncompressed text stream', async () => {
|
|
1241
|
+
const manager = new IncomingDataStreamManager();
|
|
1242
|
+
manager.setConnected(true);
|
|
1243
|
+
|
|
1244
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1245
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
const streamId = crypto.randomUUID();
|
|
1249
|
+
const text = randomText(3_000);
|
|
1250
|
+
const textBytes = new TextEncoder().encode(text);
|
|
1251
|
+
const split = Math.floor(textBytes.length / 3);
|
|
1252
|
+
|
|
1253
|
+
manager.handleDataStreamPacket(
|
|
1254
|
+
headerPacket(streamId, 'textHeader', {
|
|
1255
|
+
totalLength: BigInt(textBytes.length),
|
|
1256
|
+
compression: DataStream_CompressionType.NONE,
|
|
1257
|
+
}),
|
|
1258
|
+
Encryption_Type.NONE,
|
|
1259
|
+
);
|
|
1260
|
+
manager.handleDataStreamPacket(
|
|
1261
|
+
chunkPacket(streamId, 0, textBytes.slice(0, split)),
|
|
1262
|
+
Encryption_Type.NONE,
|
|
1263
|
+
);
|
|
1264
|
+
// Skip chunk index 1 entirely — a gap means the payload cannot be reassembled in order.
|
|
1265
|
+
manager.handleDataStreamPacket(
|
|
1266
|
+
chunkPacket(streamId, 2, textBytes.slice(split)),
|
|
1267
|
+
Encryption_Type.NONE,
|
|
1268
|
+
);
|
|
1269
|
+
|
|
1270
|
+
const reader = await readerPromise;
|
|
1271
|
+
await expect(reader.readAll()).rejects.toThrow('Missing chunk(s)');
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
it('should error on a gap in chunk indices on an uncompressed byte stream', async () => {
|
|
1275
|
+
const manager = new IncomingDataStreamManager();
|
|
1276
|
+
manager.setConnected(true);
|
|
1277
|
+
|
|
1278
|
+
const readerPromise = new Promise<ByteStreamReader>((resolve) => {
|
|
1279
|
+
manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
const streamId = crypto.randomUUID();
|
|
1283
|
+
const bytes = randomBytes(3_000);
|
|
1284
|
+
const split = Math.floor(bytes.length / 3);
|
|
1285
|
+
|
|
1286
|
+
manager.handleDataStreamPacket(
|
|
1287
|
+
headerPacket(streamId, 'byteHeader', {
|
|
1288
|
+
totalLength: BigInt(bytes.length),
|
|
1289
|
+
compression: DataStream_CompressionType.NONE,
|
|
1290
|
+
}),
|
|
1291
|
+
Encryption_Type.NONE,
|
|
1292
|
+
);
|
|
1293
|
+
manager.handleDataStreamPacket(
|
|
1294
|
+
chunkPacket(streamId, 0, bytes.slice(0, split)),
|
|
1295
|
+
Encryption_Type.NONE,
|
|
1296
|
+
);
|
|
1297
|
+
// Skip chunk index 1 entirely.
|
|
1298
|
+
manager.handleDataStreamPacket(
|
|
1299
|
+
chunkPacket(streamId, 2, bytes.slice(split)),
|
|
1300
|
+
Encryption_Type.NONE,
|
|
1301
|
+
);
|
|
1302
|
+
|
|
1303
|
+
const reader = await readerPromise;
|
|
1304
|
+
await expect(reader.readAll()).rejects.toThrow('Missing chunk(s)');
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
it('should drop a duplicate chunk index on an uncompressed text stream and still decode', async () => {
|
|
1308
|
+
const manager = new IncomingDataStreamManager();
|
|
1309
|
+
manager.setConnected(true);
|
|
1310
|
+
|
|
1311
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1312
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
const streamId = crypto.randomUUID();
|
|
1316
|
+
const text = randomText(3_000);
|
|
1317
|
+
const textBytes = new TextEncoder().encode(text);
|
|
1318
|
+
const split = Math.floor(textBytes.length / 2);
|
|
1319
|
+
|
|
1320
|
+
manager.handleDataStreamPacket(
|
|
1321
|
+
headerPacket(streamId, 'textHeader', {
|
|
1322
|
+
totalLength: BigInt(textBytes.length),
|
|
1323
|
+
compression: DataStream_CompressionType.NONE,
|
|
1324
|
+
}),
|
|
1325
|
+
Encryption_Type.NONE,
|
|
1326
|
+
);
|
|
1327
|
+
const chunk0 = chunkPacket(streamId, 0, textBytes.slice(0, split));
|
|
1328
|
+
manager.handleDataStreamPacket(chunk0, Encryption_Type.NONE);
|
|
1329
|
+
// A replayed chunk (e.g. reconnect logic) must be dropped with a warning, not appended a
|
|
1330
|
+
// second time — otherwise the payload is corrupted and exceeds `totalLength`.
|
|
1331
|
+
manager.handleDataStreamPacket(chunk0, Encryption_Type.NONE);
|
|
1332
|
+
manager.handleDataStreamPacket(
|
|
1333
|
+
chunkPacket(streamId, 1, textBytes.slice(split)),
|
|
1334
|
+
Encryption_Type.NONE,
|
|
1335
|
+
);
|
|
1336
|
+
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE);
|
|
1337
|
+
|
|
1338
|
+
const reader = await readerPromise;
|
|
1339
|
+
expect(await reader.readAll()).toStrictEqual(text);
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
it('should drop a duplicate chunk index on an uncompressed byte stream and still decode', async () => {
|
|
1343
|
+
const manager = new IncomingDataStreamManager();
|
|
1344
|
+
manager.setConnected(true);
|
|
1345
|
+
|
|
1346
|
+
const readerPromise = new Promise<ByteStreamReader>((resolve) => {
|
|
1347
|
+
manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
const streamId = crypto.randomUUID();
|
|
1351
|
+
const bytes = randomBytes(3_000);
|
|
1352
|
+
const split = Math.floor(bytes.length / 2);
|
|
1353
|
+
|
|
1354
|
+
manager.handleDataStreamPacket(
|
|
1355
|
+
headerPacket(streamId, 'byteHeader', {
|
|
1356
|
+
totalLength: BigInt(bytes.length),
|
|
1357
|
+
compression: DataStream_CompressionType.NONE,
|
|
1358
|
+
}),
|
|
1359
|
+
Encryption_Type.NONE,
|
|
1360
|
+
);
|
|
1361
|
+
const chunk0 = chunkPacket(streamId, 0, bytes.slice(0, split));
|
|
1362
|
+
manager.handleDataStreamPacket(chunk0, Encryption_Type.NONE);
|
|
1363
|
+
manager.handleDataStreamPacket(chunk0, Encryption_Type.NONE);
|
|
1364
|
+
manager.handleDataStreamPacket(
|
|
1365
|
+
chunkPacket(streamId, 1, bytes.slice(split)),
|
|
1366
|
+
Encryption_Type.NONE,
|
|
1367
|
+
);
|
|
1368
|
+
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE);
|
|
1369
|
+
|
|
1370
|
+
const reader = await readerPromise;
|
|
1371
|
+
expect(concatChunks(await reader.readAll())).toStrictEqual(bytes);
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
it('should drop chunks resent at an already-received index', async () => {
|
|
1375
|
+
const manager = new IncomingDataStreamManager();
|
|
1376
|
+
manager.setConnected(true);
|
|
1377
|
+
|
|
1378
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1379
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
const streamId = crypto.randomUUID();
|
|
1383
|
+
const text = 'hello world';
|
|
1384
|
+
const textBytes = new TextEncoder().encode(text);
|
|
1385
|
+
|
|
1386
|
+
manager.handleDataStreamPacket(
|
|
1387
|
+
headerPacket(streamId, 'textHeader', { totalLength: BigInt(textBytes.length) }),
|
|
1388
|
+
Encryption_Type.NONE,
|
|
1389
|
+
);
|
|
1390
|
+
// first assuring a version going from 1 -> 2 works as expected
|
|
1391
|
+
manager.handleDataStreamPacket(chunkPacket(streamId, 0, textBytes, 1), Encryption_Type.NONE);
|
|
1392
|
+
// Chunk-level `version` retcon is not supported: a reader that has already yielded chunk 0 to
|
|
1393
|
+
// its consumer cannot retract it, so a resend at the same index is dropped like any other
|
|
1394
|
+
// duplicate rather than superseding the original. See the note on
|
|
1395
|
+
// `TextStreamReader.handleChunkReceived`.
|
|
1396
|
+
manager.handleDataStreamPacket(
|
|
1397
|
+
chunkPacket(streamId, 0, new TextEncoder().encode('goodbye world'), 2),
|
|
1398
|
+
Encryption_Type.NONE,
|
|
1399
|
+
);
|
|
1400
|
+
// sending a lower version number again to ensure this one also gets dropped
|
|
1401
|
+
manager.handleDataStreamPacket(
|
|
1402
|
+
chunkPacket(streamId, 0, new TextEncoder().encode('goodbye world'), 0),
|
|
1403
|
+
Encryption_Type.NONE,
|
|
1404
|
+
);
|
|
1405
|
+
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE);
|
|
1406
|
+
|
|
1407
|
+
const reader = await readerPromise;
|
|
1408
|
+
expect(await reader.readAll()).toStrictEqual(text);
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1240
1411
|
it('should reframe multibyte UTF-8 on chunk boundaries when decompressing a text stream', async () => {
|
|
1241
1412
|
const manager = new IncomingDataStreamManager();
|
|
1242
1413
|
manager.setConnected(true);
|
|
@@ -241,7 +241,7 @@ export default class IncomingDataStreamManager {
|
|
|
241
241
|
info,
|
|
242
242
|
compressed
|
|
243
243
|
? inflateRawByteChunkStream(stream, streamHeader.streamId, this.maxPayloadByteLength)
|
|
244
|
-
: stream,
|
|
244
|
+
: stream.pipeThrough(ensureOrderedChunks(streamHeader.streamId)),
|
|
245
245
|
// `totalLength` is the pre-compression size, and the reader counts decompressed bytes,
|
|
246
246
|
// so it applies to both paths (mirrors text).
|
|
247
247
|
bigIntToNumber(streamHeader.totalLength),
|
|
@@ -344,7 +344,7 @@ export default class IncomingDataStreamManager {
|
|
|
344
344
|
info,
|
|
345
345
|
compressed
|
|
346
346
|
? inflateRawChunkStream(stream, streamHeader.streamId, this.maxPayloadByteLength)
|
|
347
|
-
: stream,
|
|
347
|
+
: stream.pipeThrough(ensureOrderedChunks(streamHeader.streamId)),
|
|
348
348
|
// `totalLength` is the pre-compression size, and the reader sees decompressed bytes, so
|
|
349
349
|
// it applies to both paths.
|
|
350
350
|
bigIntToNumber(streamHeader.totalLength),
|
|
@@ -367,7 +367,7 @@ export default class IncomingDataStreamManager {
|
|
|
367
367
|
),
|
|
368
368
|
);
|
|
369
369
|
this.byteStreamControllers.delete(chunk.streamId);
|
|
370
|
-
} else
|
|
370
|
+
} else {
|
|
371
371
|
fileBuffer.controller.enqueue(chunk);
|
|
372
372
|
}
|
|
373
373
|
}
|
|
@@ -381,7 +381,7 @@ export default class IncomingDataStreamManager {
|
|
|
381
381
|
),
|
|
382
382
|
);
|
|
383
383
|
this.textStreamControllers.delete(chunk.streamId);
|
|
384
|
-
} else
|
|
384
|
+
} else {
|
|
385
385
|
textBuffer.controller.enqueue(chunk);
|
|
386
386
|
}
|
|
387
387
|
}
|
|
@@ -455,15 +455,9 @@ function createInlineStream(
|
|
|
455
455
|
): ReadableStream<DataStream_Chunk> {
|
|
456
456
|
return new ReadableStream<DataStream_Chunk>({
|
|
457
457
|
start: async (controller) => {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
new DataStream_Chunk({ streamId, chunkIndex: BigInt(0), content: bytes }),
|
|
462
|
-
);
|
|
463
|
-
controller.close();
|
|
464
|
-
} catch (err) {
|
|
465
|
-
controller.error(err);
|
|
466
|
-
}
|
|
458
|
+
const bytes = await content;
|
|
459
|
+
controller.enqueue(new DataStream_Chunk({ streamId, chunkIndex: BigInt(0), content: bytes }));
|
|
460
|
+
controller.close();
|
|
467
461
|
},
|
|
468
462
|
});
|
|
469
463
|
}
|
|
@@ -471,9 +465,11 @@ function createInlineStream(
|
|
|
471
465
|
/**
|
|
472
466
|
* Validates that chunks are received in order, dropping duplicates and throwing if gaps are found.
|
|
473
467
|
*
|
|
474
|
-
*
|
|
475
|
-
* dropped (with a warning - in-order delivery is expected
|
|
476
|
-
* handling may replay) and a gap is a hard error.
|
|
468
|
+
* Reassembly (and, for compressed streams, a stateful decompressor) silently corrupts on duplicated
|
|
469
|
+
* or out-of-order input, so duplicates are dropped (with a warning - in-order delivery is expected
|
|
470
|
+
* on the reliable channel, but reconnect handling may replay) and a gap is a hard error. Empty
|
|
471
|
+
* chunks consume their index and are then dropped, so they never reach the reader. Applied to every
|
|
472
|
+
* chunked stream, compressed or not.
|
|
477
473
|
*/
|
|
478
474
|
function ensureOrderedChunks(
|
|
479
475
|
streamId: string,
|
|
@@ -484,17 +480,20 @@ function ensureOrderedChunks(
|
|
|
484
480
|
const index = bigIntToNumber(value.chunkIndex);
|
|
485
481
|
if (index <= lastChunkIndex) {
|
|
486
482
|
log.warn(
|
|
487
|
-
`ignoring duplicate chunk ${index} for
|
|
483
|
+
`ignoring duplicate chunk ${index} ${value.version > 0 ? `(version ${value.version})` : ''} for data stream ${streamId} (last processed: ${lastChunkIndex})`,
|
|
488
484
|
);
|
|
489
485
|
return;
|
|
490
486
|
}
|
|
491
487
|
if (index > lastChunkIndex + 1) {
|
|
492
488
|
throw new DataStreamError(
|
|
493
|
-
`Missing chunk(s) ${lastChunkIndex + 1}..${index - 1} for
|
|
489
|
+
`Missing chunk(s) ${lastChunkIndex + 1}..${index - 1} for data stream ${streamId} - cannot reassemble payload`,
|
|
494
490
|
DataStreamErrorReason.Incomplete,
|
|
495
491
|
);
|
|
496
492
|
}
|
|
497
493
|
lastChunkIndex = index;
|
|
494
|
+
if (value.content.length === 0) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
498
497
|
controller.enqueue(value);
|
|
499
498
|
},
|
|
500
499
|
});
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { DataStream_Chunk } from '@livekit/protocol';
|
|
2
2
|
import { DataStreamError, DataStreamErrorReason } from '../../errors';
|
|
3
3
|
import type { BaseStreamInfo, ByteStreamInfo, TextStreamInfo } from '../../types';
|
|
4
|
-
import { bigIntToNumber } from '../../utils';
|
|
5
4
|
|
|
6
5
|
export type BaseStreamReaderReadAllOpts = {
|
|
7
6
|
/** An AbortSignal can be used to terminate reads early. */
|
|
@@ -47,14 +46,11 @@ abstract class BaseStreamReader<T extends BaseStreamInfo> {
|
|
|
47
46
|
this.bytesReceived = 0;
|
|
48
47
|
}
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export class ByteStreamReader extends BaseStreamReader<ByteStreamInfo> {
|
|
49
|
+
/**
|
|
50
|
+
* Counts a chunk's bytes against `totalByteSize` and reports progress. Chunk ordering and
|
|
51
|
+
* de-duplication happen upstream in the manager's `ensureOrderedChunks`, so every chunk reaching
|
|
52
|
+
* here is new and in order.
|
|
53
|
+
*/
|
|
58
54
|
protected handleChunkReceived(chunk: DataStream_Chunk) {
|
|
59
55
|
this.bytesReceived += chunk.content.byteLength;
|
|
60
56
|
this.validateBytesReceived();
|
|
@@ -65,8 +61,15 @@ export class ByteStreamReader extends BaseStreamReader<ByteStreamInfo> {
|
|
|
65
61
|
this.onProgress?.(currentProgress);
|
|
66
62
|
}
|
|
67
63
|
|
|
64
|
+
/**
|
|
65
|
+
* @param progress - progress of the stream between 0 and 1. Undefined for streams of unknown size
|
|
66
|
+
*/
|
|
68
67
|
onProgress?: (progress: number | undefined) => void;
|
|
69
68
|
|
|
69
|
+
abstract readAll(opts?: BaseStreamReaderReadAllOpts): Promise<string | Array<Uint8Array>>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class ByteStreamReader extends BaseStreamReader<ByteStreamInfo> {
|
|
70
73
|
signal?: AbortSignal;
|
|
71
74
|
|
|
72
75
|
[Symbol.asyncIterator]() {
|
|
@@ -151,53 +154,20 @@ export class ByteStreamReader extends BaseStreamReader<ByteStreamInfo> {
|
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
/**
|
|
154
|
-
* A class to read chunks from a ReadableStream and
|
|
157
|
+
* A class to read chunks from a ReadableStream and decode them as UTF-8 text.
|
|
158
|
+
*
|
|
159
|
+
* NOTE: chunk-level `version` (resending a chunk at an already-received `chunkIndex` to supersede
|
|
160
|
+
* it) is not supported. The reader used to rebuild the whole string from a per-index chunk map and
|
|
161
|
+
* yield it as `TextStreamChunk.collected`, which made superseding work; 5d4a6346 (#1410, text auto
|
|
162
|
+
* chunking) changed the iterator to yield each chunk's text as it arrives, and a streaming reader
|
|
163
|
+
* cannot retract text it has already handed to the consumer. No sender emits a versioned chunk.
|
|
155
164
|
*/
|
|
156
165
|
export class TextStreamReader extends BaseStreamReader<TextStreamInfo> {
|
|
157
|
-
private receivedChunks: Map<number /* chunk index */, DataStream_Chunk>;
|
|
158
|
-
|
|
159
166
|
signal?: AbortSignal;
|
|
160
167
|
|
|
161
|
-
/**
|
|
162
|
-
* A TextStreamReader instance can be used as an AsyncIterator that returns the entire string
|
|
163
|
-
* that has been received up to the current point in time.
|
|
164
|
-
*/
|
|
165
|
-
constructor(
|
|
166
|
-
info: TextStreamInfo,
|
|
167
|
-
stream: ReadableStream<DataStream_Chunk>,
|
|
168
|
-
totalChunkCount?: number,
|
|
169
|
-
) {
|
|
170
|
-
super(info, stream, totalChunkCount);
|
|
171
|
-
this.receivedChunks = new Map();
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
protected handleChunkReceived(chunk: DataStream_Chunk) {
|
|
175
|
-
const index = bigIntToNumber(chunk.chunkIndex);
|
|
176
|
-
const previousChunkAtIndex = this.receivedChunks.get(index);
|
|
177
|
-
if (previousChunkAtIndex && previousChunkAtIndex.version > chunk.version) {
|
|
178
|
-
// we have a newer version already, dropping the old one
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
this.receivedChunks.set(index, chunk);
|
|
182
|
-
|
|
183
|
-
this.bytesReceived += chunk.content.byteLength;
|
|
184
|
-
this.validateBytesReceived();
|
|
185
|
-
|
|
186
|
-
const currentProgress = this.totalByteSize
|
|
187
|
-
? this.bytesReceived / this.totalByteSize
|
|
188
|
-
: undefined;
|
|
189
|
-
this.onProgress?.(currentProgress);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* @param progress - progress of the stream between 0 and 1. Undefined for streams of unknown size
|
|
194
|
-
*/
|
|
195
|
-
onProgress?: (progress: number | undefined) => void;
|
|
196
|
-
|
|
197
168
|
/**
|
|
198
169
|
* Async iterator implementation to allow usage of `for await...of` syntax.
|
|
199
|
-
* Yields
|
|
200
|
-
*
|
|
170
|
+
* Yields each chunk's decoded text as it arrives - a delta, not the string accumulated so far.
|
|
201
171
|
*/
|
|
202
172
|
[Symbol.asyncIterator]() {
|
|
203
173
|
const reader = this.reader.getReader();
|
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
|
|