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
|
@@ -0,0 +1,1601 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DataPacket,
|
|
3
|
+
DataStream_ByteHeader,
|
|
4
|
+
DataStream_Chunk,
|
|
5
|
+
DataStream_CompressionType,
|
|
6
|
+
DataStream_Header,
|
|
7
|
+
DataStream_TextHeader,
|
|
8
|
+
DataStream_Trailer,
|
|
9
|
+
Encryption_Type,
|
|
10
|
+
} from '@livekit/protocol';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import { deflateRawCompress } from '../compression';
|
|
13
|
+
import { STREAM_CHUNK_SIZE_BYTES } from '../constants';
|
|
14
|
+
import IncomingDataStreamManager from './IncomingDataStreamManager';
|
|
15
|
+
import type { ByteStreamReader, TextStreamReader } from './StreamReader';
|
|
16
|
+
|
|
17
|
+
/** Builds a low quality random string of the given length. */
|
|
18
|
+
function randomText(length: number): string {
|
|
19
|
+
let s = '';
|
|
20
|
+
while (s.length < length) {
|
|
21
|
+
s += Math.random().toString(36).slice(2);
|
|
22
|
+
}
|
|
23
|
+
return s.slice(0, length);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Fills a buffer with uniform random bytes — genuinely incompressible. */
|
|
27
|
+
function randomBytes(length: number): Uint8Array {
|
|
28
|
+
const out = new Uint8Array(length);
|
|
29
|
+
// crypto.getRandomValues rejects requests over 65536 bytes, so chunk it.
|
|
30
|
+
for (let offset = 0; offset < length; offset += 65536) {
|
|
31
|
+
crypto.getRandomValues(out.subarray(offset, offset + 65536));
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type HeaderFields = NonNullable<ConstructorParameters<typeof DataStream_Header>[0]>;
|
|
37
|
+
|
|
38
|
+
/** Wraps a `DataStream_Header` (from `alice`, topic `my-topic`) in a `DataPacket`. */
|
|
39
|
+
function headerPacket(
|
|
40
|
+
streamId: string,
|
|
41
|
+
contentCase: 'textHeader' | 'byteHeader',
|
|
42
|
+
fields: HeaderFields = {},
|
|
43
|
+
): DataPacket {
|
|
44
|
+
return new DataPacket({
|
|
45
|
+
participantIdentity: 'alice',
|
|
46
|
+
value: {
|
|
47
|
+
case: 'streamHeader',
|
|
48
|
+
value: new DataStream_Header({
|
|
49
|
+
streamId,
|
|
50
|
+
topic: 'my-topic',
|
|
51
|
+
mimeType: 'text/plain',
|
|
52
|
+
timestamp: 0n,
|
|
53
|
+
contentHeader:
|
|
54
|
+
contentCase === 'textHeader'
|
|
55
|
+
? { case: 'textHeader', value: new DataStream_TextHeader({}) }
|
|
56
|
+
: { case: 'byteHeader', value: new DataStream_ByteHeader({}) },
|
|
57
|
+
...fields,
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Wraps a `DataStream_Chunk` in a `DataPacket`. */
|
|
64
|
+
function chunkPacket(
|
|
65
|
+
streamId: string,
|
|
66
|
+
chunkIndex: number,
|
|
67
|
+
content: Uint8Array,
|
|
68
|
+
version = 0,
|
|
69
|
+
): DataPacket {
|
|
70
|
+
return new DataPacket({
|
|
71
|
+
participantIdentity: 'alice',
|
|
72
|
+
value: {
|
|
73
|
+
case: 'streamChunk',
|
|
74
|
+
value: new DataStream_Chunk({
|
|
75
|
+
streamId,
|
|
76
|
+
chunkIndex: BigInt(chunkIndex),
|
|
77
|
+
content,
|
|
78
|
+
version,
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Wraps a `DataStream_Trailer` in a `DataPacket`. */
|
|
85
|
+
function trailerPacket(
|
|
86
|
+
streamId: string,
|
|
87
|
+
attributes?: Record<string, string>,
|
|
88
|
+
reason?: string,
|
|
89
|
+
): DataPacket {
|
|
90
|
+
return new DataPacket({
|
|
91
|
+
participantIdentity: 'alice',
|
|
92
|
+
value: {
|
|
93
|
+
case: 'streamTrailer',
|
|
94
|
+
value: new DataStream_Trailer({ streamId, attributes, reason }),
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Concatenates the byte chunks a `ByteStreamReader.readAll()` resolves with. */
|
|
100
|
+
function concatChunks(chunks: Array<Uint8Array>): Uint8Array {
|
|
101
|
+
const out = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.byteLength, 0));
|
|
102
|
+
let offset = 0;
|
|
103
|
+
for (const chunk of chunks) {
|
|
104
|
+
out.set(chunk, offset);
|
|
105
|
+
offset += chunk.byteLength;
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe('IncomingDataStreamManager', () => {
|
|
111
|
+
describe('Receiving v1 data streams', () => {
|
|
112
|
+
it('should receive a v1 text data stream', async () => {
|
|
113
|
+
const manager = new IncomingDataStreamManager();
|
|
114
|
+
manager.setConnected(true);
|
|
115
|
+
|
|
116
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
117
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const streamId = crypto.randomUUID();
|
|
121
|
+
const text = 'hello world';
|
|
122
|
+
|
|
123
|
+
manager.handleDataStreamPacket(
|
|
124
|
+
new DataPacket({
|
|
125
|
+
participantIdentity: 'alice',
|
|
126
|
+
value: {
|
|
127
|
+
case: 'streamHeader',
|
|
128
|
+
value: new DataStream_Header({
|
|
129
|
+
streamId,
|
|
130
|
+
topic: 'my-topic',
|
|
131
|
+
mimeType: 'text/plain',
|
|
132
|
+
timestamp: 0n,
|
|
133
|
+
totalLength: BigInt(text.length),
|
|
134
|
+
attributes: { foo: 'bar' },
|
|
135
|
+
contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) },
|
|
136
|
+
}),
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
Encryption_Type.NONE,
|
|
140
|
+
);
|
|
141
|
+
manager.handleDataStreamPacket(
|
|
142
|
+
new DataPacket({
|
|
143
|
+
participantIdentity: 'alice',
|
|
144
|
+
value: {
|
|
145
|
+
case: 'streamChunk',
|
|
146
|
+
value: new DataStream_Chunk({
|
|
147
|
+
streamId,
|
|
148
|
+
chunkIndex: 0n,
|
|
149
|
+
content: new TextEncoder().encode(text),
|
|
150
|
+
version: 0,
|
|
151
|
+
}),
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
Encryption_Type.NONE,
|
|
155
|
+
);
|
|
156
|
+
manager.handleDataStreamPacket(
|
|
157
|
+
new DataPacket({
|
|
158
|
+
participantIdentity: 'alice',
|
|
159
|
+
value: {
|
|
160
|
+
case: 'streamTrailer',
|
|
161
|
+
value: new DataStream_Trailer({ streamId }),
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
Encryption_Type.NONE,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const reader = await readerPromise;
|
|
168
|
+
expect(await reader.readAll()).toStrictEqual('hello world');
|
|
169
|
+
expect(reader.info.attributes?.foo).toStrictEqual('bar');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should receive a v1 bytes data stream', async () => {
|
|
173
|
+
const manager = new IncomingDataStreamManager();
|
|
174
|
+
manager.setConnected(true);
|
|
175
|
+
|
|
176
|
+
const readerPromise = new Promise<ByteStreamReader>((resolve) => {
|
|
177
|
+
manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const streamId = crypto.randomUUID();
|
|
181
|
+
|
|
182
|
+
manager.handleDataStreamPacket(
|
|
183
|
+
new DataPacket({
|
|
184
|
+
participantIdentity: 'alice',
|
|
185
|
+
value: {
|
|
186
|
+
case: 'streamHeader',
|
|
187
|
+
value: new DataStream_Header({
|
|
188
|
+
streamId,
|
|
189
|
+
topic: 'my-topic',
|
|
190
|
+
mimeType: 'text/plain',
|
|
191
|
+
timestamp: 0n,
|
|
192
|
+
totalLength: 4n,
|
|
193
|
+
attributes: { foo: 'bar' },
|
|
194
|
+
contentHeader: { case: 'byteHeader', value: new DataStream_ByteHeader({}) },
|
|
195
|
+
}),
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
Encryption_Type.NONE,
|
|
199
|
+
);
|
|
200
|
+
manager.handleDataStreamPacket(
|
|
201
|
+
new DataPacket({
|
|
202
|
+
participantIdentity: 'alice',
|
|
203
|
+
value: {
|
|
204
|
+
case: 'streamChunk',
|
|
205
|
+
value: new DataStream_Chunk({
|
|
206
|
+
streamId,
|
|
207
|
+
chunkIndex: 0n,
|
|
208
|
+
content: new Uint8Array([0x01, 0x02, 0x03, 0x04]),
|
|
209
|
+
version: 0,
|
|
210
|
+
}),
|
|
211
|
+
},
|
|
212
|
+
}),
|
|
213
|
+
Encryption_Type.NONE,
|
|
214
|
+
);
|
|
215
|
+
manager.handleDataStreamPacket(
|
|
216
|
+
new DataPacket({
|
|
217
|
+
participantIdentity: 'alice',
|
|
218
|
+
value: {
|
|
219
|
+
case: 'streamTrailer',
|
|
220
|
+
value: new DataStream_Trailer({ streamId }),
|
|
221
|
+
},
|
|
222
|
+
}),
|
|
223
|
+
Encryption_Type.NONE,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const reader = await readerPromise;
|
|
227
|
+
expect(await reader.readAll()).toStrictEqual([new Uint8Array([0x01, 0x02, 0x03, 0x04])]);
|
|
228
|
+
expect(reader.info.attributes?.foo).toStrictEqual('bar');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should receive a v1 text data stream with files', async () => {
|
|
232
|
+
const manager = new IncomingDataStreamManager();
|
|
233
|
+
manager.setConnected(true);
|
|
234
|
+
|
|
235
|
+
const streamId = crypto.randomUUID();
|
|
236
|
+
const streamReaderPromise = new Promise<TextStreamReader>((resolve) => {
|
|
237
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const attachmentStreamId = crypto.randomUUID();
|
|
241
|
+
const attachmentStreamReaderPromise = new Promise<ByteStreamReader>((resolve) => {
|
|
242
|
+
manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader));
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Send the main data stream body
|
|
246
|
+
const text = 'hello world';
|
|
247
|
+
manager.handleDataStreamPacket(
|
|
248
|
+
new DataPacket({
|
|
249
|
+
participantIdentity: 'alice',
|
|
250
|
+
value: {
|
|
251
|
+
case: 'streamHeader',
|
|
252
|
+
value: new DataStream_Header({
|
|
253
|
+
streamId,
|
|
254
|
+
topic: 'my-topic',
|
|
255
|
+
mimeType: 'text/plain',
|
|
256
|
+
timestamp: 0n,
|
|
257
|
+
totalLength: BigInt(text.length),
|
|
258
|
+
attributes: { foo: 'bar' },
|
|
259
|
+
inlineContent: new TextEncoder().encode(text),
|
|
260
|
+
contentHeader: {
|
|
261
|
+
case: 'textHeader',
|
|
262
|
+
value: new DataStream_TextHeader({
|
|
263
|
+
attachedStreamIds: [attachmentStreamId],
|
|
264
|
+
}),
|
|
265
|
+
},
|
|
266
|
+
}),
|
|
267
|
+
},
|
|
268
|
+
}),
|
|
269
|
+
Encryption_Type.NONE,
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// Send an attachment
|
|
273
|
+
manager.handleDataStreamPacket(
|
|
274
|
+
new DataPacket({
|
|
275
|
+
participantIdentity: 'alice',
|
|
276
|
+
value: {
|
|
277
|
+
case: 'streamHeader',
|
|
278
|
+
value: new DataStream_Header({
|
|
279
|
+
streamId: attachmentStreamId,
|
|
280
|
+
topic: 'my-topic',
|
|
281
|
+
mimeType: 'text/plain',
|
|
282
|
+
timestamp: 0n,
|
|
283
|
+
totalLength: 3n,
|
|
284
|
+
attributes: {},
|
|
285
|
+
contentHeader: { case: 'byteHeader', value: new DataStream_ByteHeader({}) },
|
|
286
|
+
}),
|
|
287
|
+
},
|
|
288
|
+
}),
|
|
289
|
+
Encryption_Type.NONE,
|
|
290
|
+
);
|
|
291
|
+
manager.handleDataStreamPacket(
|
|
292
|
+
new DataPacket({
|
|
293
|
+
participantIdentity: 'alice',
|
|
294
|
+
value: {
|
|
295
|
+
case: 'streamChunk',
|
|
296
|
+
value: new DataStream_Chunk({
|
|
297
|
+
streamId: attachmentStreamId,
|
|
298
|
+
chunkIndex: 0n,
|
|
299
|
+
content: new Uint8Array([0x01, 0x02, 0x03]),
|
|
300
|
+
version: 0,
|
|
301
|
+
}),
|
|
302
|
+
},
|
|
303
|
+
}),
|
|
304
|
+
Encryption_Type.NONE,
|
|
305
|
+
);
|
|
306
|
+
manager.handleDataStreamPacket(
|
|
307
|
+
new DataPacket({
|
|
308
|
+
participantIdentity: 'alice',
|
|
309
|
+
value: {
|
|
310
|
+
case: 'streamTrailer',
|
|
311
|
+
value: new DataStream_Trailer({ streamId: attachmentStreamId }),
|
|
312
|
+
},
|
|
313
|
+
}),
|
|
314
|
+
Encryption_Type.NONE,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const streamReader = await streamReaderPromise;
|
|
318
|
+
expect(await streamReader.readAll()).toStrictEqual('hello world');
|
|
319
|
+
expect(streamReader.info.attachedStreamIds).toHaveLength(1);
|
|
320
|
+
|
|
321
|
+
const attachmentStreamReader = await attachmentStreamReaderPromise;
|
|
322
|
+
expect(await attachmentStreamReader.readAll()).toStrictEqual([
|
|
323
|
+
new Uint8Array([0x01, 0x02, 0x03]),
|
|
324
|
+
]);
|
|
325
|
+
expect(streamReader.info.attachedStreamIds).toHaveLength(1);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should buffer packets when disconnected', async () => {
|
|
329
|
+
const manager = new IncomingDataStreamManager();
|
|
330
|
+
manager.setConnected(false);
|
|
331
|
+
|
|
332
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
333
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const streamId = crypto.randomUUID();
|
|
337
|
+
const text = 'hello world';
|
|
338
|
+
|
|
339
|
+
// Send three packets
|
|
340
|
+
manager.handleDataStreamPacket(
|
|
341
|
+
new DataPacket({
|
|
342
|
+
participantIdentity: 'alice',
|
|
343
|
+
value: {
|
|
344
|
+
case: 'streamHeader',
|
|
345
|
+
value: new DataStream_Header({
|
|
346
|
+
streamId,
|
|
347
|
+
topic: 'my-topic',
|
|
348
|
+
mimeType: 'text/plain',
|
|
349
|
+
timestamp: 0n,
|
|
350
|
+
totalLength: BigInt(text.length),
|
|
351
|
+
attributes: { foo: 'bar' },
|
|
352
|
+
contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) },
|
|
353
|
+
}),
|
|
354
|
+
},
|
|
355
|
+
}),
|
|
356
|
+
Encryption_Type.NONE,
|
|
357
|
+
);
|
|
358
|
+
manager.handleDataStreamPacket(
|
|
359
|
+
new DataPacket({
|
|
360
|
+
participantIdentity: 'alice',
|
|
361
|
+
value: {
|
|
362
|
+
case: 'streamChunk',
|
|
363
|
+
value: new DataStream_Chunk({
|
|
364
|
+
streamId,
|
|
365
|
+
chunkIndex: 0n,
|
|
366
|
+
content: new TextEncoder().encode(text),
|
|
367
|
+
version: 0,
|
|
368
|
+
}),
|
|
369
|
+
},
|
|
370
|
+
}),
|
|
371
|
+
Encryption_Type.NONE,
|
|
372
|
+
);
|
|
373
|
+
manager.handleDataStreamPacket(
|
|
374
|
+
new DataPacket({
|
|
375
|
+
participantIdentity: 'alice',
|
|
376
|
+
value: {
|
|
377
|
+
case: 'streamTrailer',
|
|
378
|
+
value: new DataStream_Trailer({ streamId }),
|
|
379
|
+
},
|
|
380
|
+
}),
|
|
381
|
+
Encryption_Type.NONE,
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
// Make sure promise still hasn't resolved
|
|
385
|
+
await expect(
|
|
386
|
+
Promise.race([readerPromise, Promise.resolve('still pending')]),
|
|
387
|
+
).resolves.toStrictEqual('still pending');
|
|
388
|
+
|
|
389
|
+
// Simulate connecting
|
|
390
|
+
manager.setConnected(true);
|
|
391
|
+
|
|
392
|
+
// Make sure it resolves after connected state set
|
|
393
|
+
const reader = await readerPromise;
|
|
394
|
+
expect(await reader.readAll()).toStrictEqual('hello world');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('should merge in trailer attributes', async () => {
|
|
398
|
+
const manager = new IncomingDataStreamManager();
|
|
399
|
+
manager.setConnected(true);
|
|
400
|
+
|
|
401
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
402
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const streamId = crypto.randomUUID();
|
|
406
|
+
const text = 'hello world';
|
|
407
|
+
|
|
408
|
+
// Send three packets
|
|
409
|
+
manager.handleDataStreamPacket(
|
|
410
|
+
new DataPacket({
|
|
411
|
+
participantIdentity: 'alice',
|
|
412
|
+
value: {
|
|
413
|
+
case: 'streamHeader',
|
|
414
|
+
value: new DataStream_Header({
|
|
415
|
+
streamId,
|
|
416
|
+
topic: 'my-topic',
|
|
417
|
+
mimeType: 'text/plain',
|
|
418
|
+
timestamp: 0n,
|
|
419
|
+
totalLength: BigInt(text.length),
|
|
420
|
+
attributes: { foo: 'bar', baz: 'quux' },
|
|
421
|
+
contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) },
|
|
422
|
+
}),
|
|
423
|
+
},
|
|
424
|
+
}),
|
|
425
|
+
Encryption_Type.NONE,
|
|
426
|
+
);
|
|
427
|
+
manager.handleDataStreamPacket(
|
|
428
|
+
new DataPacket({
|
|
429
|
+
participantIdentity: 'alice',
|
|
430
|
+
value: {
|
|
431
|
+
case: 'streamChunk',
|
|
432
|
+
value: new DataStream_Chunk({
|
|
433
|
+
streamId,
|
|
434
|
+
chunkIndex: 0n,
|
|
435
|
+
content: new TextEncoder().encode(text),
|
|
436
|
+
version: 0,
|
|
437
|
+
}),
|
|
438
|
+
},
|
|
439
|
+
}),
|
|
440
|
+
Encryption_Type.NONE,
|
|
441
|
+
);
|
|
442
|
+
manager.handleDataStreamPacket(
|
|
443
|
+
new DataPacket({
|
|
444
|
+
participantIdentity: 'alice',
|
|
445
|
+
value: {
|
|
446
|
+
case: 'streamTrailer',
|
|
447
|
+
value: new DataStream_Trailer({
|
|
448
|
+
streamId,
|
|
449
|
+
attributes: { hello: 'world', foo: 'updated' },
|
|
450
|
+
}),
|
|
451
|
+
},
|
|
452
|
+
}),
|
|
453
|
+
Encryption_Type.NONE,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
// Make sure it resolves after connected state set
|
|
457
|
+
const reader = await readerPromise;
|
|
458
|
+
expect(reader.info.attributes?.baz).toStrictEqual('quux');
|
|
459
|
+
expect(reader.info.attributes?.hello).toStrictEqual('world');
|
|
460
|
+
expect(reader.info.attributes?.foo).toStrictEqual('updated');
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('should drop packets with incorrect EncryptionType', async () => {
|
|
464
|
+
const manager = new IncomingDataStreamManager();
|
|
465
|
+
manager.setConnected(true);
|
|
466
|
+
|
|
467
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
468
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const streamId = crypto.randomUUID();
|
|
472
|
+
const text = 'hello world';
|
|
473
|
+
|
|
474
|
+
// Send two packets, the second with an incorrect encryption value
|
|
475
|
+
manager.handleDataStreamPacket(
|
|
476
|
+
new DataPacket({
|
|
477
|
+
participantIdentity: 'alice',
|
|
478
|
+
value: {
|
|
479
|
+
case: 'streamHeader',
|
|
480
|
+
value: new DataStream_Header({
|
|
481
|
+
streamId,
|
|
482
|
+
topic: 'my-topic',
|
|
483
|
+
mimeType: 'text/plain',
|
|
484
|
+
timestamp: 0n,
|
|
485
|
+
totalLength: BigInt(text.length),
|
|
486
|
+
attributes: { foo: 'bar', baz: 'quux' },
|
|
487
|
+
contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) },
|
|
488
|
+
}),
|
|
489
|
+
},
|
|
490
|
+
}),
|
|
491
|
+
Encryption_Type.NONE,
|
|
492
|
+
);
|
|
493
|
+
manager.handleDataStreamPacket(
|
|
494
|
+
new DataPacket({
|
|
495
|
+
participantIdentity: 'alice',
|
|
496
|
+
value: {
|
|
497
|
+
case: 'streamChunk',
|
|
498
|
+
value: new DataStream_Chunk({
|
|
499
|
+
streamId,
|
|
500
|
+
chunkIndex: 0n,
|
|
501
|
+
content: new TextEncoder().encode(text),
|
|
502
|
+
version: 0,
|
|
503
|
+
}),
|
|
504
|
+
},
|
|
505
|
+
}),
|
|
506
|
+
Encryption_Type.GCM, // <-- NOTE: this has changed since the last packet
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
// Make sure an error is thrown from the reader
|
|
510
|
+
const reader = await readerPromise;
|
|
511
|
+
expect(() => reader.readAll()).rejects.toThrow('Encryption type mismatch');
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('should throw an error if data stream does not have enough packets', async () => {
|
|
515
|
+
const manager = new IncomingDataStreamManager();
|
|
516
|
+
manager.setConnected(true);
|
|
517
|
+
|
|
518
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
519
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const streamId = crypto.randomUUID();
|
|
523
|
+
|
|
524
|
+
// Send a header, a 1 byte long chunk, and a trailer
|
|
525
|
+
manager.handleDataStreamPacket(
|
|
526
|
+
new DataPacket({
|
|
527
|
+
participantIdentity: 'alice',
|
|
528
|
+
value: {
|
|
529
|
+
case: 'streamHeader',
|
|
530
|
+
value: new DataStream_Header({
|
|
531
|
+
streamId,
|
|
532
|
+
topic: 'my-topic',
|
|
533
|
+
mimeType: 'text/plain',
|
|
534
|
+
timestamp: 0n,
|
|
535
|
+
totalLength: 5n,
|
|
536
|
+
attributes: { foo: 'bar', baz: 'quux' },
|
|
537
|
+
contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) },
|
|
538
|
+
}),
|
|
539
|
+
},
|
|
540
|
+
}),
|
|
541
|
+
Encryption_Type.NONE,
|
|
542
|
+
);
|
|
543
|
+
manager.handleDataStreamPacket(
|
|
544
|
+
new DataPacket({
|
|
545
|
+
participantIdentity: 'alice',
|
|
546
|
+
value: {
|
|
547
|
+
case: 'streamChunk',
|
|
548
|
+
value: new DataStream_Chunk({
|
|
549
|
+
streamId,
|
|
550
|
+
chunkIndex: 0n,
|
|
551
|
+
content: new Uint8Array([0x01]),
|
|
552
|
+
version: 0,
|
|
553
|
+
}),
|
|
554
|
+
},
|
|
555
|
+
}),
|
|
556
|
+
Encryption_Type.NONE,
|
|
557
|
+
);
|
|
558
|
+
manager.handleDataStreamPacket(
|
|
559
|
+
new DataPacket({
|
|
560
|
+
participantIdentity: 'alice',
|
|
561
|
+
value: {
|
|
562
|
+
case: 'streamTrailer',
|
|
563
|
+
value: new DataStream_Trailer({ streamId }),
|
|
564
|
+
},
|
|
565
|
+
}),
|
|
566
|
+
Encryption_Type.NONE,
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
// Make sure an error is thrown from the reader
|
|
570
|
+
const reader = await readerPromise;
|
|
571
|
+
await expect(reader.readAll()).rejects.toThrow('Not enough chunk(s)');
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('should throw an error if data stream has too many bytes', async () => {
|
|
575
|
+
const manager = new IncomingDataStreamManager();
|
|
576
|
+
manager.setConnected(true);
|
|
577
|
+
|
|
578
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
579
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const streamId = crypto.randomUUID();
|
|
583
|
+
|
|
584
|
+
// Send a header declaring 3 bytes, then a 5 byte long chunk, and a trailer
|
|
585
|
+
manager.handleDataStreamPacket(
|
|
586
|
+
new DataPacket({
|
|
587
|
+
participantIdentity: 'alice',
|
|
588
|
+
value: {
|
|
589
|
+
case: 'streamHeader',
|
|
590
|
+
value: new DataStream_Header({
|
|
591
|
+
streamId,
|
|
592
|
+
topic: 'my-topic',
|
|
593
|
+
mimeType: 'text/plain',
|
|
594
|
+
timestamp: 0n,
|
|
595
|
+
totalLength: 3n,
|
|
596
|
+
attributes: { foo: 'bar', baz: 'quux' },
|
|
597
|
+
contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) },
|
|
598
|
+
}),
|
|
599
|
+
},
|
|
600
|
+
}),
|
|
601
|
+
Encryption_Type.NONE,
|
|
602
|
+
);
|
|
603
|
+
manager.handleDataStreamPacket(
|
|
604
|
+
new DataPacket({
|
|
605
|
+
participantIdentity: 'alice',
|
|
606
|
+
value: {
|
|
607
|
+
case: 'streamChunk',
|
|
608
|
+
value: new DataStream_Chunk({
|
|
609
|
+
streamId,
|
|
610
|
+
chunkIndex: 0n,
|
|
611
|
+
content: new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]),
|
|
612
|
+
version: 0,
|
|
613
|
+
}),
|
|
614
|
+
},
|
|
615
|
+
}),
|
|
616
|
+
Encryption_Type.NONE,
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
// Make sure an error is thrown from the reader
|
|
620
|
+
const reader = await readerPromise;
|
|
621
|
+
await expect(reader.readAll()).rejects.toThrow('Extra chunk(s)');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('should throw an error if participant disconnects while data stream is still not fully received', async () => {
|
|
625
|
+
const manager = new IncomingDataStreamManager();
|
|
626
|
+
manager.setConnected(true);
|
|
627
|
+
|
|
628
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
629
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
const streamId = crypto.randomUUID();
|
|
633
|
+
|
|
634
|
+
// Send a header declaring 10 bytes, then a 5 byte long chunk
|
|
635
|
+
manager.handleDataStreamPacket(
|
|
636
|
+
new DataPacket({
|
|
637
|
+
participantIdentity: 'alice',
|
|
638
|
+
value: {
|
|
639
|
+
case: 'streamHeader',
|
|
640
|
+
value: new DataStream_Header({
|
|
641
|
+
streamId,
|
|
642
|
+
topic: 'my-topic',
|
|
643
|
+
mimeType: 'text/plain',
|
|
644
|
+
timestamp: 0n,
|
|
645
|
+
totalLength: 10n,
|
|
646
|
+
attributes: { foo: 'bar', baz: 'quux' },
|
|
647
|
+
contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) },
|
|
648
|
+
}),
|
|
649
|
+
},
|
|
650
|
+
}),
|
|
651
|
+
Encryption_Type.NONE,
|
|
652
|
+
);
|
|
653
|
+
manager.handleDataStreamPacket(
|
|
654
|
+
new DataPacket({
|
|
655
|
+
participantIdentity: 'alice',
|
|
656
|
+
value: {
|
|
657
|
+
case: 'streamChunk',
|
|
658
|
+
value: new DataStream_Chunk({
|
|
659
|
+
streamId,
|
|
660
|
+
chunkIndex: 0n,
|
|
661
|
+
content: new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]),
|
|
662
|
+
version: 0,
|
|
663
|
+
}),
|
|
664
|
+
},
|
|
665
|
+
}),
|
|
666
|
+
Encryption_Type.NONE,
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
// Simulate a remote participant disconnect, which calls this method in the room handler
|
|
670
|
+
manager.validateParticipantHasNoActiveDataStreams('alice');
|
|
671
|
+
|
|
672
|
+
// Make sure an error is thrown from the reader
|
|
673
|
+
const reader = await readerPromise;
|
|
674
|
+
await expect(reader.readAll()).rejects.toThrow(
|
|
675
|
+
'Participant alice unexpectedly disconnected in the middle of sending data',
|
|
676
|
+
);
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
describe('Receiving v2 data streams', () => {
|
|
681
|
+
it('should receive a v2 SINGLE PACKET + UNCOMPRESSED text data stream', async () => {
|
|
682
|
+
const manager = new IncomingDataStreamManager();
|
|
683
|
+
manager.setConnected(true);
|
|
684
|
+
|
|
685
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
686
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const streamId = crypto.randomUUID();
|
|
690
|
+
const text = 'hello world';
|
|
691
|
+
|
|
692
|
+
manager.handleDataStreamPacket(
|
|
693
|
+
new DataPacket({
|
|
694
|
+
participantIdentity: 'alice',
|
|
695
|
+
value: {
|
|
696
|
+
case: 'streamHeader',
|
|
697
|
+
value: new DataStream_Header({
|
|
698
|
+
streamId,
|
|
699
|
+
topic: 'my-topic',
|
|
700
|
+
mimeType: 'text/plain',
|
|
701
|
+
timestamp: 0n,
|
|
702
|
+
totalLength: BigInt(text.length),
|
|
703
|
+
attributes: { foo: 'bar' },
|
|
704
|
+
inlineContent: new TextEncoder().encode(text),
|
|
705
|
+
contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) },
|
|
706
|
+
}),
|
|
707
|
+
},
|
|
708
|
+
}),
|
|
709
|
+
Encryption_Type.NONE,
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
const reader = await readerPromise;
|
|
713
|
+
expect(await reader.readAll()).toStrictEqual('hello world');
|
|
714
|
+
expect(reader.info.attributes?.foo).toStrictEqual('bar');
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it('should receive a v2 SINGLE PACKET + UNCOMPRESSED byte data stream', async () => {
|
|
718
|
+
const manager = new IncomingDataStreamManager();
|
|
719
|
+
manager.setConnected(true);
|
|
720
|
+
|
|
721
|
+
const readerPromise = new Promise<ByteStreamReader>((resolve) => {
|
|
722
|
+
manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader));
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
const streamId = crypto.randomUUID();
|
|
726
|
+
const bytes = new Uint8Array([0x01, 0x02, 0x03]);
|
|
727
|
+
|
|
728
|
+
manager.handleDataStreamPacket(
|
|
729
|
+
new DataPacket({
|
|
730
|
+
participantIdentity: 'alice',
|
|
731
|
+
value: {
|
|
732
|
+
case: 'streamHeader',
|
|
733
|
+
value: new DataStream_Header({
|
|
734
|
+
streamId,
|
|
735
|
+
topic: 'my-topic',
|
|
736
|
+
mimeType: 'text/plain',
|
|
737
|
+
timestamp: 0n,
|
|
738
|
+
totalLength: 3n,
|
|
739
|
+
inlineContent: bytes,
|
|
740
|
+
contentHeader: { case: 'byteHeader', value: new DataStream_ByteHeader({}) },
|
|
741
|
+
}),
|
|
742
|
+
},
|
|
743
|
+
}),
|
|
744
|
+
Encryption_Type.NONE,
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
const reader = await readerPromise;
|
|
748
|
+
expect(await reader.readAll()).toStrictEqual([new Uint8Array([0x01, 0x02, 0x03])]);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it('should receive a v2 SINGLE PACKET + COMPRESSED text data stream', async () => {
|
|
752
|
+
const manager = new IncomingDataStreamManager();
|
|
753
|
+
manager.setConnected(true);
|
|
754
|
+
|
|
755
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
756
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
const streamId = crypto.randomUUID();
|
|
760
|
+
const text = 'hello world';
|
|
761
|
+
const compressed = await deflateRawCompress(new TextEncoder().encode(text));
|
|
762
|
+
|
|
763
|
+
manager.handleDataStreamPacket(
|
|
764
|
+
new DataPacket({
|
|
765
|
+
participantIdentity: 'alice',
|
|
766
|
+
value: {
|
|
767
|
+
case: 'streamHeader',
|
|
768
|
+
value: new DataStream_Header({
|
|
769
|
+
streamId,
|
|
770
|
+
topic: 'my-topic',
|
|
771
|
+
mimeType: 'text/plain',
|
|
772
|
+
timestamp: 0n,
|
|
773
|
+
totalLength: BigInt(text.length),
|
|
774
|
+
attributes: { foo: 'bar' },
|
|
775
|
+
compression: DataStream_CompressionType.DEFLATE_RAW,
|
|
776
|
+
inlineContent: compressed,
|
|
777
|
+
contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) },
|
|
778
|
+
}),
|
|
779
|
+
},
|
|
780
|
+
}),
|
|
781
|
+
Encryption_Type.NONE,
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
const reader = await readerPromise;
|
|
785
|
+
expect(await reader.readAll()).toStrictEqual('hello world');
|
|
786
|
+
expect(reader.info.attributes?.foo).toStrictEqual('bar');
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it('should receive a v2 SINGLE PACKET + COMPRESSED byte data stream', async () => {
|
|
790
|
+
const manager = new IncomingDataStreamManager();
|
|
791
|
+
manager.setConnected(true);
|
|
792
|
+
|
|
793
|
+
const readerPromise = new Promise<ByteStreamReader>((resolve) => {
|
|
794
|
+
manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader));
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
const streamId = crypto.randomUUID();
|
|
798
|
+
const bytes = new Uint8Array([0x01, 0x02, 0x03]);
|
|
799
|
+
const compressed = await deflateRawCompress(bytes);
|
|
800
|
+
|
|
801
|
+
manager.handleDataStreamPacket(
|
|
802
|
+
new DataPacket({
|
|
803
|
+
participantIdentity: 'alice',
|
|
804
|
+
value: {
|
|
805
|
+
case: 'streamHeader',
|
|
806
|
+
value: new DataStream_Header({
|
|
807
|
+
streamId,
|
|
808
|
+
topic: 'my-topic',
|
|
809
|
+
mimeType: 'text/plain',
|
|
810
|
+
timestamp: 0n,
|
|
811
|
+
totalLength: BigInt(bytes.length),
|
|
812
|
+
compression: DataStream_CompressionType.DEFLATE_RAW,
|
|
813
|
+
inlineContent: compressed,
|
|
814
|
+
contentHeader: { case: 'byteHeader', value: new DataStream_ByteHeader({}) },
|
|
815
|
+
}),
|
|
816
|
+
},
|
|
817
|
+
}),
|
|
818
|
+
Encryption_Type.NONE,
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
const reader = await readerPromise;
|
|
822
|
+
expect(await reader.readAll()).toStrictEqual([new Uint8Array([0x01, 0x02, 0x03])]);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it('should receive a v2 MULTI PACKET + COMPRESSED text data stream', async () => {
|
|
826
|
+
const manager = new IncomingDataStreamManager();
|
|
827
|
+
manager.setConnected(true);
|
|
828
|
+
|
|
829
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
830
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
const streamId = crypto.randomUUID();
|
|
834
|
+
|
|
835
|
+
// NOTE: mostly incompressible, but the hello world parts repeating should mean that the compressed
|
|
836
|
+
// contents is smaller than the full uncompressed data.
|
|
837
|
+
const text = new Array(30)
|
|
838
|
+
.fill(null)
|
|
839
|
+
.map(() => `hello world${randomText(1_000)}`)
|
|
840
|
+
.join('');
|
|
841
|
+
|
|
842
|
+
const compressed = await deflateRawCompress(new TextEncoder().encode(text));
|
|
843
|
+
|
|
844
|
+
// Make sure the compressed text should be able to be split into two "packets" worth of data
|
|
845
|
+
expect(compressed.length).toBeLessThan(2 * STREAM_CHUNK_SIZE_BYTES);
|
|
846
|
+
|
|
847
|
+
manager.handleDataStreamPacket(
|
|
848
|
+
new DataPacket({
|
|
849
|
+
participantIdentity: 'alice',
|
|
850
|
+
value: {
|
|
851
|
+
case: 'streamHeader',
|
|
852
|
+
value: new DataStream_Header({
|
|
853
|
+
streamId,
|
|
854
|
+
topic: 'my-topic',
|
|
855
|
+
mimeType: 'text/plain',
|
|
856
|
+
timestamp: 0n,
|
|
857
|
+
totalLength: BigInt(text.length),
|
|
858
|
+
compression: DataStream_CompressionType.DEFLATE_RAW,
|
|
859
|
+
contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) },
|
|
860
|
+
}),
|
|
861
|
+
},
|
|
862
|
+
}),
|
|
863
|
+
Encryption_Type.NONE,
|
|
864
|
+
);
|
|
865
|
+
manager.handleDataStreamPacket(
|
|
866
|
+
new DataPacket({
|
|
867
|
+
participantIdentity: 'alice',
|
|
868
|
+
value: {
|
|
869
|
+
case: 'streamChunk',
|
|
870
|
+
value: new DataStream_Chunk({
|
|
871
|
+
streamId,
|
|
872
|
+
chunkIndex: 0n,
|
|
873
|
+
content: compressed.slice(0, STREAM_CHUNK_SIZE_BYTES),
|
|
874
|
+
version: 0,
|
|
875
|
+
}),
|
|
876
|
+
},
|
|
877
|
+
}),
|
|
878
|
+
Encryption_Type.NONE,
|
|
879
|
+
);
|
|
880
|
+
manager.handleDataStreamPacket(
|
|
881
|
+
new DataPacket({
|
|
882
|
+
participantIdentity: 'alice',
|
|
883
|
+
value: {
|
|
884
|
+
case: 'streamChunk',
|
|
885
|
+
value: new DataStream_Chunk({
|
|
886
|
+
streamId,
|
|
887
|
+
chunkIndex: 1n,
|
|
888
|
+
content: compressed.slice(STREAM_CHUNK_SIZE_BYTES),
|
|
889
|
+
version: 0,
|
|
890
|
+
}),
|
|
891
|
+
},
|
|
892
|
+
}),
|
|
893
|
+
Encryption_Type.NONE,
|
|
894
|
+
);
|
|
895
|
+
manager.handleDataStreamPacket(
|
|
896
|
+
new DataPacket({
|
|
897
|
+
participantIdentity: 'alice',
|
|
898
|
+
value: {
|
|
899
|
+
case: 'streamTrailer',
|
|
900
|
+
value: new DataStream_Trailer({ streamId }),
|
|
901
|
+
},
|
|
902
|
+
}),
|
|
903
|
+
Encryption_Type.NONE,
|
|
904
|
+
);
|
|
905
|
+
|
|
906
|
+
const reader = await readerPromise;
|
|
907
|
+
expect(await reader.readAll()).toStrictEqual(text);
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
it(`should ignore a v2 TEXT data stream with compression if DecompressionStream doesn't exist`, async () => {
|
|
911
|
+
const text = 'hello world';
|
|
912
|
+
const compressed = await deflateRawCompress(new TextEncoder().encode(text));
|
|
913
|
+
|
|
914
|
+
let originalCompressionStream: typeof CompressionStream,
|
|
915
|
+
originalDecompressionStream: typeof DecompressionStream;
|
|
916
|
+
try {
|
|
917
|
+
originalCompressionStream = CompressionStream;
|
|
918
|
+
(globalThis as any).CompressionStream = undefined;
|
|
919
|
+
originalDecompressionStream = DecompressionStream;
|
|
920
|
+
(globalThis as any).DecompressionStream = undefined;
|
|
921
|
+
|
|
922
|
+
const manager = new IncomingDataStreamManager();
|
|
923
|
+
manager.setConnected(true);
|
|
924
|
+
|
|
925
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
926
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
const streamId = crypto.randomUUID();
|
|
930
|
+
|
|
931
|
+
manager.handleDataStreamPacket(
|
|
932
|
+
new DataPacket({
|
|
933
|
+
participantIdentity: 'alice',
|
|
934
|
+
value: {
|
|
935
|
+
case: 'streamHeader',
|
|
936
|
+
value: new DataStream_Header({
|
|
937
|
+
streamId,
|
|
938
|
+
topic: 'my-topic',
|
|
939
|
+
mimeType: 'text/plain',
|
|
940
|
+
timestamp: 0n,
|
|
941
|
+
totalLength: BigInt(text.length),
|
|
942
|
+
compression: DataStream_CompressionType.DEFLATE_RAW,
|
|
943
|
+
contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) },
|
|
944
|
+
}),
|
|
945
|
+
},
|
|
946
|
+
}),
|
|
947
|
+
Encryption_Type.NONE,
|
|
948
|
+
);
|
|
949
|
+
manager.handleDataStreamPacket(
|
|
950
|
+
new DataPacket({
|
|
951
|
+
participantIdentity: 'alice',
|
|
952
|
+
value: {
|
|
953
|
+
case: 'streamChunk',
|
|
954
|
+
value: new DataStream_Chunk({
|
|
955
|
+
streamId,
|
|
956
|
+
chunkIndex: 0n,
|
|
957
|
+
content: compressed,
|
|
958
|
+
version: 0,
|
|
959
|
+
}),
|
|
960
|
+
},
|
|
961
|
+
}),
|
|
962
|
+
Encryption_Type.NONE,
|
|
963
|
+
);
|
|
964
|
+
manager.handleDataStreamPacket(
|
|
965
|
+
new DataPacket({
|
|
966
|
+
participantIdentity: 'alice',
|
|
967
|
+
value: {
|
|
968
|
+
case: 'streamTrailer',
|
|
969
|
+
value: new DataStream_Trailer({ streamId }),
|
|
970
|
+
},
|
|
971
|
+
}),
|
|
972
|
+
Encryption_Type.NONE,
|
|
973
|
+
);
|
|
974
|
+
|
|
975
|
+
// Make sure promise is still pending; the data stream should have been dropped
|
|
976
|
+
await expect(
|
|
977
|
+
Promise.race([readerPromise, Promise.resolve('still pending')]),
|
|
978
|
+
).resolves.toStrictEqual('still pending');
|
|
979
|
+
} finally {
|
|
980
|
+
(globalThis as any).CompressionStream = originalCompressionStream!;
|
|
981
|
+
(globalThis as any).DecompressionStream = originalDecompressionStream!;
|
|
982
|
+
}
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
it(`should ignore a v2 BYTES data stream with compression if DecompressionStream doesn't exist`, async () => {
|
|
986
|
+
const bytes = new Uint8Array([0x01, 0x02, 0x03]);
|
|
987
|
+
const compressed = await deflateRawCompress(bytes);
|
|
988
|
+
|
|
989
|
+
let originalCompressionStream: typeof CompressionStream,
|
|
990
|
+
originalDecompressionStream: typeof DecompressionStream;
|
|
991
|
+
try {
|
|
992
|
+
originalCompressionStream = CompressionStream;
|
|
993
|
+
(globalThis as any).CompressionStream = undefined;
|
|
994
|
+
originalDecompressionStream = DecompressionStream;
|
|
995
|
+
(globalThis as any).DecompressionStream = undefined;
|
|
996
|
+
|
|
997
|
+
const manager = new IncomingDataStreamManager();
|
|
998
|
+
manager.setConnected(true);
|
|
999
|
+
|
|
1000
|
+
const readerPromise = new Promise<ByteStreamReader>((resolve) => {
|
|
1001
|
+
manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
const streamId = crypto.randomUUID();
|
|
1005
|
+
|
|
1006
|
+
manager.handleDataStreamPacket(
|
|
1007
|
+
new DataPacket({
|
|
1008
|
+
participantIdentity: 'alice',
|
|
1009
|
+
value: {
|
|
1010
|
+
case: 'streamHeader',
|
|
1011
|
+
value: new DataStream_Header({
|
|
1012
|
+
streamId,
|
|
1013
|
+
topic: 'my-topic',
|
|
1014
|
+
mimeType: 'application/octet-stream',
|
|
1015
|
+
timestamp: 0n,
|
|
1016
|
+
totalLength: BigInt(bytes.length),
|
|
1017
|
+
compression: DataStream_CompressionType.DEFLATE_RAW,
|
|
1018
|
+
contentHeader: { case: 'byteHeader', value: new DataStream_ByteHeader({}) },
|
|
1019
|
+
}),
|
|
1020
|
+
},
|
|
1021
|
+
}),
|
|
1022
|
+
Encryption_Type.NONE,
|
|
1023
|
+
);
|
|
1024
|
+
manager.handleDataStreamPacket(
|
|
1025
|
+
new DataPacket({
|
|
1026
|
+
participantIdentity: 'alice',
|
|
1027
|
+
value: {
|
|
1028
|
+
case: 'streamChunk',
|
|
1029
|
+
value: new DataStream_Chunk({
|
|
1030
|
+
streamId,
|
|
1031
|
+
chunkIndex: 0n,
|
|
1032
|
+
content: compressed,
|
|
1033
|
+
version: 0,
|
|
1034
|
+
}),
|
|
1035
|
+
},
|
|
1036
|
+
}),
|
|
1037
|
+
Encryption_Type.NONE,
|
|
1038
|
+
);
|
|
1039
|
+
manager.handleDataStreamPacket(
|
|
1040
|
+
new DataPacket({
|
|
1041
|
+
participantIdentity: 'alice',
|
|
1042
|
+
value: {
|
|
1043
|
+
case: 'streamTrailer',
|
|
1044
|
+
value: new DataStream_Trailer({ streamId }),
|
|
1045
|
+
},
|
|
1046
|
+
}),
|
|
1047
|
+
Encryption_Type.NONE,
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
// Make sure promise is still pending; the data stream should have been dropped
|
|
1051
|
+
await expect(
|
|
1052
|
+
Promise.race([readerPromise, Promise.resolve('still pending')]),
|
|
1053
|
+
).resolves.toStrictEqual('still pending');
|
|
1054
|
+
} finally {
|
|
1055
|
+
(globalThis as any).CompressionStream = originalCompressionStream!;
|
|
1056
|
+
(globalThis as any).DecompressionStream = originalDecompressionStream!;
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
it('should receive a v2 SINGLE PACKET text data stream with zero length', async () => {
|
|
1061
|
+
const manager = new IncomingDataStreamManager();
|
|
1062
|
+
manager.setConnected(true);
|
|
1063
|
+
|
|
1064
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1065
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
const streamId = crypto.randomUUID();
|
|
1069
|
+
manager.handleDataStreamPacket(
|
|
1070
|
+
new DataPacket({
|
|
1071
|
+
participantIdentity: 'alice',
|
|
1072
|
+
value: {
|
|
1073
|
+
case: 'streamHeader',
|
|
1074
|
+
value: new DataStream_Header({
|
|
1075
|
+
streamId,
|
|
1076
|
+
topic: 'my-topic',
|
|
1077
|
+
mimeType: 'text/plain',
|
|
1078
|
+
timestamp: 0n,
|
|
1079
|
+
totalLength: 0n,
|
|
1080
|
+
attributes: { foo: 'bar' },
|
|
1081
|
+
inlineContent: new Uint8Array(0), // Empty buffer that is 0 bytes long
|
|
1082
|
+
contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) },
|
|
1083
|
+
}),
|
|
1084
|
+
},
|
|
1085
|
+
}),
|
|
1086
|
+
Encryption_Type.NONE,
|
|
1087
|
+
);
|
|
1088
|
+
|
|
1089
|
+
const reader = await readerPromise;
|
|
1090
|
+
expect(await reader.readAll()).toStrictEqual('');
|
|
1091
|
+
expect(reader.info.attributes?.foo).toStrictEqual('bar');
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
it('should receive a v2 SINGLE PACKET byte data stream with zero length', async () => {
|
|
1095
|
+
const manager = new IncomingDataStreamManager();
|
|
1096
|
+
manager.setConnected(true);
|
|
1097
|
+
|
|
1098
|
+
const readerPromise = new Promise<ByteStreamReader>((resolve) => {
|
|
1099
|
+
manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
const streamId = crypto.randomUUID();
|
|
1103
|
+
manager.handleDataStreamPacket(
|
|
1104
|
+
new DataPacket({
|
|
1105
|
+
participantIdentity: 'alice',
|
|
1106
|
+
value: {
|
|
1107
|
+
case: 'streamHeader',
|
|
1108
|
+
value: new DataStream_Header({
|
|
1109
|
+
streamId,
|
|
1110
|
+
topic: 'my-topic',
|
|
1111
|
+
mimeType: 'byte/plain',
|
|
1112
|
+
timestamp: 0n,
|
|
1113
|
+
totalLength: 0n,
|
|
1114
|
+
attributes: { foo: 'bar' },
|
|
1115
|
+
inlineContent: new Uint8Array(0), // Empty buffer that is 0 bytes long
|
|
1116
|
+
contentHeader: { case: 'byteHeader', value: new DataStream_ByteHeader({}) },
|
|
1117
|
+
}),
|
|
1118
|
+
},
|
|
1119
|
+
}),
|
|
1120
|
+
Encryption_Type.NONE,
|
|
1121
|
+
);
|
|
1122
|
+
|
|
1123
|
+
const reader = await readerPromise;
|
|
1124
|
+
expect(await reader.readAll()).toStrictEqual([new Uint8Array(0)]);
|
|
1125
|
+
expect(reader.info.attributes?.foo).toStrictEqual('bar');
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
it('should receive a v2 MULTI PACKET + COMPRESSED byte data stream', async () => {
|
|
1129
|
+
const manager = new IncomingDataStreamManager();
|
|
1130
|
+
manager.setConnected(true);
|
|
1131
|
+
|
|
1132
|
+
const readerPromise = new Promise<ByteStreamReader>((resolve) => {
|
|
1133
|
+
manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
const streamId = crypto.randomUUID();
|
|
1137
|
+
// Incompressible, so the compressed form stays large enough to need two chunks (but fewer
|
|
1138
|
+
// than three).
|
|
1139
|
+
const bytes = randomBytes(20_000);
|
|
1140
|
+
const compressed = await deflateRawCompress(bytes);
|
|
1141
|
+
expect(compressed.length).toBeGreaterThan(STREAM_CHUNK_SIZE_BYTES);
|
|
1142
|
+
expect(compressed.length).toBeLessThan(2 * STREAM_CHUNK_SIZE_BYTES);
|
|
1143
|
+
|
|
1144
|
+
manager.handleDataStreamPacket(
|
|
1145
|
+
headerPacket(streamId, 'byteHeader', {
|
|
1146
|
+
totalLength: BigInt(bytes.length),
|
|
1147
|
+
compression: DataStream_CompressionType.DEFLATE_RAW,
|
|
1148
|
+
}),
|
|
1149
|
+
Encryption_Type.NONE,
|
|
1150
|
+
);
|
|
1151
|
+
manager.handleDataStreamPacket(
|
|
1152
|
+
chunkPacket(streamId, 0, compressed.slice(0, STREAM_CHUNK_SIZE_BYTES)),
|
|
1153
|
+
Encryption_Type.NONE,
|
|
1154
|
+
);
|
|
1155
|
+
manager.handleDataStreamPacket(
|
|
1156
|
+
chunkPacket(streamId, 1, compressed.slice(STREAM_CHUNK_SIZE_BYTES)),
|
|
1157
|
+
Encryption_Type.NONE,
|
|
1158
|
+
);
|
|
1159
|
+
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE);
|
|
1160
|
+
|
|
1161
|
+
const reader = await readerPromise;
|
|
1162
|
+
expect(concatChunks(await reader.readAll())).toStrictEqual(bytes);
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
it('should drop a duplicate chunk index on a compressed stream and still decode', async () => {
|
|
1166
|
+
const manager = new IncomingDataStreamManager();
|
|
1167
|
+
manager.setConnected(true);
|
|
1168
|
+
|
|
1169
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1170
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
const streamId = crypto.randomUUID();
|
|
1174
|
+
const text = new Array(30)
|
|
1175
|
+
.fill(null)
|
|
1176
|
+
.map(() => `hello world${randomText(1_000)}`)
|
|
1177
|
+
.join('');
|
|
1178
|
+
const compressed = await deflateRawCompress(new TextEncoder().encode(text));
|
|
1179
|
+
expect(compressed.length).toBeGreaterThan(STREAM_CHUNK_SIZE_BYTES);
|
|
1180
|
+
expect(compressed.length).toBeLessThan(2 * STREAM_CHUNK_SIZE_BYTES);
|
|
1181
|
+
|
|
1182
|
+
manager.handleDataStreamPacket(
|
|
1183
|
+
headerPacket(streamId, 'textHeader', {
|
|
1184
|
+
totalLength: BigInt(text.length),
|
|
1185
|
+
compression: DataStream_CompressionType.DEFLATE_RAW,
|
|
1186
|
+
}),
|
|
1187
|
+
Encryption_Type.NONE,
|
|
1188
|
+
);
|
|
1189
|
+
const chunk0 = chunkPacket(streamId, 0, compressed.slice(0, STREAM_CHUNK_SIZE_BYTES));
|
|
1190
|
+
manager.handleDataStreamPacket(chunk0, Encryption_Type.NONE);
|
|
1191
|
+
// A replayed chunk (e.g. reconnect logic) must be dropped with a warning, not fed to the
|
|
1192
|
+
// stateful decompressor a second time.
|
|
1193
|
+
manager.handleDataStreamPacket(chunk0, Encryption_Type.NONE);
|
|
1194
|
+
manager.handleDataStreamPacket(
|
|
1195
|
+
chunkPacket(streamId, 1, compressed.slice(STREAM_CHUNK_SIZE_BYTES)),
|
|
1196
|
+
Encryption_Type.NONE,
|
|
1197
|
+
);
|
|
1198
|
+
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE);
|
|
1199
|
+
|
|
1200
|
+
const reader = await readerPromise;
|
|
1201
|
+
expect(await reader.readAll()).toStrictEqual(text);
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
it('should error on a gap in chunk indices on a compressed stream', async () => {
|
|
1205
|
+
const manager = new IncomingDataStreamManager();
|
|
1206
|
+
manager.setConnected(true);
|
|
1207
|
+
|
|
1208
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1209
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
const streamId = crypto.randomUUID();
|
|
1213
|
+
const text = new Array(30)
|
|
1214
|
+
.fill(null)
|
|
1215
|
+
.map(() => `hello world${randomText(1_000)}`)
|
|
1216
|
+
.join('');
|
|
1217
|
+
const compressed = await deflateRawCompress(new TextEncoder().encode(text));
|
|
1218
|
+
|
|
1219
|
+
manager.handleDataStreamPacket(
|
|
1220
|
+
headerPacket(streamId, 'textHeader', {
|
|
1221
|
+
totalLength: BigInt(text.length),
|
|
1222
|
+
compression: DataStream_CompressionType.DEFLATE_RAW,
|
|
1223
|
+
}),
|
|
1224
|
+
Encryption_Type.NONE,
|
|
1225
|
+
);
|
|
1226
|
+
manager.handleDataStreamPacket(
|
|
1227
|
+
chunkPacket(streamId, 0, compressed.slice(0, STREAM_CHUNK_SIZE_BYTES)),
|
|
1228
|
+
Encryption_Type.NONE,
|
|
1229
|
+
);
|
|
1230
|
+
// Skip chunk index 1 entirely — the stateful decompressor cannot continue past a gap.
|
|
1231
|
+
manager.handleDataStreamPacket(
|
|
1232
|
+
chunkPacket(streamId, 2, compressed.slice(STREAM_CHUNK_SIZE_BYTES)),
|
|
1233
|
+
Encryption_Type.NONE,
|
|
1234
|
+
);
|
|
1235
|
+
|
|
1236
|
+
const reader = await readerPromise;
|
|
1237
|
+
await expect(reader.readAll()).rejects.toThrow('Missing chunk(s)');
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
it('should reframe multibyte UTF-8 on chunk boundaries when decompressing a text stream', async () => {
|
|
1241
|
+
const manager = new IncomingDataStreamManager();
|
|
1242
|
+
manager.setConnected(true);
|
|
1243
|
+
|
|
1244
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1245
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
const streamId = crypto.randomUUID();
|
|
1249
|
+
const text = '😀你好世界 café — ¡ñandú! '.repeat(500);
|
|
1250
|
+
const textBytes = new TextEncoder().encode(text);
|
|
1251
|
+
const compressed = await deflateRawCompress(textBytes);
|
|
1252
|
+
|
|
1253
|
+
// Split the compressed bytes at an arbitrary midpoint: the decompressor's output at the
|
|
1254
|
+
// seam can land mid-codepoint, exercising the UTF-8 reframing stage.
|
|
1255
|
+
const split = Math.floor(compressed.length / 2);
|
|
1256
|
+
|
|
1257
|
+
manager.handleDataStreamPacket(
|
|
1258
|
+
headerPacket(streamId, 'textHeader', {
|
|
1259
|
+
totalLength: BigInt(textBytes.length), // NOTE: byte length, not character count
|
|
1260
|
+
compression: DataStream_CompressionType.DEFLATE_RAW,
|
|
1261
|
+
}),
|
|
1262
|
+
Encryption_Type.NONE,
|
|
1263
|
+
);
|
|
1264
|
+
manager.handleDataStreamPacket(
|
|
1265
|
+
chunkPacket(streamId, 0, compressed.slice(0, split)),
|
|
1266
|
+
Encryption_Type.NONE,
|
|
1267
|
+
);
|
|
1268
|
+
manager.handleDataStreamPacket(
|
|
1269
|
+
chunkPacket(streamId, 1, compressed.slice(split)),
|
|
1270
|
+
Encryption_Type.NONE,
|
|
1271
|
+
);
|
|
1272
|
+
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE);
|
|
1273
|
+
|
|
1274
|
+
const reader = await readerPromise;
|
|
1275
|
+
expect(await reader.readAll()).toStrictEqual(text);
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
it('should error when a compressed stream decompresses to fewer bytes than totalLength', async () => {
|
|
1279
|
+
const manager = new IncomingDataStreamManager();
|
|
1280
|
+
manager.setConnected(true);
|
|
1281
|
+
|
|
1282
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1283
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
const streamId = crypto.randomUUID();
|
|
1287
|
+
const compressed = await deflateRawCompress(new TextEncoder().encode('hello world')); // 11 bytes
|
|
1288
|
+
|
|
1289
|
+
manager.handleDataStreamPacket(
|
|
1290
|
+
headerPacket(streamId, 'textHeader', {
|
|
1291
|
+
totalLength: 16n, // more than the decompressed payload
|
|
1292
|
+
compression: DataStream_CompressionType.DEFLATE_RAW,
|
|
1293
|
+
}),
|
|
1294
|
+
Encryption_Type.NONE,
|
|
1295
|
+
);
|
|
1296
|
+
manager.handleDataStreamPacket(chunkPacket(streamId, 0, compressed), Encryption_Type.NONE);
|
|
1297
|
+
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE);
|
|
1298
|
+
|
|
1299
|
+
// The reader counts DECOMPRESSED bytes against totalLength.
|
|
1300
|
+
const reader = await readerPromise;
|
|
1301
|
+
await expect(reader.readAll()).rejects.toThrow('Not enough chunk(s)');
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
it('should error when a compressed stream decompresses to more bytes than totalLength', async () => {
|
|
1305
|
+
const manager = new IncomingDataStreamManager();
|
|
1306
|
+
manager.setConnected(true);
|
|
1307
|
+
|
|
1308
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1309
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
const streamId = crypto.randomUUID();
|
|
1313
|
+
const compressed = await deflateRawCompress(new TextEncoder().encode('hello world')); // 11 bytes
|
|
1314
|
+
|
|
1315
|
+
manager.handleDataStreamPacket(
|
|
1316
|
+
headerPacket(streamId, 'textHeader', {
|
|
1317
|
+
totalLength: 5n, // fewer than the decompressed payload
|
|
1318
|
+
compression: DataStream_CompressionType.DEFLATE_RAW,
|
|
1319
|
+
}),
|
|
1320
|
+
Encryption_Type.NONE,
|
|
1321
|
+
);
|
|
1322
|
+
manager.handleDataStreamPacket(chunkPacket(streamId, 0, compressed), Encryption_Type.NONE);
|
|
1323
|
+
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE);
|
|
1324
|
+
|
|
1325
|
+
const reader = await readerPromise;
|
|
1326
|
+
await expect(reader.readAll()).rejects.toThrow('Extra chunk(s)');
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
it('should merge trailer attributes on a compressed stream', async () => {
|
|
1330
|
+
const manager = new IncomingDataStreamManager();
|
|
1331
|
+
manager.setConnected(true);
|
|
1332
|
+
|
|
1333
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1334
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
const streamId = crypto.randomUUID();
|
|
1338
|
+
const text = 'hello world';
|
|
1339
|
+
const compressed = await deflateRawCompress(new TextEncoder().encode(text));
|
|
1340
|
+
|
|
1341
|
+
manager.handleDataStreamPacket(
|
|
1342
|
+
headerPacket(streamId, 'textHeader', {
|
|
1343
|
+
totalLength: BigInt(text.length),
|
|
1344
|
+
compression: DataStream_CompressionType.DEFLATE_RAW,
|
|
1345
|
+
attributes: { foo: 'bar', baz: 'quux' },
|
|
1346
|
+
}),
|
|
1347
|
+
Encryption_Type.NONE,
|
|
1348
|
+
);
|
|
1349
|
+
manager.handleDataStreamPacket(chunkPacket(streamId, 0, compressed), Encryption_Type.NONE);
|
|
1350
|
+
manager.handleDataStreamPacket(
|
|
1351
|
+
trailerPacket(streamId, { hello: 'world', foo: 'updated' }),
|
|
1352
|
+
Encryption_Type.NONE,
|
|
1353
|
+
);
|
|
1354
|
+
|
|
1355
|
+
const reader = await readerPromise;
|
|
1356
|
+
expect(await reader.readAll()).toStrictEqual(text);
|
|
1357
|
+
expect(reader.info.attributes?.baz).toStrictEqual('quux');
|
|
1358
|
+
expect(reader.info.attributes?.hello).toStrictEqual('world');
|
|
1359
|
+
expect(reader.info.attributes?.foo).toStrictEqual('updated');
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
it('should ignore a stream with an unknown compression type', async () => {
|
|
1363
|
+
const manager = new IncomingDataStreamManager();
|
|
1364
|
+
manager.setConnected(true);
|
|
1365
|
+
|
|
1366
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1367
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
const streamId = crypto.randomUUID();
|
|
1371
|
+
const text = 'hello world';
|
|
1372
|
+
|
|
1373
|
+
manager.handleDataStreamPacket(
|
|
1374
|
+
headerPacket(streamId, 'textHeader', {
|
|
1375
|
+
totalLength: BigInt(text.length),
|
|
1376
|
+
compression: 99 as DataStream_CompressionType,
|
|
1377
|
+
}),
|
|
1378
|
+
Encryption_Type.NONE,
|
|
1379
|
+
);
|
|
1380
|
+
manager.handleDataStreamPacket(
|
|
1381
|
+
chunkPacket(streamId, 0, new TextEncoder().encode(text)),
|
|
1382
|
+
Encryption_Type.NONE,
|
|
1383
|
+
);
|
|
1384
|
+
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE);
|
|
1385
|
+
|
|
1386
|
+
// A compression type from a future protocol version can't be decoded; the stream is
|
|
1387
|
+
// dropped and the handler is never invoked.
|
|
1388
|
+
await expect(
|
|
1389
|
+
Promise.race([readerPromise, Promise.resolve('still pending')]),
|
|
1390
|
+
).resolves.toStrictEqual('still pending');
|
|
1391
|
+
});
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
describe('Receive-side protocol edge cases', () => {
|
|
1395
|
+
it('should reject a duplicate TEXT streamId whose stream is already open', async () => {
|
|
1396
|
+
const manager = new IncomingDataStreamManager();
|
|
1397
|
+
manager.setConnected(true);
|
|
1398
|
+
|
|
1399
|
+
const readers: Array<TextStreamReader> = [];
|
|
1400
|
+
manager.registerTextStreamHandler('my-topic', (reader) => readers.push(reader));
|
|
1401
|
+
|
|
1402
|
+
const streamId = crypto.randomUUID();
|
|
1403
|
+
const header = headerPacket(streamId, 'textHeader', { totalLength: 11n });
|
|
1404
|
+
|
|
1405
|
+
manager.handleDataStreamPacket(header, Encryption_Type.NONE);
|
|
1406
|
+
expect(readers).toHaveLength(1);
|
|
1407
|
+
|
|
1408
|
+
// A second header re-using an open streamId must be rejected, not silently replace (or
|
|
1409
|
+
// corrupt) the in-flight stream.
|
|
1410
|
+
expect(() => manager.handleDataStreamPacket(header, Encryption_Type.NONE)).toThrow(
|
|
1411
|
+
'already in progress',
|
|
1412
|
+
);
|
|
1413
|
+
expect(readers).toHaveLength(1);
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
it('should reject a duplicate BYTE streamId whose stream is already open', async () => {
|
|
1417
|
+
const manager = new IncomingDataStreamManager();
|
|
1418
|
+
manager.setConnected(true);
|
|
1419
|
+
|
|
1420
|
+
const readers: Array<ByteStreamReader> = [];
|
|
1421
|
+
manager.registerByteStreamHandler('my-topic', (reader) => readers.push(reader));
|
|
1422
|
+
|
|
1423
|
+
const streamId = crypto.randomUUID();
|
|
1424
|
+
const header = headerPacket(streamId, 'byteHeader', { totalLength: 4n });
|
|
1425
|
+
|
|
1426
|
+
manager.handleDataStreamPacket(header, Encryption_Type.NONE);
|
|
1427
|
+
expect(readers).toHaveLength(1);
|
|
1428
|
+
|
|
1429
|
+
expect(() => manager.handleDataStreamPacket(header, Encryption_Type.NONE)).toThrow(
|
|
1430
|
+
'already in progress',
|
|
1431
|
+
);
|
|
1432
|
+
expect(readers).toHaveLength(1);
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
it('should ignore empty chunks', async () => {
|
|
1436
|
+
const manager = new IncomingDataStreamManager();
|
|
1437
|
+
manager.setConnected(true);
|
|
1438
|
+
|
|
1439
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1440
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
const streamId = crypto.randomUUID();
|
|
1444
|
+
const text = 'hello world';
|
|
1445
|
+
|
|
1446
|
+
manager.handleDataStreamPacket(
|
|
1447
|
+
headerPacket(streamId, 'textHeader', { totalLength: BigInt(text.length) }),
|
|
1448
|
+
Encryption_Type.NONE,
|
|
1449
|
+
);
|
|
1450
|
+
// An empty chunk must not count against totalLength or corrupt the stream.
|
|
1451
|
+
manager.handleDataStreamPacket(
|
|
1452
|
+
chunkPacket(streamId, 0, new Uint8Array(0)),
|
|
1453
|
+
Encryption_Type.NONE,
|
|
1454
|
+
);
|
|
1455
|
+
manager.handleDataStreamPacket(
|
|
1456
|
+
chunkPacket(streamId, 1, new TextEncoder().encode(text)),
|
|
1457
|
+
Encryption_Type.NONE,
|
|
1458
|
+
);
|
|
1459
|
+
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE);
|
|
1460
|
+
|
|
1461
|
+
const reader = await readerPromise;
|
|
1462
|
+
expect(await reader.readAll()).toStrictEqual(text);
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
it('should silently drop packets for a topic with no registered handler', () => {
|
|
1466
|
+
const manager = new IncomingDataStreamManager();
|
|
1467
|
+
manager.setConnected(true);
|
|
1468
|
+
|
|
1469
|
+
const streamId = crypto.randomUUID();
|
|
1470
|
+
|
|
1471
|
+
// No handler registered for the topic: header, chunks, and trailer are all dropped
|
|
1472
|
+
// without raising.
|
|
1473
|
+
expect(() => {
|
|
1474
|
+
manager.handleDataStreamPacket(
|
|
1475
|
+
headerPacket(streamId, 'textHeader', { totalLength: 11n }),
|
|
1476
|
+
Encryption_Type.NONE,
|
|
1477
|
+
);
|
|
1478
|
+
manager.handleDataStreamPacket(
|
|
1479
|
+
chunkPacket(streamId, 0, new TextEncoder().encode('hello world')),
|
|
1480
|
+
Encryption_Type.NONE,
|
|
1481
|
+
);
|
|
1482
|
+
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE);
|
|
1483
|
+
}).not.toThrow();
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
it('should error the reader when the trailer reports an abnormal close reason', async () => {
|
|
1487
|
+
const manager = new IncomingDataStreamManager();
|
|
1488
|
+
manager.setConnected(true);
|
|
1489
|
+
|
|
1490
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1491
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
const streamId = crypto.randomUUID();
|
|
1495
|
+
const text = 'hello world';
|
|
1496
|
+
|
|
1497
|
+
manager.handleDataStreamPacket(
|
|
1498
|
+
headerPacket(streamId, 'textHeader', { totalLength: BigInt(text.length) }),
|
|
1499
|
+
Encryption_Type.NONE,
|
|
1500
|
+
);
|
|
1501
|
+
manager.handleDataStreamPacket(
|
|
1502
|
+
chunkPacket(streamId, 0, new TextEncoder().encode(text)),
|
|
1503
|
+
Encryption_Type.NONE,
|
|
1504
|
+
);
|
|
1505
|
+
// A non-empty trailer reason means the sender aborted; the stream must not be reported as
|
|
1506
|
+
// a successful close.
|
|
1507
|
+
manager.handleDataStreamPacket(
|
|
1508
|
+
trailerPacket(streamId, undefined, 'cancelled'),
|
|
1509
|
+
Encryption_Type.NONE,
|
|
1510
|
+
);
|
|
1511
|
+
|
|
1512
|
+
const reader = await readerPromise;
|
|
1513
|
+
await expect(reader.readAll()).rejects.toThrow('closed abnormally: cancelled');
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
it('should error an inline compressed payload that inflates past the max payload size', async () => {
|
|
1517
|
+
// Decompression-bomb guard: a tiny compressed inline payload must not be allowed to
|
|
1518
|
+
// expand without bound.
|
|
1519
|
+
const manager = new IncomingDataStreamManager(1_000);
|
|
1520
|
+
manager.setConnected(true);
|
|
1521
|
+
|
|
1522
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1523
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
const streamId = crypto.randomUUID();
|
|
1527
|
+
const text = randomText(50_000);
|
|
1528
|
+
const textBytes = new TextEncoder().encode(text);
|
|
1529
|
+
const compressed = await deflateRawCompress(textBytes);
|
|
1530
|
+
|
|
1531
|
+
manager.handleDataStreamPacket(
|
|
1532
|
+
headerPacket(streamId, 'textHeader', {
|
|
1533
|
+
totalLength: BigInt(textBytes.length),
|
|
1534
|
+
compression: DataStream_CompressionType.DEFLATE_RAW,
|
|
1535
|
+
inlineContent: compressed,
|
|
1536
|
+
}),
|
|
1537
|
+
Encryption_Type.NONE,
|
|
1538
|
+
);
|
|
1539
|
+
|
|
1540
|
+
const reader = await readerPromise;
|
|
1541
|
+
await expect(reader.readAll()).rejects.toThrow('maximum payload size');
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
it('should error a chunked compressed stream that inflates past the max payload size', async () => {
|
|
1545
|
+
const manager = new IncomingDataStreamManager(50_000);
|
|
1546
|
+
manager.setConnected(true);
|
|
1547
|
+
|
|
1548
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1549
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
const streamId = crypto.randomUUID();
|
|
1553
|
+
const text = randomText(60_000); // decompresses past the 50k cap
|
|
1554
|
+
const textBytes = new TextEncoder().encode(text);
|
|
1555
|
+
const compressed = await deflateRawCompress(textBytes);
|
|
1556
|
+
|
|
1557
|
+
manager.handleDataStreamPacket(
|
|
1558
|
+
headerPacket(streamId, 'textHeader', {
|
|
1559
|
+
totalLength: BigInt(textBytes.length),
|
|
1560
|
+
compression: DataStream_CompressionType.DEFLATE_RAW,
|
|
1561
|
+
}),
|
|
1562
|
+
Encryption_Type.NONE,
|
|
1563
|
+
);
|
|
1564
|
+
for (let offset = 0, index = 0; offset < compressed.length; offset += 15_000, index += 1) {
|
|
1565
|
+
manager.handleDataStreamPacket(
|
|
1566
|
+
chunkPacket(streamId, index, compressed.slice(offset, offset + 15_000)),
|
|
1567
|
+
Encryption_Type.NONE,
|
|
1568
|
+
);
|
|
1569
|
+
}
|
|
1570
|
+
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE);
|
|
1571
|
+
|
|
1572
|
+
const reader = await readerPromise;
|
|
1573
|
+
await expect(reader.readAll()).rejects.toThrow('maximum payload size');
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
it('should error the reader when the trailer has a mismatched encryption type', async () => {
|
|
1577
|
+
const manager = new IncomingDataStreamManager();
|
|
1578
|
+
manager.setConnected(true);
|
|
1579
|
+
|
|
1580
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1581
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
const streamId = crypto.randomUUID();
|
|
1585
|
+
const text = 'hello world';
|
|
1586
|
+
|
|
1587
|
+
manager.handleDataStreamPacket(
|
|
1588
|
+
headerPacket(streamId, 'textHeader', { totalLength: BigInt(text.length) }),
|
|
1589
|
+
Encryption_Type.NONE,
|
|
1590
|
+
);
|
|
1591
|
+
manager.handleDataStreamPacket(
|
|
1592
|
+
chunkPacket(streamId, 0, new TextEncoder().encode(text)),
|
|
1593
|
+
Encryption_Type.NONE,
|
|
1594
|
+
);
|
|
1595
|
+
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.GCM);
|
|
1596
|
+
|
|
1597
|
+
const reader = await readerPromise;
|
|
1598
|
+
await expect(reader.readAll()).rejects.toThrow('Encryption type mismatch');
|
|
1599
|
+
});
|
|
1600
|
+
});
|
|
1601
|
+
});
|