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,30 +1,45 @@
1
1
  import { Mutex } from '@livekit/mutex';
2
2
  import {
3
+ ClientInfo_Capability,
3
4
  DataPacket,
4
- DataStream_ByteHeader,
5
5
  DataStream_Chunk,
6
- DataStream_Header,
7
- DataStream_OperationType,
8
- DataStream_TextHeader,
6
+ DataStream_CompressionType,
9
7
  DataStream_Trailer,
10
8
  Encryption_Type,
11
9
  } from '@livekit/protocol';
12
10
  import { type StructuredLogger } from '../../../logger';
11
+ import { type NonSharedUint8Array } from '../../../type-polyfills/non-shared-typed-arrays';
12
+ import { CLIENT_PROTOCOL_DATA_STREAM_V2 } from '../../../version';
13
13
  import type RTCEngine from '../../RTCEngine';
14
14
  import { DataChannelKind } from '../../RTCEngine';
15
+ import { DataStreamError, DataStreamErrorReason } from '../../errors';
15
16
  import { EngineEvent } from '../../events';
16
17
  import type {
17
18
  ByteStreamInfo,
19
+ SendBytesOptions,
18
20
  SendFileOptions,
19
21
  SendTextOptions,
20
22
  StreamBytesOptions,
21
23
  StreamTextOptions,
22
24
  TextStreamInfo,
23
25
  } from '../../types';
24
- import { numberToBigInt, splitUtf8 } from '../../utils';
26
+ import {
27
+ isCompressionStreamSupported,
28
+ numberToBigInt,
29
+ readBytesInChunks,
30
+ readableFromBytes,
31
+ splitUtf8,
32
+ } from '../../utils';
33
+ import { collect, deflateRawTransform } from '../compression';
34
+ import { STREAM_CHUNK_SIZE_BYTES } from '../constants';
25
35
  import { ByteStreamWriter, TextStreamWriter } from './StreamWriter';
36
+ import {
37
+ buildByteStreamHeader,
38
+ buildTextStreamHeader,
39
+ createStreamHeaderPacket,
40
+ } from './header-utils';
26
41
 
27
- const STREAM_CHUNK_SIZE = 15_000;
42
+ const textEncoder = new TextEncoder();
28
43
 
29
44
  /**
30
45
  * Manages sending custom user data via data channels.
@@ -35,9 +50,30 @@ export default class OutgoingDataStreamManager {
35
50
 
36
51
  protected log: StructuredLogger;
37
52
 
38
- constructor(engine: RTCEngine, log: StructuredLogger) {
53
+ /** Returns the advertised client protocol of a remote participant, used to decide whether a
54
+ * recipient can receive single-packet (inline) data streams. */
55
+ protected getRemoteParticipantClientProtocol: (identity: string) => number;
56
+
57
+ /** Returns the client capabilities a remote participant advertises, used to decide whether a
58
+ * recipient can decompress a deflate-raw compressed stream. */
59
+ protected getRemoteParticipantCapabilities: (identity: string) => Array<ClientInfo_Capability>;
60
+
61
+ /** Returns the identities of every remote participant currently in the room, used to decide
62
+ * whether a broadcast (no explicit destinations) can be sent inline. */
63
+ protected getAllRemoteParticipantIdentities: () => Array<string>;
64
+
65
+ constructor(
66
+ engine: RTCEngine,
67
+ log: StructuredLogger,
68
+ getRemoteParticipantClientProtocol: (identity: string) => number,
69
+ getRemoteParticipantCapabilities: (identity: string) => Array<ClientInfo_Capability>,
70
+ getAllRemoteParticipantIdentities: () => Array<string>,
71
+ ) {
39
72
  this.engine = engine;
40
73
  this.log = log;
74
+ this.getRemoteParticipantClientProtocol = getRemoteParticipantClientProtocol;
75
+ this.getRemoteParticipantCapabilities = getRemoteParticipantCapabilities;
76
+ this.getAllRemoteParticipantIdentities = getAllRemoteParticipantIdentities;
41
77
  }
42
78
 
