livekit-client 2.20.2 → 2.21.0

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 (105) 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 +4 -0
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +1993 -791
  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 +4 -0
  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/index.d.ts +1 -1
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/room/RTCEngine.d.ts +28 -64
  16. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  17. package/dist/src/room/Room.d.ts +4 -0
  18. package/dist/src/room/Room.d.ts.map +1 -1
  19. package/dist/src/room/data-channel/DataChannelManager.d.ts +62 -0
  20. package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -0
  21. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
  22. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -0
  23. package/dist/src/room/data-channel/LossyDataChannel.d.ts +40 -0
  24. package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -0
  25. package/dist/src/room/data-channel/ReliableDataChannel.d.ts +56 -0
  26. package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -0
  27. package/dist/src/room/data-channel/types.d.ts +12 -0
  28. package/dist/src/room/data-channel/types.d.ts.map +1 -0
  29. package/dist/src/room/data-stream/compression.d.ts +49 -0
  30. package/dist/src/room/data-stream/compression.d.ts.map +1 -0
  31. package/dist/src/room/data-stream/constants.d.ts +18 -0
  32. package/dist/src/room/data-stream/constants.d.ts.map +1 -0
  33. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
  34. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
  35. package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
  36. package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
  37. package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts.map +1 -1
  38. package/dist/src/room/data-stream/outgoing/header-utils.d.ts +18 -0
  39. package/dist/src/room/data-stream/outgoing/header-utils.d.ts.map +1 -0
  40. package/dist/src/room/errors.d.ts +3 -1
  41. package/dist/src/room/errors.d.ts.map +1 -1
  42. package/dist/src/room/participant/LocalParticipant.d.ts +9 -1
  43. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  44. package/dist/src/room/participant/RemoteParticipant.d.ts +11 -2
  45. package/dist/src/room/participant/RemoteParticipant.d.ts.map +1 -1
  46. package/dist/src/room/rpc/client/RpcClientManager.d.ts.map +1 -1
  47. package/dist/src/room/rpc/server/RpcServerManager.d.ts.map +1 -1
  48. package/dist/src/room/types.d.ts +17 -0
  49. package/dist/src/room/types.d.ts.map +1 -1
  50. package/dist/src/room/utils.d.ts +13 -0
  51. package/dist/src/room/utils.d.ts.map +1 -1
  52. package/dist/src/version.d.ts +5 -1
  53. package/dist/src/version.d.ts.map +1 -1
  54. package/dist/ts4.2/index.d.ts +1 -1
  55. package/dist/ts4.2/room/RTCEngine.d.ts +28 -64
  56. package/dist/ts4.2/room/Room.d.ts +4 -0
  57. package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +62 -0
  58. package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
  59. package/dist/ts4.2/room/data-channel/LossyDataChannel.d.ts +41 -0
  60. package/dist/ts4.2/room/data-channel/ReliableDataChannel.d.ts +57 -0
  61. package/dist/ts4.2/room/data-channel/types.d.ts +12 -0
  62. package/dist/ts4.2/room/data-stream/compression.d.ts +49 -0
  63. package/dist/ts4.2/room/data-stream/constants.d.ts +18 -0
  64. package/dist/ts4.2/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
  65. package/dist/ts4.2/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
  66. package/dist/ts4.2/room/data-stream/outgoing/header-utils.d.ts +18 -0
  67. package/dist/ts4.2/room/errors.d.ts +3 -1
  68. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +9 -1
  69. package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +11 -1
  70. package/dist/ts4.2/room/types.d.ts +17 -0
  71. package/dist/ts4.2/room/utils.d.ts +13 -0
  72. package/dist/ts4.2/version.d.ts +5 -1
  73. package/package.json +2 -2
  74. package/src/index.ts +2 -0
  75. package/src/room/RTCEngine.test.ts +58 -99
  76. package/src/room/RTCEngine.ts +92 -534
  77. package/src/room/Room.test.ts +4 -1
  78. package/src/room/Room.ts +40 -8
  79. package/src/room/data-channel/DataChannelManager.test.ts +152 -0
  80. package/src/room/data-channel/DataChannelManager.ts +237 -0
  81. package/src/room/data-channel/FlowControlledDataChannel.test.ts +154 -0
  82. package/src/room/data-channel/FlowControlledDataChannel.ts +216 -0
  83. package/src/room/data-channel/LossyDataChannel.test.ts +118 -0
  84. package/src/room/data-channel/LossyDataChannel.ts +125 -0
  85. package/src/room/data-channel/ReliableDataChannel.test.ts +158 -0
  86. package/src/room/data-channel/ReliableDataChannel.ts +153 -0
  87. package/src/room/data-channel/types.ts +30 -0
  88. package/src/room/data-stream/compression.ts +117 -0
  89. package/src/room/data-stream/constants.ts +18 -0
  90. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +1601 -0
  91. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +438 -86
  92. package/src/room/data-stream/incoming/StreamReader.ts +8 -2
  93. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.test.ts +1292 -0
  94. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +489 -116
  95. package/src/room/data-stream/outgoing/header-utils.ts +87 -0
  96. package/src/room/errors.ts +6 -0
  97. package/src/room/participant/LocalParticipant.ts +13 -0
  98. package/src/room/participant/RemoteParticipant.ts +19 -5
  99. package/src/room/rpc/client/RpcClientManager.test.ts +20 -27
  100. package/src/room/rpc/client/RpcClientManager.ts +1 -4
  101. package/src/room/rpc/server/RpcServerManager.test.ts +24 -23
  102. package/src/room/rpc/server/RpcServerManager.ts +1 -3
  103. package/src/room/types.ts +18 -0
  104. package/src/room/utils.ts +67 -0
  105. package/src/version.ts +5 -1
