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