livekit-client 2.20.1 → 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 +37 -7
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +2279 -651
- 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 +37 -7
- 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/api/SignalClient.d.ts.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/logger.d.ts +3 -1
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/room/PCTransport.d.ts +2 -0
- package/dist/src/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/PCTransportManager.d.ts +1 -0
- package/dist/src/room/PCTransportManager.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +31 -26
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/RegionUrlProvider.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/data-track/incoming/IncomingDataTrackManager.d.ts +7 -0
- package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts.map +1 -1
- package/dist/src/room/data-track/outgoing/OutgoingDataTrackManager.d.ts.map +1 -1
- 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/Participant.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/participant/publishUtils.d.ts +7 -0
- package/dist/src/room/participant/publishUtils.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/track/LocalAudioTrack.d.ts +1 -0
- package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
- package/dist/src/room/track/Track.d.ts.map +1 -1
- package/dist/src/room/track/options.d.ts +11 -1
- package/dist/src/room/track/options.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/test/signalToken.d.ts +26 -0
- package/dist/src/test/signalToken.d.ts.map +1 -0
- package/dist/src/utils/dataPacketBuffer.d.ts +18 -0
- package/dist/src/utils/dataPacketBuffer.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/logger.d.ts +3 -1
- package/dist/ts4.2/room/PCTransport.d.ts +2 -0
- package/dist/ts4.2/room/PCTransportManager.d.ts +1 -0
- package/dist/ts4.2/room/RTCEngine.d.ts +31 -26
- 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/data-track/incoming/IncomingDataTrackManager.d.ts +7 -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/participant/publishUtils.d.ts +7 -0
- package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +1 -0
- package/dist/ts4.2/room/track/options.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/test/signalToken.d.ts +26 -0
- package/dist/ts4.2/utils/dataPacketBuffer.d.ts +18 -0
- package/dist/ts4.2/version.d.ts +5 -1
- package/package.json +8 -4
- package/src/api/SignalClient.e2e.test.ts +264 -0
- package/src/api/SignalClient.ts +34 -2
- package/src/index.ts +2 -0
- package/src/logger.ts +2 -0
- package/src/room/PCTransport.ts +66 -31
- package/src/room/PCTransportManager.ts +10 -6
- package/src/room/RTCEngine.test.ts +238 -6
- package/src/room/RTCEngine.ts +101 -338
- package/src/room/RegionUrlProvider.ts +4 -2
- package/src/room/Room.test.ts +4 -1
- package/src/room/Room.ts +47 -9
- 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/data-track/incoming/IncomingDataTrackManager.test.ts +303 -0
- package/src/room/data-track/incoming/IncomingDataTrackManager.ts +70 -8
- package/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +2 -0
- package/src/room/errors.ts +10 -4
- package/src/room/participant/LocalParticipant.ts +29 -6
- package/src/room/participant/Participant.ts +5 -0
- package/src/room/participant/RemoteParticipant.ts +19 -5
- package/src/room/participant/publishUtils.ts +14 -11
- 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/track/LocalAudioTrack.ts +16 -0
- package/src/room/track/Track.ts +10 -6
- package/src/room/track/options.ts +11 -1
- package/src/room/types.ts +18 -0
- package/src/room/utils.ts +67 -0
- package/src/test/signalServerSetup.ts +151 -0
- package/src/test/signalToken.ts +59 -0
- package/src/test/vitest-context.d.ts +10 -0
- package/src/utils/dataPacketBuffer.test.ts +100 -0
- package/src/utils/dataPacketBuffer.ts +46 -1
- package/src/version.ts +5 -1
|
@@ -0,0 +1,1292 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ClientInfo_Capability,
|
|
3
|
+
type DataPacket,
|
|
4
|
+
type DataStream_ByteHeader,
|
|
5
|
+
DataStream_CompressionType,
|
|
6
|
+
type DataStream_TextHeader,
|
|
7
|
+
} from '@livekit/protocol';
|
|
8
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import log from '../../../logger';
|
|
10
|
+
import {
|
|
11
|
+
CLIENT_PROTOCOL_DATA_STREAM_RPC,
|
|
12
|
+
CLIENT_PROTOCOL_DATA_STREAM_V2,
|
|
13
|
+
CLIENT_PROTOCOL_DEFAULT,
|
|
14
|
+
} from '../../../version';
|
|
15
|
+
import type RTCEngine from '../../RTCEngine';
|
|
16
|
+
import OutgoingDataStreamManager from './OutgoingDataStreamManager';
|
|
17
|
+
|
|
18
|
+
/** Builds a low quality random string of the given length. */
|
|
19
|
+
function randomText(length: number): string {
|
|
20
|
+
let s = '';
|
|
21
|
+
while (s.length < length) {
|
|
22
|
+
s += Math.random().toString(36).slice(2);
|
|
23
|
+
}
|
|
24
|
+
return s.slice(0, length);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Fills a buffer with uniform random bytes — genuinely incompressible. */
|
|
28
|
+
function randomBytes(length: number): Uint8Array {
|
|
29
|
+
const out = new Uint8Array(length);
|
|
30
|
+
// crypto.getRandomValues rejects requests over 65536 bytes, so chunk it.
|
|
31
|
+
for (let offset = 0; offset < length; offset += 65536) {
|
|
32
|
+
crypto.getRandomValues(out.subarray(offset, offset + 65536));
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param participants the remote participants in the room, mapped from identity to the client
|
|
39
|
+
* protocol each advertises. Defaults to a single v2 participant named "bob".
|
|
40
|
+
*/
|
|
41
|
+
function createManager(
|
|
42
|
+
participants: Record<string, number | [number, Array<number>]> = {
|
|
43
|
+
bob: CLIENT_PROTOCOL_DATA_STREAM_V2,
|
|
44
|
+
},
|
|
45
|
+
) {
|
|
46
|
+
const sentPackets: DataPacket[] = [];
|
|
47
|
+
const engine = {
|
|
48
|
+
sendDataPacket: vi.fn(async (packet: DataPacket) => {
|
|
49
|
+
sentPackets.push(packet);
|
|
50
|
+
}),
|
|
51
|
+
e2eeManager: undefined,
|
|
52
|
+
once: vi.fn(),
|
|
53
|
+
off: vi.fn(),
|
|
54
|
+
} as unknown as RTCEngine;
|
|
55
|
+
const manager = new OutgoingDataStreamManager(
|
|
56
|
+
engine,
|
|
57
|
+
log,
|
|
58
|
+
(identity) =>
|
|
59
|
+
(Array.isArray(participants[identity])
|
|
60
|
+
? participants[identity][0]
|
|
61
|
+
: participants[identity]) ?? CLIENT_PROTOCOL_DEFAULT,
|
|
62
|
+
(identity) =>
|
|
63
|
+
Array.isArray(participants[identity])
|
|
64
|
+
? participants[identity][1]
|
|
65
|
+
: [ClientInfo_Capability.CAP_COMPRESSION_DEFLATE_RAW],
|
|
66
|
+
() => Object.keys(participants),
|
|
67
|
+
);
|
|
68
|
+
return { manager, sentPackets };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function headerOf(packet: DataPacket) {
|
|
72
|
+
return packet.value.value as Extract<DataPacket['value'], { case: 'streamHeader' }>['value'];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function chunkOf(packet: DataPacket) {
|
|
76
|
+
return packet.value.value as Extract<DataPacket['value'], { case: 'streamChunk' }>['value'];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function trailerOf(packet: DataPacket) {
|
|
80
|
+
return packet.value.value as Extract<DataPacket['value'], { case: 'streamTrailer' }>['value'];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('OutgoingDataStreamManager', () => {
|
|
84
|
+
describe('v2 -> room of all v1', () => {
|
|
85
|
+
let manager: OutgoingDataStreamManager, sentPackets: Array<DataPacket>;
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
const result = createManager({
|
|
88
|
+
alice: CLIENT_PROTOCOL_DEFAULT,
|
|
89
|
+
bob: CLIENT_PROTOCOL_DEFAULT,
|
|
90
|
+
jim: CLIENT_PROTOCOL_DATA_STREAM_RPC,
|
|
91
|
+
});
|
|
92
|
+
manager = result.manager;
|
|
93
|
+
sentPackets = result.sentPackets;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should send short TEXT data stream using non single packet "legacy" format and NO compression (happy path)', async () => {
|
|
97
|
+
const info = await manager.sendText('hello world', {
|
|
98
|
+
topic: 'my-topic',
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Make sure three packets were received, matching the legacy v1 data stream format
|
|
102
|
+
expect(sentPackets).toHaveLength(3);
|
|
103
|
+
|
|
104
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
105
|
+
const header = headerOf(sentPackets[0]);
|
|
106
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
107
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
108
|
+
expect(header.contentHeader.case).toBe('textHeader');
|
|
109
|
+
|
|
110
|
+
expect(sentPackets[1].value.case).toStrictEqual('streamChunk');
|
|
111
|
+
const chunk = chunkOf(sentPackets[1]);
|
|
112
|
+
expect(chunk.streamId).toStrictEqual(info.id);
|
|
113
|
+
expect(chunk.chunkIndex).toStrictEqual(0n);
|
|
114
|
+
expect(chunk.content).toStrictEqual(new TextEncoder().encode('hello world'));
|
|
115
|
+
|
|
116
|
+
expect(sentPackets[2].value.case).toStrictEqual('streamTrailer');
|
|
117
|
+
const trailer = trailerOf(sentPackets[2]);
|
|
118
|
+
expect(trailer.streamId).toStrictEqual(info.id);
|
|
119
|
+
expect(trailer.reason).toStrictEqual('');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should send short BYTE data stream using non single packet "legacy" format and NO compression (happy path)', async () => {
|
|
123
|
+
const writer = await manager.streamBytes({
|
|
124
|
+
topic: 'my-topic',
|
|
125
|
+
});
|
|
126
|
+
await writer.write(new Uint8Array([0x00, 0x01, 0x02, 0x03]));
|
|
127
|
+
await writer.close();
|
|
128
|
+
|
|
129
|
+
// Make sure three packets were received, matching the legacy v1 data stream format
|
|
130
|
+
expect(sentPackets).toHaveLength(3);
|
|
131
|
+
|
|
132
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
133
|
+
const header = headerOf(sentPackets[0]);
|
|
134
|
+
expect(header.streamId).toStrictEqual(writer.info.id);
|
|
135
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
136
|
+
expect(header.contentHeader.case).toBe('byteHeader');
|
|
137
|
+
|
|
138
|
+
expect(sentPackets[1].value.case).toStrictEqual('streamChunk');
|
|
139
|
+
const chunk = chunkOf(sentPackets[1]);
|
|
140
|
+
expect(chunk.streamId).toStrictEqual(writer.info.id);
|
|
141
|
+
expect(chunk.chunkIndex).toStrictEqual(0n);
|
|
142
|
+
expect(chunk.content).toStrictEqual(new Uint8Array([0x00, 0x01, 0x02, 0x03]));
|
|
143
|
+
|
|
144
|
+
expect(sentPackets[2].value.case).toStrictEqual('streamTrailer');
|
|
145
|
+
const trailer = trailerOf(sentPackets[2]);
|
|
146
|
+
expect(trailer.streamId).toStrictEqual(writer.info.id);
|
|
147
|
+
expect(trailer.reason).toStrictEqual('');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should send long TEXT data stream without compression (happy path)', async () => {
|
|
151
|
+
const longPayload = new Array(40_000).fill('A').join('');
|
|
152
|
+
const info = await manager.sendText(longPayload, {
|
|
153
|
+
topic: 'my-topic',
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Make sure five packets were received, matching the legacy v1 data stream format
|
|
157
|
+
expect(sentPackets).toHaveLength(5);
|
|
158
|
+
|
|
159
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
160
|
+
const header = headerOf(sentPackets[0]);
|
|
161
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
162
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
163
|
+
expect(header.contentHeader.case).toBe('textHeader');
|
|
164
|
+
|
|
165
|
+
for (let i = 0; i < 3; i += 1) {
|
|
166
|
+
expect(sentPackets[i + 1].value.case).toStrictEqual('streamChunk');
|
|
167
|
+
const chunk = chunkOf(sentPackets[i + 1]);
|
|
168
|
+
expect(chunk.streamId).toStrictEqual(info.id);
|
|
169
|
+
expect(chunk.chunkIndex).toStrictEqual(BigInt(i));
|
|
170
|
+
expect(chunk.content.every((char) => char === 'A'.charCodeAt(0))).toBeTruthy();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
expect(sentPackets[4].value.case).toStrictEqual('streamTrailer');
|
|
174
|
+
const trailer = trailerOf(sentPackets[4]);
|
|
175
|
+
expect(trailer.streamId).toStrictEqual(info.id);
|
|
176
|
+
expect(trailer.reason).toStrictEqual('');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should send long BYTE data stream without compression (happy path)', async () => {
|
|
180
|
+
const writer = await manager.streamBytes({
|
|
181
|
+
topic: 'my-topic',
|
|
182
|
+
});
|
|
183
|
+
await writer.write(new Uint8Array(20_000).fill(0x01));
|
|
184
|
+
await writer.write(new Uint8Array(20_000).fill(0x01));
|
|
185
|
+
await writer.close();
|
|
186
|
+
|
|
187
|
+
// Make sure five packets were received, matching the legacy v1 data stream format
|
|
188
|
+
expect(sentPackets).toHaveLength(6);
|
|
189
|
+
|
|
190
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
191
|
+
const header = headerOf(sentPackets[0]);
|
|
192
|
+
expect(header.streamId).toStrictEqual(writer.info.id);
|
|
193
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
194
|
+
expect(header.contentHeader.case).toBe('byteHeader');
|
|
195
|
+
|
|
196
|
+
// First write generates two packets, 15k long + 5k long
|
|
197
|
+
expect(sentPackets[1].value.case).toStrictEqual('streamChunk');
|
|
198
|
+
let chunk = chunkOf(sentPackets[1]);
|
|
199
|
+
expect(chunk.streamId).toStrictEqual(writer.info.id);
|
|
200
|
+
expect(chunk.chunkIndex).toStrictEqual(0n);
|
|
201
|
+
expect(chunk.content).toHaveLength(15_000); // MTU
|
|
202
|
+
expect(chunk.content.every((byte) => byte === 0x01)).toBeTruthy();
|
|
203
|
+
|
|
204
|
+
expect(sentPackets[2].value.case).toStrictEqual('streamChunk');
|
|
205
|
+
chunk = chunkOf(sentPackets[2]);
|
|
206
|
+
expect(chunk.streamId).toStrictEqual(writer.info.id);
|
|
207
|
+
expect(chunk.chunkIndex).toStrictEqual(1n);
|
|
208
|
+
expect(chunk.content).toHaveLength(5_000); // MTU
|
|
209
|
+
expect(chunk.content.every((byte) => byte === 0x01)).toBeTruthy();
|
|
210
|
+
|
|
211
|
+
// Second write generates two packets, 15k long + 5k long
|
|
212
|
+
expect(sentPackets[3].value.case).toStrictEqual('streamChunk');
|
|
213
|
+
chunk = chunkOf(sentPackets[3]);
|
|
214
|
+
expect(chunk.streamId).toStrictEqual(writer.info.id);
|
|
215
|
+
expect(chunk.chunkIndex).toStrictEqual(2n);
|
|
216
|
+
expect(chunk.content).toHaveLength(15_000);
|
|
217
|
+
expect(chunk.content.every((byte) => byte === 0x01)).toBeTruthy();
|
|
218
|
+
|
|
219
|
+
expect(sentPackets[4].value.case).toStrictEqual('streamChunk');
|
|
220
|
+
chunk = chunkOf(sentPackets[4]);
|
|
221
|
+
expect(chunk.streamId).toStrictEqual(writer.info.id);
|
|
222
|
+
expect(chunk.chunkIndex).toStrictEqual(3n);
|
|
223
|
+
expect(chunk.content).toHaveLength(5_000);
|
|
224
|
+
expect(chunk.content.every((byte) => byte === 0x01)).toBeTruthy();
|
|
225
|
+
|
|
226
|
+
expect(sentPackets[5].value.case).toStrictEqual('streamTrailer');
|
|
227
|
+
const trailer = trailerOf(sentPackets[5]);
|
|
228
|
+
expect(trailer.streamId).toStrictEqual(writer.info.id);
|
|
229
|
+
expect(trailer.reason).toStrictEqual('');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should send a FILE via sendFile without compression (happy path)', async () => {
|
|
233
|
+
const bytes = new Uint8Array(20_000).fill(0x07);
|
|
234
|
+
const info = await manager.sendFile(new File([bytes as NonSharedUint8Array], 'text.txt'), {
|
|
235
|
+
topic: 'my-topic',
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Pre-v2 recipients: uncompressed, multi-packet legacy format.
|
|
239
|
+
// 20k of data -> 15k + 5k chunks. 1 header + 2 chunks + 1 trailer = 4 packets.
|
|
240
|
+
expect(sentPackets).toHaveLength(4);
|
|
241
|
+
|
|
242
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
243
|
+
const header = headerOf(sentPackets[0]);
|
|
244
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
245
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
246
|
+
expect(header.contentHeader.case).toBe('byteHeader');
|
|
247
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
248
|
+
|
|
249
|
+
expect(sentPackets[1].value.case).toStrictEqual('streamChunk');
|
|
250
|
+
let chunk = chunkOf(sentPackets[1]);
|
|
251
|
+
expect(chunk.chunkIndex).toStrictEqual(0n);
|
|
252
|
+
expect(chunk.content).toHaveLength(15_000); // MTU
|
|
253
|
+
expect(chunk.content.every((byte) => byte === 0x07)).toBeTruthy();
|
|
254
|
+
|
|
255
|
+
expect(sentPackets[2].value.case).toStrictEqual('streamChunk');
|
|
256
|
+
chunk = chunkOf(sentPackets[2]);
|
|
257
|
+
expect(chunk.chunkIndex).toStrictEqual(1n);
|
|
258
|
+
expect(chunk.content).toHaveLength(5_000);
|
|
259
|
+
expect(chunk.content.every((byte) => byte === 0x07)).toBeTruthy();
|
|
260
|
+
|
|
261
|
+
expect(sentPackets[3].value.case).toStrictEqual('streamTrailer');
|
|
262
|
+
expect(trailerOf(sentPackets[3]).streamId).toStrictEqual(info.id);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
describe('v2 -> room of all v2', () => {
|
|
266
|
+
let manager: OutgoingDataStreamManager, sentPackets: Array<DataPacket>;
|
|
267
|
+
beforeEach(() => {
|
|
268
|
+
const result = createManager({
|
|
269
|
+
alice: CLIENT_PROTOCOL_DATA_STREAM_V2,
|
|
270
|
+
bob: CLIENT_PROTOCOL_DATA_STREAM_V2,
|
|
271
|
+
noCompression: [CLIENT_PROTOCOL_DATA_STREAM_V2, []],
|
|
272
|
+
});
|
|
273
|
+
manager = result.manager;
|
|
274
|
+
sentPackets = result.sentPackets;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should send short TEXT data stream with single packet and compression (happy path)', async () => {
|
|
278
|
+
const info = await manager.sendText('hello hello compressible world', {
|
|
279
|
+
topic: 'my-topic',
|
|
280
|
+
destinationIdentities: ['alice', 'bob'],
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Make sure one single packet was used, since data streams v2 + compression is supported
|
|
284
|
+
// across all participants
|
|
285
|
+
expect(sentPackets).toHaveLength(1);
|
|
286
|
+
|
|
287
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
288
|
+
const header = headerOf(sentPackets[0]);
|
|
289
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
290
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
291
|
+
expect(header.contentHeader.case).toBe('textHeader');
|
|
292
|
+
|
|
293
|
+
// Make sure the contents of that packet was compressed
|
|
294
|
+
expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW);
|
|
295
|
+
expect(header.inlineContent).toBeInstanceOf(Uint8Array);
|
|
296
|
+
expect(header.inlineContent).not.toStrictEqual(
|
|
297
|
+
new TextEncoder().encode('hello hello compressible world'),
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
it('should send short TEXT data stream with uncompressible payload in single packet', async () => {
|
|
301
|
+
const info = await manager.sendText('short', {
|
|
302
|
+
topic: 'my-topic',
|
|
303
|
+
destinationIdentities: ['alice', 'bob'],
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Make sure one single packet was used, since data streams v2 + compression is supported
|
|
307
|
+
// across all participants
|
|
308
|
+
expect(sentPackets).toHaveLength(1);
|
|
309
|
+
|
|
310
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
311
|
+
const header = headerOf(sentPackets[0]);
|
|
312
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
313
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
314
|
+
expect(header.contentHeader.case).toBe('textHeader');
|
|
315
|
+
|
|
316
|
+
// Make sure the contents of that packet was uncompressed - "short" isn't long enough to
|
|
317
|
+
// meaningfully compress with DEFLATE
|
|
318
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
319
|
+
expect(header.inlineContent).toStrictEqual(new TextEncoder().encode('short'));
|
|
320
|
+
});
|
|
321
|
+
it('should send short data stream with single packet and NO compression if remote participant does not support compression', async () => {
|
|
322
|
+
const info = await manager.sendText('hello hello compressible world', {
|
|
323
|
+
topic: 'my-topic',
|
|
324
|
+
destinationIdentities: ['noCompression'],
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Make sure one single packet was used, since data streams v2 is supported for that
|
|
328
|
+
// participant.
|
|
329
|
+
expect(sentPackets).toHaveLength(1);
|
|
330
|
+
|
|
331
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
332
|
+
const header = headerOf(sentPackets[0]);
|
|
333
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
334
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
335
|
+
expect(header.contentHeader.case).toBe('textHeader');
|
|
336
|
+
|
|
337
|
+
// Make sure the contents of that packet was NOT compressed
|
|
338
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
339
|
+
expect(header.inlineContent).toStrictEqual(
|
|
340
|
+
new TextEncoder().encode('hello hello compressible world'),
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
it('should send long but highly compressible TEXT data stream as single packet', async () => {
|
|
344
|
+
// A phrase which repeats over and over should compress extremely well.
|
|
345
|
+
const text = new Array(20_000).fill('hello world').join('');
|
|
346
|
+
|
|
347
|
+
const info = await manager.sendText(text, {
|
|
348
|
+
topic: 'my-topic',
|
|
349
|
+
destinationIdentities: ['alice', 'bob'],
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// Make sure one single packet was used, since data streams v2 is supported and the contents
|
|
353
|
+
// should be able to be highly compressed to be well under the 15k MTU
|
|
354
|
+
expect(sentPackets).toHaveLength(1);
|
|
355
|
+
|
|
356
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
357
|
+
const header = headerOf(sentPackets[0]);
|
|
358
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
359
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
360
|
+
expect(header.contentHeader.case).toBe('textHeader');
|
|
361
|
+
|
|
362
|
+
// Make sure the contents of that packet was compressed
|
|
363
|
+
expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW);
|
|
364
|
+
expect(header.inlineContent).toBeInstanceOf(Uint8Array);
|
|
365
|
+
// Compressed bytes must not begin with the raw UTF-8 prefix of the payload.
|
|
366
|
+
const helloWorld = new TextEncoder().encode('hello world');
|
|
367
|
+
expect(header.inlineContent!.slice(0, helloWorld.length)).not.toStrictEqual(helloWorld);
|
|
368
|
+
});
|
|
369
|
+
it('should send long but somewhat compressible data stream as a compressed multi packet data stream', async () => {
|
|
370
|
+
// Mostly incompressible, but the hello world parts repeating should mean that the compressed
|
|
371
|
+
// contents is smaller than the full uncompressed data.
|
|
372
|
+
const text = new Array(50)
|
|
373
|
+
.fill(null)
|
|
374
|
+
.map(() => `hello world${randomText(1_000)}`)
|
|
375
|
+
.join('');
|
|
376
|
+
|
|
377
|
+
const info = await manager.sendText(text, {
|
|
378
|
+
topic: 'my-topic',
|
|
379
|
+
destinationIdentities: ['alice', 'bob'],
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// 1 header + 3 data packets + 1 trailer = 5 total packets
|
|
383
|
+
//
|
|
384
|
+
// 3 data packets is less than the Math.ceil(~50k / 15k) = 4 packets that would be
|
|
385
|
+
// required if data was uncompressed
|
|
386
|
+
expect(sentPackets).toHaveLength(5);
|
|
387
|
+
|
|
388
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
389
|
+
const header = headerOf(sentPackets[0]);
|
|
390
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
391
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
392
|
+
expect(header.contentHeader.case).toBe('textHeader');
|
|
393
|
+
|
|
394
|
+
// Make sure the contents of that packet was compressed
|
|
395
|
+
expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW);
|
|
396
|
+
|
|
397
|
+
// Verify there are three data packets:
|
|
398
|
+
expect(sentPackets[1].value.case).toStrictEqual('streamChunk');
|
|
399
|
+
let chunk = chunkOf(sentPackets[1]);
|
|
400
|
+
expect(chunk.streamId).toStrictEqual(info.id);
|
|
401
|
+
expect(chunk.chunkIndex).toStrictEqual(0n);
|
|
402
|
+
expect(chunk.content).toHaveLength(15_000); // MTU
|
|
403
|
+
|
|
404
|
+
expect(sentPackets[2].value.case).toStrictEqual('streamChunk');
|
|
405
|
+
expect(sentPackets[3].value.case).toStrictEqual('streamChunk');
|
|
406
|
+
|
|
407
|
+
// Final packet should be a trailer
|
|
408
|
+
expect(sentPackets[4].value.case).toStrictEqual('streamTrailer');
|
|
409
|
+
const trailer = trailerOf(sentPackets[4]);
|
|
410
|
+
expect(trailer.streamId).toStrictEqual(info.id);
|
|
411
|
+
expect(trailer.reason).toStrictEqual('');
|
|
412
|
+
});
|
|
413
|
+
it('should send long, uncompressible data stream as a compressed multi packet data stream', async () => {
|
|
414
|
+
// This is random data which should be uncompressible
|
|
415
|
+
const bytes = randomBytes(50_000);
|
|
416
|
+
const info = await manager.sendFile(new File([bytes as NonSharedUint8Array], 'text.txt'), {
|
|
417
|
+
topic: 'my-topic',
|
|
418
|
+
destinationIdentities: ['alice', 'bob'],
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// Math.ceil(~50k / 15k) = 4 data packets
|
|
422
|
+
// 1 header + 4 data packets + 1 trailer = 6 total packets
|
|
423
|
+
expect(sentPackets).toHaveLength(6);
|
|
424
|
+
|
|
425
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
426
|
+
const header = headerOf(sentPackets[0]);
|
|
427
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
428
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
429
|
+
expect(header.contentHeader.case).toBe('byteHeader');
|
|
430
|
+
|
|
431
|
+
// Make sure the contents of that packet was NOT compressed
|
|
432
|
+
expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW);
|
|
433
|
+
|
|
434
|
+
// Verify there are four data packets:
|
|
435
|
+
let totalLength = 0;
|
|
436
|
+
expect(sentPackets[1].value.case).toStrictEqual('streamChunk');
|
|
437
|
+
let chunk = chunkOf(sentPackets[1]);
|
|
438
|
+
expect(chunk.streamId).toStrictEqual(info.id);
|
|
439
|
+
expect(chunk.chunkIndex).toStrictEqual(0n);
|
|
440
|
+
expect(chunk.content).toHaveLength(15_000); // MTU
|
|
441
|
+
totalLength += chunk.content.byteLength;
|
|
442
|
+
|
|
443
|
+
expect(sentPackets[2].value.case).toStrictEqual('streamChunk');
|
|
444
|
+
chunk = chunkOf(sentPackets[2]);
|
|
445
|
+
totalLength += chunk.content.byteLength;
|
|
446
|
+
|
|
447
|
+
expect(sentPackets[3].value.case).toStrictEqual('streamChunk');
|
|
448
|
+
chunk = chunkOf(sentPackets[3]);
|
|
449
|
+
totalLength += chunk.content.byteLength;
|
|
450
|
+
|
|
451
|
+
expect(sentPackets[4].value.case).toStrictEqual('streamChunk');
|
|
452
|
+
chunk = chunkOf(sentPackets[4]);
|
|
453
|
+
totalLength += chunk.content.byteLength;
|
|
454
|
+
|
|
455
|
+
// Make sure total length is LARGER than the raw bytes length (only slightly, due to the extra
|
|
456
|
+
// DEFLATE metadata being added to an otherwise incompressible binary blob)
|
|
457
|
+
//
|
|
458
|
+
// This is sort of unfortunate that this happens, but the tradeoff to this slight size bump is
|
|
459
|
+
// that the whole binary doesn't have to be buffered into memory all at once.
|
|
460
|
+
expect(totalLength).toBeGreaterThan(bytes.byteLength);
|
|
461
|
+
|
|
462
|
+
// Final packet should be a trailer
|
|
463
|
+
expect(sentPackets[5].value.case).toStrictEqual('streamTrailer');
|
|
464
|
+
const trailer = trailerOf(sentPackets[5]);
|
|
465
|
+
expect(trailer.streamId).toStrictEqual(info.id);
|
|
466
|
+
expect(trailer.reason).toStrictEqual('');
|
|
467
|
+
});
|
|
468
|
+
it('should send short data stream with single packet but skip compression due to compress: false being passed', async () => {
|
|
469
|
+
const info = await manager.sendText('hello hello compressible world', {
|
|
470
|
+
topic: 'my-topic',
|
|
471
|
+
destinationIdentities: ['alice', 'bob'],
|
|
472
|
+
compress: false,
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// Make sure one single packet was used, since data streams v2 is supported across all participants
|
|
476
|
+
expect(sentPackets).toHaveLength(1);
|
|
477
|
+
|
|
478
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
479
|
+
const header = headerOf(sentPackets[0]);
|
|
480
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
481
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
482
|
+
expect(header.contentHeader.case).toBe('textHeader');
|
|
483
|
+
|
|
484
|
+
// Make sure the contents of that packet was NOT compressed (compress: false opt-out)
|
|
485
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
486
|
+
expect(header.inlineContent).toStrictEqual(
|
|
487
|
+
new TextEncoder().encode('hello hello compressible world'),
|
|
488
|
+
);
|
|
489
|
+
});
|
|
490
|
+
it('should send long but somewhat compressible data stream but skip compression due to compress: false being passed', async () => {
|
|
491
|
+
// Mostly incompressible, but the hello world parts repeating should mean that the compressed
|
|
492
|
+
// contents is smaller than the full uncompressed data.
|
|
493
|
+
const text = new Array(50)
|
|
494
|
+
.fill(null)
|
|
495
|
+
.map(() => `hello world${randomText(1_000)}`)
|
|
496
|
+
.join('');
|
|
497
|
+
|
|
498
|
+
const info = await manager.sendText(text, {
|
|
499
|
+
topic: 'my-topic',
|
|
500
|
+
destinationIdentities: ['alice', 'bob'],
|
|
501
|
+
compress: false,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// Math.ceil(~50k / 15k) = 4 data packets
|
|
505
|
+
// 1 header + 4 data packets + 1 trailer = 6 total packets
|
|
506
|
+
expect(sentPackets).toHaveLength(6);
|
|
507
|
+
|
|
508
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
509
|
+
const header = headerOf(sentPackets[0]);
|
|
510
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
511
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
512
|
+
expect(header.contentHeader.case).toBe('textHeader');
|
|
513
|
+
|
|
514
|
+
// Make sure the contents of that packet was uncompressed
|
|
515
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
516
|
+
|
|
517
|
+
// Verify there are four data packets:
|
|
518
|
+
expect(sentPackets[1].value.case).toStrictEqual('streamChunk');
|
|
519
|
+
let chunk = chunkOf(sentPackets[1]);
|
|
520
|
+
expect(chunk.streamId).toStrictEqual(info.id);
|
|
521
|
+
expect(chunk.chunkIndex).toStrictEqual(0n);
|
|
522
|
+
expect(chunk.content).toHaveLength(15_000); // MTU
|
|
523
|
+
|
|
524
|
+
expect(sentPackets[2].value.case).toStrictEqual('streamChunk');
|
|
525
|
+
expect(sentPackets[3].value.case).toStrictEqual('streamChunk');
|
|
526
|
+
expect(sentPackets[4].value.case).toStrictEqual('streamChunk');
|
|
527
|
+
|
|
528
|
+
// Final packet should be a trailer
|
|
529
|
+
expect(sentPackets[5].value.case).toStrictEqual('streamTrailer');
|
|
530
|
+
const trailer = trailerOf(sentPackets[5]);
|
|
531
|
+
expect(trailer.streamId).toStrictEqual(info.id);
|
|
532
|
+
expect(trailer.reason).toStrictEqual('');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('should NEVER use compression or single packet data streams with streamText', async () => {
|
|
536
|
+
const writer = await manager.streamText({
|
|
537
|
+
topic: 'my-topic',
|
|
538
|
+
destinationIdentities: ['noCompression'],
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// Make sure the header packet was sent
|
|
542
|
+
expect(sentPackets).toHaveLength(1);
|
|
543
|
+
|
|
544
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
545
|
+
const header = headerOf(sentPackets[0]);
|
|
546
|
+
expect(header.streamId).toStrictEqual(writer.info.id);
|
|
547
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
548
|
+
expect(header.contentHeader.case).toBe('textHeader');
|
|
549
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE); // Make sure compression is disabled
|
|
550
|
+
|
|
551
|
+
await writer.write('hello world');
|
|
552
|
+
|
|
553
|
+
// Make sure a single chunk packet was emitted
|
|
554
|
+
expect(sentPackets).toHaveLength(2 /* 1 header + 1 chunk */);
|
|
555
|
+
|
|
556
|
+
expect(sentPackets[1].value.case).toBe('streamChunk');
|
|
557
|
+
const chunk = chunkOf(sentPackets[1]);
|
|
558
|
+
expect(chunk.streamId).toStrictEqual(writer.info.id);
|
|
559
|
+
expect(chunk.content).toStrictEqual(new TextEncoder().encode('hello world'));
|
|
560
|
+
|
|
561
|
+
await writer.close();
|
|
562
|
+
|
|
563
|
+
// Finally, verify the trailer
|
|
564
|
+
expect(sentPackets).toHaveLength(3 /* 1 header + 1 chunk + 1 trailer */);
|
|
565
|
+
expect(sentPackets[2].value.case).toBe('streamTrailer');
|
|
566
|
+
});
|
|
567
|
+
it('should NEVER use compression or single packet data streams with streamBytes', async () => {
|
|
568
|
+
const writer = await manager.streamBytes({
|
|
569
|
+
topic: 'my-topic',
|
|
570
|
+
destinationIdentities: ['noCompression'],
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// Make sure the header packet was sent
|
|
574
|
+
expect(sentPackets).toHaveLength(1);
|
|
575
|
+
|
|
576
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
577
|
+
const header = headerOf(sentPackets[0]);
|
|
578
|
+
expect(header.streamId).toStrictEqual(writer.info.id);
|
|
579
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
580
|
+
expect(header.contentHeader.case).toBe('byteHeader');
|
|
581
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE); // Make sure compression is disabled
|
|
582
|
+
|
|
583
|
+
await writer.write(new Uint8Array([0x00, 0x01, 0x02, 0x03]));
|
|
584
|
+
|
|
585
|
+
// Make sure a single chunk packet was emitted
|
|
586
|
+
expect(sentPackets).toHaveLength(2 /* 1 header + 1 chunk */);
|
|
587
|
+
|
|
588
|
+
expect(sentPackets[1].value.case).toBe('streamChunk');
|
|
589
|
+
const chunk = chunkOf(sentPackets[1]);
|
|
590
|
+
expect(chunk.streamId).toStrictEqual(writer.info.id);
|
|
591
|
+
expect(chunk.content).toStrictEqual(new Uint8Array([0x00, 0x01, 0x02, 0x03]));
|
|
592
|
+
|
|
593
|
+
await writer.close();
|
|
594
|
+
|
|
595
|
+
// Finally, verify the trailer
|
|
596
|
+
expect(sentPackets).toHaveLength(3 /* 1 header + 1 chunk + 1 trailer */);
|
|
597
|
+
expect(sentPackets[2].value.case).toBe('streamTrailer');
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('should NOT send bytes single packet with sendFile', async () => {
|
|
601
|
+
// This is random data which should be uncompressible
|
|
602
|
+
const bytes = new Uint8Array(10_000).fill(0x01);
|
|
603
|
+
const info = await manager.sendFile(new File([bytes as NonSharedUint8Array], 'text.txt'), {
|
|
604
|
+
topic: 'my-topic',
|
|
605
|
+
destinationIdentities: ['alice', 'bob'],
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// Should be a multi-packet result
|
|
609
|
+
//
|
|
610
|
+
// Sending single packet data streams for files is tricky because it's really difficult to
|
|
611
|
+
// determine ahead of time if a file can fit into a single packet without a ton of ahead of
|
|
612
|
+
// time in memory buffering.
|
|
613
|
+
expect(sentPackets).toHaveLength(3);
|
|
614
|
+
|
|
615
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
616
|
+
const header = headerOf(sentPackets[0]);
|
|
617
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
618
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
619
|
+
expect(header.contentHeader.case).toBe('byteHeader');
|
|
620
|
+
|
|
621
|
+
// Make sure the contents of that packet was NOT compressed
|
|
622
|
+
expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW);
|
|
623
|
+
|
|
624
|
+
expect(sentPackets[1].value.case).toStrictEqual('streamChunk');
|
|
625
|
+
let chunk = chunkOf(sentPackets[1]);
|
|
626
|
+
expect(chunk.streamId).toStrictEqual(info.id);
|
|
627
|
+
expect(chunk.chunkIndex).toStrictEqual(0n);
|
|
628
|
+
|
|
629
|
+
// Make sure contents were compressed
|
|
630
|
+
expect(chunk.content.byteLength).toBeLessThan(bytes.byteLength);
|
|
631
|
+
|
|
632
|
+
// Final packet should be a trailer
|
|
633
|
+
expect(sentPackets[2].value.case).toStrictEqual('streamTrailer');
|
|
634
|
+
const trailer = trailerOf(sentPackets[2]);
|
|
635
|
+
expect(trailer.streamId).toStrictEqual(info.id);
|
|
636
|
+
expect(trailer.reason).toStrictEqual('');
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it('should send a FILE via sendFile WITHOUT compression if remote does not support compression', async () => {
|
|
640
|
+
const bytes = new Uint8Array(10_000).fill(0x07);
|
|
641
|
+
const info = await manager.sendFile(new File([bytes as NonSharedUint8Array], 'text.txt'), {
|
|
642
|
+
topic: 'my-topic',
|
|
643
|
+
destinationIdentities: ['noCompression'],
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// v2 recipient but no deflate-raw capability: uncompressed, multi-packet (never inline).
|
|
647
|
+
expect(sentPackets).toHaveLength(3);
|
|
648
|
+
|
|
649
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
650
|
+
const header = headerOf(sentPackets[0]);
|
|
651
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
652
|
+
expect(header.contentHeader.case).toBe('byteHeader');
|
|
653
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
654
|
+
expect(header.inlineContent).toBeUndefined();
|
|
655
|
+
|
|
656
|
+
expect(sentPackets[1].value.case).toStrictEqual('streamChunk');
|
|
657
|
+
const chunk = chunkOf(sentPackets[1]);
|
|
658
|
+
expect(chunk.chunkIndex).toStrictEqual(0n);
|
|
659
|
+
expect(chunk.content).toHaveLength(10_000); // uncompressed, single chunk under the MTU
|
|
660
|
+
expect(chunk.content.every((byte) => byte === 0x07)).toBeTruthy();
|
|
661
|
+
|
|
662
|
+
expect(sentPackets[2].value.case).toStrictEqual('streamTrailer');
|
|
663
|
+
expect(trailerOf(sentPackets[2]).streamId).toStrictEqual(info.id);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('should send an empty FILE via sendFile', async () => {
|
|
667
|
+
const info = await manager.sendFile(new File([], 'empty.bin'), {
|
|
668
|
+
topic: 'my-topic',
|
|
669
|
+
destinationIdentities: ['alice', 'bob'],
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// An empty file still produces a well-formed compressed byte stream: a header declaring zero
|
|
673
|
+
// length, the deflate stream's final block, and a trailer.
|
|
674
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
675
|
+
const header = headerOf(sentPackets[0]);
|
|
676
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
677
|
+
expect(header.contentHeader.case).toBe('byteHeader');
|
|
678
|
+
expect(header.totalLength).toStrictEqual(0n);
|
|
679
|
+
expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW);
|
|
680
|
+
|
|
681
|
+
const last = sentPackets[sentPackets.length - 1];
|
|
682
|
+
expect(last.value.case).toStrictEqual('streamTrailer');
|
|
683
|
+
expect(trailerOf(last).streamId).toStrictEqual(info.id);
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
describe('v2 -> room of mixed v1 / v2', () => {
|
|
687
|
+
let manager: OutgoingDataStreamManager, sentPackets: Array<DataPacket>;
|
|
688
|
+
beforeEach(() => {
|
|
689
|
+
const result = createManager({
|
|
690
|
+
alice: CLIENT_PROTOCOL_DEFAULT,
|
|
691
|
+
bob: CLIENT_PROTOCOL_DATA_STREAM_V2,
|
|
692
|
+
jim: CLIENT_PROTOCOL_DATA_STREAM_V2,
|
|
693
|
+
mallory: CLIENT_PROTOCOL_DATA_STREAM_RPC,
|
|
694
|
+
noCompression: [CLIENT_PROTOCOL_DATA_STREAM_V2, []],
|
|
695
|
+
});
|
|
696
|
+
manager = result.manager;
|
|
697
|
+
sentPackets = result.sentPackets;
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it('should send data stream using v1 legacy data stream format in room of mixed v1/v2', async () => {
|
|
701
|
+
const info = await manager.sendText('hello world', {
|
|
702
|
+
topic: 'my-topic',
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
// Make sure three packets were received, matching the legacy v1 data stream format
|
|
706
|
+
expect(sentPackets).toHaveLength(3);
|
|
707
|
+
|
|
708
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
709
|
+
const header = headerOf(sentPackets[0]);
|
|
710
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
711
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
712
|
+
expect(header.contentHeader.case).toBe('textHeader');
|
|
713
|
+
|
|
714
|
+
expect(sentPackets[1].value.case).toStrictEqual('streamChunk');
|
|
715
|
+
const chunk = chunkOf(sentPackets[1]);
|
|
716
|
+
expect(chunk.streamId).toStrictEqual(info.id);
|
|
717
|
+
expect(chunk.chunkIndex).toStrictEqual(0n);
|
|
718
|
+
expect(chunk.content).toStrictEqual(new TextEncoder().encode('hello world'));
|
|
719
|
+
|
|
720
|
+
expect(sentPackets[2].value.case).toStrictEqual('streamTrailer');
|
|
721
|
+
const trailer = trailerOf(sentPackets[2]);
|
|
722
|
+
expect(trailer.streamId).toStrictEqual(info.id);
|
|
723
|
+
expect(trailer.reason).toStrictEqual('');
|
|
724
|
+
});
|
|
725
|
+
it('should send data stream using data stream v2 format + compression when only sending to a subset of participants that are all v2', async () => {
|
|
726
|
+
const info = await manager.sendText('hello hello compressible world', {
|
|
727
|
+
topic: 'my-topic',
|
|
728
|
+
destinationIdentities: ['bob', 'jim'],
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// Make sure one single packet was used, since data streams v2 + compression is supported
|
|
732
|
+
// across bob + jim
|
|
733
|
+
expect(sentPackets).toHaveLength(1);
|
|
734
|
+
|
|
735
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
736
|
+
const header = headerOf(sentPackets[0]);
|
|
737
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
738
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
739
|
+
expect(header.contentHeader.case).toBe('textHeader');
|
|
740
|
+
|
|
741
|
+
// Make sure the contents of that packet was compressed
|
|
742
|
+
expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW);
|
|
743
|
+
expect(header.inlineContent).toBeInstanceOf(Uint8Array);
|
|
744
|
+
expect(header.inlineContent).not.toStrictEqual(
|
|
745
|
+
new TextEncoder().encode('hello hello compressible world'),
|
|
746
|
+
);
|
|
747
|
+
});
|
|
748
|
+
it('should send data stream using data stream v2 format but NO compression when only sending to a subset of participants where one does NOT support compression', async () => {
|
|
749
|
+
const info = await manager.sendText('hello hello compressible world', {
|
|
750
|
+
topic: 'my-topic',
|
|
751
|
+
destinationIdentities: ['bob', 'jim', 'noCompression'],
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// Make sure one single packet was used, since data streams v2 + compression is supported
|
|
755
|
+
// across bob + jim
|
|
756
|
+
expect(sentPackets).toHaveLength(1);
|
|
757
|
+
|
|
758
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
759
|
+
const header = headerOf(sentPackets[0]);
|
|
760
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
761
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
762
|
+
expect(header.contentHeader.case).toBe('textHeader');
|
|
763
|
+
|
|
764
|
+
// Make sure the contents of that packet was compressed
|
|
765
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
766
|
+
expect(header.inlineContent).toStrictEqual(
|
|
767
|
+
new TextEncoder().encode('hello hello compressible world'),
|
|
768
|
+
);
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
describe('sendBytes', () => {
|
|
773
|
+
describe('v2 -> room of all v2', () => {
|
|
774
|
+
let manager: OutgoingDataStreamManager, sentPackets: Array<DataPacket>;
|
|
775
|
+
beforeEach(() => {
|
|
776
|
+
const result = createManager({
|
|
777
|
+
alice: CLIENT_PROTOCOL_DATA_STREAM_V2,
|
|
778
|
+
bob: CLIENT_PROTOCOL_DATA_STREAM_V2,
|
|
779
|
+
noCompression: [CLIENT_PROTOCOL_DATA_STREAM_V2, []],
|
|
780
|
+
});
|
|
781
|
+
manager = result.manager;
|
|
782
|
+
sentPackets = result.sentPackets;
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it('should send short compressible BYTE payload as a single compressed packet (happy path)', async () => {
|
|
786
|
+
const bytes = new TextEncoder().encode('hello hello compressible world');
|
|
787
|
+
const info = await manager.sendBytes(bytes, {
|
|
788
|
+
topic: 'my-topic',
|
|
789
|
+
attributes: { foo: 'bar' },
|
|
790
|
+
destinationIdentities: ['alice', 'bob'],
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
expect(sentPackets).toHaveLength(1);
|
|
794
|
+
expect(sentPackets[0].value.case).toBe('streamHeader');
|
|
795
|
+
const header = headerOf(sentPackets[0]);
|
|
796
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
797
|
+
expect(header.topic).toStrictEqual('my-topic');
|
|
798
|
+
expect(header.contentHeader.case).toBe('byteHeader');
|
|
799
|
+
|
|
800
|
+
// Compressed inline payload
|
|
801
|
+
expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW);
|
|
802
|
+
expect(header.inlineContent).toBeInstanceOf(Uint8Array);
|
|
803
|
+
expect(header.inlineContent).not.toStrictEqual(bytes);
|
|
804
|
+
|
|
805
|
+
// Returned info uses the byte-stream defaults
|
|
806
|
+
expect(info.name).toStrictEqual('unknown');
|
|
807
|
+
expect(info.mimeType).toStrictEqual('application/octet-stream');
|
|
808
|
+
expect(info.size).toStrictEqual(bytes.byteLength);
|
|
809
|
+
expect(info.attributes).toStrictEqual({ foo: 'bar' });
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it('should send short uncompressible BYTE payload inline without compression', async () => {
|
|
813
|
+
const bytes = new Uint8Array([0x00, 0x01, 0x02, 0x03]);
|
|
814
|
+
const info = await manager.sendBytes(bytes, {
|
|
815
|
+
topic: 'my-topic',
|
|
816
|
+
destinationIdentities: ['alice', 'bob'],
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
expect(sentPackets).toHaveLength(1);
|
|
820
|
+
const header = headerOf(sentPackets[0]);
|
|
821
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
822
|
+
expect(header.contentHeader.case).toBe('byteHeader');
|
|
823
|
+
// Tiny payload doesn't shrink under DEFLATE framing, so it's sent raw.
|
|
824
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
825
|
+
expect(header.inlineContent).toStrictEqual(bytes);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it('should send BYTE payload inline with NO compression if a recipient does not support compression', async () => {
|
|
829
|
+
const bytes = new TextEncoder().encode('hello hello compressible world');
|
|
830
|
+
const info = await manager.sendBytes(bytes, {
|
|
831
|
+
topic: 'my-topic',
|
|
832
|
+
destinationIdentities: ['noCompression'],
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// Inline still applies (gated on v2 alone), but compression is skipped.
|
|
836
|
+
expect(sentPackets).toHaveLength(1);
|
|
837
|
+
const header = headerOf(sentPackets[0]);
|
|
838
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
839
|
+
expect(header.contentHeader.case).toBe('byteHeader');
|
|
840
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
841
|
+
expect(header.inlineContent).toStrictEqual(bytes);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it('should send long, highly compressible BYTE payload as a single compressed packet', async () => {
|
|
845
|
+
// Repeating bytes compress well under the 15k MTU even though the raw payload is 50k.
|
|
846
|
+
const bytes = new Uint8Array(50_000).fill(0x01);
|
|
847
|
+
const info = await manager.sendBytes(bytes, {
|
|
848
|
+
topic: 'my-topic',
|
|
849
|
+
destinationIdentities: ['alice', 'bob'],
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
expect(sentPackets).toHaveLength(1);
|
|
853
|
+
const header = headerOf(sentPackets[0]);
|
|
854
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
855
|
+
expect(header.contentHeader.case).toBe('byteHeader');
|
|
856
|
+
expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW);
|
|
857
|
+
expect(header.inlineContent).toBeInstanceOf(Uint8Array);
|
|
858
|
+
expect(header.inlineContent!.byteLength).toBeLessThan(bytes.byteLength);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it('should send long but somewhat compressible BYTE payload as a compressed multi packet stream', async () => {
|
|
862
|
+
const bytes = new TextEncoder().encode(
|
|
863
|
+
new Array(50)
|
|
864
|
+
.fill(null)
|
|
865
|
+
.map(() => `hello world${randomText(1_000)}`)
|
|
866
|
+
.join(''),
|
|
867
|
+
);
|
|
868
|
+
const info = await manager.sendBytes(bytes, {
|
|
869
|
+
topic: 'my-topic',
|
|
870
|
+
destinationIdentities: ['alice', 'bob'],
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
// 1 header + 3 chunks + 1 trailer (compressed ~45k -> 3 MTU chunks)
|
|
874
|
+
expect(sentPackets).toHaveLength(5);
|
|
875
|
+
const header = headerOf(sentPackets[0]);
|
|
876
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
877
|
+
expect(header.contentHeader.case).toBe('byteHeader');
|
|
878
|
+
expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW);
|
|
879
|
+
expect(header.inlineContent).toBeUndefined();
|
|
880
|
+
|
|
881
|
+
expect(sentPackets[1].value.case).toStrictEqual('streamChunk');
|
|
882
|
+
const chunk = chunkOf(sentPackets[1]);
|
|
883
|
+
expect(chunk.chunkIndex).toStrictEqual(0n);
|
|
884
|
+
expect(chunk.content).toHaveLength(15_000); // MTU
|
|
885
|
+
expect(sentPackets[2].value.case).toStrictEqual('streamChunk');
|
|
886
|
+
expect(sentPackets[3].value.case).toStrictEqual('streamChunk');
|
|
887
|
+
expect(sentPackets[4].value.case).toStrictEqual('streamTrailer');
|
|
888
|
+
expect(trailerOf(sentPackets[4]).streamId).toStrictEqual(info.id);
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it('should skip compression for an inline payload when compress: false', async () => {
|
|
892
|
+
const bytes = new TextEncoder().encode('hello hello compressible world');
|
|
893
|
+
const info = await manager.sendBytes(bytes, {
|
|
894
|
+
topic: 'my-topic',
|
|
895
|
+
destinationIdentities: ['alice', 'bob'],
|
|
896
|
+
compress: false,
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
expect(sentPackets).toHaveLength(1);
|
|
900
|
+
const header = headerOf(sentPackets[0]);
|
|
901
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
902
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
903
|
+
expect(header.inlineContent).toStrictEqual(bytes);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it('should send an uncompressed multi packet stream when compress: false and payload exceeds the MTU', async () => {
|
|
907
|
+
const bytes = new Uint8Array(40_000).fill(0x07);
|
|
908
|
+
const info = await manager.sendBytes(bytes, {
|
|
909
|
+
topic: 'my-topic',
|
|
910
|
+
destinationIdentities: ['alice', 'bob'],
|
|
911
|
+
compress: false,
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
// 40k -> 15k + 15k + 10k chunks. 1 header + 3 chunks + 1 trailer = 5 packets.
|
|
915
|
+
expect(sentPackets).toHaveLength(5);
|
|
916
|
+
const header = headerOf(sentPackets[0]);
|
|
917
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
918
|
+
expect(header.contentHeader.case).toBe('byteHeader');
|
|
919
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
920
|
+
expect(header.inlineContent).toBeUndefined();
|
|
921
|
+
|
|
922
|
+
expect(sentPackets[1].value.case).toStrictEqual('streamChunk');
|
|
923
|
+
let chunk = chunkOf(sentPackets[1]);
|
|
924
|
+
expect(chunk.chunkIndex).toStrictEqual(0n);
|
|
925
|
+
expect(chunk.content).toHaveLength(15_000);
|
|
926
|
+
expect(chunk.content.every((byte) => byte === 0x07)).toBeTruthy();
|
|
927
|
+
chunk = chunkOf(sentPackets[3]);
|
|
928
|
+
expect(chunk.content).toHaveLength(10_000);
|
|
929
|
+
expect(sentPackets[4].value.case).toStrictEqual('streamTrailer');
|
|
930
|
+
});
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
describe('v2 -> room of all v1', () => {
|
|
934
|
+
it('should send a legacy uncompressed multi packet stream', async () => {
|
|
935
|
+
const { manager, sentPackets } = createManager({
|
|
936
|
+
alice: CLIENT_PROTOCOL_DEFAULT,
|
|
937
|
+
jim: CLIENT_PROTOCOL_DATA_STREAM_RPC,
|
|
938
|
+
});
|
|
939
|
+
const bytes = new Uint8Array([0x00, 0x01, 0x02, 0x03]);
|
|
940
|
+
const info = await manager.sendBytes(bytes, { topic: 'my-topic' });
|
|
941
|
+
|
|
942
|
+
// No v2 recipients: header + chunk + trailer, never inline, never compressed.
|
|
943
|
+
expect(sentPackets).toHaveLength(3);
|
|
944
|
+
const header = headerOf(sentPackets[0]);
|
|
945
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
946
|
+
expect(header.contentHeader.case).toBe('byteHeader');
|
|
947
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
948
|
+
expect(header.inlineContent).toBeUndefined();
|
|
949
|
+
|
|
950
|
+
expect(sentPackets[1].value.case).toStrictEqual('streamChunk');
|
|
951
|
+
const chunk = chunkOf(sentPackets[1]);
|
|
952
|
+
expect(chunk.chunkIndex).toStrictEqual(0n);
|
|
953
|
+
expect(chunk.content).toStrictEqual(bytes);
|
|
954
|
+
expect(sentPackets[2].value.case).toStrictEqual('streamTrailer');
|
|
955
|
+
expect(trailerOf(sentPackets[2]).streamId).toStrictEqual(info.id);
|
|
956
|
+
});
|
|
957
|
+
});
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
describe('header size limit (MTU)', () => {
|
|
961
|
+
// Attributes large enough that the serialized header packet alone exceeds
|
|
962
|
+
// STREAM_CHUNK_SIZE_BYTES (15 000).
|
|
963
|
+
const hugeAttributes = { big: 'x'.repeat(20_000) };
|
|
964
|
+
|
|
965
|
+
it('should throw HeaderTooLarge when sending a chunked TEXT stream with oversized attributes (pre-v2 room)', async () => {
|
|
966
|
+
const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DEFAULT });
|
|
967
|
+
|
|
968
|
+
await expect(
|
|
969
|
+
manager.sendText('hello world', { topic: 'my-topic', attributes: hugeAttributes }),
|
|
970
|
+
).rejects.toThrow('exceeds');
|
|
971
|
+
|
|
972
|
+
// The error must be raised before anything hits the wire.
|
|
973
|
+
expect(sentPackets).toHaveLength(0);
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it('should throw HeaderTooLarge when oversized attributes force the inline path to fall back (v2 room)', async () => {
|
|
977
|
+
const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DATA_STREAM_V2 });
|
|
978
|
+
|
|
979
|
+
// The payload is small, but the attributes alone blow the MTU budget: the inline attempt
|
|
980
|
+
// falls through gracefully (no throw), and then the chunked header send enforces the hard
|
|
981
|
+
// limit — per spec, large *attributes* throw while a large *payload* falls back fine.
|
|
982
|
+
await expect(
|
|
983
|
+
manager.sendText('hello hello compressible world', {
|
|
984
|
+
topic: 'my-topic',
|
|
985
|
+
destinationIdentities: ['alice'],
|
|
986
|
+
attributes: hugeAttributes,
|
|
987
|
+
}),
|
|
988
|
+
).rejects.toThrow('exceeds');
|
|
989
|
+
|
|
990
|
+
expect(sentPackets).toHaveLength(0);
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
it('should throw HeaderTooLarge when opening a streamText writer with oversized attributes', async () => {
|
|
994
|
+
const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DATA_STREAM_V2 });
|
|
995
|
+
|
|
996
|
+
await expect(
|
|
997
|
+
manager.streamText({ topic: 'my-topic', attributes: hugeAttributes }),
|
|
998
|
+
).rejects.toThrow('exceeds');
|
|
999
|
+
|
|
1000
|
+
expect(sentPackets).toHaveLength(0);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
it('should throw HeaderTooLarge when opening a streamBytes writer with oversized attributes', async () => {
|
|
1004
|
+
const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DATA_STREAM_V2 });
|
|
1005
|
+
|
|
1006
|
+
await expect(
|
|
1007
|
+
manager.streamBytes({ topic: 'my-topic', attributes: hugeAttributes }),
|
|
1008
|
+
).rejects.toThrow('exceeds');
|
|
1009
|
+
|
|
1010
|
+
expect(sentPackets).toHaveLength(0);
|
|
1011
|
+
});
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
describe('attachments', () => {
|
|
1015
|
+
it('should send attachments as separate byte streams referenced by attachedStreamIds (never inline)', async () => {
|
|
1016
|
+
const { manager, sentPackets } = createManager({
|
|
1017
|
+
alice: CLIENT_PROTOCOL_DATA_STREAM_V2,
|
|
1018
|
+
bob: CLIENT_PROTOCOL_DATA_STREAM_V2,
|
|
1019
|
+
});
|
|
1020
|
+
const file = new File([new Uint8Array(100).fill(0x07) as NonSharedUint8Array], 'att.bin');
|
|
1021
|
+
|
|
1022
|
+
const info = await manager.sendText('hello hello compressible world', {
|
|
1023
|
+
topic: 'my-topic',
|
|
1024
|
+
destinationIdentities: ['alice', 'bob'],
|
|
1025
|
+
attachments: [file],
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
// The text part must NOT be inline — attachments disable the single-packet path — so both
|
|
1029
|
+
// the text stream and the attachment stream open with their own headers.
|
|
1030
|
+
const headers = sentPackets.filter((p) => p.value.case === 'streamHeader');
|
|
1031
|
+
expect(headers).toHaveLength(2);
|
|
1032
|
+
|
|
1033
|
+
const textHeader = headerOf(headers[0]);
|
|
1034
|
+
expect(textHeader.streamId).toStrictEqual(info.id);
|
|
1035
|
+
expect(textHeader.contentHeader.case).toBe('textHeader');
|
|
1036
|
+
expect(textHeader.inlineContent).toBeUndefined();
|
|
1037
|
+
const attachedStreamIds = (textHeader.contentHeader.value as DataStream_TextHeader)
|
|
1038
|
+
.attachedStreamIds;
|
|
1039
|
+
expect(attachedStreamIds).toHaveLength(1);
|
|
1040
|
+
|
|
1041
|
+
// The attachment is a byte stream whose streamId matches the reference in the text header.
|
|
1042
|
+
const byteHeader = headerOf(headers[1]);
|
|
1043
|
+
expect(byteHeader.contentHeader.case).toBe('byteHeader');
|
|
1044
|
+
expect(byteHeader.streamId).toStrictEqual(attachedStreamIds[0]);
|
|
1045
|
+
expect((byteHeader.contentHeader.value as DataStream_ByteHeader).name).toStrictEqual(
|
|
1046
|
+
'att.bin',
|
|
1047
|
+
);
|
|
1048
|
+
|
|
1049
|
+
// Both streams are terminated by trailers.
|
|
1050
|
+
const trailers = sentPackets.filter((p) => p.value.case === 'streamTrailer');
|
|
1051
|
+
expect(trailers.map((p) => trailerOf(p).streamId).sort()).toStrictEqual(
|
|
1052
|
+
[info.id, attachedStreamIds[0]].sort(),
|
|
1053
|
+
);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
it('should forward destinationIdentities to attachment byte streams', async () => {
|
|
1057
|
+
// Targeted send to the v2 subset of a mixed room: the attachment streams must be targeted
|
|
1058
|
+
// the same way, or participants outside the destination list receive stray byte streams
|
|
1059
|
+
// referenced by a text stream they never got.
|
|
1060
|
+
const { manager, sentPackets } = createManager({
|
|
1061
|
+
alice: CLIENT_PROTOCOL_DEFAULT,
|
|
1062
|
+
bob: CLIENT_PROTOCOL_DATA_STREAM_V2,
|
|
1063
|
+
jim: CLIENT_PROTOCOL_DATA_STREAM_V2,
|
|
1064
|
+
});
|
|
1065
|
+
const file = new File([new Uint8Array(100).fill(0x07) as NonSharedUint8Array], 'att.bin');
|
|
1066
|
+
|
|
1067
|
+
await manager.sendText('hello world', {
|
|
1068
|
+
topic: 'my-topic',
|
|
1069
|
+
destinationIdentities: ['bob', 'jim'],
|
|
1070
|
+
attachments: [file],
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
for (const packet of sentPackets) {
|
|
1074
|
+
expect(packet.destinationIdentities).toStrictEqual(['bob', 'jim']);
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
describe('recipient eligibility edge cases', () => {
|
|
1080
|
+
it('should send inline + compressed on a broadcast when every participant is v2 with the capability', async () => {
|
|
1081
|
+
const { manager, sentPackets } = createManager({
|
|
1082
|
+
alice: CLIENT_PROTOCOL_DATA_STREAM_V2,
|
|
1083
|
+
bob: CLIENT_PROTOCOL_DATA_STREAM_V2,
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
// No destinationIdentities: eligibility is evaluated over every remote participant.
|
|
1087
|
+
await manager.sendText('hello hello compressible world', { topic: 'my-topic' });
|
|
1088
|
+
|
|
1089
|
+
expect(sentPackets).toHaveLength(1);
|
|
1090
|
+
const header = headerOf(sentPackets[0]);
|
|
1091
|
+
expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW);
|
|
1092
|
+
expect(header.inlineContent).toBeInstanceOf(Uint8Array);
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
it('should send inline but uncompressed on a broadcast when one participant lacks the compression capability', async () => {
|
|
1096
|
+
const { manager, sentPackets } = createManager({
|
|
1097
|
+
alice: CLIENT_PROTOCOL_DATA_STREAM_V2,
|
|
1098
|
+
noCompression: [CLIENT_PROTOCOL_DATA_STREAM_V2, []],
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
await manager.sendText('hello hello compressible world', { topic: 'my-topic' });
|
|
1102
|
+
|
|
1103
|
+
expect(sentPackets).toHaveLength(1);
|
|
1104
|
+
const header = headerOf(sentPackets[0]);
|
|
1105
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
1106
|
+
expect(header.inlineContent).toStrictEqual(
|
|
1107
|
+
new TextEncoder().encode('hello hello compressible world'),
|
|
1108
|
+
);
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
it('should treat an empty room as v2-eligible for a broadcast', async () => {
|
|
1112
|
+
const { manager, sentPackets } = createManager({});
|
|
1113
|
+
|
|
1114
|
+
await manager.sendText('hello hello compressible world', { topic: 'my-topic' });
|
|
1115
|
+
|
|
1116
|
+
// Nobody to receive it, so nothing gates the v2 fast path: single inline compressed packet.
|
|
1117
|
+
expect(sentPackets).toHaveLength(1);
|
|
1118
|
+
expect(headerOf(sentPackets[0]).compression).toBe(DataStream_CompressionType.DEFLATE_RAW);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
it('should treat an unknown destination identity as pre-v2 (legacy multi-packet)', async () => {
|
|
1122
|
+
const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DATA_STREAM_V2 });
|
|
1123
|
+
|
|
1124
|
+
// "stranger" has no ParticipantInfo, so its clientProtocol resolves to 0 — the send must
|
|
1125
|
+
// fall back to the legacy format rather than assuming v2 support.
|
|
1126
|
+
await manager.sendText('hello world', {
|
|
1127
|
+
topic: 'my-topic',
|
|
1128
|
+
destinationIdentities: ['stranger'],
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
expect(sentPackets).toHaveLength(3);
|
|
1132
|
+
const header = headerOf(sentPackets[0]);
|
|
1133
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
1134
|
+
expect(header.inlineContent).toBeUndefined();
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
it('should send a large TEXT payload to a v2 recipient without the capability as uncompressed MTU-split chunks', async () => {
|
|
1138
|
+
const { manager, sentPackets } = createManager({
|
|
1139
|
+
noCompression: [CLIENT_PROTOCOL_DATA_STREAM_V2, []],
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
const info = await manager.sendText('A'.repeat(40_000), {
|
|
1143
|
+
topic: 'my-topic',
|
|
1144
|
+
destinationIdentities: ['noCompression'],
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
// Inline is attempted (recipient is v2) but the raw payload overflows the MTU, and
|
|
1148
|
+
// compression is gated off by the missing capability — so this falls back to an
|
|
1149
|
+
// uncompressed multi-packet stream split at STREAM_CHUNK_SIZE_BYTES.
|
|
1150
|
+
expect(sentPackets).toHaveLength(5);
|
|
1151
|
+
const header = headerOf(sentPackets[0]);
|
|
1152
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
1153
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
1154
|
+
expect(header.inlineContent).toBeUndefined();
|
|
1155
|
+
expect(chunkOf(sentPackets[1]).content).toHaveLength(15_000);
|
|
1156
|
+
expect(chunkOf(sentPackets[2]).content).toHaveLength(15_000);
|
|
1157
|
+
expect(chunkOf(sentPackets[3]).content).toHaveLength(10_000);
|
|
1158
|
+
expect(sentPackets[4].value.case).toBe('streamTrailer');
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
it('should send a FILE uncompressed when compress: false even to capable recipients', async () => {
|
|
1162
|
+
const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DATA_STREAM_V2 });
|
|
1163
|
+
const bytes = new Uint8Array(10_000).fill(0x07);
|
|
1164
|
+
|
|
1165
|
+
const info = await manager.sendFile(new File([bytes as NonSharedUint8Array], 'text.txt'), {
|
|
1166
|
+
topic: 'my-topic',
|
|
1167
|
+
destinationIdentities: ['alice'],
|
|
1168
|
+
compress: false,
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
expect(sentPackets).toHaveLength(3);
|
|
1172
|
+
const header = headerOf(sentPackets[0]);
|
|
1173
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
1174
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
1175
|
+
const chunk = chunkOf(sentPackets[1]);
|
|
1176
|
+
expect(chunk.content).toHaveLength(10_000);
|
|
1177
|
+
expect(chunk.content.every((byte) => byte === 0x07)).toBeTruthy();
|
|
1178
|
+
});
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
describe('UTF-8 chunk splitting', () => {
|
|
1182
|
+
it('should split streamText writes on UTF-8 character boundaries at the MTU', async () => {
|
|
1183
|
+
const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DEFAULT });
|
|
1184
|
+
|
|
1185
|
+
// 14 999 single-byte chars put the 4-byte emoji straddling the 15 000-byte MTU boundary.
|
|
1186
|
+
const text = `${'a'.repeat(14_999)}😀${'b'.repeat(10)}`;
|
|
1187
|
+
const writer = await manager.streamText({ topic: 'my-topic' });
|
|
1188
|
+
await writer.write(text);
|
|
1189
|
+
await writer.close();
|
|
1190
|
+
|
|
1191
|
+
expect(sentPackets).toHaveLength(4); // header + 2 chunks + trailer
|
|
1192
|
+
const first = chunkOf(sentPackets[1]);
|
|
1193
|
+
const second = chunkOf(sentPackets[2]);
|
|
1194
|
+
|
|
1195
|
+
// The split backs off below the MTU rather than bisecting the emoji, so each chunk decodes
|
|
1196
|
+
// independently.
|
|
1197
|
+
expect(first.content).toHaveLength(14_999);
|
|
1198
|
+
const decode = (bytes: Uint8Array) => new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
1199
|
+
expect(() => decode(first.content)).not.toThrow();
|
|
1200
|
+
expect(() => decode(second.content)).not.toThrow();
|
|
1201
|
+
expect(decode(first.content) + decode(second.content)).toStrictEqual(text);
|
|
1202
|
+
});
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
describe('empty payloads', () => {
|
|
1206
|
+
it('should send an empty TEXT payload to a pre-v2 room as header + trailer', async () => {
|
|
1207
|
+
const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DEFAULT });
|
|
1208
|
+
|
|
1209
|
+
const info = await manager.sendText('', { topic: 'my-topic' });
|
|
1210
|
+
|
|
1211
|
+
// A well-formed (if contentless) stream: header declaring zero length, then the trailer.
|
|
1212
|
+
expect(sentPackets).toHaveLength(2);
|
|
1213
|
+
const header = headerOf(sentPackets[0]);
|
|
1214
|
+
expect(header.streamId).toStrictEqual(info.id);
|
|
1215
|
+
expect(header.totalLength).toStrictEqual(0n);
|
|
1216
|
+
expect(sentPackets[1].value.case).toBe('streamTrailer');
|
|
1217
|
+
expect(trailerOf(sentPackets[1]).streamId).toStrictEqual(info.id);
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
it('should send an empty TEXT payload to a v2 room as a single raw inline packet', async () => {
|
|
1221
|
+
const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DATA_STREAM_V2 });
|
|
1222
|
+
|
|
1223
|
+
await manager.sendText('', { topic: 'my-topic', destinationIdentities: ['alice'] });
|
|
1224
|
+
|
|
1225
|
+
expect(sentPackets).toHaveLength(1);
|
|
1226
|
+
const header = headerOf(sentPackets[0]);
|
|
1227
|
+
// Deflate framing can't shrink an empty payload, so the raw (empty) bytes are kept.
|
|
1228
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
1229
|
+
expect(header.inlineContent).toStrictEqual(new Uint8Array(0));
|
|
1230
|
+
expect(header.totalLength).toStrictEqual(0n);
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
it('should send an empty BYTE payload to a pre-v2 room as header + trailer', async () => {
|
|
1234
|
+
const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DEFAULT });
|
|
1235
|
+
|
|
1236
|
+
const info = await manager.sendBytes(new Uint8Array(0), { topic: 'my-topic' });
|
|
1237
|
+
|
|
1238
|
+
expect(sentPackets).toHaveLength(2);
|
|
1239
|
+
const header = headerOf(sentPackets[0]);
|
|
1240
|
+
expect(header.totalLength).toStrictEqual(0n);
|
|
1241
|
+
expect(sentPackets[1].value.case).toBe('streamTrailer');
|
|
1242
|
+
expect(trailerOf(sentPackets[1]).streamId).toStrictEqual(info.id);
|
|
1243
|
+
});
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
describe('runtime without CompressionStream', () => {
|
|
1247
|
+
it('should send raw inline even to compression-capable recipients when the runtime cannot compress', async () => {
|
|
1248
|
+
const originalCompressionStream = CompressionStream;
|
|
1249
|
+
try {
|
|
1250
|
+
(globalThis as any).CompressionStream = undefined;
|
|
1251
|
+
|
|
1252
|
+
const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DATA_STREAM_V2 });
|
|
1253
|
+
await manager.sendText('hello hello compressible world', {
|
|
1254
|
+
topic: 'my-topic',
|
|
1255
|
+
destinationIdentities: ['alice'],
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
// Compression eligibility also requires a local compressor; without one, inline still
|
|
1259
|
+
// applies but the payload is sent raw.
|
|
1260
|
+
expect(sentPackets).toHaveLength(1);
|
|
1261
|
+
const header = headerOf(sentPackets[0]);
|
|
1262
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
1263
|
+
expect(header.inlineContent).toStrictEqual(
|
|
1264
|
+
new TextEncoder().encode('hello hello compressible world'),
|
|
1265
|
+
);
|
|
1266
|
+
} finally {
|
|
1267
|
+
(globalThis as any).CompressionStream = originalCompressionStream;
|
|
1268
|
+
}
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
it('should send a FILE uncompressed when the runtime cannot compress', async () => {
|
|
1272
|
+
const originalCompressionStream = CompressionStream;
|
|
1273
|
+
try {
|
|
1274
|
+
(globalThis as any).CompressionStream = undefined;
|
|
1275
|
+
|
|
1276
|
+
const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DATA_STREAM_V2 });
|
|
1277
|
+
const bytes = new Uint8Array(10_000).fill(0x07);
|
|
1278
|
+
await manager.sendFile(new File([bytes as NonSharedUint8Array], 'text.txt'), {
|
|
1279
|
+
topic: 'my-topic',
|
|
1280
|
+
destinationIdentities: ['alice'],
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
expect(sentPackets).toHaveLength(3);
|
|
1284
|
+
const header = headerOf(sentPackets[0]);
|
|
1285
|
+
expect(header.compression).toBe(DataStream_CompressionType.NONE);
|
|
1286
|
+
expect(chunkOf(sentPackets[1]).content).toHaveLength(10_000);
|
|
1287
|
+
} finally {
|
|
1288
|
+
(globalThis as any).CompressionStream = originalCompressionStream;
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
});
|
|
1292
|
+
});
|