@@ -1,14 +1,18 @@
1
1
  import {
2
2
  type DataPacket,
3
3
  DataStream_Chunk,
4
+ DataStream_CompressionType,
4
5
  DataStream_Header,
5
6
  DataStream_Trailer,
6
7
  Encryption_Type,
7
8
  } from '@livekit/protocol';
8
9
  import log from '../../../logger';
10
+ import { type NonSharedUint8Array } from '../../../type-polyfills/non-shared-typed-arrays';
9
11
  import { DataStreamError, DataStreamErrorReason } from '../../errors';
10
12
  import { type ByteStreamInfo, type StreamController, type TextStreamInfo } from '../../types';
11
- import { bigIntToNumber } from '../../utils';
13
+ import { bigIntToNumber, isCompressionStreamSupported, numberToBigInt } from '../../utils';
14
+ import { deflateRawDecompress, inflateRawTransform } from '../compression';
15
+ import { DEFAULT_MAX_PAYLOAD_BYTE_LENGTH } from '../constants';
12
16
  import {
13
17
  type ByteStreamHandler,
14
18
  ByteStreamReader,
@@ -19,6 +23,14 @@ import {
19
23
  export default class IncomingDataStreamManager {
20
24
  private log = log;
21
25
 
26
+ /** Max number of decompressed bytes an incoming compressed stream may produce before it is
27
+ * errored (decompression-bomb guard). */
28
+ private maxPayloadByteLength: number;
29
+
30
+ constructor(maxPayloadByteLength: number = DEFAULT_MAX_PAYLOAD_BYTE_LENGTH) {
31
+ this.maxPayloadByteLength = maxPayloadByteLength;
32
+ }
33
+
22
34
  private byteStreamControllers = new Map<string, StreamController<DataStream_Chunk>>();
23
35
 
24
36
  private textStreamControllers = new Map<string, StreamController<DataStream_Chunk>>();
@@ -134,99 +146,213 @@ export default class IncomingDataStreamManager {
134
146
  participantIdentity: string,
135
147
  encryptionType: Encryption_Type,
136
148
  ) {
137
- if (streamHeader.contentHeader.case === 'byteHeader') {
138
- const streamHandlerCallback = this.byteStreamHandlers.get(streamHeader.topic);
139
- if (!streamHandlerCallback) {
140
- this.log.debug(
141
- 'ignoring incoming byte stream due to no handler for topic',
142
- streamHeader.topic,
143
- );
144
- return;
145
- }
149
+ switch (streamHeader.contentHeader.case) {
150
+ case 'byteHeader': {
151
+ const streamHandlerCallback = this.byteStreamHandlers.get(streamHeader.topic);
152
+ if (!streamHandlerCallback) {
153
+ this.log.debug(
154
+ 'ignoring incoming byte stream due to no handler for topic',
155
+ streamHeader.topic,
156
+ );
157
+ return;
158
+ }
159
+
160
+ let streamController: ReadableStreamDefaultController<DataStream_Chunk>;
161
+
162
+ const info: ByteStreamInfo = {
163
+ id: streamHeader.streamId,
164
+ name: streamHeader.contentHeader.value.name ?? 'unknown',
165
+ mimeType: streamHeader.mimeType,
166
+ size: streamHeader.totalLength ? Number(streamHeader.totalLength) : undefined,
167
+ topic: streamHeader.topic,
168
+ timestamp: bigIntToNumber(streamHeader.timestamp),
169
+ attributes: streamHeader.attributes,
170
+ encryptionType,
171
+ };
146
172
 
147
- let streamController: ReadableStreamDefaultController<DataStream_Chunk>;
148
-
149
- const info: ByteStreamInfo = {
150
- id: streamHeader.streamId,
151
- name: streamHeader.contentHeader.value.name ?? 'unknown',
152
- mimeType: streamHeader.mimeType,
153
- size: streamHeader.totalLength ? Number(streamHeader.totalLength) : undefined,
154
- topic: streamHeader.topic,
155
- timestamp: bigIntToNumber(streamHeader.timestamp),
156
- attributes: streamHeader.attributes,
157
- encryptionType,
158
- };
159
- const stream = new ReadableStream({
160
- start: (controller) => {
161
- streamController = controller;
162
-
163
- if (this.textStreamControllers.has(streamHeader.streamId)) {
164
- throw new DataStreamError(
165
- `A data stream read is already in progress for a stream with id ${streamHeader.streamId}.`,
166
- DataStreamErrorReason.AlreadyOpened,
173
+ // Determine if the byte payload needs to be decompressed.
174
+ let compressed;
175
+ switch (streamHeader.compression) {
176
+ case DataStream_CompressionType.DEFLATE_RAW:
177
+ if (!isCompressionStreamSupported()) {
178
+ // NOTE: this shouldn't really ever happen, if this warning is logged then the sender
179
+ // isn't properly abiding by the data streams v2 protocol.
180
+ log.warn(
181
+ `Data stream ${streamHeader.streamId} received with deflate-raw compression, but this browser does not have support for DecompressionStream. Dropping...`,
182
+ );
183
+ return;
184
+ }
185
+ compressed = true;
186
+ break;
187
+ case DataStream_CompressionType.NONE:
188
+ compressed = false;
189
+ break;
190
+ default:
191
+ // NOTE: this shouldn't really ever happen, if this warning is logged then the sender
192
+ // isn't properly abiding by the data streams v2 protocol.
193
+ log.warn(
194
+ `Data stream ${streamHeader.streamId} received with unknown compression type ${streamHeader.compression}, dropping...`,
167
195
  );
168
- }
196
+ return;
197
+ }
198
+
199
+ // Single-packet stream: the entire payload was packaged into the header's `inlineContent`.
200
+ // Synthesize an already-complete stream and skip waiting for chunk/trailer packets.
201
+ const inlineContent = streamHeader.inlineContent as NonSharedUint8Array;
202
+ if (typeof inlineContent !== 'undefined') {
203
+ // Inline bytes are the raw payload, optionally deflate-raw compressed.
204
+ streamHandlerCallback(
205
+ new ByteStreamReader(
206
+ info,
207
+ createInlineStream(
208
+ streamHeader.streamId,
209
+ compressed
210
+ ? deflateRawDecompress(inlineContent, this.maxPayloadByteLength)
211
+ : inlineContent,
212
+ ),
213
+ bigIntToNumber(streamHeader.totalLength),
214
+ ),
215
+ { identity: participantIdentity },
216
+ );
217
+ return;
218
+ }
219
+
220
+ const stream = new ReadableStream<DataStream_Chunk>({
221
+ start: (controller) => {
222
+ streamController = controller;
223
+
224
+ if (this.byteStreamControllers.has(streamHeader.streamId)) {
225
+ throw new DataStreamError(
226
+ `A data stream read is already in progress for a stream with id ${streamHeader.streamId}.`,
227
+ DataStreamErrorReason.AlreadyOpened,
228
+ );
229
+ }
169
230
 
170
- this.byteStreamControllers.set(streamHeader.streamId, {
231
+ this.byteStreamControllers.set(streamHeader.streamId, {
232
+ info,
233
+ controller: streamController,
234
+ startTime: Date.now(),
235
+ sendingParticipantIdentity: participantIdentity,
236
+ });
237
+ },
238
+ });
239
+ streamHandlerCallback(
240
+ new ByteStreamReader(
171
241
  info,
172
- controller: streamController,
173
- startTime: Date.now(),
174
- sendingParticipantIdentity: participantIdentity,
175
- });
176
- },
177
- });
178
- streamHandlerCallback(
179
- new ByteStreamReader(info, stream, bigIntToNumber(streamHeader.totalLength)),
180
- {
181
- identity: participantIdentity,
182
- },
183
- );
184
- } else if (streamHeader.contentHeader.case === 'textHeader') {
185
- const streamHandlerCallback = this.textStreamHandlers.get(streamHeader.topic);
186
- if (!streamHandlerCallback) {
187
- this.log.debug(
188
- 'ignoring incoming text stream due to no handler for topic',
189
- streamHeader.topic,
242
+ compressed
243
+ ? inflateRawByteChunkStream(stream, streamHeader.streamId, this.maxPayloadByteLength)
244
+ : stream,
245
+ // `totalLength` is the pre-compression size, and the reader counts decompressed bytes,
246
+ // so it applies to both paths (mirrors text).
247
+ bigIntToNumber(streamHeader.totalLength),
248
+ ),
249
+ {
250
+ identity: participantIdentity,
251
+ },
190
252
  );
191
253
  return;
192
254
  }
255
+ case 'textHeader': {
256
+ const streamHandlerCallback = this.textStreamHandlers.get(streamHeader.topic);
257
+ if (!streamHandlerCallback) {
258
+ this.log.debug(
259
+ 'ignoring incoming text stream due to no handler for topic',
260
+ streamHeader.topic,
261
+ );
262
+ return;
263
+ }
193
264
 
194
- let streamController: ReadableStreamDefaultController<DataStream_Chunk>;
195
-
196
- const info: TextStreamInfo = {
197
- id: streamHeader.streamId,
198
- mimeType: streamHeader.mimeType,
199
- size: streamHeader.totalLength ? Number(streamHeader.totalLength) : undefined,
200
- topic: streamHeader.topic,
201
- timestamp: Number(streamHeader.timestamp),
202
- attributes: streamHeader.attributes,
203
- encryptionType,
204
- attachedStreamIds: streamHeader.contentHeader.value.attachedStreamIds,
205
- };
206
-
207
- const stream = new ReadableStream<DataStream_Chunk>({
208
- start: (controller) => {
209
- streamController = controller;
210
-
211
- if (this.textStreamControllers.has(streamHeader.streamId)) {
212
- throw new DataStreamError(
213
- `A data stream read is already in progress for a stream with id ${streamHeader.streamId}.`,
214
- DataStreamErrorReason.AlreadyOpened,
265
+ let streamController: ReadableStreamDefaultController<DataStream_Chunk>;
266
+
267
+ const info: TextStreamInfo = {
268
+ id: streamHeader.streamId,
269
+ mimeType: streamHeader.mimeType,
270
+ size: streamHeader.totalLength ? Number(streamHeader.totalLength) : undefined,
271
+ topic: streamHeader.topic,
272
+ timestamp: Number(streamHeader.timestamp),
273
+ attributes: streamHeader.attributes,
274
+ encryptionType,
275
+ attachedStreamIds: streamHeader.contentHeader.value.attachedStreamIds,
276
+ };
277
+
278
+ // Determine if the byte payload needs to be decompressed.
279
+ let compressed;
280
+ switch (streamHeader.compression) {
281
+ case DataStream_CompressionType.DEFLATE_RAW:
282
+ if (!isCompressionStreamSupported()) {
283
+ // NOTE: this shouldn't really ever happen, if this warning is logged then the sender
284
+ // isn't properly abiding by the data streams v2 protocol.
285
+ log.warn(
286
+ `Data stream ${streamHeader.streamId} received with deflate-raw compression, but this browser does not have support for DecompressionStream. Dropping...`,
287
+ );
288
+ return;
289
+ }
290
+ compressed = true;
291
+ break;
292
+ case DataStream_CompressionType.NONE:
293
+ compressed = false;
294
+ break;
295
+ default:
296
+ // NOTE: this shouldn't really ever happen, if this warning is logged then the sender
297
+ // isn't properly abiding by the data streams v2 protocol.
298
+ log.warn(
299
+ `Data stream ${streamHeader.streamId} received with unknown compression type ${streamHeader.compression}, dropping...`,
215
300
  );
216
- }
301
+ return;
302
+ }
303
+
304
+ // Single-packet stream: the entire payload was smuggled into the header's `inlineContent`.
305
+ // Synthesize an already-complete stream and skip waiting for chunk/trailer packets.
306
+ const inlineContent = streamHeader.inlineContent as NonSharedUint8Array;
307
+ if (typeof inlineContent !== 'undefined') {
308
+ // Inline text is the raw UTF-8 payload, optionally deflate-raw compressed.
309
+ const content = compressed
310
+ ? deflateRawDecompress(inlineContent, this.maxPayloadByteLength)
311
+ : inlineContent;
312
+ streamHandlerCallback(
313
+ new TextStreamReader(
314
+ info,
315
+ createInlineStream(streamHeader.streamId, content),
316
+ bigIntToNumber(streamHeader.totalLength),
317
+ ),
318
+ { identity: participantIdentity },
319
+ );
320
+ return;
321
+ }
322
+
323
+ const stream = new ReadableStream<DataStream_Chunk>({
324
+ start: (controller) => {
325
+ streamController = controller;
326
+
327
+ if (this.textStreamControllers.has(streamHeader.streamId)) {
328
+ throw new DataStreamError(
329
+ `A data stream read is already in progress for a stream with id ${streamHeader.streamId}.`,
330
+ DataStreamErrorReason.AlreadyOpened,
331
+ );
332
+ }
217
333
 
218
- this.textStreamControllers.set(streamHeader.streamId, {
334
+ this.textStreamControllers.set(streamHeader.streamId, {
335
+ info,
336
+ controller: streamController,
337
+ startTime: Date.now(),
338
+ sendingParticipantIdentity: participantIdentity,
339
+ });
340
+ },
341
+ });
342
+ streamHandlerCallback(
343
+ new TextStreamReader(
219
344
  info,
220
- controller: streamController,
221
- startTime: Date.now(),
222
- sendingParticipantIdentity: participantIdentity,
223
- });
224
- },
225
- });
226
- streamHandlerCallback(
227
- new TextStreamReader(info, stream, bigIntToNumber(streamHeader.totalLength)),
228
- { identity: participantIdentity },
229
- );
345
+ compressed
346
+ ? inflateRawChunkStream(stream, streamHeader.streamId, this.maxPayloadByteLength)
347
+ : stream,
348
+ // `totalLength` is the pre-compression size, and the reader sees decompressed bytes, so
349
+ // it applies to both paths.
350
+ bigIntToNumber(streamHeader.totalLength),
351
+ ),
352
+ { identity: participantIdentity },
353
+ );
354
+ return;
355
+ }
230
356
  }
231
357
  }
232
358
 
@@ -273,9 +399,20 @@ export default class IncomingDataStreamManager {
273
399
  );
274
400
  } else {
275
401
  textBuffer.info.attributes = { ...textBuffer.info.attributes, ...trailer.attributes };
276
- textBuffer.controller.close();
277
- this.textStreamControllers.delete(trailer.streamId);
402
+ if (trailer.reason) {
403
+ // A non-empty reason marks an abnormal close by the sender (e.g. an aborted send);
404
+ // surface it as an error rather than pretending the stream completed.
405
+ textBuffer.controller.error(
406
+ new DataStreamError(
407
+ `Data stream ${trailer.streamId} closed abnormally: ${trailer.reason}`,
408
+ DataStreamErrorReason.AbnormalEnd,
409
+ ),
410
+ );
411
+ } else {
412
+ textBuffer.controller.close();
413
+ }
278
414
  }
415
+ this.textStreamControllers.delete(trailer.streamId);
279
416
  }
280
417
 
281
418
  const fileBuffer = this.byteStreamControllers.get(trailer.streamId);
@@ -289,9 +426,224 @@ export default class IncomingDataStreamManager {
289
426
  );
290
427
  } else {
291
428
  fileBuffer.info.attributes = { ...fileBuffer.info.attributes, ...trailer.attributes };
292
- fileBuffer.controller.close();
429
+ if (trailer.reason) {
430
+ // A non-empty reason marks an abnormal close by the sender (e.g. an aborted send);
431
+ // surface it as an error rather than pretending the stream completed.
432
+ fileBuffer.controller.error(
433
+ new DataStreamError(
434
+ `Data stream ${trailer.streamId} closed abnormally: ${trailer.reason}`,
435
+ DataStreamErrorReason.AbnormalEnd,
436
+ ),
437
+ );
438
+ } else {
439
+ fileBuffer.controller.close();
440
+ }
293
441
  }
294
442
  this.byteStreamControllers.delete(trailer.streamId);
295
443
  }
296
444
  }
297
445
  }
446
+
447
+ /**
448
+ * Builds a `ReadableStream` that yields the given content as a single chunk and then immediately
449
+ * closes - used to surface an inline (single-packet) data stream as a fully-formed stream. `content`
450
+ * may be a promise (e.g. async gzip decompression); a rejection errors the stream.
451
+ */
452
+ function createInlineStream(
453
+ streamId: string,
454
+ content: Uint8Array | Promise<Uint8Array>,
455
+ ): ReadableStream<DataStream_Chunk> {
456
+ return new ReadableStream<DataStream_Chunk>({
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
+ }
467
+ },
468
+ });
469
+ }
470
+
471
+ /**
472
+ * Validates that chunks are received in order, dropping duplicates and throwing if gaps are found.
473
+ *
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.
477
+ */
478
+ function ensureOrderedChunks(
479
+ streamId: string,
480
+ ): TransformStream<DataStream_Chunk, DataStream_Chunk> {
481
+ let lastChunkIndex = -1;
482
+ return new TransformStream({
483
+ transform: (value, controller) => {
484
+ const index = bigIntToNumber(value.chunkIndex);
485
+ if (index <= lastChunkIndex) {
486
+ log.warn(
487
+ `ignoring duplicate chunk ${index} for compressed data stream ${streamId} (last processed: ${lastChunkIndex})`,
488
+ );
489
+ return;
490
+ }
491
+ if (index > lastChunkIndex + 1) {
492
+ throw new DataStreamError(
493
+ `Missing chunk(s) ${lastChunkIndex + 1}..${index - 1} for compressed data stream ${streamId} - cannot continue decompressing`,
494
+ DataStreamErrorReason.Incomplete,
495
+ );
496
+ }
497
+ lastChunkIndex = index;
498
+ controller.enqueue(value);
499
+ },
500
+ });
501
+ }
502
+
503
+ /** Unwraps compressed `DataStream_Chunk`s to their compressed bytes (in `chunkIndex` order), */
504
+ function chunksToBytes(): TransformStream<DataStream_Chunk, Uint8Array> {
505
+ return new TransformStream({
506
+ transform: (value, controller) => {
507
+ controller.enqueue(value.content);
508
+ },
509
+ });
510
+ }
511
+
512
+ /** Re-wraps decompressed bytes into contiguous `DataStream_Chunk`s, skipping inflate's empty reads. */
513
+ function bytesToChunks(streamId: string): TransformStream<Uint8Array, DataStream_Chunk> {
514
+ let outIndex = 0;
515
+ return new TransformStream({
516
+ transform: (value, controller) => {
517
+ // Inflate can emit empty reads; only synthesize a chunk when there is content.
518
+ if (value.byteLength > 0) {
519
+ controller.enqueue(
520
+ new DataStream_Chunk({
521
+ streamId,
522
+ chunkIndex: numberToBigInt(outIndex),
523
+ content: value,
524
+ }),
525
+ );
526
+ outIndex += 1;
527
+ }
528
+ },
529
+ });
530
+ }
531
+
532
+ /**
533
+ * Reframes decompressed byte chunks onto UTF-8 character boundaries via a streaming `TextDecoder`
534
+ * (a write larger than the MTU spans several packets, which may split a codepoint), so each
535
+ * synthesized text chunk decodes independently. The `flush` emits the decoder's trailing bytes.
536
+ */
537
+ function bytesToDecodedUtf8(streamId: string): TransformStream<Uint8Array, DataStream_Chunk> {
538
+ const decoder = new TextDecoder('utf-8', { fatal: true });
539
+ const encoder = new TextEncoder();
540
+
541
+ let outIndex = 0;
542
+ const decodeOrThrow = (bytes?: Uint8Array): string => {
543
+ try {
544
+ return bytes ? decoder.decode(bytes, { stream: true }) : decoder.decode();
545
+ } catch (err) {
546
+ throw new DataStreamError(
547
+ `Cannot decode compressed data stream ${streamId} as text: ${err}`,
548
+ DataStreamErrorReason.DecodeFailed,
549
+ );
550
+ }
551
+ };
552
+
553
+ return new TransformStream({
554
+ transform: (value, controller) => {
555
+ const text = decodeOrThrow(value);
556
+ // Everything so far may have been a partial codepoint; only emit once we have characters.
557
+ if (text.length > 0) {
558
+ controller.enqueue(
559
+ new DataStream_Chunk({
560
+ streamId,
561
+ chunkIndex: numberToBigInt(outIndex),
562
+ content: encoder.encode(text),
563
+ }),
564
+ );
565
+ outIndex += 1;
566
+ }
567
+ },
568
+ flush: (controller) => {
569
+ const tail = decodeOrThrow();
570
+ if (tail.length > 0) {
571
+ controller.enqueue(
572
+ new DataStream_Chunk({
573
+ streamId,
574
+ chunkIndex: numberToBigInt(outIndex),
575
+ content: encoder.encode(tail),
576
+ }),
577
+ );
578
+ outIndex += 1;
579
+ }
580
+ },
581
+ });
582
+ }
583
+
584
+ /**
585
+ * Transforms a stream of deflate-raw-compressed byte `DataStream_Chunk`s into a stream of
586
+ * decompressed chunks, so `ByteStreamReader` consumes it unchanged. All chunk contents are fed (in
587
+ * `chunkIndex` order) through ONE raw-deflate decompressor for the stream's lifetime; decompressed
588
+ * output is re-wrapped as chunks as soon as it is produced. The sender (sendFile) compresses the
589
+ * whole payload in one shot, but the format also supports a single context-takeover stream
590
+ * sync-flushed at write boundaries, so a future incremental streamBytes could compress with no
591
+ * protocol change. Errors and cancellation propagate through the pipe chain.
592
+ */
593
+ function inflateRawByteChunkStream(
594
+ raw: ReadableStream<DataStream_Chunk>,
595
+ streamId: string,
596
+ maxPayloadByteLength: number,
597
+ ): ReadableStream<DataStream_Chunk> {
598
+ return raw
599
+ .pipeThrough(ensureOrderedChunks(streamId))
600
+ .pipeThrough(chunksToBytes())
601
+ .pipeThrough(inflateRawTransform())
602
+ .pipeThrough(maxDecompressedLengthGuard(streamId, maxPayloadByteLength))
603
+ .pipeThrough(bytesToChunks(streamId));
604
+ }
605
+
606
+ /**
607
+ * Transforms a stream of deflate-raw-compressed text `DataStream_Chunk`s into a stream of
608
+ * decompressed chunks, so `TextStreamReader` consumes it unchanged. Builds on
609
+ * {@link inflateRawByteChunkStream} (single decompressor + ordering guard) and adds a streaming
610
+ * `TextDecoder` that reframes the decompressed bytes on UTF-8 character boundaries so each
611
+ * synthesized chunk decodes independently. Errors and cancellation propagate through the pipe chain.
612
+ */
613
+ function inflateRawChunkStream(
614
+ raw: ReadableStream<DataStream_Chunk>,
615
+ streamId: string,
616
+ maxPayloadByteLength: number,
617
+ ): ReadableStream<DataStream_Chunk> {
618
+ return raw
619
+ .pipeThrough(ensureOrderedChunks(streamId))
620
+ .pipeThrough(chunksToBytes())
621
+ .pipeThrough(inflateRawTransform())
622
+ .pipeThrough(maxDecompressedLengthGuard(streamId, maxPayloadByteLength))
623
+ .pipeThrough(bytesToDecodedUtf8(streamId));
624
+ }
625
+
626
+ /**
627
+ * Caps the total decompressed byte count of a compressed stream (decompression-bomb guard): a
628
+ * tiny compressed payload can inflate to an arbitrarily large output, so the decompressor's
629
+ * output is bounded rather than trusting the wire size. Exceeding the cap errors the stream with
630
+ * `PayloadTooLarge`.
631
+ */
632
+ function maxDecompressedLengthGuard(
633
+ streamId: string,
634
+ maxByteLength: number,
635
+ ): TransformStream<Uint8Array, Uint8Array> {
636
+ let total = 0;
637
+ return new TransformStream({
638
+ transform: (value, controller) => {
639
+ total += value.byteLength;
640
+ if (total > maxByteLength) {
641
+ throw new DataStreamError(
642
+ `Data stream ${streamId} exceeds the maximum payload size of ${maxByteLength} bytes`,
643
+ DataStreamErrorReason.PayloadTooLarge,
644
+ );
645
+ }
646
+ controller.enqueue(value);
647
+ },
648
+ });
649
+ }
@@ -105,6 +105,9 @@ export class ByteStreamReader extends BaseStreamReader<ByteStreamInfo> {
105
105
  );
