livekit-client 2.22.1 → 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.
Files changed (42) 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 +177 -39
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +85 -60
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.umd.js +1 -1
  8. package/dist/livekit-client.umd.js.map +1 -1
  9. package/dist/src/api/SignalClient.d.ts.map +1 -1
  10. package/dist/src/e2ee/E2eeManager.d.ts +7 -0
  11. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  12. package/dist/src/e2ee/constants.d.ts +5 -0
  13. package/dist/src/e2ee/constants.d.ts.map +1 -1
  14. package/dist/src/e2ee/types.d.ts +9 -2
  15. package/dist/src/e2ee/types.d.ts.map +1 -1
  16. package/dist/src/e2ee/worker/FrameCryptor.d.ts +42 -1
  17. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  18. package/dist/src/room/PCTransportManager.d.ts +12 -0
  19. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  20. package/dist/src/room/data-stream/incoming/StreamReader.d.ts +17 -17
  21. package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
  22. package/dist/ts4.2/e2ee/E2eeManager.d.ts +7 -0
  23. package/dist/ts4.2/e2ee/constants.d.ts +5 -0
  24. package/dist/ts4.2/e2ee/types.d.ts +9 -2
  25. package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +42 -1
  26. package/dist/ts4.2/room/PCTransportManager.d.ts +12 -0
  27. package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +17 -17
  28. package/package.json +1 -1
  29. package/src/api/SignalClient.ts +2 -1
  30. package/src/e2ee/E2eeManager.ts +38 -14
  31. package/src/e2ee/constants.ts +6 -0
  32. package/src/e2ee/subscriberBlackScreen.test.ts +544 -0
  33. package/src/e2ee/types.ts +9 -2
  34. package/src/e2ee/worker/FrameCryptor.race.test.ts +9 -26
  35. package/src/e2ee/worker/FrameCryptor.test.ts +0 -1
  36. package/src/e2ee/worker/FrameCryptor.ts +185 -52
  37. package/src/e2ee/worker/e2ee.worker.ts +38 -6
  38. package/src/room/PCTransportManager.test.ts +35 -0
  39. package/src/room/PCTransportManager.ts +12 -4
  40. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +171 -0
  41. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +17 -18
  42. package/src/room/data-stream/incoming/StreamReader.ts +20 -50
@@ -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 if (chunk.content.length > 0) {
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 if (chunk.content.length > 0) {
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
- try {
459
- const bytes = await content;
460
- controller.enqueue(
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
- * A stateful decompressor silently corrupts on duplicated or out-of-order input, so duplicates are
475
- * dropped (with a warning - in-order delivery is expected on the reliable channel, but reconnect
476
- * handling may replay) and a gap is a hard error. Shared by the text and byte deflate-raw decoders.
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 compressed data stream ${streamId} (last processed: ${lastChunkIndex})`,
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 compressed data stream ${streamId} - cannot continue decompressing`,
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
- protected abstract handleChunkReceived(chunk: DataStream_Chunk): void;
51
-
52
- onProgress?: (progress: number | undefined) => void;
53
-
54
- abstract readAll(opts?: BaseStreamReaderReadAllOpts): Promise<string | Array<Uint8Array>>;
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 provide them in a structured format.
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 structured chunks from the stream.
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();