43
79
  setupEngine(engine: RTCEngine) {
@@ -47,33 +83,114 @@ export default class OutgoingDataStreamManager {
47
83
  /** {@inheritDoc LocalParticipant.sendText} */
48
84
  async sendText(text: string, options?: SendTextOptions): Promise<TextStreamInfo> {
49
85
  const streamId = crypto.randomUUID();
50
- const textInBytes = new TextEncoder().encode(text);
86
+ const textInBytes = textEncoder.encode(text);
51
87
  const totalTextLength = textInBytes.byteLength;
88
+ const compress = options?.compress ?? true;
89
+
90
+ let info: TextStreamInfo = {
91
+ id: streamId,
92
+ mimeType: 'text/plain',
93
+ timestamp: Date.now(),
94
+ topic: options?.topic ?? '',
95
+ size: totalTextLength, // NOTE: size is always the pre-compression byte length
96
+ attributes: options?.attributes,
97
+ encryptionType: this.engine.e2eeManager?.isDataChannelEncryptionEnabled
98
+ ? Encryption_Type.GCM
99
+ : Encryption_Type.NONE,
100
+ };
101
+
102
+ const compressEligible =
103
+ compress &&
104
+ isCompressionStreamSupported() &&
105
+ this.allRecipientsSupportV2(options?.destinationIdentities) &&
106
+ this.allRecipientsSupportCompression(options?.destinationIdentities);
107
+ let compressedStream = compressEligible
108
+ ? MaybeCollectedStream.fromStream(
109
+ readableFromBytes(textInBytes).pipeThrough(deflateRawTransform()),
110
+ )
111
+ : null;
112
+
113
+ // Phase 1: Try to send as a single packet data stream
114
+ const noAttachments = !options?.attachments || options.attachments.length === 0;
115
+ if (noAttachments && this.allRecipientsSupportV2(options?.destinationIdentities)) {
116
+ // The payload rides in the header's `inlineContent` (raw bytes). Keep the compressed form only
117
+ // if it actually shrinks the payload (deflate framing makes tiny strings larger). The
118
+ // compression flag is carried in the header's `compression` field; user attributes are left
119
+ // untouched.
120
+ let inlineContent: Uint8Array = textInBytes;
121
+ let compression = DataStream_CompressionType.NONE;
122
+ if (compressedStream) {
123
+ const collectedBytes = await compressedStream.collect();
124
+ if (collectedBytes.byteLength < textInBytes.byteLength) {
125
+ inlineContent = collectedBytes;
126
+ compression = DataStream_CompressionType.DEFLATE_RAW;
127
+ }
128
+ }
129
+
130
+ const header = buildTextStreamHeader(info, undefined, { compression, inlineContent });
131
+ const packet = createStreamHeaderPacket(header, options?.destinationIdentities);
132
+
133
+ if (packet.toBinary().byteLength <= STREAM_CHUNK_SIZE_BYTES) {
134
+ await this.engine.sendDataPacket(packet, DataChannelKind.RELIABLE);
135
+ options?.onProgress?.(1);
136
+ return info;
137
+ }
138
+ }
52
139
 
53
140
  const fileIds = options?.attachments?.map(() => crypto.randomUUID());
54
141
 
55
- const progresses = new Array<number>(fileIds ? fileIds.length + 1 : 1).fill(0);
142
+ // Progress is split evenly across the text part (slot 0) and one slot per attachment, then
143
+ // normalized to a [0,1] fraction. Each slot climbs monotonically to 1, so the aggregate ends at
144
+ // exactly 1 once every part has completed.
145
+ const parts = fileIds ? fileIds.length + 1 : 1;
146
+ const progresses = new Array<number>(parts).fill(0);
56
147
 
57
148
  const handleProgress = (progress: number, idx: number) => {
58
149
  progresses[idx] = progress;
59
- const totalProgress = progresses.reduce((acc, val) => acc + val, 0);
60
- options?.onProgress?.(totalProgress);
150
+ options?.onProgress?.(progresses.reduce((acc, val) => acc + val, 0) / parts);
61
151
  };
62
152
 
63
- const writer = await this.streamText({
64
- streamId,
65
- totalSize: totalTextLength,
66
- destinationIdentities: options?.destinationIdentities,
67
- topic: options?.topic,
68
- attachedStreamIds: fileIds,
69
- attributes: options?.attributes,
70
- });
153
+ // Phase 2: Try to send a multi packet data stream with compressed bytes
154
+ if (compressedStream) {
155
+ info.attachedStreamIds = fileIds;
156
+
157
+ const header = buildTextStreamHeader(info, undefined, {
158
+ compression: DataStream_CompressionType.DEFLATE_RAW,
159
+ });
160
+ const packet = createStreamHeaderPacket(header, options?.destinationIdentities);
161
+ await this.sendChunkedByteStream(
162
+ packet,
163
+ streamId,
164
+ options?.destinationIdentities,
165
+ compressedStream
166
+ .stream()
167
+ .pipeThrough(
168
+ progressReportingStream(textInBytes.length, (progress) => handleProgress(progress, 0)),
169
+ ),
170
+ );
71
171
 
72
- await writer.write(text);
73
- // set text part of progress to 1
74
- handleProgress(1, 0);
172
+ // Ensure there's always a 100% progress event fired, even if the string is zero bytes long
173
+ if (textInBytes.length === 0) {
174
+ handleProgress(1, 0);
175
+ }
176
+ } else {
177
+ // Phase 3 / fallback: header + plain uncompressed chunk packets + trailer.
178
+ const writer = await this.streamText({
179
+ streamId,
180
+ totalSize: totalTextLength,
181
+ destinationIdentities: options?.destinationIdentities,
182
+ topic: options?.topic,
183
+ attachedStreamIds: fileIds,
184
+ attributes: options?.attributes,
185
+ });
186
+
187
+ await writer.write(text);
188
+ // set text part of progress to 1
189
+ handleProgress(1, 0);
75
190
 
76
- await writer.close();
191
+ await writer.close();
192
+ info = writer.info;
193
+ }
77
194
 
78
195
  if (options?.attachments && fileIds) {
79
196
  await Promise.all(
@@ -81,6 +198,8 @@ export default class OutgoingDataStreamManager {
81
198
  this._sendFile(fileIds[idx], file, {
82
199
  topic: options.topic,
83
200
  mimeType: file.type,
201
+ destinationIdentities: options.destinationIdentities,
202
+ compress: options.compress,
84
203
  onProgress: (progress) => {
85
204
  handleProgress(progress, idx + 1);
86
205
  },
@@ -88,7 +207,164 @@ export default class OutgoingDataStreamManager {
88
207
  ),
89
208
  );
90
209
  }
91
- return writer.info;
210
+ return info;
211
+ }
212
+
213
+ /**
214
+ * Sends a complete in-memory byte payload. Mirrors {@link sendText}'s semantics: when every
215
+ * recipient supports data streams v2 the payload rides inline in a single header packet
216
+ * (optionally deflate-raw compressed), otherwise it is sent as a (optionally compressed)
217
+ * chunked byte stream. Unlike {@link sendFile}, the whole payload is already in memory, so the
218
+ * inline single-packet fast path applies.
219
+ */
220
+ async sendBytes(bytes: Uint8Array, options?: SendBytesOptions): Promise<ByteStreamInfo> {
221
+ const streamId = crypto.randomUUID();
222
+ const destinationIdentities = options?.destinationIdentities;
223
+ const compress = options?.compress ?? true;
224
+
225
+ const info: ByteStreamInfo = {
226
+ id: streamId,
227
+ name: options?.name ?? 'unknown',
228
+ mimeType: options?.mimeType ?? 'application/octet-stream',
229
+ timestamp: Date.now(),
230
+ topic: options?.topic ?? '',
231
+ size: bytes.byteLength, // NOTE: size is always the pre-compression byte length
232
+ attributes: options?.attributes,
233
+ encryptionType: this.engine.e2eeManager?.isDataChannelEncryptionEnabled
234
+ ? Encryption_Type.GCM
235
+ : Encryption_Type.NONE,
236
+ };
237
+
238
+ const progressMonitorTap = progressReportingStream(bytes.length, options?.onProgress);
239
+
240
+ const compressEligible =
241
+ compress &&
242
+ isCompressionStreamSupported() &&
243
+ this.allRecipientsSupportV2(destinationIdentities) &&
244
+ this.allRecipientsSupportCompression(destinationIdentities);
245
+ let compressedStream = compressEligible
246
+ ? MaybeCollectedStream.fromStream(
247
+ readableFromBytes(bytes as NonSharedUint8Array)
248
+ .pipeThrough(progressMonitorTap)
249
+ .pipeThrough(deflateRawTransform()),
250
+ )
251
+ : null;
252
+
253
+ // Phase 1: Try to send as a single packet data stream
254
+ if (this.allRecipientsSupportV2(destinationIdentities)) {
255
+ // The payload rides in the header's `inlineContent` (raw bytes). Keep the compressed form only
256
+ // if it actually shrinks the payload (deflate framing makes tiny payloads larger). The
257
+ // compression flag is carried in the header's `compression` field; user attributes are left
258
+ // untouched.
259
+ let inlineContent: Uint8Array = bytes;
260
+ let compression = DataStream_CompressionType.NONE;
261
+ if (compressedStream) {
262
+ const collectedBytes = await compressedStream.collect();
263
+ if (collectedBytes.byteLength < bytes.byteLength) {
264
+ inlineContent = collectedBytes;
265
+ compression = DataStream_CompressionType.DEFLATE_RAW;
266
+ }
267
+ }
268
+
269
+ const header = buildByteStreamHeader(info, { compression, inlineContent });
270
+ const packet = createStreamHeaderPacket(header, destinationIdentities);
271
+
272
+ if (packet.toBinary().byteLength <= STREAM_CHUNK_SIZE_BYTES) {
273
+ await this.engine.sendDataPacket(packet, DataChannelKind.RELIABLE);
274
+ options?.onProgress?.(1);
275
+ return info;
276
+ }
277
+ }
278
+
279
+ // Phase 2/3: header + (optionally compressed) chunk packets + trailer.
280
+ const header = buildByteStreamHeader(info, {
281
+ compression: compressedStream
282
+ ? DataStream_CompressionType.DEFLATE_RAW
283
+ : DataStream_CompressionType.NONE,
284
+ });
285
+ const packet = createStreamHeaderPacket(header, destinationIdentities);
286
+ const source = compressedStream
287
+ ? compressedStream.stream()
288
+ : readableFromBytes(bytes as NonSharedUint8Array).pipeThrough(progressMonitorTap);
289
+ await this.sendChunkedByteStream(packet, streamId, destinationIdentities, source);
290
+
291
+ // Ensure there's always a 100% progress event fired, even if the buffer is zero bytes long
292
+ if (bytes.length === 0) {
293
+ options?.onProgress?.(1);
294
+ }
295
+
296
+ return info;
297
+ }
298
+
299
+ /**
300
+ * Returns true only if every recipient is known to support data streams v2 (single-packet inline
301
+ * streams and compression). For a targeted send this checks the named destination identities; for
302
+ * a broadcast (no explicit destinations) it checks every remote participant currently in the room.
303
+ * An empty room (nobody to receive) is considered eligible.
304
+ */
305
+ private allRecipientsSupportV2(destinationIdentities?: Array<string>): boolean {
306
+ const identities =
307
+ destinationIdentities && destinationIdentities.length > 0
308
+ ? destinationIdentities
309
+ : this.getAllRemoteParticipantIdentities();
310
+ return identities.every(
311
+ (identity) =>
312
+ this.getRemoteParticipantClientProtocol(identity) >= CLIENT_PROTOCOL_DATA_STREAM_V2,
313
+ );
314
+ }
315
+
316
+ /**
317
+ * Returns true only if every recipient advertises the deflate-raw compression capability (so it
318
+ * can decompress a compressed stream). Resolved the same way as {@link allRecipientsSupportV2}:
319
+ * named destinations, or every remote participant for a broadcast; an empty room is eligible.
320
+ */
321
+ private allRecipientsSupportCompression(destinationIdentities?: Array<string>): boolean {
322
+ const identities =
323
+ destinationIdentities && destinationIdentities.length > 0
324
+ ? destinationIdentities
325
+ : this.getAllRemoteParticipantIdentities();
326
+ return identities.every((identity) =>
327
+ this.getRemoteParticipantCapabilities(identity).includes(
328
+ ClientInfo_Capability.CAP_COMPRESSION_DEFLATE_RAW,
329
+ ),
330
+ );
331
+ }
332
+
333
+ /**
334
+ * Shared chunked-stream send for `sendText`/`sendFile`: sends the prebuilt header packet, then
335
+ * forwards `source` (optionally deflate-raw compressed) as `streamChunk` packets re-chunked to
336
+ * the MTU budget with contiguous indices, then sends the trailer. The source is consumed
337
+ * incrementally, so a `file.stream()` is never buffered in full. The platform compressor can't
338
+ * flush mid-stream, so compression is only used when the whole payload is available as a stream
339
+ * up front (not for incremental writers like `streamText`/`streamBytes`).
340
+ */
341
+ private async sendChunkedByteStream(
342
+ headerPacket: DataPacket,
343
+ streamId: string,
344
+ destinationIdentities: Array<string> | undefined,
345
+ source: ReadableStream<NonSharedUint8Array>,
346
+ ): Promise<void> {
347
+ const engine = this.engine;
348
+ await sendHeaderPacket(engine, headerPacket);
349
+
350
+ let chunkId = 0;
351
+ for await (const chunk of readBytesInChunks(source, STREAM_CHUNK_SIZE_BYTES)) {
352
+ const chunkPacket = new DataPacket({
353
+ destinationIdentities,
354
+ value: {
355
+ case: 'streamChunk',
356
+ value: new DataStream_Chunk({
357
+ content: chunk,
358
+ streamId,
359
+ chunkIndex: numberToBigInt(chunkId),
360
+ }),
361
+ },
362
+ });
363
+ await engine.sendDataPacket(chunkPacket, DataChannelKind.RELIABLE);
364
+ chunkId += 1;
365
+ }
366
+
367
+ await sendStreamTrailer(streamId, destinationIdentities, engine);
92
368
  }
93
369
 
94
370
  /**
@@ -96,6 +372,7 @@ export default class OutgoingDataStreamManager {
96
372
  */
97
373
  async streamText(options?: StreamTextOptions): Promise<TextStreamWriter> {
98
374
  const streamId = options?.streamId ?? crypto.randomUUID();
375
+ const destinationIdentities = options?.destinationIdentities;
99
376
 
100
377
  const info: TextStreamInfo = {
101
378
  id: streamId,
@@ -109,43 +386,21 @@ export default class OutgoingDataStreamManager {
109
386
  : Encryption_Type.NONE,
110
387
  attachedStreamIds: options?.attachedStreamIds,
111
388
  };
112
- const header = new DataStream_Header({
113
- streamId,
114
- mimeType: info.mimeType,
115
- topic: info.topic,
116
- timestamp: numberToBigInt(info.timestamp),
117
- totalLength: numberToBigInt(info.size),
118
- attributes: info.attributes,
119
- contentHeader: {
120
- case: 'textHeader',
121
- value: new DataStream_TextHeader({
122
- version: options?.version,
123
- attachedStreamIds: info.attachedStreamIds,
124
- replyToStreamId: options?.replyToStreamId,
125
- operationType:
126
- options?.type === 'update'
127
- ? DataStream_OperationType.UPDATE
128
- : DataStream_OperationType.CREATE,
129
- }),
130
- },
131
- });
132
- const destinationIdentities = options?.destinationIdentities;
133
- const packet = new DataPacket({
134
- destinationIdentities,
135
- value: {
136
- case: 'streamHeader',
137
- value: header,
138
- },
139
- });
140
- await this.engine.sendDataPacket(packet, DataChannelKind.RELIABLE);
389
+ const header = buildTextStreamHeader(info, options);
390
+ const packet = createStreamHeaderPacket(header, destinationIdentities);
391
+ await sendHeaderPacket(this.engine, packet);
141
392
 
142
393
  let chunkId = 0;
143
394
  const engine = this.engine;
144
395
 
396
+ // Incremental text streams are never compressed (CompressionStream does not support flushing
397
+ // mid-stream); one-shot compression lives in sendText.
398
+ //
399
+ // Note that a future streamText could send a context-takeover style deflate-raw stream with
400
+ // intermedia explicit `Z_SYNC_FLUSH`s - receivers already will handle this properly today.
145
401
  const writableStream = new WritableStream<string>({
146
- // Implement the sink
147
402
  async write(text) {
148
- for (const textByteChunk of splitUtf8(text, STREAM_CHUNK_SIZE)) {
403
+ for (const textByteChunk of splitUtf8(text, STREAM_CHUNK_SIZE_BYTES)) {
149
404
  const chunk = new DataStream_Chunk({
150
405
  content: textByteChunk,
151
406
  streamId,
@@ -164,17 +419,7 @@ export default class OutgoingDataStreamManager {
164
419
  }
165
420
  },
166
421
  async close() {
167
- const trailer = new DataStream_Trailer({
168
- streamId,
169
- });
170
- const trailerPacket = new DataPacket({
171
- destinationIdentities,
172
- value: {
173
- case: 'streamTrailer',
174
- value: trailer,
175
- },
176
- });
177
- await engine.sendDataPacket(trailerPacket, DataChannelKind.RELIABLE);
422
+ await sendStreamTrailer(streamId, destinationIdentities, engine);
178
423
  },
179
424
  abort(err) {
180
425
  console.log('Sink error:', err);
@@ -186,6 +431,8 @@ export default class OutgoingDataStreamManager {
186
431
  await writer.close();
187
432
  };
188
433
 
434
+ // FIXME: make this a global event to ensure "max listener" warning won't get logged for lots of
435
+ // in flight data streams.
189
436
  engine.once(EngineEvent.Closing, onEngineClose);
190
437
 
191
438
  const writer = new TextStreamWriter(writableStream, info, () =>
@@ -201,25 +448,65 @@ export default class OutgoingDataStreamManager {
201
448
  return { id: streamId };
202
449
  }
203
450
 
204
- private async _sendFile(streamId: string, file: File, options?: SendFileOptions) {
205
- const writer = await this.streamBytes({
206
- streamId,
207
- totalSize: file.size,
451
+ /**
452
+ * Streams a file as a chunked byte stream, compressed (deflate-raw) when the runtime supports it
453
+ * and every recipient is on data streams v2. The file is piped `file.stream()` →
454
+ * (`CompressionStream`) → chunk packets via {@link sendChunkedByteStream}, so it is never fully
455
+ * buffered in memory — unlike {@link sendBytes}, there is no inline single-packet fast path for
456
+ * files (the compressed size can't be known up front without buffering the whole file).
457
+ */
458
+ private async _sendFile(
459
+ streamId: string,
460
+ file: File,
461
+ options?: SendFileOptions,
462
+ ): Promise<ByteStreamInfo> {
463
+ const destinationIdentities = options?.destinationIdentities;
464
+ const compress =
465
+ (options?.compress ?? true) &&
466
+ isCompressionStreamSupported() &&
467
+ this.allRecipientsSupportV2(destinationIdentities) &&
468
+ this.allRecipientsSupportCompression(destinationIdentities);
469
+
470
+ const info: ByteStreamInfo = {
471
+ id: streamId,
208
472
  name: file.name,
209
473
  mimeType: options?.mimeType ?? file.type,
210
- topic: options?.topic,
211
- destinationIdentities: options?.destinationIdentities,
474
+ topic: options?.topic ?? '',
475
+ timestamp: Date.now(),
476
+ size: file.size,
477
+ encryptionType: this.engine.e2eeManager?.isDataChannelEncryptionEnabled
478
+ ? Encryption_Type.GCM
479
+ : Encryption_Type.NONE,
480
+ };
481
+
482
+ // Phase 1: Try to send as a single packet data stream
483
+ //
484
+ // This is not being done explictly for files, because it's challenging to determine ahead of
485
+ // time how well the file contents will compress (and whether the total output will be under the
486
+ // MTU). Revisit this in the future though.
487
+
488
+ // Phase 2 (compressed) / Phase 3 (uncompressed fallback): header + chunk packets + trailer. Both
489
+ // funnel through the shared sendChunkedByteStream primitive, differing only by whether the file
490
+ // stream is wrapped in the deflate-raw compressor; the file is never fully buffered in memory.
491
+ const header = buildByteStreamHeader(info, {
492
+ compression: compress
493
+ ? DataStream_CompressionType.DEFLATE_RAW
494
+ : DataStream_CompressionType.NONE,
212
495
  });
213
- const reader = file.stream().getReader();
214
- while (true) {
215
- const { done, value } = await reader.read();
216
- if (done) {
217
- break;
218
- }
219
- await writer.write(value);
496
+ const packet = createStreamHeaderPacket(header, destinationIdentities);
497
+
498
+ const tapped = file
499
+ .stream()
500
+ .pipeThrough(progressReportingStream(file.size, options?.onProgress));
501
+ const source = compress ? tapped.pipeThrough(deflateRawTransform()) : tapped;
502
+ await this.sendChunkedByteStream(packet, streamId, destinationIdentities, source);
503
+
504
+ // Ensure there's always a 100% progress event fired, even if the file is zero bytes long
505
+ if (file.size === 0) {
506
+ options?.onProgress?.(1);
220
507
  }
221
- await writer.close();
222
- return writer.info;
508
+
509
+ return info;
223
510
  }
224
511
 
225
512
  async streamBytes(options?: StreamBytesOptions) {
@@ -239,36 +526,21 @@ export default class OutgoingDataStreamManager {
239
526
  : Encryption_Type.NONE,
240
527
  };
241
528
 
242
- const header = new DataStream_Header({
243
- totalLength: numberToBigInt(info.size),
244
- mimeType: info.mimeType,
245
- streamId,
246
- topic: info.topic,
247
- timestamp: numberToBigInt(Date.now()),
248
- attributes: info.attributes,
249
- contentHeader: {
250
- case: 'byteHeader',
251
- value: new DataStream_ByteHeader({
252
- name: info.name,
253
- }),
254
- },
255
- });
256
-
257
- const packet = new DataPacket({
258
- destinationIdentities,
259
- value: {
260
- case: 'streamHeader',
261
- value: header,
262
- },
263
- });
529
+ const header = buildByteStreamHeader(info);
530
+ const packet = createStreamHeaderPacket(header, destinationIdentities);
264
531
 
265
- await this.engine.sendDataPacket(packet, DataChannelKind.RELIABLE);
532
+ await sendHeaderPacket(this.engine, packet);
266
533
 
267
534
  let chunkId = 0;
268
535
  const writeMutex = new Mutex();
269
536
  const engine = this.engine;
270
537
  const logLocal = this.log;
271
538
 
539
+ // Incremental byte streams are never compressed (CompressionStream does not support flushing
540
+ // mid-stream); one-shot compression lives in sendFile.
541
+ //
542
+ // Note that a future streamBytes could send a context-takeover style deflate-raw stream with
543
+ // intermedia explicit `Z_SYNC_FLUSH`s - receivers already will handle this properly today.
272
544
  const writableStream = new WritableStream<Uint8Array>({
273
545
  async write(chunk) {
274
546
  const unlock = await writeMutex.lock();
@@ -276,7 +548,7 @@ export default class OutgoingDataStreamManager {
276
548
  let byteOffset = 0;
277
549
  try {
278
550
  while (byteOffset < chunk.byteLength) {
279
- const subChunk = chunk.slice(byteOffset, byteOffset + STREAM_CHUNK_SIZE);
551
+ const subChunk = chunk.slice(byteOffset, byteOffset + STREAM_CHUNK_SIZE_BYTES);
280
552
  const chunkPacket = new DataPacket({
281
553
  destinationIdentities,
282
554
  value: {
@@ -297,17 +569,7 @@ export default class OutgoingDataStreamManager {
297
569
  }
298
570
  },
299
571
  async close() {
300
- const trailer = new DataStream_Trailer({
301
- streamId,
302
- });
303
- const trailerPacket = new DataPacket({
304
- destinationIdentities,
305
- value: {
306
- case: 'streamTrailer',
307
- value: trailer,
308
- },
309
- });
310
- await engine.sendDataPacket(trailerPacket, DataChannelKind.RELIABLE);
572
+ await sendStreamTrailer(streamId, destinationIdentities, engine);
311
573
  },
312
574
  abort(err) {
313
575
  logLocal.error('Sink error:', err);
@@ -319,3 +581,114 @@ export default class OutgoingDataStreamManager {
319
581
  return byteWriter;
320
582
  }
321
583
  }
584
+
585
+ /**
586
+ * Wraps a stream of compressed bytes and defers buffering it into memory until (and only if) the
587
+ * full length is actually needed.
588
+ *
589
+ * The ideal way to send a compressed stream is incrementally: compress each chunk and send it as
590
+ * soon as it's ready, without ever holding the whole payload in memory. But to decide whether a
591
+ * payload is small enough to fit in a single-packet data stream, we have to know the compressed
592
+ * length — and the only way to learn that is to compress everything and add up the bytes. That
593
+ * forces us to buffer the entire compressed output before we can make the decision.
594
+ *
595
+ * This class lets us have it both ways. It holds the compressed stream unread by default and only
596
+ * collects it into memory ({@link collect}) when a caller needs the length for the single-packet
597
+ * size check. If that check never happens, the stream is instead {@link stream|passed straight
598
+ * through} to the downstream consumer without ever being fully buffered. Once collected, later
599
+ * calls reuse the buffered bytes rather than re-collecting.
600
+ */
601
+ class MaybeCollectedStream {
602
+ private state:
603
+ | { type: 'stream'; stream: ReadableStream<NonSharedUint8Array> }
604
+ | { type: 'collected'; bytes: NonSharedUint8Array };
605
+
606
+ private constructor(state: typeof this.state) {
607
+ this.state = state;
608
+ }
609
+
610
+ static fromStream(stream: ReadableStream<NonSharedUint8Array>) {
611
+ return new MaybeCollectedStream({ type: 'stream', stream });
612
+ }
613
+
614
+ /** Collect data from the stream into memory and return as a Uint8Array. */
615
+ async collect() {
616
+ switch (this.state.type) {
617
+ case 'stream':
618
+ const bytes = await collect(this.state.stream);
619
+ this.state = { type: 'collected', bytes };
620
+ return bytes;
621
+ case 'collected':
622
+ return this.state.bytes;
623
+ }
624
+ }
625
+
626
+ /** Pass wrapped stream through to downstream consumer. */
627
+ stream() {
628
+ switch (this.state.type) {
629
+ case 'stream':
630
+ return this.state.stream;
631
+ case 'collected':
632
+ return readableFromBytes(this.state.bytes);
633
+ }
634
+ }
635
+ }
636
+
637
+ /**
638
+ * A pass-through byte transform that reports progress as bytes flow through it, measured against
639
+ * `totalLength`. Used as a "tap" to instrument the source of a chunked send without threading a
640
+ * progress callback through the send primitive.
641
+ *
642
+ * IMPORTANT: This should be upstream of any compression step.
643
+ *
644
+ * Emits nothing when the total is unknown or zero.
645
+ */
646
+ function progressReportingStream(
647
+ totalPreCompressionLength: number | undefined,
648
+ onProgress?: (progress: number) => void,
649
+ ): ReadableWritablePair<NonSharedUint8Array, NonSharedUint8Array> {
650
+ let sent = 0;
651
+ return new TransformStream<NonSharedUint8Array, NonSharedUint8Array>({
652
+ transform(chunk, controller) {
653
+ sent += chunk.byteLength;
654
+ if (
655
+ onProgress &&
656
+ typeof totalPreCompressionLength === 'number' &&
657
+ totalPreCompressionLength > 0
658
+ ) {
659
+ onProgress(Math.min(sent / totalPreCompressionLength, 1));
660
+ }
661
+ controller.enqueue(chunk);
662
+ },
663
+ });
664
+ }
665
+
666
+ /**
667
+ * Sends a stream `streamHeader` packet, enforcing that it fits the MTU budget. The header carries
668
+ * the user attributes (plus topic/framing), and a single `DataPacket` larger than the MTU can't be
669
+ * reliably sent — so an oversized header (almost always due to large attributes) is a hard error
670
+ * rather than a malformed packet on the wire. The inline fast path does its own size check and
671
+ * falls back to the chunked path instead of calling this.
672
+ */
673
+ async function sendHeaderPacket(engine: RTCEngine, packet: DataPacket): Promise<void> {
674
+ if (packet.toBinary().byteLength > STREAM_CHUNK_SIZE_BYTES) {
675
+ throw new DataStreamError(
676
+ `data stream header exceeds the ${STREAM_CHUNK_SIZE_BYTES}-byte limit; reduce attribute size`,
677
+ DataStreamErrorReason.HeaderTooLarge,
678
+ );
679
+ }
680
+ await engine.sendDataPacket(packet, DataChannelKind.RELIABLE);
681
+ }
682
+
683
+ /** Sends a `streamTrailer` packet, marking the end of a stream. */
684
+ async function sendStreamTrailer(
685
+ streamId: string,
686
+ destinationIdentities: Array<string> | undefined,
687
+ engine: RTCEngine,
688
+ ): Promise<void> {
689
+ const trailerPacket = new DataPacket({
690
+ destinationIdentities,
691
+ value: { case: 'streamTrailer', value: new DataStream_Trailer({ streamId }) },
692
+ });
693
+ await engine.sendDataPacket(trailerPacket, DataChannelKind.RELIABLE);
694
+ }