106
106
  if (result.done) {
107
107
  this.validateBytesReceived(true);
108
+ if (typeof this.totalByteSize === 'number') {
109
+ this.onProgress?.(1);
110
+ }
108
111
  return { done: true, value: undefined as any };
109
112
  } else {
110
113
  this.handleChunkReceived(result.value);
@@ -151,7 +154,7 @@ export class ByteStreamReader extends BaseStreamReader<ByteStreamInfo> {
151
154
  * A class to read chunks from a ReadableStream and provide them in a structured format.
152
155
  */
153
156
  export class TextStreamReader extends BaseStreamReader<TextStreamInfo> {
154
- private receivedChunks: Map<number, DataStream_Chunk>;
157
+ private receivedChunks: Map<number /* chunk index */, DataStream_Chunk>;
155
158
 
156
159
  signal?: AbortSignal;
157
160
 
@@ -233,11 +236,14 @@ export class TextStreamReader extends BaseStreamReader<TextStreamInfo> {
233
236
  );
234
237
  if (result.done) {
235
238
  this.validateBytesReceived(true);
239
+ if (typeof this.totalByteSize === 'number') {
240
+ this.onProgress?.(1);
241
+ }
236
242
  return { done: true, value: undefined };
237
243
  } else {
238
244
  this.handleChunkReceived(result.value);
239
245
 
240
- let decodedResult;
246
+ let decodedResult: string;
241
247
  try {
242
248
  decodedResult = decoder.decode(result.value.content);
243
249
  } catch (err) {