import { Mutex } from '@livekit/mutex'; import { ClientInfo_Capability, DataPacket, DataStream_Chunk, DataStream_CompressionType, DataStream_Trailer, Encryption_Type, } from '@livekit/protocol'; import { type StructuredLogger } from '../../../logger'; import { type NonSharedUint8Array } from '../../../type-polyfills/non-shared-typed-arrays'; import { CLIENT_PROTOCOL_DATA_STREAM_V2 } from '../../../version'; import type RTCEngine from '../../RTCEngine'; import { DataChannelKind } from '../../RTCEngine'; import { DataStreamError, DataStreamErrorReason } from '../../errors'; import { EngineEvent } from '../../events'; import type { ByteStreamInfo, SendBytesOptions, SendFileOptions, SendTextOptions, StreamBytesOptions, StreamTextOptions, TextStreamInfo, } from '../../types'; import { isCompressionStreamSupported, numberToBigInt, readBytesInChunks, readableFromBytes, splitUtf8, } from '../../utils'; import { collect, deflateRawTransform } from '../compression'; import { STREAM_CHUNK_SIZE_BYTES } from '../constants'; import { ByteStreamWriter, TextStreamWriter } from './StreamWriter'; import { buildByteStreamHeader, buildTextStreamHeader, createStreamHeaderPacket, } from './header-utils'; const textEncoder = new TextEncoder(); /** * Manages sending custom user data via data channels. * @internal */ export default class OutgoingDataStreamManager { protected engine: RTCEngine; protected log: StructuredLogger; /** Returns the advertised client protocol of a remote participant, used to decide whether a * recipient can receive single-packet (inline) data streams. */ protected getRemoteParticipantClientProtocol: (identity: string) => number; /** Returns the client capabilities a remote participant advertises, used to decide whether a * recipient can decompress a deflate-raw compressed stream. */ protected getRemoteParticipantCapabilities: (identity: string) => Array; /** Returns the identities of every remote participant currently in the room, used to decide * whether a broadcast (no explicit destinations) can be sent inline. */ protected getAllRemoteParticipantIdentities: () => Array; constructor( engine: RTCEngine, log: StructuredLogger, getRemoteParticipantClientProtocol: (identity: string) => number, getRemoteParticipantCapabilities: (identity: string) => Array, getAllRemoteParticipantIdentities: () => Array, ) { this.engine = engine; this.log = log; this.getRemoteParticipantClientProtocol = getRemoteParticipantClientProtocol; this.getRemoteParticipantCapabilities = getRemoteParticipantCapabilities; this.getAllRemoteParticipantIdentities = getAllRemoteParticipantIdentities; } setupEngine(engine: RTCEngine) { this.engine = engine; } /** {@inheritDoc LocalParticipant.sendText} */ async sendText(text: string, options?: SendTextOptions): Promise { const streamId = crypto.randomUUID(); const textInBytes = textEncoder.encode(text); const totalTextLength = textInBytes.byteLength; const compress = options?.compress ?? true; let info: TextStreamInfo = { id: streamId, mimeType: 'text/plain', timestamp: Date.now(), topic: options?.topic ?? '', size: totalTextLength, // NOTE: size is always the pre-compression byte length attributes: options?.attributes, encryptionType: this.engine.e2eeManager?.isDataChannelEncryptionEnabled ? Encryption_Type.GCM : Encryption_Type.NONE, }; const compressEligible = compress && isCompressionStreamSupported() && this.allRecipientsSupportV2(options?.destinationIdentities) && this.allRecipientsSupportCompression(options?.destinationIdentities); let compressedStream = compressEligible ? MaybeCollectedStream.fromStream( readableFromBytes(textInBytes).pipeThrough(deflateRawTransform()), ) : null; // Phase 1: Try to send as a single packet data stream const noAttachments = !options?.attachments || options.attachments.length === 0; if (noAttachments && this.allRecipientsSupportV2(options?.destinationIdentities)) { // The payload rides in the header's `inlineContent` (raw bytes). Keep the compressed form only // if it actually shrinks the payload (deflate framing makes tiny strings larger). The // compression flag is carried in the header's `compression` field; user attributes are left // untouched. let inlineContent: Uint8Array = textInBytes; let compression = DataStream_CompressionType.NONE; if (compressedStream) { const collectedBytes = await compressedStream.collect(); if (collectedBytes.byteLength < textInBytes.byteLength) { inlineContent = collectedBytes; compression = DataStream_CompressionType.DEFLATE_RAW; } } const header = buildTextStreamHeader(info, undefined, { compression, inlineContent }); const packet = createStreamHeaderPacket(header, options?.destinationIdentities); if (packet.toBinary().byteLength <= STREAM_CHUNK_SIZE_BYTES) { await this.engine.sendDataPacket(packet, DataChannelKind.RELIABLE); options?.onProgress?.(1); return info; } } const fileIds = options?.attachments?.map(() => crypto.randomUUID()); // Progress is split evenly across the text part (slot 0) and one slot per attachment, then // normalized to a [0,1] fraction. Each slot climbs monotonically to 1, so the aggregate ends at // exactly 1 once every part has completed. const parts = fileIds ? fileIds.length + 1 : 1; const progresses = new Array(parts).fill(0); const handleProgress = (progress: number, idx: number) => { progresses[idx] = progress; options?.onProgress?.(progresses.reduce((acc, val) => acc + val, 0) / parts); }; // Phase 2: Try to send a multi packet data stream with compressed bytes if (compressedStream) { info.attachedStreamIds = fileIds; const header = buildTextStreamHeader(info, undefined, { compression: DataStream_CompressionType.DEFLATE_RAW, }); const packet = createStreamHeaderPacket(header, options?.destinationIdentities); await this.sendChunkedByteStream( packet, streamId, options?.destinationIdentities, compressedStream .stream() .pipeThrough( progressReportingStream(textInBytes.length, (progress) => handleProgress(progress, 0)), ), ); // Ensure there's always a 100% progress event fired, even if the string is zero bytes long if (textInBytes.length === 0) { handleProgress(1, 0); } } else { // Phase 3 / fallback: header + plain uncompressed chunk packets + trailer. const writer = await this.streamText({ streamId, totalSize: totalTextLength, destinationIdentities: options?.destinationIdentities, topic: options?.topic, attachedStreamIds: fileIds, attributes: options?.attributes, }); await writer.write(text); // set text part of progress to 1 handleProgress(1, 0); await writer.close(); info = writer.info; } if (options?.attachments && fileIds) { await Promise.all( options.attachments.map(async (file, idx) => this._sendFile(fileIds[idx], file, { topic: options.topic, mimeType: file.type, destinationIdentities: options.destinationIdentities, compress: options.compress, onProgress: (progress) => { handleProgress(progress, idx + 1); }, }), ), ); } return info; } /** * Sends a complete in-memory byte payload. Mirrors {@link sendText}'s semantics: when every * recipient supports data streams v2 the payload rides inline in a single header packet * (optionally deflate-raw compressed), otherwise it is sent as a (optionally compressed) * chunked byte stream. Unlike {@link sendFile}, the whole payload is already in memory, so the * inline single-packet fast path applies. */ async sendBytes(bytes: Uint8Array, options?: SendBytesOptions): Promise { const streamId = crypto.randomUUID(); const destinationIdentities = options?.destinationIdentities; const compress = options?.compress ?? true; const info: ByteStreamInfo = { id: streamId, name: options?.name ?? 'unknown', mimeType: options?.mimeType ?? 'application/octet-stream', timestamp: Date.now(), topic: options?.topic ?? '', size: bytes.byteLength, // NOTE: size is always the pre-compression byte length attributes: options?.attributes, encryptionType: this.engine.e2eeManager?.isDataChannelEncryptionEnabled ? Encryption_Type.GCM : Encryption_Type.NONE, }; const progressMonitorTap = progressReportingStream(bytes.length, options?.onProgress); const compressEligible = compress && isCompressionStreamSupported() && this.allRecipientsSupportV2(destinationIdentities) && this.allRecipientsSupportCompression(destinationIdentities); let compressedStream = compressEligible ? MaybeCollectedStream.fromStream( readableFromBytes(bytes as NonSharedUint8Array) .pipeThrough(progressMonitorTap) .pipeThrough(deflateRawTransform()), ) : null; // Phase 1: Try to send as a single packet data stream if (this.allRecipientsSupportV2(destinationIdentities)) { // The payload rides in the header's `inlineContent` (raw bytes). Keep the compressed form only // if it actually shrinks the payload (deflate framing makes tiny payloads larger). The // compression flag is carried in the header's `compression` field; user attributes are left // untouched. let inlineContent: Uint8Array = bytes; let compression = DataStream_CompressionType.NONE; if (compressedStream) { const collectedBytes = await compressedStream.collect(); if (collectedBytes.byteLength < bytes.byteLength) { inlineContent = collectedBytes; compression = DataStream_CompressionType.DEFLATE_RAW; } } const header = buildByteStreamHeader(info, { compression, inlineContent }); const packet = createStreamHeaderPacket(header, destinationIdentities); if (packet.toBinary().byteLength <= STREAM_CHUNK_SIZE_BYTES) { await this.engine.sendDataPacket(packet, DataChannelKind.RELIABLE); options?.onProgress?.(1); return info; } } // Phase 2/3: header + (optionally compressed) chunk packets + trailer. const header = buildByteStreamHeader(info, { compression: compressedStream ? DataStream_CompressionType.DEFLATE_RAW : DataStream_CompressionType.NONE, }); const packet = createStreamHeaderPacket(header, destinationIdentities); const source = compressedStream ? compressedStream.stream() : readableFromBytes(bytes as NonSharedUint8Array).pipeThrough(progressMonitorTap); await this.sendChunkedByteStream(packet, streamId, destinationIdentities, source); // Ensure there's always a 100% progress event fired, even if the buffer is zero bytes long if (bytes.length === 0) { options?.onProgress?.(1); } return info; } /** * Returns true only if every recipient is known to support data streams v2 (single-packet inline * streams and compression). For a targeted send this checks the named destination identities; for * a broadcast (no explicit destinations) it checks every remote participant currently in the room. * An empty room (nobody to receive) is considered eligible. */ private allRecipientsSupportV2(destinationIdentities?: Array): boolean { const identities = destinationIdentities && destinationIdentities.length > 0 ? destinationIdentities : this.getAllRemoteParticipantIdentities(); return identities.every( (identity) => this.getRemoteParticipantClientProtocol(identity) >= CLIENT_PROTOCOL_DATA_STREAM_V2, ); } /** * Returns true only if every recipient advertises the deflate-raw compression capability (so it * can decompress a compressed stream). Resolved the same way as {@link allRecipientsSupportV2}: * named destinations, or every remote participant for a broadcast; an empty room is eligible. */ private allRecipientsSupportCompression(destinationIdentities?: Array): boolean { const identities = destinationIdentities && destinationIdentities.length > 0 ? destinationIdentities : this.getAllRemoteParticipantIdentities(); return identities.every((identity) => this.getRemoteParticipantCapabilities(identity).includes( ClientInfo_Capability.CAP_COMPRESSION_DEFLATE_RAW, ), ); } /** * Shared chunked-stream send for `sendText`/`sendFile`: sends the prebuilt header packet, then * forwards `source` (optionally deflate-raw compressed) as `streamChunk` packets re-chunked to * the MTU budget with contiguous indices, then sends the trailer. The source is consumed * incrementally, so a `file.stream()` is never buffered in full. The platform compressor can't * flush mid-stream, so compression is only used when the whole payload is available as a stream * up front (not for incremental writers like `streamText`/`streamBytes`). */ private async sendChunkedByteStream( headerPacket: DataPacket, streamId: string, destinationIdentities: Array | undefined, source: ReadableStream, ): Promise { const engine = this.engine; await sendHeaderPacket(engine, headerPacket); let chunkId = 0; for await (const chunk of readBytesInChunks(source, STREAM_CHUNK_SIZE_BYTES)) { const chunkPacket = new DataPacket({ destinationIdentities, value: { case: 'streamChunk', value: new DataStream_Chunk({ content: chunk, streamId, chunkIndex: numberToBigInt(chunkId), }), }, }); await engine.sendDataPacket(chunkPacket, DataChannelKind.RELIABLE); chunkId += 1; } await sendStreamTrailer(streamId, destinationIdentities, engine); } /** * @internal */ async streamText(options?: StreamTextOptions): Promise { const streamId = options?.streamId ?? crypto.randomUUID(); const destinationIdentities = options?.destinationIdentities; const info: TextStreamInfo = { id: streamId, mimeType: 'text/plain', timestamp: Date.now(), topic: options?.topic ?? '', size: options?.totalSize, attributes: options?.attributes, encryptionType: this.engine.e2eeManager?.isDataChannelEncryptionEnabled ? Encryption_Type.GCM : Encryption_Type.NONE, attachedStreamIds: options?.attachedStreamIds, }; const header = buildTextStreamHeader(info, options); const packet = createStreamHeaderPacket(header, destinationIdentities); await sendHeaderPacket(this.engine, packet); let chunkId = 0; const engine = this.engine; // Incremental text streams are never compressed (CompressionStream does not support flushing // mid-stream); one-shot compression lives in sendText. // // Note that a future streamText could send a context-takeover style deflate-raw stream with // intermedia explicit `Z_SYNC_FLUSH`s - receivers already will handle this properly today. const writableStream = new WritableStream({ async write(text) { for (const textByteChunk of splitUtf8(text, STREAM_CHUNK_SIZE_BYTES)) { const chunk = new DataStream_Chunk({ content: textByteChunk, streamId, chunkIndex: numberToBigInt(chunkId), }); const chunkPacket = new DataPacket({ destinationIdentities, value: { case: 'streamChunk', value: chunk, }, }); await engine.sendDataPacket(chunkPacket, DataChannelKind.RELIABLE); chunkId += 1; } }, async close() { await sendStreamTrailer(streamId, destinationIdentities, engine); }, abort(err) { console.log('Sink error:', err); // TODO handle aborts to signal something to receiver side }, }); let onEngineClose = async () => { await writer.close(); }; // FIXME: make this a global event to ensure "max listener" warning won't get logged for lots of // in flight data streams. engine.once(EngineEvent.Closing, onEngineClose); const writer = new TextStreamWriter(writableStream, info, () => this.engine.off(EngineEvent.Closing, onEngineClose), ); return writer; } async sendFile(file: File, options?: SendFileOptions): Promise<{ id: string }> { const streamId = crypto.randomUUID(); await this._sendFile(streamId, file, options); return { id: streamId }; } /** * Streams a file as a chunked byte stream, compressed (deflate-raw) when the runtime supports it * and every recipient is on data streams v2. The file is piped `file.stream()` → * (`CompressionStream`) → chunk packets via {@link sendChunkedByteStream}, so it is never fully * buffered in memory — unlike {@link sendBytes}, there is no inline single-packet fast path for * files (the compressed size can't be known up front without buffering the whole file). */ private async _sendFile( streamId: string, file: File, options?: SendFileOptions, ): Promise { const destinationIdentities = options?.destinationIdentities; const compress = (options?.compress ?? true) && isCompressionStreamSupported() && this.allRecipientsSupportV2(destinationIdentities) && this.allRecipientsSupportCompression(destinationIdentities); const info: ByteStreamInfo = { id: streamId, name: file.name, mimeType: options?.mimeType ?? file.type, topic: options?.topic ?? '', timestamp: Date.now(), size: file.size, encryptionType: this.engine.e2eeManager?.isDataChannelEncryptionEnabled ? Encryption_Type.GCM : Encryption_Type.NONE, }; // Phase 1: Try to send as a single packet data stream // // This is not being done explictly for files, because it's challenging to determine ahead of // time how well the file contents will compress (and whether the total output will be under the // MTU). Revisit this in the future though. // Phase 2 (compressed) / Phase 3 (uncompressed fallback): header + chunk packets + trailer. Both // funnel through the shared sendChunkedByteStream primitive, differing only by whether the file // stream is wrapped in the deflate-raw compressor; the file is never fully buffered in memory. const header = buildByteStreamHeader(info, { compression: compress ? DataStream_CompressionType.DEFLATE_RAW : DataStream_CompressionType.NONE, }); const packet = createStreamHeaderPacket(header, destinationIdentities); const tapped = file .stream() .pipeThrough(progressReportingStream(file.size, options?.onProgress)); const source = compress ? tapped.pipeThrough(deflateRawTransform()) : tapped; await this.sendChunkedByteStream(packet, streamId, destinationIdentities, source); // Ensure there's always a 100% progress event fired, even if the file is zero bytes long if (file.size === 0) { options?.onProgress?.(1); } return info; } async streamBytes(options?: StreamBytesOptions) { const streamId = options?.streamId ?? crypto.randomUUID(); const destinationIdentities = options?.destinationIdentities; const info: ByteStreamInfo = { id: streamId, mimeType: options?.mimeType ?? 'application/octet-stream', topic: options?.topic ?? '', timestamp: Date.now(), attributes: options?.attributes, size: options?.totalSize, name: options?.name ?? 'unknown', encryptionType: this.engine.e2eeManager?.isDataChannelEncryptionEnabled ? Encryption_Type.GCM : Encryption_Type.NONE, }; const header = buildByteStreamHeader(info); const packet = createStreamHeaderPacket(header, destinationIdentities); await sendHeaderPacket(this.engine, packet); let chunkId = 0; const writeMutex = new Mutex(); const engine = this.engine; const logLocal = this.log; // Incremental byte streams are never compressed (CompressionStream does not support flushing // mid-stream); one-shot compression lives in sendFile. // // Note that a future streamBytes could send a context-takeover style deflate-raw stream with // intermedia explicit `Z_SYNC_FLUSH`s - receivers already will handle this properly today. const writableStream = new WritableStream({ async write(chunk) { const unlock = await writeMutex.lock(); let byteOffset = 0; try { while (byteOffset < chunk.byteLength) { const subChunk = chunk.slice(byteOffset, byteOffset + STREAM_CHUNK_SIZE_BYTES); const chunkPacket = new DataPacket({ destinationIdentities, value: { case: 'streamChunk', value: new DataStream_Chunk({ content: subChunk, streamId, chunkIndex: numberToBigInt(chunkId), }), }, }); await engine.sendDataPacket(chunkPacket, DataChannelKind.RELIABLE); chunkId += 1; byteOffset += subChunk.byteLength; } } finally { unlock(); } }, async close() { await sendStreamTrailer(streamId, destinationIdentities, engine); }, abort(err) { logLocal.error('Sink error:', err); }, }); const byteWriter = new ByteStreamWriter(writableStream, info); return byteWriter; } } /** * Wraps a stream of compressed bytes and defers buffering it into memory until (and only if) the * full length is actually needed. * * The ideal way to send a compressed stream is incrementally: compress each chunk and send it as * soon as it's ready, without ever holding the whole payload in memory. But to decide whether a * payload is small enough to fit in a single-packet data stream, we have to know the compressed * length — and the only way to learn that is to compress everything and add up the bytes. That * forces us to buffer the entire compressed output before we can make the decision. * * This class lets us have it both ways. It holds the compressed stream unread by default and only * collects it into memory ({@link collect}) when a caller needs the length for the single-packet * size check. If that check never happens, the stream is instead {@link stream|passed straight * through} to the downstream consumer without ever being fully buffered. Once collected, later * calls reuse the buffered bytes rather than re-collecting. */ class MaybeCollectedStream { private state: | { type: 'stream'; stream: ReadableStream } | { type: 'collected'; bytes: NonSharedUint8Array }; private constructor(state: typeof this.state) { this.state = state; } static fromStream(stream: ReadableStream) { return new MaybeCollectedStream({ type: 'stream', stream }); } /** Collect data from the stream into memory and return as a Uint8Array. */ async collect() { switch (this.state.type) { case 'stream': const bytes = await collect(this.state.stream); this.state = { type: 'collected', bytes }; return bytes; case 'collected': return this.state.bytes; } } /** Pass wrapped stream through to downstream consumer. */ stream() { switch (this.state.type) { case 'stream': return this.state.stream; case 'collected': return readableFromBytes(this.state.bytes); } } } /** * A pass-through byte transform that reports progress as bytes flow through it, measured against * `totalLength`. Used as a "tap" to instrument the source of a chunked send without threading a * progress callback through the send primitive. * * IMPORTANT: This should be upstream of any compression step. * * Emits nothing when the total is unknown or zero. */ function progressReportingStream( totalPreCompressionLength: number | undefined, onProgress?: (progress: number) => void, ): ReadableWritablePair { let sent = 0; return new TransformStream({ transform(chunk, controller) { sent += chunk.byteLength; if ( onProgress && typeof totalPreCompressionLength === 'number' && totalPreCompressionLength > 0 ) { onProgress(Math.min(sent / totalPreCompressionLength, 1)); } controller.enqueue(chunk); }, }); } /** * Sends a stream `streamHeader` packet, enforcing that it fits the MTU budget. The header carries * the user attributes (plus topic/framing), and a single `DataPacket` larger than the MTU can't be * reliably sent — so an oversized header (almost always due to large attributes) is a hard error * rather than a malformed packet on the wire. The inline fast path does its own size check and * falls back to the chunked path instead of calling this. */ async function sendHeaderPacket(engine: RTCEngine, packet: DataPacket): Promise { if (packet.toBinary().byteLength > STREAM_CHUNK_SIZE_BYTES) { throw new DataStreamError( `data stream header exceeds the ${STREAM_CHUNK_SIZE_BYTES}-byte limit; reduce attribute size`, DataStreamErrorReason.HeaderTooLarge, ); } await engine.sendDataPacket(packet, DataChannelKind.RELIABLE); } /** Sends a `streamTrailer` packet, marking the end of a stream. */ async function sendStreamTrailer( streamId: string, destinationIdentities: Array | undefined, engine: RTCEngine, ): Promise { const trailerPacket = new DataPacket({ destinationIdentities, value: { case: 'streamTrailer', value: new DataStream_Trailer({ streamId }) }, }); await engine.sendDataPacket(trailerPacket, DataChannelKind.RELIABLE); }