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.
- 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 +4 -0
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +1993 -791
- 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 +4 -0
- 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/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +28 -64
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +4 -0
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/data-channel/DataChannelManager.d.ts +62 -0
- package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -0
- package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
- package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -0
- package/dist/src/room/data-channel/LossyDataChannel.d.ts +40 -0
- package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -0
- package/dist/src/room/data-channel/ReliableDataChannel.d.ts +56 -0
- package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -0
- package/dist/src/room/data-channel/types.d.ts +12 -0
- package/dist/src/room/data-channel/types.d.ts.map +1 -0
- package/dist/src/room/data-stream/compression.d.ts +49 -0
- package/dist/src/room/data-stream/compression.d.ts.map +1 -0
- package/dist/src/room/data-stream/constants.d.ts +18 -0
- package/dist/src/room/data-stream/constants.d.ts.map +1 -0
- package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
- package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
- package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
- package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
- package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts.map +1 -1
- package/dist/src/room/data-stream/outgoing/header-utils.d.ts +18 -0
- package/dist/src/room/data-stream/outgoing/header-utils.d.ts.map +1 -0
- package/dist/src/room/errors.d.ts +3 -1
- package/dist/src/room/errors.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts +9 -1
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/RemoteParticipant.d.ts +11 -2
- package/dist/src/room/participant/RemoteParticipant.d.ts.map +1 -1
- package/dist/src/room/rpc/client/RpcClientManager.d.ts.map +1 -1
- package/dist/src/room/rpc/server/RpcServerManager.d.ts.map +1 -1
- package/dist/src/room/types.d.ts +17 -0
- package/dist/src/room/types.d.ts.map +1 -1
- package/dist/src/room/utils.d.ts +13 -0
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/src/version.d.ts +5 -1
- package/dist/src/version.d.ts.map +1 -1
- package/dist/ts4.2/index.d.ts +1 -1
- package/dist/ts4.2/room/RTCEngine.d.ts +28 -64
- package/dist/ts4.2/room/Room.d.ts +4 -0
- package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +62 -0
- package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
- package/dist/ts4.2/room/data-channel/LossyDataChannel.d.ts +41 -0
- package/dist/ts4.2/room/data-channel/ReliableDataChannel.d.ts +57 -0
- package/dist/ts4.2/room/data-channel/types.d.ts +12 -0
- package/dist/ts4.2/room/data-stream/compression.d.ts +49 -0
- package/dist/ts4.2/room/data-stream/constants.d.ts +18 -0
- package/dist/ts4.2/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
- package/dist/ts4.2/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
- package/dist/ts4.2/room/data-stream/outgoing/header-utils.d.ts +18 -0
- package/dist/ts4.2/room/errors.d.ts +3 -1
- package/dist/ts4.2/room/participant/LocalParticipant.d.ts +9 -1
- package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +11 -1
- package/dist/ts4.2/room/types.d.ts +17 -0
- package/dist/ts4.2/room/utils.d.ts +13 -0
- package/dist/ts4.2/version.d.ts +5 -1
- package/package.json +2 -2
- package/src/index.ts +2 -0
- package/src/room/RTCEngine.test.ts +58 -99
- package/src/room/RTCEngine.ts +92 -534
- package/src/room/Room.test.ts +4 -1
- package/src/room/Room.ts +40 -8
- package/src/room/data-channel/DataChannelManager.test.ts +152 -0
- package/src/room/data-channel/DataChannelManager.ts +237 -0
- package/src/room/data-channel/FlowControlledDataChannel.test.ts +154 -0
- package/src/room/data-channel/FlowControlledDataChannel.ts +216 -0
- package/src/room/data-channel/LossyDataChannel.test.ts +118 -0
- package/src/room/data-channel/LossyDataChannel.ts +125 -0
- package/src/room/data-channel/ReliableDataChannel.test.ts +158 -0
- package/src/room/data-channel/ReliableDataChannel.ts +153 -0
- package/src/room/data-channel/types.ts +30 -0
- package/src/room/data-stream/compression.ts +117 -0
- package/src/room/data-stream/constants.ts +18 -0
- package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +1601 -0
- package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +438 -86
- package/src/room/data-stream/incoming/StreamReader.ts +8 -2
- package/src/room/data-stream/outgoing/OutgoingDataStreamManager.test.ts +1292 -0
- package/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +489 -116
- package/src/room/data-stream/outgoing/header-utils.ts +87 -0
- package/src/room/errors.ts +6 -0
- package/src/room/participant/LocalParticipant.ts +13 -0
- package/src/room/participant/RemoteParticipant.ts +19 -5
- package/src/room/rpc/client/RpcClientManager.test.ts +20 -27
- package/src/room/rpc/client/RpcClientManager.ts +1 -4
- package/src/room/rpc/server/RpcServerManager.test.ts +24 -23
- package/src/room/rpc/server/RpcServerManager.ts +1 -3
- package/src/room/types.ts +18 -0
- package/src/room/utils.ts +67 -0
- 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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|