diff --git a/.changeset/slick-geese-happen.md b/.changeset/slick-geese-happen.md new file mode 100644 index 0000000000..f759375a77 --- /dev/null +++ b/.changeset/slick-geese-happen.md @@ -0,0 +1,5 @@ +--- +'livekit-client': minor +--- + +Add support for data streams v2 diff --git a/package.json b/package.json index 2561dbc657..b11f4fa9e9 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ }, "dependencies": { "@livekit/mutex": "1.1.1", - "@livekit/protocol": "1.46.6", + "@livekit/protocol": "1.50.4", "events": "^3.3.0", "jose": "^6.1.0", "loglevel": "^1.9.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 247d0397cf..ab7fc902b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: 1.1.1 version: 1.1.1 '@livekit/protocol': - specifier: 1.46.6 - version: 1.46.6 + specifier: 1.50.4 + version: 1.50.4 '@types/dom-mediacapture-record': specifier: ^1 version: 1.0.22 @@ -1224,8 +1224,8 @@ packages: '@livekit/mutex@1.1.1': resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==} - '@livekit/protocol@1.46.6': - resolution: {integrity: sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==} + '@livekit/protocol@1.50.4': + resolution: {integrity: sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==} '@livekit/throws-transformer@0.1.3': resolution: {integrity: sha512-PBttE6W6g/2ALGu6kWOunZ5qdrXwP9Ge1An2/62OfE6Rhc0Abd4yp6ex2pWhwUfGxDsSZvFgoB1Ia/5mWAMuKQ==} @@ -5121,7 +5121,7 @@ snapshots: '@livekit/mutex@1.1.1': {} - '@livekit/protocol@1.46.6': + '@livekit/protocol@1.50.4': dependencies: '@bufbuild/protobuf': 1.10.1 diff --git a/src/index.ts b/src/index.ts index 1d218cd89d..be7a70b0f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -104,6 +104,8 @@ export type { TranscriptionSegment, ChatMessage, SendTextOptions, + SendBytesOptions, + ByteStreamInfo, } from './room/types'; export * from './version'; export { diff --git a/src/room/Room.test.ts b/src/room/Room.test.ts index b6703d436b..ea837de892 100644 --- a/src/room/Room.test.ts +++ b/src/room/Room.test.ts @@ -81,7 +81,10 @@ describe('Room signaling options', () => { 'wss://test.livekit.io', 'test-token', expect.objectContaining({ - clientInfoCapabilities: [ClientInfo_Capability.CAP_PACKET_TRAILER], + clientInfoCapabilities: [ + ClientInfo_Capability.CAP_PACKET_TRAILER, + ClientInfo_Capability.CAP_COMPRESSION_DEFLATE_RAW, + ], e2eeEnabled: true, }), expect.any(AbortSignal), diff --git a/src/room/Room.ts b/src/room/Room.ts index b09f79bb8f..acabb86c38 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -118,6 +118,7 @@ import { getEmptyAudioStreamTrack, isBrowserSupported, isCloud, + isCompressionStreamSupported, isLocalAudioTrack, isLocalParticipant, isReactNative, @@ -268,7 +269,13 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.maybeCreateEngine(); this.incomingDataStreamManager = new IncomingDataStreamManager(); - this.outgoingDataStreamManager = new OutgoingDataStreamManager(this.engine, this.log); + this.outgoingDataStreamManager = new OutgoingDataStreamManager( + this.engine, + this.log, + this.getRemoteParticipantClientProtocol, + this.getRemoteParticipantCapabilities, + this.getAllRemoteParticipantIdentities, + ); this.incomingDataTrackManager = new IncomingDataTrackManager({ e2eeManager: this.e2eeManager }); this.incomingDataTrackManager @@ -971,11 +978,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) autoSubscribe: connectOptions.autoSubscribe, adaptiveStream: typeof roomOptions.adaptiveStream === 'object' ? true : roomOptions.adaptiveStream, - clientInfoCapabilities: - isFrameMetadataSupported(roomOptions.frameMetadata ?? roomOptions.packetTrailer) || - !!this.e2eeManager - ? [ClientInfo_Capability.CAP_PACKET_TRAILER] - : undefined, + clientInfoCapabilities: this.getClientInfoCapabilities(roomOptions), maxRetries: connectOptions.maxRetries, e2eeEnabled: !!this.e2eeManager, websocketTimeout: connectOptions.websocketTimeout, @@ -2505,10 +2508,39 @@ class Room extends (EventEmitter as new () => TypedEmitter) } } + /** The client capabilities this SDK advertises to other participants in its `ClientInfo`. */ + private getClientInfoCapabilities( + roomOptions: InternalRoomOptions, + ): Array { + const capabilities: Array = []; + if ( + isFrameMetadataSupported(roomOptions.frameMetadata ?? roomOptions.packetTrailer) || + !!this.e2eeManager + ) { + capabilities.push(ClientInfo_Capability.CAP_PACKET_TRAILER); + } + // Advertise deflate-raw decompression support so peers know they can send us compressed data + // streams (gated separately from clientProtocol β€” see the data streams v2 spec). + if (isCompressionStreamSupported()) { + capabilities.push(ClientInfo_Capability.CAP_COMPRESSION_DEFLATE_RAW); + } + return capabilities; + } + private getRemoteParticipantClientProtocol = (identity: Participant['identity']) => { return this.remoteParticipants.get(identity)?.clientProtocol ?? CLIENT_PROTOCOL_DEFAULT; }; + private getRemoteParticipantCapabilities = ( + identity: Participant['identity'], + ): Array => { + return this.remoteParticipants.get(identity)?.capabilities ?? []; + }; + + private getAllRemoteParticipantIdentities = () => { + return Array.from(this.remoteParticipants.keys()); + }; + private registerRpcDataStreamHandler() { this.incomingDataStreamManager.registerTextStreamHandler( RPC_REQUEST_DATA_STREAM_TOPIC, diff --git a/src/room/data-stream/compression.ts b/src/room/data-stream/compression.ts new file mode 100644 index 0000000000..b733b0fe86 --- /dev/null +++ b/src/room/data-stream/compression.ts @@ -0,0 +1,117 @@ +/** + * Compression helpers for data streams. The buffered deflate-raw variant ({@link deflateRawCompress}) + * is for the inline (single-packet) case where the payload is small and bounded; + * {@link deflateRawTransform} / {@link inflateRawTransform} serve the chunked (multi-packet) + * `sendText`/`sendBytes`/`sendFile` paths, streaming the bytes through without buffering the whole + * payload. + * + * These operate on bytes (not strings) so a single set of helpers serves both text and byte streams; + * the `TextEncoder`/`TextDecoder` boundary lives at the manager/reader edges. + * + * Both streaming variants are exposed as `ReadableWritablePair`s so they drop straight into a + * `pipeThrough` chain. Each needs one localized cast to bridge a DOM lib-type mismatch: the platform + * `CompressionStream`/`DecompressionStream` type their `writable` as `WritableStream` + * (a wider element type than `Uint8Array`), and `WritableStream` is covariant in `W`, so neither + * is structurally a `ReadableWritablePair` without help. + * + * @internal + */ +import { type NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays'; +import { DataStreamError, DataStreamErrorReason } from '../errors'; + +/** + * A `deflate-raw` compression transform (inverse of {@link inflateRawTransform}): pipe a byte stream + * through it to get the compressed bytes without buffering the whole payload. Used for the chunked + * `sendText`/`sendBytes`/`sendFile` paths, where the full payload is known up front but is streamed + * (e.g. from `file.stream()`) rather than held in memory. + */ +export function deflateRawTransform(): ReadableWritablePair< + NonSharedUint8Array, + NonSharedUint8Array +> { + return new CompressionStream('deflate-raw') as unknown as ReadableWritablePair< + NonSharedUint8Array, + NonSharedUint8Array + >; +} + +/** + * A `deflate-raw` decompression transform (inverse of {@link deflateRawTransform}): pipe a + * stream of compressed bytes through it to get the decompressed bytes. Inflate emits output greedily, + * so as long as the sender flushed at write boundaries each write's content is produced as soon as + * its compressed bytes arrive. + */ +export function inflateRawTransform(): ReadableWritablePair< + NonSharedUint8Array, + NonSharedUint8Array +> { + return new DecompressionStream('deflate-raw') as unknown as ReadableWritablePair< + NonSharedUint8Array, + NonSharedUint8Array + >; +} + +/** deflate-raw compresses a byte array in full. Use for inline payloads; prefer the streaming + * path for the chunked case. */ +export async function deflateRawCompress(data: NonSharedUint8Array): Promise { + const cs = new CompressionStream('deflate-raw'); + const writer = cs.writable.getWriter(); + writer.write(data as NonSharedUint8Array); + writer.close(); + return collect(cs.readable); +} + +/** + * Decompresses a raw-deflate byte array in full (inverse of {@link deflateRawCompress}). + * `maxByteLength` bounds the decompressed output (decompression-bomb guard); exceeding it rejects + * with a `PayloadTooLarge` error. + */ +export async function deflateRawDecompress( + data: NonSharedUint8Array, + maxByteLength?: number, +): Promise { + const ds = new DecompressionStream('deflate-raw'); + const writer = ds.writable.getWriter(); + // The writer promises are intentionally not awaited (output is consumed via `collect`), but + // they reject when the byte cap cancels the readable mid-stream β€” swallow that so an enforced + // cap doesn't surface as an unhandled rejection. + writer.write(data as NonSharedUint8Array).catch(() => {}); + writer.close().catch(() => {}); + return collect(ds.readable, maxByteLength); +} + +/** + * Drains a byte stream, concatenating all of its chunks into a single array. When + * `maxByteLength` is given, drops the stream and throws `PayloadTooLarge` as soon as the + * accumulated output exceeds it. + */ +export async function collect( + stream: ReadableStream, + maxByteLength?: number, +): Promise { + const reader = stream.getReader(); + const chunks: Array = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + chunks.push(value); + total += value.byteLength; + if (typeof maxByteLength === 'number' && total > maxByteLength) { + await reader.cancel(); + throw new DataStreamError( + `Decompressed payload exceeds the maximum payload size of ${maxByteLength} bytes`, + DataStreamErrorReason.PayloadTooLarge, + ); + } + } + const result = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; +} diff --git a/src/room/data-stream/constants.ts b/src/room/data-stream/constants.ts new file mode 100644 index 0000000000..5a93c6da64 --- /dev/null +++ b/src/room/data-stream/constants.ts @@ -0,0 +1,18 @@ +/** + * Maximum size of a single data-stream chunk in bytes, and the budget used to decide whether a + * payload can be sent inline as a single header packet. Kept below the ~16k data-channel MTU to + * leave headroom for protocol framing and E2EE overhead. + * + * @internal + */ +export const STREAM_CHUNK_SIZE_BYTES = 15_000; + +/** + * Default cap on the number of decompressed bytes a single incoming compressed data stream may + * produce (5 GB). A tiny compressed payload can inflate to an arbitrarily large output + * (decompression bomb), so the decompressor's output is bounded rather than trusting the wire + * size; streams exceeding the cap error with `PayloadTooLarge`. + * + * @internal + */ +export const DEFAULT_MAX_PAYLOAD_BYTE_LENGTH = 5_000_000_000; diff --git a/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts b/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts new file mode 100644 index 0000000000..672bdaaab5 --- /dev/null +++ b/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts @@ -0,0 +1,1601 @@ +import { + DataPacket, + DataStream_ByteHeader, + DataStream_Chunk, + DataStream_CompressionType, + DataStream_Header, + DataStream_TextHeader, + DataStream_Trailer, + Encryption_Type, +} from '@livekit/protocol'; +import { describe, expect, it } from 'vitest'; +import { deflateRawCompress } from '../compression'; +import { STREAM_CHUNK_SIZE_BYTES } from '../constants'; +import IncomingDataStreamManager from './IncomingDataStreamManager'; +import type { ByteStreamReader, TextStreamReader } from './StreamReader'; + +/** Builds a low quality random string of the given length. */ +function randomText(length: number): string { + let s = ''; + while (s.length < length) { + s += Math.random().toString(36).slice(2); + } + return s.slice(0, length); +} + +/** Fills a buffer with uniform random bytes β€” genuinely incompressible. */ +function randomBytes(length: number): Uint8Array { + const out = new Uint8Array(length); + // crypto.getRandomValues rejects requests over 65536 bytes, so chunk it. + for (let offset = 0; offset < length; offset += 65536) { + crypto.getRandomValues(out.subarray(offset, offset + 65536)); + } + return out; +} + +type HeaderFields = NonNullable[0]>; + +/** Wraps a `DataStream_Header` (from `alice`, topic `my-topic`) in a `DataPacket`. */ +function headerPacket( + streamId: string, + contentCase: 'textHeader' | 'byteHeader', + fields: HeaderFields = {}, +): DataPacket { + return new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + contentHeader: + contentCase === 'textHeader' + ? { case: 'textHeader', value: new DataStream_TextHeader({}) } + : { case: 'byteHeader', value: new DataStream_ByteHeader({}) }, + ...fields, + }), + }, + }); +} + +/** Wraps a `DataStream_Chunk` in a `DataPacket`. */ +function chunkPacket( + streamId: string, + chunkIndex: number, + content: Uint8Array, + version = 0, +): DataPacket { + return new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamChunk', + value: new DataStream_Chunk({ + streamId, + chunkIndex: BigInt(chunkIndex), + content, + version, + }), + }, + }); +} + +/** Wraps a `DataStream_Trailer` in a `DataPacket`. */ +function trailerPacket( + streamId: string, + attributes?: Record, + reason?: string, +): DataPacket { + return new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamTrailer', + value: new DataStream_Trailer({ streamId, attributes, reason }), + }, + }); +} + +/** Concatenates the byte chunks a `ByteStreamReader.readAll()` resolves with. */ +function concatChunks(chunks: Array): Uint8Array { + const out = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.byteLength, 0)); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; +} + +describe('IncomingDataStreamManager', () => { + describe('Receiving v1 data streams', () => { + it('should receive a v1 text data stream', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const text = 'hello world'; + + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + totalLength: BigInt(text.length), + attributes: { foo: 'bar' }, + contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamChunk', + value: new DataStream_Chunk({ + streamId, + chunkIndex: 0n, + content: new TextEncoder().encode(text), + version: 0, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamTrailer', + value: new DataStream_Trailer({ streamId }), + }, + }), + Encryption_Type.NONE, + ); + + const reader = await readerPromise; + expect(await reader.readAll()).toStrictEqual('hello world'); + expect(reader.info.attributes?.foo).toStrictEqual('bar'); + }); + + it('should receive a v1 bytes data stream', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + totalLength: 4n, + attributes: { foo: 'bar' }, + contentHeader: { case: 'byteHeader', value: new DataStream_ByteHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamChunk', + value: new DataStream_Chunk({ + streamId, + chunkIndex: 0n, + content: new Uint8Array([0x01, 0x02, 0x03, 0x04]), + version: 0, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamTrailer', + value: new DataStream_Trailer({ streamId }), + }, + }), + Encryption_Type.NONE, + ); + + const reader = await readerPromise; + expect(await reader.readAll()).toStrictEqual([new Uint8Array([0x01, 0x02, 0x03, 0x04])]); + expect(reader.info.attributes?.foo).toStrictEqual('bar'); + }); + + it('should receive a v1 text data stream with files', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const streamId = crypto.randomUUID(); + const streamReaderPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const attachmentStreamId = crypto.randomUUID(); + const attachmentStreamReaderPromise = new Promise((resolve) => { + manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + // Send the main data stream body + const text = 'hello world'; + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + totalLength: BigInt(text.length), + attributes: { foo: 'bar' }, + inlineContent: new TextEncoder().encode(text), + contentHeader: { + case: 'textHeader', + value: new DataStream_TextHeader({ + attachedStreamIds: [attachmentStreamId], + }), + }, + }), + }, + }), + Encryption_Type.NONE, + ); + + // Send an attachment + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId: attachmentStreamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + totalLength: 3n, + attributes: {}, + contentHeader: { case: 'byteHeader', value: new DataStream_ByteHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamChunk', + value: new DataStream_Chunk({ + streamId: attachmentStreamId, + chunkIndex: 0n, + content: new Uint8Array([0x01, 0x02, 0x03]), + version: 0, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamTrailer', + value: new DataStream_Trailer({ streamId: attachmentStreamId }), + }, + }), + Encryption_Type.NONE, + ); + + const streamReader = await streamReaderPromise; + expect(await streamReader.readAll()).toStrictEqual('hello world'); + expect(streamReader.info.attachedStreamIds).toHaveLength(1); + + const attachmentStreamReader = await attachmentStreamReaderPromise; + expect(await attachmentStreamReader.readAll()).toStrictEqual([ + new Uint8Array([0x01, 0x02, 0x03]), + ]); + expect(streamReader.info.attachedStreamIds).toHaveLength(1); + }); + + it('should buffer packets when disconnected', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(false); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const text = 'hello world'; + + // Send three packets + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + totalLength: BigInt(text.length), + attributes: { foo: 'bar' }, + contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamChunk', + value: new DataStream_Chunk({ + streamId, + chunkIndex: 0n, + content: new TextEncoder().encode(text), + version: 0, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamTrailer', + value: new DataStream_Trailer({ streamId }), + }, + }), + Encryption_Type.NONE, + ); + + // Make sure promise still hasn't resolved + await expect( + Promise.race([readerPromise, Promise.resolve('still pending')]), + ).resolves.toStrictEqual('still pending'); + + // Simulate connecting + manager.setConnected(true); + + // Make sure it resolves after connected state set + const reader = await readerPromise; + expect(await reader.readAll()).toStrictEqual('hello world'); + }); + + it('should merge in trailer attributes', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const text = 'hello world'; + + // Send three packets + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + totalLength: BigInt(text.length), + attributes: { foo: 'bar', baz: 'quux' }, + contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamChunk', + value: new DataStream_Chunk({ + streamId, + chunkIndex: 0n, + content: new TextEncoder().encode(text), + version: 0, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamTrailer', + value: new DataStream_Trailer({ + streamId, + attributes: { hello: 'world', foo: 'updated' }, + }), + }, + }), + Encryption_Type.NONE, + ); + + // Make sure it resolves after connected state set + const reader = await readerPromise; + expect(reader.info.attributes?.baz).toStrictEqual('quux'); + expect(reader.info.attributes?.hello).toStrictEqual('world'); + expect(reader.info.attributes?.foo).toStrictEqual('updated'); + }); + + it('should drop packets with incorrect EncryptionType', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const text = 'hello world'; + + // Send two packets, the second with an incorrect encryption value + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + totalLength: BigInt(text.length), + attributes: { foo: 'bar', baz: 'quux' }, + contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamChunk', + value: new DataStream_Chunk({ + streamId, + chunkIndex: 0n, + content: new TextEncoder().encode(text), + version: 0, + }), + }, + }), + Encryption_Type.GCM, // <-- NOTE: this has changed since the last packet + ); + + // Make sure an error is thrown from the reader + const reader = await readerPromise; + expect(() => reader.readAll()).rejects.toThrow('Encryption type mismatch'); + }); + + it('should throw an error if data stream does not have enough packets', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + + // Send a header, a 1 byte long chunk, and a trailer + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + totalLength: 5n, + attributes: { foo: 'bar', baz: 'quux' }, + contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamChunk', + value: new DataStream_Chunk({ + streamId, + chunkIndex: 0n, + content: new Uint8Array([0x01]), + version: 0, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamTrailer', + value: new DataStream_Trailer({ streamId }), + }, + }), + Encryption_Type.NONE, + ); + + // Make sure an error is thrown from the reader + const reader = await readerPromise; + await expect(reader.readAll()).rejects.toThrow('Not enough chunk(s)'); + }); + + it('should throw an error if data stream has too many bytes', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + + // Send a header declaring 3 bytes, then a 5 byte long chunk, and a trailer + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + totalLength: 3n, + attributes: { foo: 'bar', baz: 'quux' }, + contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamChunk', + value: new DataStream_Chunk({ + streamId, + chunkIndex: 0n, + content: new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]), + version: 0, + }), + }, + }), + Encryption_Type.NONE, + ); + + // Make sure an error is thrown from the reader + const reader = await readerPromise; + await expect(reader.readAll()).rejects.toThrow('Extra chunk(s)'); + }); + + it('should throw an error if participant disconnects while data stream is still not fully received', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + + // Send a header declaring 10 bytes, then a 5 byte long chunk + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + totalLength: 10n, + attributes: { foo: 'bar', baz: 'quux' }, + contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamChunk', + value: new DataStream_Chunk({ + streamId, + chunkIndex: 0n, + content: new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]), + version: 0, + }), + }, + }), + Encryption_Type.NONE, + ); + + // Simulate a remote participant disconnect, which calls this method in the room handler + manager.validateParticipantHasNoActiveDataStreams('alice'); + + // Make sure an error is thrown from the reader + const reader = await readerPromise; + await expect(reader.readAll()).rejects.toThrow( + 'Participant alice unexpectedly disconnected in the middle of sending data', + ); + }); + }); + + describe('Receiving v2 data streams', () => { + it('should receive a v2 SINGLE PACKET + UNCOMPRESSED text data stream', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const text = 'hello world'; + + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + totalLength: BigInt(text.length), + attributes: { foo: 'bar' }, + inlineContent: new TextEncoder().encode(text), + contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + + const reader = await readerPromise; + expect(await reader.readAll()).toStrictEqual('hello world'); + expect(reader.info.attributes?.foo).toStrictEqual('bar'); + }); + + it('should receive a v2 SINGLE PACKET + UNCOMPRESSED byte data stream', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const bytes = new Uint8Array([0x01, 0x02, 0x03]); + + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + totalLength: 3n, + inlineContent: bytes, + contentHeader: { case: 'byteHeader', value: new DataStream_ByteHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + + const reader = await readerPromise; + expect(await reader.readAll()).toStrictEqual([new Uint8Array([0x01, 0x02, 0x03])]); + }); + + it('should receive a v2 SINGLE PACKET + COMPRESSED text data stream', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const text = 'hello world'; + const compressed = await deflateRawCompress(new TextEncoder().encode(text)); + + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + totalLength: BigInt(text.length), + attributes: { foo: 'bar' }, + compression: DataStream_CompressionType.DEFLATE_RAW, + inlineContent: compressed, + contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + + const reader = await readerPromise; + expect(await reader.readAll()).toStrictEqual('hello world'); + expect(reader.info.attributes?.foo).toStrictEqual('bar'); + }); + + it('should receive a v2 SINGLE PACKET + COMPRESSED byte data stream', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const bytes = new Uint8Array([0x01, 0x02, 0x03]); + const compressed = await deflateRawCompress(bytes); + + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + totalLength: BigInt(bytes.length), + compression: DataStream_CompressionType.DEFLATE_RAW, + inlineContent: compressed, + contentHeader: { case: 'byteHeader', value: new DataStream_ByteHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + + const reader = await readerPromise; + expect(await reader.readAll()).toStrictEqual([new Uint8Array([0x01, 0x02, 0x03])]); + }); + + it('should receive a v2 MULTI PACKET + COMPRESSED text data stream', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + + // NOTE: mostly incompressible, but the hello world parts repeating should mean that the compressed + // contents is smaller than the full uncompressed data. + const text = new Array(30) + .fill(null) + .map(() => `hello world${randomText(1_000)}`) + .join(''); + + const compressed = await deflateRawCompress(new TextEncoder().encode(text)); + + // Make sure the compressed text should be able to be split into two "packets" worth of data + expect(compressed.length).toBeLessThan(2 * STREAM_CHUNK_SIZE_BYTES); + + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + totalLength: BigInt(text.length), + compression: DataStream_CompressionType.DEFLATE_RAW, + contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamChunk', + value: new DataStream_Chunk({ + streamId, + chunkIndex: 0n, + content: compressed.slice(0, STREAM_CHUNK_SIZE_BYTES), + version: 0, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamChunk', + value: new DataStream_Chunk({ + streamId, + chunkIndex: 1n, + content: compressed.slice(STREAM_CHUNK_SIZE_BYTES), + version: 0, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamTrailer', + value: new DataStream_Trailer({ streamId }), + }, + }), + Encryption_Type.NONE, + ); + + const reader = await readerPromise; + expect(await reader.readAll()).toStrictEqual(text); + }); + + it(`should ignore a v2 TEXT data stream with compression if DecompressionStream doesn't exist`, async () => { + const text = 'hello world'; + const compressed = await deflateRawCompress(new TextEncoder().encode(text)); + + let originalCompressionStream: typeof CompressionStream, + originalDecompressionStream: typeof DecompressionStream; + try { + originalCompressionStream = CompressionStream; + (globalThis as any).CompressionStream = undefined; + originalDecompressionStream = DecompressionStream; + (globalThis as any).DecompressionStream = undefined; + + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + totalLength: BigInt(text.length), + compression: DataStream_CompressionType.DEFLATE_RAW, + contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamChunk', + value: new DataStream_Chunk({ + streamId, + chunkIndex: 0n, + content: compressed, + version: 0, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamTrailer', + value: new DataStream_Trailer({ streamId }), + }, + }), + Encryption_Type.NONE, + ); + + // Make sure promise is still pending; the data stream should have been dropped + await expect( + Promise.race([readerPromise, Promise.resolve('still pending')]), + ).resolves.toStrictEqual('still pending'); + } finally { + (globalThis as any).CompressionStream = originalCompressionStream!; + (globalThis as any).DecompressionStream = originalDecompressionStream!; + } + }); + + it(`should ignore a v2 BYTES data stream with compression if DecompressionStream doesn't exist`, async () => { + const bytes = new Uint8Array([0x01, 0x02, 0x03]); + const compressed = await deflateRawCompress(bytes); + + let originalCompressionStream: typeof CompressionStream, + originalDecompressionStream: typeof DecompressionStream; + try { + originalCompressionStream = CompressionStream; + (globalThis as any).CompressionStream = undefined; + originalDecompressionStream = DecompressionStream; + (globalThis as any).DecompressionStream = undefined; + + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'application/octet-stream', + timestamp: 0n, + totalLength: BigInt(bytes.length), + compression: DataStream_CompressionType.DEFLATE_RAW, + contentHeader: { case: 'byteHeader', value: new DataStream_ByteHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamChunk', + value: new DataStream_Chunk({ + streamId, + chunkIndex: 0n, + content: compressed, + version: 0, + }), + }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamTrailer', + value: new DataStream_Trailer({ streamId }), + }, + }), + Encryption_Type.NONE, + ); + + // Make sure promise is still pending; the data stream should have been dropped + await expect( + Promise.race([readerPromise, Promise.resolve('still pending')]), + ).resolves.toStrictEqual('still pending'); + } finally { + (globalThis as any).CompressionStream = originalCompressionStream!; + (globalThis as any).DecompressionStream = originalDecompressionStream!; + } + }); + + it('should receive a v2 SINGLE PACKET text data stream with zero length', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'text/plain', + timestamp: 0n, + totalLength: 0n, + attributes: { foo: 'bar' }, + inlineContent: new Uint8Array(0), // Empty buffer that is 0 bytes long + contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + + const reader = await readerPromise; + expect(await reader.readAll()).toStrictEqual(''); + expect(reader.info.attributes?.foo).toStrictEqual('bar'); + }); + + it('should receive a v2 SINGLE PACKET byte data stream with zero length', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + manager.handleDataStreamPacket( + new DataPacket({ + participantIdentity: 'alice', + value: { + case: 'streamHeader', + value: new DataStream_Header({ + streamId, + topic: 'my-topic', + mimeType: 'byte/plain', + timestamp: 0n, + totalLength: 0n, + attributes: { foo: 'bar' }, + inlineContent: new Uint8Array(0), // Empty buffer that is 0 bytes long + contentHeader: { case: 'byteHeader', value: new DataStream_ByteHeader({}) }, + }), + }, + }), + Encryption_Type.NONE, + ); + + const reader = await readerPromise; + expect(await reader.readAll()).toStrictEqual([new Uint8Array(0)]); + expect(reader.info.attributes?.foo).toStrictEqual('bar'); + }); + + it('should receive a v2 MULTI PACKET + COMPRESSED byte data stream', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + // Incompressible, so the compressed form stays large enough to need two chunks (but fewer + // than three). + const bytes = randomBytes(20_000); + const compressed = await deflateRawCompress(bytes); + expect(compressed.length).toBeGreaterThan(STREAM_CHUNK_SIZE_BYTES); + expect(compressed.length).toBeLessThan(2 * STREAM_CHUNK_SIZE_BYTES); + + manager.handleDataStreamPacket( + headerPacket(streamId, 'byteHeader', { + totalLength: BigInt(bytes.length), + compression: DataStream_CompressionType.DEFLATE_RAW, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + chunkPacket(streamId, 0, compressed.slice(0, STREAM_CHUNK_SIZE_BYTES)), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + chunkPacket(streamId, 1, compressed.slice(STREAM_CHUNK_SIZE_BYTES)), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE); + + const reader = await readerPromise; + expect(concatChunks(await reader.readAll())).toStrictEqual(bytes); + }); + + it('should drop a duplicate chunk index on a compressed stream and still decode', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const text = new Array(30) + .fill(null) + .map(() => `hello world${randomText(1_000)}`) + .join(''); + const compressed = await deflateRawCompress(new TextEncoder().encode(text)); + expect(compressed.length).toBeGreaterThan(STREAM_CHUNK_SIZE_BYTES); + expect(compressed.length).toBeLessThan(2 * STREAM_CHUNK_SIZE_BYTES); + + manager.handleDataStreamPacket( + headerPacket(streamId, 'textHeader', { + totalLength: BigInt(text.length), + compression: DataStream_CompressionType.DEFLATE_RAW, + }), + Encryption_Type.NONE, + ); + const chunk0 = chunkPacket(streamId, 0, compressed.slice(0, STREAM_CHUNK_SIZE_BYTES)); + manager.handleDataStreamPacket(chunk0, Encryption_Type.NONE); + // A replayed chunk (e.g. reconnect logic) must be dropped with a warning, not fed to the + // stateful decompressor a second time. + manager.handleDataStreamPacket(chunk0, Encryption_Type.NONE); + manager.handleDataStreamPacket( + chunkPacket(streamId, 1, compressed.slice(STREAM_CHUNK_SIZE_BYTES)), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE); + + const reader = await readerPromise; + expect(await reader.readAll()).toStrictEqual(text); + }); + + it('should error on a gap in chunk indices on a compressed stream', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const text = new Array(30) + .fill(null) + .map(() => `hello world${randomText(1_000)}`) + .join(''); + const compressed = await deflateRawCompress(new TextEncoder().encode(text)); + + manager.handleDataStreamPacket( + headerPacket(streamId, 'textHeader', { + totalLength: BigInt(text.length), + compression: DataStream_CompressionType.DEFLATE_RAW, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + chunkPacket(streamId, 0, compressed.slice(0, STREAM_CHUNK_SIZE_BYTES)), + Encryption_Type.NONE, + ); + // Skip chunk index 1 entirely β€” the stateful decompressor cannot continue past a gap. + manager.handleDataStreamPacket( + chunkPacket(streamId, 2, compressed.slice(STREAM_CHUNK_SIZE_BYTES)), + Encryption_Type.NONE, + ); + + const reader = await readerPromise; + await expect(reader.readAll()).rejects.toThrow('Missing chunk(s)'); + }); + + it('should reframe multibyte UTF-8 on chunk boundaries when decompressing a text stream', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const text = 'πŸ˜€δ½ ε₯½δΈ–η•Œ cafΓ© β€” ‘ñandΓΊ! '.repeat(500); + const textBytes = new TextEncoder().encode(text); + const compressed = await deflateRawCompress(textBytes); + + // Split the compressed bytes at an arbitrary midpoint: the decompressor's output at the + // seam can land mid-codepoint, exercising the UTF-8 reframing stage. + const split = Math.floor(compressed.length / 2); + + manager.handleDataStreamPacket( + headerPacket(streamId, 'textHeader', { + totalLength: BigInt(textBytes.length), // NOTE: byte length, not character count + compression: DataStream_CompressionType.DEFLATE_RAW, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + chunkPacket(streamId, 0, compressed.slice(0, split)), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + chunkPacket(streamId, 1, compressed.slice(split)), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE); + + const reader = await readerPromise; + expect(await reader.readAll()).toStrictEqual(text); + }); + + it('should error when a compressed stream decompresses to fewer bytes than totalLength', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const compressed = await deflateRawCompress(new TextEncoder().encode('hello world')); // 11 bytes + + manager.handleDataStreamPacket( + headerPacket(streamId, 'textHeader', { + totalLength: 16n, // more than the decompressed payload + compression: DataStream_CompressionType.DEFLATE_RAW, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket(chunkPacket(streamId, 0, compressed), Encryption_Type.NONE); + manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE); + + // The reader counts DECOMPRESSED bytes against totalLength. + const reader = await readerPromise; + await expect(reader.readAll()).rejects.toThrow('Not enough chunk(s)'); + }); + + it('should error when a compressed stream decompresses to more bytes than totalLength', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const compressed = await deflateRawCompress(new TextEncoder().encode('hello world')); // 11 bytes + + manager.handleDataStreamPacket( + headerPacket(streamId, 'textHeader', { + totalLength: 5n, // fewer than the decompressed payload + compression: DataStream_CompressionType.DEFLATE_RAW, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket(chunkPacket(streamId, 0, compressed), Encryption_Type.NONE); + manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE); + + const reader = await readerPromise; + await expect(reader.readAll()).rejects.toThrow('Extra chunk(s)'); + }); + + it('should merge trailer attributes on a compressed stream', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const text = 'hello world'; + const compressed = await deflateRawCompress(new TextEncoder().encode(text)); + + manager.handleDataStreamPacket( + headerPacket(streamId, 'textHeader', { + totalLength: BigInt(text.length), + compression: DataStream_CompressionType.DEFLATE_RAW, + attributes: { foo: 'bar', baz: 'quux' }, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket(chunkPacket(streamId, 0, compressed), Encryption_Type.NONE); + manager.handleDataStreamPacket( + trailerPacket(streamId, { hello: 'world', foo: 'updated' }), + Encryption_Type.NONE, + ); + + const reader = await readerPromise; + expect(await reader.readAll()).toStrictEqual(text); + expect(reader.info.attributes?.baz).toStrictEqual('quux'); + expect(reader.info.attributes?.hello).toStrictEqual('world'); + expect(reader.info.attributes?.foo).toStrictEqual('updated'); + }); + + it('should ignore a stream with an unknown compression type', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const text = 'hello world'; + + manager.handleDataStreamPacket( + headerPacket(streamId, 'textHeader', { + totalLength: BigInt(text.length), + compression: 99 as DataStream_CompressionType, + }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + chunkPacket(streamId, 0, new TextEncoder().encode(text)), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE); + + // A compression type from a future protocol version can't be decoded; the stream is + // dropped and the handler is never invoked. + await expect( + Promise.race([readerPromise, Promise.resolve('still pending')]), + ).resolves.toStrictEqual('still pending'); + }); + }); + + describe('Receive-side protocol edge cases', () => { + it('should reject a duplicate TEXT streamId whose stream is already open', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readers: Array = []; + manager.registerTextStreamHandler('my-topic', (reader) => readers.push(reader)); + + const streamId = crypto.randomUUID(); + const header = headerPacket(streamId, 'textHeader', { totalLength: 11n }); + + manager.handleDataStreamPacket(header, Encryption_Type.NONE); + expect(readers).toHaveLength(1); + + // A second header re-using an open streamId must be rejected, not silently replace (or + // corrupt) the in-flight stream. + expect(() => manager.handleDataStreamPacket(header, Encryption_Type.NONE)).toThrow( + 'already in progress', + ); + expect(readers).toHaveLength(1); + }); + + it('should reject a duplicate BYTE streamId whose stream is already open', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readers: Array = []; + manager.registerByteStreamHandler('my-topic', (reader) => readers.push(reader)); + + const streamId = crypto.randomUUID(); + const header = headerPacket(streamId, 'byteHeader', { totalLength: 4n }); + + manager.handleDataStreamPacket(header, Encryption_Type.NONE); + expect(readers).toHaveLength(1); + + expect(() => manager.handleDataStreamPacket(header, Encryption_Type.NONE)).toThrow( + 'already in progress', + ); + expect(readers).toHaveLength(1); + }); + + it('should ignore empty chunks', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const text = 'hello world'; + + manager.handleDataStreamPacket( + headerPacket(streamId, 'textHeader', { totalLength: BigInt(text.length) }), + Encryption_Type.NONE, + ); + // An empty chunk must not count against totalLength or corrupt the stream. + manager.handleDataStreamPacket( + chunkPacket(streamId, 0, new Uint8Array(0)), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + chunkPacket(streamId, 1, new TextEncoder().encode(text)), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE); + + const reader = await readerPromise; + expect(await reader.readAll()).toStrictEqual(text); + }); + + it('should silently drop packets for a topic with no registered handler', () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const streamId = crypto.randomUUID(); + + // No handler registered for the topic: header, chunks, and trailer are all dropped + // without raising. + expect(() => { + manager.handleDataStreamPacket( + headerPacket(streamId, 'textHeader', { totalLength: 11n }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + chunkPacket(streamId, 0, new TextEncoder().encode('hello world')), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE); + }).not.toThrow(); + }); + + it('should error the reader when the trailer reports an abnormal close reason', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const text = 'hello world'; + + manager.handleDataStreamPacket( + headerPacket(streamId, 'textHeader', { totalLength: BigInt(text.length) }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + chunkPacket(streamId, 0, new TextEncoder().encode(text)), + Encryption_Type.NONE, + ); + // A non-empty trailer reason means the sender aborted; the stream must not be reported as + // a successful close. + manager.handleDataStreamPacket( + trailerPacket(streamId, undefined, 'cancelled'), + Encryption_Type.NONE, + ); + + const reader = await readerPromise; + await expect(reader.readAll()).rejects.toThrow('closed abnormally: cancelled'); + }); + + it('should error an inline compressed payload that inflates past the max payload size', async () => { + // Decompression-bomb guard: a tiny compressed inline payload must not be allowed to + // expand without bound. + const manager = new IncomingDataStreamManager(1_000); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const text = randomText(50_000); + const textBytes = new TextEncoder().encode(text); + const compressed = await deflateRawCompress(textBytes); + + manager.handleDataStreamPacket( + headerPacket(streamId, 'textHeader', { + totalLength: BigInt(textBytes.length), + compression: DataStream_CompressionType.DEFLATE_RAW, + inlineContent: compressed, + }), + Encryption_Type.NONE, + ); + + const reader = await readerPromise; + await expect(reader.readAll()).rejects.toThrow('maximum payload size'); + }); + + it('should error a chunked compressed stream that inflates past the max payload size', async () => { + const manager = new IncomingDataStreamManager(50_000); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const text = randomText(60_000); // decompresses past the 50k cap + const textBytes = new TextEncoder().encode(text); + const compressed = await deflateRawCompress(textBytes); + + manager.handleDataStreamPacket( + headerPacket(streamId, 'textHeader', { + totalLength: BigInt(textBytes.length), + compression: DataStream_CompressionType.DEFLATE_RAW, + }), + Encryption_Type.NONE, + ); + for (let offset = 0, index = 0; offset < compressed.length; offset += 15_000, index += 1) { + manager.handleDataStreamPacket( + chunkPacket(streamId, index, compressed.slice(offset, offset + 15_000)), + Encryption_Type.NONE, + ); + } + manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE); + + const reader = await readerPromise; + await expect(reader.readAll()).rejects.toThrow('maximum payload size'); + }); + + it('should error the reader when the trailer has a mismatched encryption type', async () => { + const manager = new IncomingDataStreamManager(); + manager.setConnected(true); + + const readerPromise = new Promise((resolve) => { + manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader)); + }); + + const streamId = crypto.randomUUID(); + const text = 'hello world'; + + manager.handleDataStreamPacket( + headerPacket(streamId, 'textHeader', { totalLength: BigInt(text.length) }), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket( + chunkPacket(streamId, 0, new TextEncoder().encode(text)), + Encryption_Type.NONE, + ); + manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.GCM); + + const reader = await readerPromise; + await expect(reader.readAll()).rejects.toThrow('Encryption type mismatch'); + }); + }); +}); diff --git a/src/room/data-stream/incoming/IncomingDataStreamManager.ts b/src/room/data-stream/incoming/IncomingDataStreamManager.ts index 0ac45e793e..5e10b0aae6 100644 --- a/src/room/data-stream/incoming/IncomingDataStreamManager.ts +++ b/src/room/data-stream/incoming/IncomingDataStreamManager.ts @@ -1,14 +1,18 @@ import { type DataPacket, DataStream_Chunk, + DataStream_CompressionType, DataStream_Header, DataStream_Trailer, Encryption_Type, } from '@livekit/protocol'; import log from '../../../logger'; +import { type NonSharedUint8Array } from '../../../type-polyfills/non-shared-typed-arrays'; import { DataStreamError, DataStreamErrorReason } from '../../errors'; import { type ByteStreamInfo, type StreamController, type TextStreamInfo } from '../../types'; -import { bigIntToNumber } from '../../utils'; +import { bigIntToNumber, isCompressionStreamSupported, numberToBigInt } from '../../utils'; +import { deflateRawDecompress, inflateRawTransform } from '../compression'; +import { DEFAULT_MAX_PAYLOAD_BYTE_LENGTH } from '../constants'; import { type ByteStreamHandler, ByteStreamReader, @@ -19,6 +23,14 @@ import { export default class IncomingDataStreamManager { private log = log; + /** Max number of decompressed bytes an incoming compressed stream may produce before it is + * errored (decompression-bomb guard). */ + private maxPayloadByteLength: number; + + constructor(maxPayloadByteLength: number = DEFAULT_MAX_PAYLOAD_BYTE_LENGTH) { + this.maxPayloadByteLength = maxPayloadByteLength; + } + private byteStreamControllers = new Map>(); private textStreamControllers = new Map>(); @@ -134,99 +146,213 @@ export default class IncomingDataStreamManager { participantIdentity: string, encryptionType: Encryption_Type, ) { - if (streamHeader.contentHeader.case === 'byteHeader') { - const streamHandlerCallback = this.byteStreamHandlers.get(streamHeader.topic); - if (!streamHandlerCallback) { - this.log.debug( - 'ignoring incoming byte stream due to no handler for topic', - streamHeader.topic, - ); - return; - } + switch (streamHeader.contentHeader.case) { + case 'byteHeader': { + const streamHandlerCallback = this.byteStreamHandlers.get(streamHeader.topic); + if (!streamHandlerCallback) { + this.log.debug( + 'ignoring incoming byte stream due to no handler for topic', + streamHeader.topic, + ); + return; + } + + let streamController: ReadableStreamDefaultController; + + const info: ByteStreamInfo = { + id: streamHeader.streamId, + name: streamHeader.contentHeader.value.name ?? 'unknown', + mimeType: streamHeader.mimeType, + size: streamHeader.totalLength ? Number(streamHeader.totalLength) : undefined, + topic: streamHeader.topic, + timestamp: bigIntToNumber(streamHeader.timestamp), + attributes: streamHeader.attributes, + encryptionType, + }; - let streamController: ReadableStreamDefaultController; - - const info: ByteStreamInfo = { - id: streamHeader.streamId, - name: streamHeader.contentHeader.value.name ?? 'unknown', - mimeType: streamHeader.mimeType, - size: streamHeader.totalLength ? Number(streamHeader.totalLength) : undefined, - topic: streamHeader.topic, - timestamp: bigIntToNumber(streamHeader.timestamp), - attributes: streamHeader.attributes, - encryptionType, - }; - const stream = new ReadableStream({ - start: (controller) => { - streamController = controller; - - if (this.textStreamControllers.has(streamHeader.streamId)) { - throw new DataStreamError( - `A data stream read is already in progress for a stream with id ${streamHeader.streamId}.`, - DataStreamErrorReason.AlreadyOpened, + // Determine if the byte payload needs to be decompressed. + let compressed; + switch (streamHeader.compression) { + case DataStream_CompressionType.DEFLATE_RAW: + if (!isCompressionStreamSupported()) { + // NOTE: this shouldn't really ever happen, if this warning is logged then the sender + // isn't properly abiding by the data streams v2 protocol. + log.warn( + `Data stream ${streamHeader.streamId} received with deflate-raw compression, but this browser does not have support for DecompressionStream. Dropping...`, + ); + return; + } + compressed = true; + break; + case DataStream_CompressionType.NONE: + compressed = false; + break; + default: + // NOTE: this shouldn't really ever happen, if this warning is logged then the sender + // isn't properly abiding by the data streams v2 protocol. + log.warn( + `Data stream ${streamHeader.streamId} received with unknown compression type ${streamHeader.compression}, dropping...`, ); - } + return; + } + + // Single-packet stream: the entire payload was packaged into the header's `inlineContent`. + // Synthesize an already-complete stream and skip waiting for chunk/trailer packets. + const inlineContent = streamHeader.inlineContent as NonSharedUint8Array; + if (typeof inlineContent !== 'undefined') { + // Inline bytes are the raw payload, optionally deflate-raw compressed. + streamHandlerCallback( + new ByteStreamReader( + info, + createInlineStream( + streamHeader.streamId, + compressed + ? deflateRawDecompress(inlineContent, this.maxPayloadByteLength) + : inlineContent, + ), + bigIntToNumber(streamHeader.totalLength), + ), + { identity: participantIdentity }, + ); + return; + } + + const stream = new ReadableStream({ + start: (controller) => { + streamController = controller; + + if (this.byteStreamControllers.has(streamHeader.streamId)) { + throw new DataStreamError( + `A data stream read is already in progress for a stream with id ${streamHeader.streamId}.`, + DataStreamErrorReason.AlreadyOpened, + ); + } - this.byteStreamControllers.set(streamHeader.streamId, { + this.byteStreamControllers.set(streamHeader.streamId, { + info, + controller: streamController, + startTime: Date.now(), + sendingParticipantIdentity: participantIdentity, + }); + }, + }); + streamHandlerCallback( + new ByteStreamReader( info, - controller: streamController, - startTime: Date.now(), - sendingParticipantIdentity: participantIdentity, - }); - }, - }); - streamHandlerCallback( - new ByteStreamReader(info, stream, bigIntToNumber(streamHeader.totalLength)), - { - identity: participantIdentity, - }, - ); - } else if (streamHeader.contentHeader.case === 'textHeader') { - const streamHandlerCallback = this.textStreamHandlers.get(streamHeader.topic); - if (!streamHandlerCallback) { - this.log.debug( - 'ignoring incoming text stream due to no handler for topic', - streamHeader.topic, + compressed + ? inflateRawByteChunkStream(stream, streamHeader.streamId, this.maxPayloadByteLength) + : stream, + // `totalLength` is the pre-compression size, and the reader counts decompressed bytes, + // so it applies to both paths (mirrors text). + bigIntToNumber(streamHeader.totalLength), + ), + { + identity: participantIdentity, + }, ); return; } + case 'textHeader': { + const streamHandlerCallback = this.textStreamHandlers.get(streamHeader.topic); + if (!streamHandlerCallback) { + this.log.debug( + 'ignoring incoming text stream due to no handler for topic', + streamHeader.topic, + ); + return; + } - let streamController: ReadableStreamDefaultController; - - const info: TextStreamInfo = { - id: streamHeader.streamId, - mimeType: streamHeader.mimeType, - size: streamHeader.totalLength ? Number(streamHeader.totalLength) : undefined, - topic: streamHeader.topic, - timestamp: Number(streamHeader.timestamp), - attributes: streamHeader.attributes, - encryptionType, - attachedStreamIds: streamHeader.contentHeader.value.attachedStreamIds, - }; - - const stream = new ReadableStream({ - start: (controller) => { - streamController = controller; - - if (this.textStreamControllers.has(streamHeader.streamId)) { - throw new DataStreamError( - `A data stream read is already in progress for a stream with id ${streamHeader.streamId}.`, - DataStreamErrorReason.AlreadyOpened, + let streamController: ReadableStreamDefaultController; + + const info: TextStreamInfo = { + id: streamHeader.streamId, + mimeType: streamHeader.mimeType, + size: streamHeader.totalLength ? Number(streamHeader.totalLength) : undefined, + topic: streamHeader.topic, + timestamp: Number(streamHeader.timestamp), + attributes: streamHeader.attributes, + encryptionType, + attachedStreamIds: streamHeader.contentHeader.value.attachedStreamIds, + }; + + // Determine if the byte payload needs to be decompressed. + let compressed; + switch (streamHeader.compression) { + case DataStream_CompressionType.DEFLATE_RAW: + if (!isCompressionStreamSupported()) { + // NOTE: this shouldn't really ever happen, if this warning is logged then the sender + // isn't properly abiding by the data streams v2 protocol. + log.warn( + `Data stream ${streamHeader.streamId} received with deflate-raw compression, but this browser does not have support for DecompressionStream. Dropping...`, + ); + return; + } + compressed = true; + break; + case DataStream_CompressionType.NONE: + compressed = false; + break; + default: + // NOTE: this shouldn't really ever happen, if this warning is logged then the sender + // isn't properly abiding by the data streams v2 protocol. + log.warn( + `Data stream ${streamHeader.streamId} received with unknown compression type ${streamHeader.compression}, dropping...`, ); - } + return; + } + + // Single-packet stream: the entire payload was smuggled into the header's `inlineContent`. + // Synthesize an already-complete stream and skip waiting for chunk/trailer packets. + const inlineContent = streamHeader.inlineContent as NonSharedUint8Array; + if (typeof inlineContent !== 'undefined') { + // Inline text is the raw UTF-8 payload, optionally deflate-raw compressed. + const content = compressed + ? deflateRawDecompress(inlineContent, this.maxPayloadByteLength) + : inlineContent; + streamHandlerCallback( + new TextStreamReader( + info, + createInlineStream(streamHeader.streamId, content), + bigIntToNumber(streamHeader.totalLength), + ), + { identity: participantIdentity }, + ); + return; + } + + const stream = new ReadableStream({ + start: (controller) => { + streamController = controller; + + if (this.textStreamControllers.has(streamHeader.streamId)) { + throw new DataStreamError( + `A data stream read is already in progress for a stream with id ${streamHeader.streamId}.`, + DataStreamErrorReason.AlreadyOpened, + ); + } - this.textStreamControllers.set(streamHeader.streamId, { + this.textStreamControllers.set(streamHeader.streamId, { + info, + controller: streamController, + startTime: Date.now(), + sendingParticipantIdentity: participantIdentity, + }); + }, + }); + streamHandlerCallback( + new TextStreamReader( info, - controller: streamController, - startTime: Date.now(), - sendingParticipantIdentity: participantIdentity, - }); - }, - }); - streamHandlerCallback( - new TextStreamReader(info, stream, bigIntToNumber(streamHeader.totalLength)), - { identity: participantIdentity }, - ); + compressed + ? inflateRawChunkStream(stream, streamHeader.streamId, this.maxPayloadByteLength) + : stream, + // `totalLength` is the pre-compression size, and the reader sees decompressed bytes, so + // it applies to both paths. + bigIntToNumber(streamHeader.totalLength), + ), + { identity: participantIdentity }, + ); + return; + } } } @@ -273,9 +399,20 @@ export default class IncomingDataStreamManager { ); } else { textBuffer.info.attributes = { ...textBuffer.info.attributes, ...trailer.attributes }; - textBuffer.controller.close(); - this.textStreamControllers.delete(trailer.streamId); + if (trailer.reason) { + // A non-empty reason marks an abnormal close by the sender (e.g. an aborted send); + // surface it as an error rather than pretending the stream completed. + textBuffer.controller.error( + new DataStreamError( + `Data stream ${trailer.streamId} closed abnormally: ${trailer.reason}`, + DataStreamErrorReason.AbnormalEnd, + ), + ); + } else { + textBuffer.controller.close(); + } } + this.textStreamControllers.delete(trailer.streamId); } const fileBuffer = this.byteStreamControllers.get(trailer.streamId); @@ -289,9 +426,224 @@ export default class IncomingDataStreamManager { ); } else { fileBuffer.info.attributes = { ...fileBuffer.info.attributes, ...trailer.attributes }; - fileBuffer.controller.close(); + if (trailer.reason) { + // A non-empty reason marks an abnormal close by the sender (e.g. an aborted send); + // surface it as an error rather than pretending the stream completed. + fileBuffer.controller.error( + new DataStreamError( + `Data stream ${trailer.streamId} closed abnormally: ${trailer.reason}`, + DataStreamErrorReason.AbnormalEnd, + ), + ); + } else { + fileBuffer.controller.close(); + } } this.byteStreamControllers.delete(trailer.streamId); } } } + +/** + * Builds a `ReadableStream` that yields the given content as a single chunk and then immediately + * closes - used to surface an inline (single-packet) data stream as a fully-formed stream. `content` + * may be a promise (e.g. async gzip decompression); a rejection errors the stream. + */ +function createInlineStream( + streamId: string, + content: Uint8Array | Promise, +): ReadableStream { + return new ReadableStream({ + start: async (controller) => { + try { + const bytes = await content; + controller.enqueue( + new DataStream_Chunk({ streamId, chunkIndex: BigInt(0), content: bytes }), + ); + controller.close(); + } catch (err) { + controller.error(err); + } + }, + }); +} + +/** + * Validates that chunks are received in order, dropping duplicates and throwing if gaps are found. + * + * A stateful decompressor silently corrupts on duplicated or out-of-order input, so duplicates are + * dropped (with a warning - in-order delivery is expected on the reliable channel, but reconnect + * handling may replay) and a gap is a hard error. Shared by the text and byte deflate-raw decoders. + */ +function ensureOrderedChunks( + streamId: string, +): TransformStream { + let lastChunkIndex = -1; + return new TransformStream({ + transform: (value, controller) => { + const index = bigIntToNumber(value.chunkIndex); + if (index <= lastChunkIndex) { + log.warn( + `ignoring duplicate chunk ${index} for compressed data stream ${streamId} (last processed: ${lastChunkIndex})`, + ); + return; + } + if (index > lastChunkIndex + 1) { + throw new DataStreamError( + `Missing chunk(s) ${lastChunkIndex + 1}..${index - 1} for compressed data stream ${streamId} - cannot continue decompressing`, + DataStreamErrorReason.Incomplete, + ); + } + lastChunkIndex = index; + controller.enqueue(value); + }, + }); +} + +/** Unwraps compressed `DataStream_Chunk`s to their compressed bytes (in `chunkIndex` order), */ +function chunksToBytes(): TransformStream { + return new TransformStream({ + transform: (value, controller) => { + controller.enqueue(value.content); + }, + }); +} + +/** Re-wraps decompressed bytes into contiguous `DataStream_Chunk`s, skipping inflate's empty reads. */ +function bytesToChunks(streamId: string): TransformStream { + let outIndex = 0; + return new TransformStream({ + transform: (value, controller) => { + // Inflate can emit empty reads; only synthesize a chunk when there is content. + if (value.byteLength > 0) { + controller.enqueue( + new DataStream_Chunk({ + streamId, + chunkIndex: numberToBigInt(outIndex), + content: value, + }), + ); + outIndex += 1; + } + }, + }); +} + +/** + * Reframes decompressed byte chunks onto UTF-8 character boundaries via a streaming `TextDecoder` + * (a write larger than the MTU spans several packets, which may split a codepoint), so each + * synthesized text chunk decodes independently. The `flush` emits the decoder's trailing bytes. + */ +function bytesToDecodedUtf8(streamId: string): TransformStream { + const decoder = new TextDecoder('utf-8', { fatal: true }); + const encoder = new TextEncoder(); + + let outIndex = 0; + const decodeOrThrow = (bytes?: Uint8Array): string => { + try { + return bytes ? decoder.decode(bytes, { stream: true }) : decoder.decode(); + } catch (err) { + throw new DataStreamError( + `Cannot decode compressed data stream ${streamId} as text: ${err}`, + DataStreamErrorReason.DecodeFailed, + ); + } + }; + + return new TransformStream({ + transform: (value, controller) => { + const text = decodeOrThrow(value); + // Everything so far may have been a partial codepoint; only emit once we have characters. + if (text.length > 0) { + controller.enqueue( + new DataStream_Chunk({ + streamId, + chunkIndex: numberToBigInt(outIndex), + content: encoder.encode(text), + }), + ); + outIndex += 1; + } + }, + flush: (controller) => { + const tail = decodeOrThrow(); + if (tail.length > 0) { + controller.enqueue( + new DataStream_Chunk({ + streamId, + chunkIndex: numberToBigInt(outIndex), + content: encoder.encode(tail), + }), + ); + outIndex += 1; + } + }, + }); +} + +/** + * Transforms a stream of deflate-raw-compressed byte `DataStream_Chunk`s into a stream of + * decompressed chunks, so `ByteStreamReader` consumes it unchanged. All chunk contents are fed (in + * `chunkIndex` order) through ONE raw-deflate decompressor for the stream's lifetime; decompressed + * output is re-wrapped as chunks as soon as it is produced. The sender (sendFile) compresses the + * whole payload in one shot, but the format also supports a single context-takeover stream + * sync-flushed at write boundaries, so a future incremental streamBytes could compress with no + * protocol change. Errors and cancellation propagate through the pipe chain. + */ +function inflateRawByteChunkStream( + raw: ReadableStream, + streamId: string, + maxPayloadByteLength: number, +): ReadableStream { + return raw + .pipeThrough(ensureOrderedChunks(streamId)) + .pipeThrough(chunksToBytes()) + .pipeThrough(inflateRawTransform()) + .pipeThrough(maxDecompressedLengthGuard(streamId, maxPayloadByteLength)) + .pipeThrough(bytesToChunks(streamId)); +} + +/** + * Transforms a stream of deflate-raw-compressed text `DataStream_Chunk`s into a stream of + * decompressed chunks, so `TextStreamReader` consumes it unchanged. Builds on + * {@link inflateRawByteChunkStream} (single decompressor + ordering guard) and adds a streaming + * `TextDecoder` that reframes the decompressed bytes on UTF-8 character boundaries so each + * synthesized chunk decodes independently. Errors and cancellation propagate through the pipe chain. + */ +function inflateRawChunkStream( + raw: ReadableStream, + streamId: string, + maxPayloadByteLength: number, +): ReadableStream { + return raw + .pipeThrough(ensureOrderedChunks(streamId)) + .pipeThrough(chunksToBytes()) + .pipeThrough(inflateRawTransform()) + .pipeThrough(maxDecompressedLengthGuard(streamId, maxPayloadByteLength)) + .pipeThrough(bytesToDecodedUtf8(streamId)); +} + +/** + * Caps the total decompressed byte count of a compressed stream (decompression-bomb guard): a + * tiny compressed payload can inflate to an arbitrarily large output, so the decompressor's + * output is bounded rather than trusting the wire size. Exceeding the cap errors the stream with + * `PayloadTooLarge`. + */ +function maxDecompressedLengthGuard( + streamId: string, + maxByteLength: number, +): TransformStream { + let total = 0; + return new TransformStream({ + transform: (value, controller) => { + total += value.byteLength; + if (total > maxByteLength) { + throw new DataStreamError( + `Data stream ${streamId} exceeds the maximum payload size of ${maxByteLength} bytes`, + DataStreamErrorReason.PayloadTooLarge, + ); + } + controller.enqueue(value); + }, + }); +} diff --git a/src/room/data-stream/incoming/StreamReader.ts b/src/room/data-stream/incoming/StreamReader.ts index 4d5e0c2a4d..bd700285f0 100644 --- a/src/room/data-stream/incoming/StreamReader.ts +++ b/src/room/data-stream/incoming/StreamReader.ts @@ -105,6 +105,9 @@ export class ByteStreamReader extends BaseStreamReader { ); if (result.done) { this.validateBytesReceived(true); + if (typeof this.totalByteSize === 'number') { + this.onProgress?.(1); + } return { done: true, value: undefined as any }; } else { this.handleChunkReceived(result.value); @@ -151,7 +154,7 @@ export class ByteStreamReader extends BaseStreamReader { * A class to read chunks from a ReadableStream and provide them in a structured format. */ export class TextStreamReader extends BaseStreamReader { - private receivedChunks: Map; + private receivedChunks: Map; signal?: AbortSignal; @@ -233,11 +236,14 @@ export class TextStreamReader extends BaseStreamReader { ); if (result.done) { this.validateBytesReceived(true); + if (typeof this.totalByteSize === 'number') { + this.onProgress?.(1); + } return { done: true, value: undefined }; } else { this.handleChunkReceived(result.value); - let decodedResult; + let decodedResult: string; try { decodedResult = decoder.decode(result.value.content); } catch (err) { diff --git a/src/room/data-stream/outgoing/OutgoingDataStreamManager.test.ts b/src/room/data-stream/outgoing/OutgoingDataStreamManager.test.ts new file mode 100644 index 0000000000..bb0425d3d2 --- /dev/null +++ b/src/room/data-stream/outgoing/OutgoingDataStreamManager.test.ts @@ -0,0 +1,1292 @@ +import { + ClientInfo_Capability, + type DataPacket, + type DataStream_ByteHeader, + DataStream_CompressionType, + type DataStream_TextHeader, +} from '@livekit/protocol'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import log from '../../../logger'; +import { + CLIENT_PROTOCOL_DATA_STREAM_RPC, + CLIENT_PROTOCOL_DATA_STREAM_V2, + CLIENT_PROTOCOL_DEFAULT, +} from '../../../version'; +import type RTCEngine from '../../RTCEngine'; +import OutgoingDataStreamManager from './OutgoingDataStreamManager'; + +/** Builds a low quality random string of the given length. */ +function randomText(length: number): string { + let s = ''; + while (s.length < length) { + s += Math.random().toString(36).slice(2); + } + return s.slice(0, length); +} + +/** Fills a buffer with uniform random bytes β€” genuinely incompressible. */ +function randomBytes(length: number): Uint8Array { + const out = new Uint8Array(length); + // crypto.getRandomValues rejects requests over 65536 bytes, so chunk it. + for (let offset = 0; offset < length; offset += 65536) { + crypto.getRandomValues(out.subarray(offset, offset + 65536)); + } + return out; +} + +/** + * @param participants the remote participants in the room, mapped from identity to the client + * protocol each advertises. Defaults to a single v2 participant named "bob". + */ +function createManager( + participants: Record]> = { + bob: CLIENT_PROTOCOL_DATA_STREAM_V2, + }, +) { + const sentPackets: DataPacket[] = []; + const engine = { + sendDataPacket: vi.fn(async (packet: DataPacket) => { + sentPackets.push(packet); + }), + e2eeManager: undefined, + once: vi.fn(), + off: vi.fn(), + } as unknown as RTCEngine; + const manager = new OutgoingDataStreamManager( + engine, + log, + (identity) => + (Array.isArray(participants[identity]) + ? participants[identity][0] + : participants[identity]) ?? CLIENT_PROTOCOL_DEFAULT, + (identity) => + Array.isArray(participants[identity]) + ? participants[identity][1] + : [ClientInfo_Capability.CAP_COMPRESSION_DEFLATE_RAW], + () => Object.keys(participants), + ); + return { manager, sentPackets }; +} + +function headerOf(packet: DataPacket) { + return packet.value.value as Extract['value']; +} + +function chunkOf(packet: DataPacket) { + return packet.value.value as Extract['value']; +} + +function trailerOf(packet: DataPacket) { + return packet.value.value as Extract['value']; +} + +describe('OutgoingDataStreamManager', () => { + describe('v2 -> room of all v1', () => { + let manager: OutgoingDataStreamManager, sentPackets: Array; + beforeEach(() => { + const result = createManager({ + alice: CLIENT_PROTOCOL_DEFAULT, + bob: CLIENT_PROTOCOL_DEFAULT, + jim: CLIENT_PROTOCOL_DATA_STREAM_RPC, + }); + manager = result.manager; + sentPackets = result.sentPackets; + }); + + it('should send short TEXT data stream using non single packet "legacy" format and NO compression (happy path)', async () => { + const info = await manager.sendText('hello world', { + topic: 'my-topic', + }); + + // Make sure three packets were received, matching the legacy v1 data stream format + expect(sentPackets).toHaveLength(3); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('textHeader'); + + expect(sentPackets[1].value.case).toStrictEqual('streamChunk'); + const chunk = chunkOf(sentPackets[1]); + expect(chunk.streamId).toStrictEqual(info.id); + expect(chunk.chunkIndex).toStrictEqual(0n); + expect(chunk.content).toStrictEqual(new TextEncoder().encode('hello world')); + + expect(sentPackets[2].value.case).toStrictEqual('streamTrailer'); + const trailer = trailerOf(sentPackets[2]); + expect(trailer.streamId).toStrictEqual(info.id); + expect(trailer.reason).toStrictEqual(''); + }); + + it('should send short BYTE data stream using non single packet "legacy" format and NO compression (happy path)', async () => { + const writer = await manager.streamBytes({ + topic: 'my-topic', + }); + await writer.write(new Uint8Array([0x00, 0x01, 0x02, 0x03])); + await writer.close(); + + // Make sure three packets were received, matching the legacy v1 data stream format + expect(sentPackets).toHaveLength(3); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(writer.info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('byteHeader'); + + expect(sentPackets[1].value.case).toStrictEqual('streamChunk'); + const chunk = chunkOf(sentPackets[1]); + expect(chunk.streamId).toStrictEqual(writer.info.id); + expect(chunk.chunkIndex).toStrictEqual(0n); + expect(chunk.content).toStrictEqual(new Uint8Array([0x00, 0x01, 0x02, 0x03])); + + expect(sentPackets[2].value.case).toStrictEqual('streamTrailer'); + const trailer = trailerOf(sentPackets[2]); + expect(trailer.streamId).toStrictEqual(writer.info.id); + expect(trailer.reason).toStrictEqual(''); + }); + + it('should send long TEXT data stream without compression (happy path)', async () => { + const longPayload = new Array(40_000).fill('A').join(''); + const info = await manager.sendText(longPayload, { + topic: 'my-topic', + }); + + // Make sure five packets were received, matching the legacy v1 data stream format + expect(sentPackets).toHaveLength(5); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('textHeader'); + + for (let i = 0; i < 3; i += 1) { + expect(sentPackets[i + 1].value.case).toStrictEqual('streamChunk'); + const chunk = chunkOf(sentPackets[i + 1]); + expect(chunk.streamId).toStrictEqual(info.id); + expect(chunk.chunkIndex).toStrictEqual(BigInt(i)); + expect(chunk.content.every((char) => char === 'A'.charCodeAt(0))).toBeTruthy(); + } + + expect(sentPackets[4].value.case).toStrictEqual('streamTrailer'); + const trailer = trailerOf(sentPackets[4]); + expect(trailer.streamId).toStrictEqual(info.id); + expect(trailer.reason).toStrictEqual(''); + }); + + it('should send long BYTE data stream without compression (happy path)', async () => { + const writer = await manager.streamBytes({ + topic: 'my-topic', + }); + await writer.write(new Uint8Array(20_000).fill(0x01)); + await writer.write(new Uint8Array(20_000).fill(0x01)); + await writer.close(); + + // Make sure five packets were received, matching the legacy v1 data stream format + expect(sentPackets).toHaveLength(6); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(writer.info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('byteHeader'); + + // First write generates two packets, 15k long + 5k long + expect(sentPackets[1].value.case).toStrictEqual('streamChunk'); + let chunk = chunkOf(sentPackets[1]); + expect(chunk.streamId).toStrictEqual(writer.info.id); + expect(chunk.chunkIndex).toStrictEqual(0n); + expect(chunk.content).toHaveLength(15_000); // MTU + expect(chunk.content.every((byte) => byte === 0x01)).toBeTruthy(); + + expect(sentPackets[2].value.case).toStrictEqual('streamChunk'); + chunk = chunkOf(sentPackets[2]); + expect(chunk.streamId).toStrictEqual(writer.info.id); + expect(chunk.chunkIndex).toStrictEqual(1n); + expect(chunk.content).toHaveLength(5_000); // MTU + expect(chunk.content.every((byte) => byte === 0x01)).toBeTruthy(); + + // Second write generates two packets, 15k long + 5k long + expect(sentPackets[3].value.case).toStrictEqual('streamChunk'); + chunk = chunkOf(sentPackets[3]); + expect(chunk.streamId).toStrictEqual(writer.info.id); + expect(chunk.chunkIndex).toStrictEqual(2n); + expect(chunk.content).toHaveLength(15_000); + expect(chunk.content.every((byte) => byte === 0x01)).toBeTruthy(); + + expect(sentPackets[4].value.case).toStrictEqual('streamChunk'); + chunk = chunkOf(sentPackets[4]); + expect(chunk.streamId).toStrictEqual(writer.info.id); + expect(chunk.chunkIndex).toStrictEqual(3n); + expect(chunk.content).toHaveLength(5_000); + expect(chunk.content.every((byte) => byte === 0x01)).toBeTruthy(); + + expect(sentPackets[5].value.case).toStrictEqual('streamTrailer'); + const trailer = trailerOf(sentPackets[5]); + expect(trailer.streamId).toStrictEqual(writer.info.id); + expect(trailer.reason).toStrictEqual(''); + }); + + it('should send a FILE via sendFile without compression (happy path)', async () => { + const bytes = new Uint8Array(20_000).fill(0x07); + const info = await manager.sendFile(new File([bytes as NonSharedUint8Array], 'text.txt'), { + topic: 'my-topic', + }); + + // Pre-v2 recipients: uncompressed, multi-packet legacy format. + // 20k of data -> 15k + 5k chunks. 1 header + 2 chunks + 1 trailer = 4 packets. + expect(sentPackets).toHaveLength(4); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('byteHeader'); + expect(header.compression).toBe(DataStream_CompressionType.NONE); + + expect(sentPackets[1].value.case).toStrictEqual('streamChunk'); + let chunk = chunkOf(sentPackets[1]); + expect(chunk.chunkIndex).toStrictEqual(0n); + expect(chunk.content).toHaveLength(15_000); // MTU + expect(chunk.content.every((byte) => byte === 0x07)).toBeTruthy(); + + expect(sentPackets[2].value.case).toStrictEqual('streamChunk'); + chunk = chunkOf(sentPackets[2]); + expect(chunk.chunkIndex).toStrictEqual(1n); + expect(chunk.content).toHaveLength(5_000); + expect(chunk.content.every((byte) => byte === 0x07)).toBeTruthy(); + + expect(sentPackets[3].value.case).toStrictEqual('streamTrailer'); + expect(trailerOf(sentPackets[3]).streamId).toStrictEqual(info.id); + }); + }); + describe('v2 -> room of all v2', () => { + let manager: OutgoingDataStreamManager, sentPackets: Array; + beforeEach(() => { + const result = createManager({ + alice: CLIENT_PROTOCOL_DATA_STREAM_V2, + bob: CLIENT_PROTOCOL_DATA_STREAM_V2, + noCompression: [CLIENT_PROTOCOL_DATA_STREAM_V2, []], + }); + manager = result.manager; + sentPackets = result.sentPackets; + }); + + it('should send short TEXT data stream with single packet and compression (happy path)', async () => { + const info = await manager.sendText('hello hello compressible world', { + topic: 'my-topic', + destinationIdentities: ['alice', 'bob'], + }); + + // Make sure one single packet was used, since data streams v2 + compression is supported + // across all participants + expect(sentPackets).toHaveLength(1); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('textHeader'); + + // Make sure the contents of that packet was compressed + expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW); + expect(header.inlineContent).toBeInstanceOf(Uint8Array); + expect(header.inlineContent).not.toStrictEqual( + new TextEncoder().encode('hello hello compressible world'), + ); + }); + it('should send short TEXT data stream with uncompressible payload in single packet', async () => { + const info = await manager.sendText('short', { + topic: 'my-topic', + destinationIdentities: ['alice', 'bob'], + }); + + // Make sure one single packet was used, since data streams v2 + compression is supported + // across all participants + expect(sentPackets).toHaveLength(1); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('textHeader'); + + // Make sure the contents of that packet was uncompressed - "short" isn't long enough to + // meaningfully compress with DEFLATE + expect(header.compression).toBe(DataStream_CompressionType.NONE); + expect(header.inlineContent).toStrictEqual(new TextEncoder().encode('short')); + }); + it('should send short data stream with single packet and NO compression if remote participant does not support compression', async () => { + const info = await manager.sendText('hello hello compressible world', { + topic: 'my-topic', + destinationIdentities: ['noCompression'], + }); + + // Make sure one single packet was used, since data streams v2 is supported for that + // participant. + expect(sentPackets).toHaveLength(1); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('textHeader'); + + // Make sure the contents of that packet was NOT compressed + expect(header.compression).toBe(DataStream_CompressionType.NONE); + expect(header.inlineContent).toStrictEqual( + new TextEncoder().encode('hello hello compressible world'), + ); + }); + it('should send long but highly compressible TEXT data stream as single packet', async () => { + // A phrase which repeats over and over should compress extremely well. + const text = new Array(20_000).fill('hello world').join(''); + + const info = await manager.sendText(text, { + topic: 'my-topic', + destinationIdentities: ['alice', 'bob'], + }); + + // Make sure one single packet was used, since data streams v2 is supported and the contents + // should be able to be highly compressed to be well under the 15k MTU + expect(sentPackets).toHaveLength(1); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('textHeader'); + + // Make sure the contents of that packet was compressed + expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW); + expect(header.inlineContent).toBeInstanceOf(Uint8Array); + // Compressed bytes must not begin with the raw UTF-8 prefix of the payload. + const helloWorld = new TextEncoder().encode('hello world'); + expect(header.inlineContent!.slice(0, helloWorld.length)).not.toStrictEqual(helloWorld); + }); + it('should send long but somewhat compressible data stream as a compressed multi packet data stream', async () => { + // Mostly incompressible, but the hello world parts repeating should mean that the compressed + // contents is smaller than the full uncompressed data. + const text = new Array(50) + .fill(null) + .map(() => `hello world${randomText(1_000)}`) + .join(''); + + const info = await manager.sendText(text, { + topic: 'my-topic', + destinationIdentities: ['alice', 'bob'], + }); + + // 1 header + 3 data packets + 1 trailer = 5 total packets + // + // 3 data packets is less than the Math.ceil(~50k / 15k) = 4 packets that would be + // required if data was uncompressed + expect(sentPackets).toHaveLength(5); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('textHeader'); + + // Make sure the contents of that packet was compressed + expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW); + + // Verify there are three data packets: + expect(sentPackets[1].value.case).toStrictEqual('streamChunk'); + let chunk = chunkOf(sentPackets[1]); + expect(chunk.streamId).toStrictEqual(info.id); + expect(chunk.chunkIndex).toStrictEqual(0n); + expect(chunk.content).toHaveLength(15_000); // MTU + + expect(sentPackets[2].value.case).toStrictEqual('streamChunk'); + expect(sentPackets[3].value.case).toStrictEqual('streamChunk'); + + // Final packet should be a trailer + expect(sentPackets[4].value.case).toStrictEqual('streamTrailer'); + const trailer = trailerOf(sentPackets[4]); + expect(trailer.streamId).toStrictEqual(info.id); + expect(trailer.reason).toStrictEqual(''); + }); + it('should send long, uncompressible data stream as a compressed multi packet data stream', async () => { + // This is random data which should be uncompressible + const bytes = randomBytes(50_000); + const info = await manager.sendFile(new File([bytes as NonSharedUint8Array], 'text.txt'), { + topic: 'my-topic', + destinationIdentities: ['alice', 'bob'], + }); + + // Math.ceil(~50k / 15k) = 4 data packets + // 1 header + 4 data packets + 1 trailer = 6 total packets + expect(sentPackets).toHaveLength(6); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('byteHeader'); + + // Make sure the contents of that packet was NOT compressed + expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW); + + // Verify there are four data packets: + let totalLength = 0; + expect(sentPackets[1].value.case).toStrictEqual('streamChunk'); + let chunk = chunkOf(sentPackets[1]); + expect(chunk.streamId).toStrictEqual(info.id); + expect(chunk.chunkIndex).toStrictEqual(0n); + expect(chunk.content).toHaveLength(15_000); // MTU + totalLength += chunk.content.byteLength; + + expect(sentPackets[2].value.case).toStrictEqual('streamChunk'); + chunk = chunkOf(sentPackets[2]); + totalLength += chunk.content.byteLength; + + expect(sentPackets[3].value.case).toStrictEqual('streamChunk'); + chunk = chunkOf(sentPackets[3]); + totalLength += chunk.content.byteLength; + + expect(sentPackets[4].value.case).toStrictEqual('streamChunk'); + chunk = chunkOf(sentPackets[4]); + totalLength += chunk.content.byteLength; + + // Make sure total length is LARGER than the raw bytes length (only slightly, due to the extra + // DEFLATE metadata being added to an otherwise incompressible binary blob) + // + // This is sort of unfortunate that this happens, but the tradeoff to this slight size bump is + // that the whole binary doesn't have to be buffered into memory all at once. + expect(totalLength).toBeGreaterThan(bytes.byteLength); + + // Final packet should be a trailer + expect(sentPackets[5].value.case).toStrictEqual('streamTrailer'); + const trailer = trailerOf(sentPackets[5]); + expect(trailer.streamId).toStrictEqual(info.id); + expect(trailer.reason).toStrictEqual(''); + }); + it('should send short data stream with single packet but skip compression due to compress: false being passed', async () => { + const info = await manager.sendText('hello hello compressible world', { + topic: 'my-topic', + destinationIdentities: ['alice', 'bob'], + compress: false, + }); + + // Make sure one single packet was used, since data streams v2 is supported across all participants + expect(sentPackets).toHaveLength(1); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('textHeader'); + + // Make sure the contents of that packet was NOT compressed (compress: false opt-out) + expect(header.compression).toBe(DataStream_CompressionType.NONE); + expect(header.inlineContent).toStrictEqual( + new TextEncoder().encode('hello hello compressible world'), + ); + }); + it('should send long but somewhat compressible data stream but skip compression due to compress: false being passed', async () => { + // Mostly incompressible, but the hello world parts repeating should mean that the compressed + // contents is smaller than the full uncompressed data. + const text = new Array(50) + .fill(null) + .map(() => `hello world${randomText(1_000)}`) + .join(''); + + const info = await manager.sendText(text, { + topic: 'my-topic', + destinationIdentities: ['alice', 'bob'], + compress: false, + }); + + // Math.ceil(~50k / 15k) = 4 data packets + // 1 header + 4 data packets + 1 trailer = 6 total packets + expect(sentPackets).toHaveLength(6); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('textHeader'); + + // Make sure the contents of that packet was uncompressed + expect(header.compression).toBe(DataStream_CompressionType.NONE); + + // Verify there are four data packets: + expect(sentPackets[1].value.case).toStrictEqual('streamChunk'); + let chunk = chunkOf(sentPackets[1]); + expect(chunk.streamId).toStrictEqual(info.id); + expect(chunk.chunkIndex).toStrictEqual(0n); + expect(chunk.content).toHaveLength(15_000); // MTU + + expect(sentPackets[2].value.case).toStrictEqual('streamChunk'); + expect(sentPackets[3].value.case).toStrictEqual('streamChunk'); + expect(sentPackets[4].value.case).toStrictEqual('streamChunk'); + + // Final packet should be a trailer + expect(sentPackets[5].value.case).toStrictEqual('streamTrailer'); + const trailer = trailerOf(sentPackets[5]); + expect(trailer.streamId).toStrictEqual(info.id); + expect(trailer.reason).toStrictEqual(''); + }); + + it('should NEVER use compression or single packet data streams with streamText', async () => { + const writer = await manager.streamText({ + topic: 'my-topic', + destinationIdentities: ['noCompression'], + }); + + // Make sure the header packet was sent + expect(sentPackets).toHaveLength(1); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(writer.info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('textHeader'); + expect(header.compression).toBe(DataStream_CompressionType.NONE); // Make sure compression is disabled + + await writer.write('hello world'); + + // Make sure a single chunk packet was emitted + expect(sentPackets).toHaveLength(2 /* 1 header + 1 chunk */); + + expect(sentPackets[1].value.case).toBe('streamChunk'); + const chunk = chunkOf(sentPackets[1]); + expect(chunk.streamId).toStrictEqual(writer.info.id); + expect(chunk.content).toStrictEqual(new TextEncoder().encode('hello world')); + + await writer.close(); + + // Finally, verify the trailer + expect(sentPackets).toHaveLength(3 /* 1 header + 1 chunk + 1 trailer */); + expect(sentPackets[2].value.case).toBe('streamTrailer'); + }); + it('should NEVER use compression or single packet data streams with streamBytes', async () => { + const writer = await manager.streamBytes({ + topic: 'my-topic', + destinationIdentities: ['noCompression'], + }); + + // Make sure the header packet was sent + expect(sentPackets).toHaveLength(1); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(writer.info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('byteHeader'); + expect(header.compression).toBe(DataStream_CompressionType.NONE); // Make sure compression is disabled + + await writer.write(new Uint8Array([0x00, 0x01, 0x02, 0x03])); + + // Make sure a single chunk packet was emitted + expect(sentPackets).toHaveLength(2 /* 1 header + 1 chunk */); + + expect(sentPackets[1].value.case).toBe('streamChunk'); + const chunk = chunkOf(sentPackets[1]); + expect(chunk.streamId).toStrictEqual(writer.info.id); + expect(chunk.content).toStrictEqual(new Uint8Array([0x00, 0x01, 0x02, 0x03])); + + await writer.close(); + + // Finally, verify the trailer + expect(sentPackets).toHaveLength(3 /* 1 header + 1 chunk + 1 trailer */); + expect(sentPackets[2].value.case).toBe('streamTrailer'); + }); + + it('should NOT send bytes single packet with sendFile', async () => { + // This is random data which should be uncompressible + const bytes = new Uint8Array(10_000).fill(0x01); + const info = await manager.sendFile(new File([bytes as NonSharedUint8Array], 'text.txt'), { + topic: 'my-topic', + destinationIdentities: ['alice', 'bob'], + }); + + // Should be a multi-packet result + // + // Sending single packet data streams for files is tricky because it's really difficult to + // determine ahead of time if a file can fit into a single packet without a ton of ahead of + // time in memory buffering. + expect(sentPackets).toHaveLength(3); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('byteHeader'); + + // Make sure the contents of that packet was NOT compressed + expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW); + + expect(sentPackets[1].value.case).toStrictEqual('streamChunk'); + let chunk = chunkOf(sentPackets[1]); + expect(chunk.streamId).toStrictEqual(info.id); + expect(chunk.chunkIndex).toStrictEqual(0n); + + // Make sure contents were compressed + expect(chunk.content.byteLength).toBeLessThan(bytes.byteLength); + + // Final packet should be a trailer + expect(sentPackets[2].value.case).toStrictEqual('streamTrailer'); + const trailer = trailerOf(sentPackets[2]); + expect(trailer.streamId).toStrictEqual(info.id); + expect(trailer.reason).toStrictEqual(''); + }); + + it('should send a FILE via sendFile WITHOUT compression if remote does not support compression', async () => { + const bytes = new Uint8Array(10_000).fill(0x07); + const info = await manager.sendFile(new File([bytes as NonSharedUint8Array], 'text.txt'), { + topic: 'my-topic', + destinationIdentities: ['noCompression'], + }); + + // v2 recipient but no deflate-raw capability: uncompressed, multi-packet (never inline). + expect(sentPackets).toHaveLength(3); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.contentHeader.case).toBe('byteHeader'); + expect(header.compression).toBe(DataStream_CompressionType.NONE); + expect(header.inlineContent).toBeUndefined(); + + expect(sentPackets[1].value.case).toStrictEqual('streamChunk'); + const chunk = chunkOf(sentPackets[1]); + expect(chunk.chunkIndex).toStrictEqual(0n); + expect(chunk.content).toHaveLength(10_000); // uncompressed, single chunk under the MTU + expect(chunk.content.every((byte) => byte === 0x07)).toBeTruthy(); + + expect(sentPackets[2].value.case).toStrictEqual('streamTrailer'); + expect(trailerOf(sentPackets[2]).streamId).toStrictEqual(info.id); + }); + + it('should send an empty FILE via sendFile', async () => { + const info = await manager.sendFile(new File([], 'empty.bin'), { + topic: 'my-topic', + destinationIdentities: ['alice', 'bob'], + }); + + // An empty file still produces a well-formed compressed byte stream: a header declaring zero + // length, the deflate stream's final block, and a trailer. + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.contentHeader.case).toBe('byteHeader'); + expect(header.totalLength).toStrictEqual(0n); + expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW); + + const last = sentPackets[sentPackets.length - 1]; + expect(last.value.case).toStrictEqual('streamTrailer'); + expect(trailerOf(last).streamId).toStrictEqual(info.id); + }); + }); + describe('v2 -> room of mixed v1 / v2', () => { + let manager: OutgoingDataStreamManager, sentPackets: Array; + beforeEach(() => { + const result = createManager({ + alice: CLIENT_PROTOCOL_DEFAULT, + bob: CLIENT_PROTOCOL_DATA_STREAM_V2, + jim: CLIENT_PROTOCOL_DATA_STREAM_V2, + mallory: CLIENT_PROTOCOL_DATA_STREAM_RPC, + noCompression: [CLIENT_PROTOCOL_DATA_STREAM_V2, []], + }); + manager = result.manager; + sentPackets = result.sentPackets; + }); + + it('should send data stream using v1 legacy data stream format in room of mixed v1/v2', async () => { + const info = await manager.sendText('hello world', { + topic: 'my-topic', + }); + + // Make sure three packets were received, matching the legacy v1 data stream format + expect(sentPackets).toHaveLength(3); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('textHeader'); + + expect(sentPackets[1].value.case).toStrictEqual('streamChunk'); + const chunk = chunkOf(sentPackets[1]); + expect(chunk.streamId).toStrictEqual(info.id); + expect(chunk.chunkIndex).toStrictEqual(0n); + expect(chunk.content).toStrictEqual(new TextEncoder().encode('hello world')); + + expect(sentPackets[2].value.case).toStrictEqual('streamTrailer'); + const trailer = trailerOf(sentPackets[2]); + expect(trailer.streamId).toStrictEqual(info.id); + expect(trailer.reason).toStrictEqual(''); + }); + it('should send data stream using data stream v2 format + compression when only sending to a subset of participants that are all v2', async () => { + const info = await manager.sendText('hello hello compressible world', { + topic: 'my-topic', + destinationIdentities: ['bob', 'jim'], + }); + + // Make sure one single packet was used, since data streams v2 + compression is supported + // across bob + jim + expect(sentPackets).toHaveLength(1); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('textHeader'); + + // Make sure the contents of that packet was compressed + expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW); + expect(header.inlineContent).toBeInstanceOf(Uint8Array); + expect(header.inlineContent).not.toStrictEqual( + new TextEncoder().encode('hello hello compressible world'), + ); + }); + 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 () => { + const info = await manager.sendText('hello hello compressible world', { + topic: 'my-topic', + destinationIdentities: ['bob', 'jim', 'noCompression'], + }); + + // Make sure one single packet was used, since data streams v2 + compression is supported + // across bob + jim + expect(sentPackets).toHaveLength(1); + + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('textHeader'); + + // Make sure the contents of that packet was compressed + expect(header.compression).toBe(DataStream_CompressionType.NONE); + expect(header.inlineContent).toStrictEqual( + new TextEncoder().encode('hello hello compressible world'), + ); + }); + }); + + describe('sendBytes', () => { + describe('v2 -> room of all v2', () => { + let manager: OutgoingDataStreamManager, sentPackets: Array; + beforeEach(() => { + const result = createManager({ + alice: CLIENT_PROTOCOL_DATA_STREAM_V2, + bob: CLIENT_PROTOCOL_DATA_STREAM_V2, + noCompression: [CLIENT_PROTOCOL_DATA_STREAM_V2, []], + }); + manager = result.manager; + sentPackets = result.sentPackets; + }); + + it('should send short compressible BYTE payload as a single compressed packet (happy path)', async () => { + const bytes = new TextEncoder().encode('hello hello compressible world'); + const info = await manager.sendBytes(bytes, { + topic: 'my-topic', + attributes: { foo: 'bar' }, + destinationIdentities: ['alice', 'bob'], + }); + + expect(sentPackets).toHaveLength(1); + expect(sentPackets[0].value.case).toBe('streamHeader'); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.topic).toStrictEqual('my-topic'); + expect(header.contentHeader.case).toBe('byteHeader'); + + // Compressed inline payload + expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW); + expect(header.inlineContent).toBeInstanceOf(Uint8Array); + expect(header.inlineContent).not.toStrictEqual(bytes); + + // Returned info uses the byte-stream defaults + expect(info.name).toStrictEqual('unknown'); + expect(info.mimeType).toStrictEqual('application/octet-stream'); + expect(info.size).toStrictEqual(bytes.byteLength); + expect(info.attributes).toStrictEqual({ foo: 'bar' }); + }); + + it('should send short uncompressible BYTE payload inline without compression', async () => { + const bytes = new Uint8Array([0x00, 0x01, 0x02, 0x03]); + const info = await manager.sendBytes(bytes, { + topic: 'my-topic', + destinationIdentities: ['alice', 'bob'], + }); + + expect(sentPackets).toHaveLength(1); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.contentHeader.case).toBe('byteHeader'); + // Tiny payload doesn't shrink under DEFLATE framing, so it's sent raw. + expect(header.compression).toBe(DataStream_CompressionType.NONE); + expect(header.inlineContent).toStrictEqual(bytes); + }); + + it('should send BYTE payload inline with NO compression if a recipient does not support compression', async () => { + const bytes = new TextEncoder().encode('hello hello compressible world'); + const info = await manager.sendBytes(bytes, { + topic: 'my-topic', + destinationIdentities: ['noCompression'], + }); + + // Inline still applies (gated on v2 alone), but compression is skipped. + expect(sentPackets).toHaveLength(1); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.contentHeader.case).toBe('byteHeader'); + expect(header.compression).toBe(DataStream_CompressionType.NONE); + expect(header.inlineContent).toStrictEqual(bytes); + }); + + it('should send long, highly compressible BYTE payload as a single compressed packet', async () => { + // Repeating bytes compress well under the 15k MTU even though the raw payload is 50k. + const bytes = new Uint8Array(50_000).fill(0x01); + const info = await manager.sendBytes(bytes, { + topic: 'my-topic', + destinationIdentities: ['alice', 'bob'], + }); + + expect(sentPackets).toHaveLength(1); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.contentHeader.case).toBe('byteHeader'); + expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW); + expect(header.inlineContent).toBeInstanceOf(Uint8Array); + expect(header.inlineContent!.byteLength).toBeLessThan(bytes.byteLength); + }); + + it('should send long but somewhat compressible BYTE payload as a compressed multi packet stream', async () => { + const bytes = new TextEncoder().encode( + new Array(50) + .fill(null) + .map(() => `hello world${randomText(1_000)}`) + .join(''), + ); + const info = await manager.sendBytes(bytes, { + topic: 'my-topic', + destinationIdentities: ['alice', 'bob'], + }); + + // 1 header + 3 chunks + 1 trailer (compressed ~45k -> 3 MTU chunks) + expect(sentPackets).toHaveLength(5); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.contentHeader.case).toBe('byteHeader'); + expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW); + expect(header.inlineContent).toBeUndefined(); + + expect(sentPackets[1].value.case).toStrictEqual('streamChunk'); + const chunk = chunkOf(sentPackets[1]); + expect(chunk.chunkIndex).toStrictEqual(0n); + expect(chunk.content).toHaveLength(15_000); // MTU + expect(sentPackets[2].value.case).toStrictEqual('streamChunk'); + expect(sentPackets[3].value.case).toStrictEqual('streamChunk'); + expect(sentPackets[4].value.case).toStrictEqual('streamTrailer'); + expect(trailerOf(sentPackets[4]).streamId).toStrictEqual(info.id); + }); + + it('should skip compression for an inline payload when compress: false', async () => { + const bytes = new TextEncoder().encode('hello hello compressible world'); + const info = await manager.sendBytes(bytes, { + topic: 'my-topic', + destinationIdentities: ['alice', 'bob'], + compress: false, + }); + + expect(sentPackets).toHaveLength(1); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.compression).toBe(DataStream_CompressionType.NONE); + expect(header.inlineContent).toStrictEqual(bytes); + }); + + it('should send an uncompressed multi packet stream when compress: false and payload exceeds the MTU', async () => { + const bytes = new Uint8Array(40_000).fill(0x07); + const info = await manager.sendBytes(bytes, { + topic: 'my-topic', + destinationIdentities: ['alice', 'bob'], + compress: false, + }); + + // 40k -> 15k + 15k + 10k chunks. 1 header + 3 chunks + 1 trailer = 5 packets. + expect(sentPackets).toHaveLength(5); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.contentHeader.case).toBe('byteHeader'); + expect(header.compression).toBe(DataStream_CompressionType.NONE); + expect(header.inlineContent).toBeUndefined(); + + expect(sentPackets[1].value.case).toStrictEqual('streamChunk'); + let chunk = chunkOf(sentPackets[1]); + expect(chunk.chunkIndex).toStrictEqual(0n); + expect(chunk.content).toHaveLength(15_000); + expect(chunk.content.every((byte) => byte === 0x07)).toBeTruthy(); + chunk = chunkOf(sentPackets[3]); + expect(chunk.content).toHaveLength(10_000); + expect(sentPackets[4].value.case).toStrictEqual('streamTrailer'); + }); + }); + + describe('v2 -> room of all v1', () => { + it('should send a legacy uncompressed multi packet stream', async () => { + const { manager, sentPackets } = createManager({ + alice: CLIENT_PROTOCOL_DEFAULT, + jim: CLIENT_PROTOCOL_DATA_STREAM_RPC, + }); + const bytes = new Uint8Array([0x00, 0x01, 0x02, 0x03]); + const info = await manager.sendBytes(bytes, { topic: 'my-topic' }); + + // No v2 recipients: header + chunk + trailer, never inline, never compressed. + expect(sentPackets).toHaveLength(3); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.contentHeader.case).toBe('byteHeader'); + expect(header.compression).toBe(DataStream_CompressionType.NONE); + expect(header.inlineContent).toBeUndefined(); + + expect(sentPackets[1].value.case).toStrictEqual('streamChunk'); + const chunk = chunkOf(sentPackets[1]); + expect(chunk.chunkIndex).toStrictEqual(0n); + expect(chunk.content).toStrictEqual(bytes); + expect(sentPackets[2].value.case).toStrictEqual('streamTrailer'); + expect(trailerOf(sentPackets[2]).streamId).toStrictEqual(info.id); + }); + }); + }); + + describe('header size limit (MTU)', () => { + // Attributes large enough that the serialized header packet alone exceeds + // STREAM_CHUNK_SIZE_BYTES (15 000). + const hugeAttributes = { big: 'x'.repeat(20_000) }; + + it('should throw HeaderTooLarge when sending a chunked TEXT stream with oversized attributes (pre-v2 room)', async () => { + const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DEFAULT }); + + await expect( + manager.sendText('hello world', { topic: 'my-topic', attributes: hugeAttributes }), + ).rejects.toThrow('exceeds'); + + // The error must be raised before anything hits the wire. + expect(sentPackets).toHaveLength(0); + }); + + it('should throw HeaderTooLarge when oversized attributes force the inline path to fall back (v2 room)', async () => { + const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DATA_STREAM_V2 }); + + // The payload is small, but the attributes alone blow the MTU budget: the inline attempt + // falls through gracefully (no throw), and then the chunked header send enforces the hard + // limit β€” per spec, large *attributes* throw while a large *payload* falls back fine. + await expect( + manager.sendText('hello hello compressible world', { + topic: 'my-topic', + destinationIdentities: ['alice'], + attributes: hugeAttributes, + }), + ).rejects.toThrow('exceeds'); + + expect(sentPackets).toHaveLength(0); + }); + + it('should throw HeaderTooLarge when opening a streamText writer with oversized attributes', async () => { + const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DATA_STREAM_V2 }); + + await expect( + manager.streamText({ topic: 'my-topic', attributes: hugeAttributes }), + ).rejects.toThrow('exceeds'); + + expect(sentPackets).toHaveLength(0); + }); + + it('should throw HeaderTooLarge when opening a streamBytes writer with oversized attributes', async () => { + const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DATA_STREAM_V2 }); + + await expect( + manager.streamBytes({ topic: 'my-topic', attributes: hugeAttributes }), + ).rejects.toThrow('exceeds'); + + expect(sentPackets).toHaveLength(0); + }); + }); + + describe('attachments', () => { + it('should send attachments as separate byte streams referenced by attachedStreamIds (never inline)', async () => { + const { manager, sentPackets } = createManager({ + alice: CLIENT_PROTOCOL_DATA_STREAM_V2, + bob: CLIENT_PROTOCOL_DATA_STREAM_V2, + }); + const file = new File([new Uint8Array(100).fill(0x07) as NonSharedUint8Array], 'att.bin'); + + const info = await manager.sendText('hello hello compressible world', { + topic: 'my-topic', + destinationIdentities: ['alice', 'bob'], + attachments: [file], + }); + + // The text part must NOT be inline β€” attachments disable the single-packet path β€” so both + // the text stream and the attachment stream open with their own headers. + const headers = sentPackets.filter((p) => p.value.case === 'streamHeader'); + expect(headers).toHaveLength(2); + + const textHeader = headerOf(headers[0]); + expect(textHeader.streamId).toStrictEqual(info.id); + expect(textHeader.contentHeader.case).toBe('textHeader'); + expect(textHeader.inlineContent).toBeUndefined(); + const attachedStreamIds = (textHeader.contentHeader.value as DataStream_TextHeader) + .attachedStreamIds; + expect(attachedStreamIds).toHaveLength(1); + + // The attachment is a byte stream whose streamId matches the reference in the text header. + const byteHeader = headerOf(headers[1]); + expect(byteHeader.contentHeader.case).toBe('byteHeader'); + expect(byteHeader.streamId).toStrictEqual(attachedStreamIds[0]); + expect((byteHeader.contentHeader.value as DataStream_ByteHeader).name).toStrictEqual( + 'att.bin', + ); + + // Both streams are terminated by trailers. + const trailers = sentPackets.filter((p) => p.value.case === 'streamTrailer'); + expect(trailers.map((p) => trailerOf(p).streamId).sort()).toStrictEqual( + [info.id, attachedStreamIds[0]].sort(), + ); + }); + + it('should forward destinationIdentities to attachment byte streams', async () => { + // Targeted send to the v2 subset of a mixed room: the attachment streams must be targeted + // the same way, or participants outside the destination list receive stray byte streams + // referenced by a text stream they never got. + const { manager, sentPackets } = createManager({ + alice: CLIENT_PROTOCOL_DEFAULT, + bob: CLIENT_PROTOCOL_DATA_STREAM_V2, + jim: CLIENT_PROTOCOL_DATA_STREAM_V2, + }); + const file = new File([new Uint8Array(100).fill(0x07) as NonSharedUint8Array], 'att.bin'); + + await manager.sendText('hello world', { + topic: 'my-topic', + destinationIdentities: ['bob', 'jim'], + attachments: [file], + }); + + for (const packet of sentPackets) { + expect(packet.destinationIdentities).toStrictEqual(['bob', 'jim']); + } + }); + }); + + describe('recipient eligibility edge cases', () => { + it('should send inline + compressed on a broadcast when every participant is v2 with the capability', async () => { + const { manager, sentPackets } = createManager({ + alice: CLIENT_PROTOCOL_DATA_STREAM_V2, + bob: CLIENT_PROTOCOL_DATA_STREAM_V2, + }); + + // No destinationIdentities: eligibility is evaluated over every remote participant. + await manager.sendText('hello hello compressible world', { topic: 'my-topic' }); + + expect(sentPackets).toHaveLength(1); + const header = headerOf(sentPackets[0]); + expect(header.compression).toBe(DataStream_CompressionType.DEFLATE_RAW); + expect(header.inlineContent).toBeInstanceOf(Uint8Array); + }); + + it('should send inline but uncompressed on a broadcast when one participant lacks the compression capability', async () => { + const { manager, sentPackets } = createManager({ + alice: CLIENT_PROTOCOL_DATA_STREAM_V2, + noCompression: [CLIENT_PROTOCOL_DATA_STREAM_V2, []], + }); + + await manager.sendText('hello hello compressible world', { topic: 'my-topic' }); + + expect(sentPackets).toHaveLength(1); + const header = headerOf(sentPackets[0]); + expect(header.compression).toBe(DataStream_CompressionType.NONE); + expect(header.inlineContent).toStrictEqual( + new TextEncoder().encode('hello hello compressible world'), + ); + }); + + it('should treat an empty room as v2-eligible for a broadcast', async () => { + const { manager, sentPackets } = createManager({}); + + await manager.sendText('hello hello compressible world', { topic: 'my-topic' }); + + // Nobody to receive it, so nothing gates the v2 fast path: single inline compressed packet. + expect(sentPackets).toHaveLength(1); + expect(headerOf(sentPackets[0]).compression).toBe(DataStream_CompressionType.DEFLATE_RAW); + }); + + it('should treat an unknown destination identity as pre-v2 (legacy multi-packet)', async () => { + const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DATA_STREAM_V2 }); + + // "stranger" has no ParticipantInfo, so its clientProtocol resolves to 0 β€” the send must + // fall back to the legacy format rather than assuming v2 support. + await manager.sendText('hello world', { + topic: 'my-topic', + destinationIdentities: ['stranger'], + }); + + expect(sentPackets).toHaveLength(3); + const header = headerOf(sentPackets[0]); + expect(header.compression).toBe(DataStream_CompressionType.NONE); + expect(header.inlineContent).toBeUndefined(); + }); + + it('should send a large TEXT payload to a v2 recipient without the capability as uncompressed MTU-split chunks', async () => { + const { manager, sentPackets } = createManager({ + noCompression: [CLIENT_PROTOCOL_DATA_STREAM_V2, []], + }); + + const info = await manager.sendText('A'.repeat(40_000), { + topic: 'my-topic', + destinationIdentities: ['noCompression'], + }); + + // Inline is attempted (recipient is v2) but the raw payload overflows the MTU, and + // compression is gated off by the missing capability β€” so this falls back to an + // uncompressed multi-packet stream split at STREAM_CHUNK_SIZE_BYTES. + expect(sentPackets).toHaveLength(5); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.compression).toBe(DataStream_CompressionType.NONE); + expect(header.inlineContent).toBeUndefined(); + expect(chunkOf(sentPackets[1]).content).toHaveLength(15_000); + expect(chunkOf(sentPackets[2]).content).toHaveLength(15_000); + expect(chunkOf(sentPackets[3]).content).toHaveLength(10_000); + expect(sentPackets[4].value.case).toBe('streamTrailer'); + }); + + it('should send a FILE uncompressed when compress: false even to capable recipients', async () => { + const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DATA_STREAM_V2 }); + const bytes = new Uint8Array(10_000).fill(0x07); + + const info = await manager.sendFile(new File([bytes as NonSharedUint8Array], 'text.txt'), { + topic: 'my-topic', + destinationIdentities: ['alice'], + compress: false, + }); + + expect(sentPackets).toHaveLength(3); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.compression).toBe(DataStream_CompressionType.NONE); + const chunk = chunkOf(sentPackets[1]); + expect(chunk.content).toHaveLength(10_000); + expect(chunk.content.every((byte) => byte === 0x07)).toBeTruthy(); + }); + }); + + describe('UTF-8 chunk splitting', () => { + it('should split streamText writes on UTF-8 character boundaries at the MTU', async () => { + const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DEFAULT }); + + // 14 999 single-byte chars put the 4-byte emoji straddling the 15 000-byte MTU boundary. + const text = `${'a'.repeat(14_999)}πŸ˜€${'b'.repeat(10)}`; + const writer = await manager.streamText({ topic: 'my-topic' }); + await writer.write(text); + await writer.close(); + + expect(sentPackets).toHaveLength(4); // header + 2 chunks + trailer + const first = chunkOf(sentPackets[1]); + const second = chunkOf(sentPackets[2]); + + // The split backs off below the MTU rather than bisecting the emoji, so each chunk decodes + // independently. + expect(first.content).toHaveLength(14_999); + const decode = (bytes: Uint8Array) => new TextDecoder('utf-8', { fatal: true }).decode(bytes); + expect(() => decode(first.content)).not.toThrow(); + expect(() => decode(second.content)).not.toThrow(); + expect(decode(first.content) + decode(second.content)).toStrictEqual(text); + }); + }); + + describe('empty payloads', () => { + it('should send an empty TEXT payload to a pre-v2 room as header + trailer', async () => { + const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DEFAULT }); + + const info = await manager.sendText('', { topic: 'my-topic' }); + + // A well-formed (if contentless) stream: header declaring zero length, then the trailer. + expect(sentPackets).toHaveLength(2); + const header = headerOf(sentPackets[0]); + expect(header.streamId).toStrictEqual(info.id); + expect(header.totalLength).toStrictEqual(0n); + expect(sentPackets[1].value.case).toBe('streamTrailer'); + expect(trailerOf(sentPackets[1]).streamId).toStrictEqual(info.id); + }); + + it('should send an empty TEXT payload to a v2 room as a single raw inline packet', async () => { + const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DATA_STREAM_V2 }); + + await manager.sendText('', { topic: 'my-topic', destinationIdentities: ['alice'] }); + + expect(sentPackets).toHaveLength(1); + const header = headerOf(sentPackets[0]); + // Deflate framing can't shrink an empty payload, so the raw (empty) bytes are kept. + expect(header.compression).toBe(DataStream_CompressionType.NONE); + expect(header.inlineContent).toStrictEqual(new Uint8Array(0)); + expect(header.totalLength).toStrictEqual(0n); + }); + + it('should send an empty BYTE payload to a pre-v2 room as header + trailer', async () => { + const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DEFAULT }); + + const info = await manager.sendBytes(new Uint8Array(0), { topic: 'my-topic' }); + + expect(sentPackets).toHaveLength(2); + const header = headerOf(sentPackets[0]); + expect(header.totalLength).toStrictEqual(0n); + expect(sentPackets[1].value.case).toBe('streamTrailer'); + expect(trailerOf(sentPackets[1]).streamId).toStrictEqual(info.id); + }); + }); + + describe('runtime without CompressionStream', () => { + it('should send raw inline even to compression-capable recipients when the runtime cannot compress', async () => { + const originalCompressionStream = CompressionStream; + try { + (globalThis as any).CompressionStream = undefined; + + const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DATA_STREAM_V2 }); + await manager.sendText('hello hello compressible world', { + topic: 'my-topic', + destinationIdentities: ['alice'], + }); + + // Compression eligibility also requires a local compressor; without one, inline still + // applies but the payload is sent raw. + expect(sentPackets).toHaveLength(1); + const header = headerOf(sentPackets[0]); + expect(header.compression).toBe(DataStream_CompressionType.NONE); + expect(header.inlineContent).toStrictEqual( + new TextEncoder().encode('hello hello compressible world'), + ); + } finally { + (globalThis as any).CompressionStream = originalCompressionStream; + } + }); + + it('should send a FILE uncompressed when the runtime cannot compress', async () => { + const originalCompressionStream = CompressionStream; + try { + (globalThis as any).CompressionStream = undefined; + + const { manager, sentPackets } = createManager({ alice: CLIENT_PROTOCOL_DATA_STREAM_V2 }); + const bytes = new Uint8Array(10_000).fill(0x07); + await manager.sendFile(new File([bytes as NonSharedUint8Array], 'text.txt'), { + topic: 'my-topic', + destinationIdentities: ['alice'], + }); + + expect(sentPackets).toHaveLength(3); + const header = headerOf(sentPackets[0]); + expect(header.compression).toBe(DataStream_CompressionType.NONE); + expect(chunkOf(sentPackets[1]).content).toHaveLength(10_000); + } finally { + (globalThis as any).CompressionStream = originalCompressionStream; + } + }); + }); +}); diff --git a/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts b/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts index 9443c7c673..6dbe3b66d2 100644 --- a/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +++ b/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts @@ -1,30 +1,45 @@ import { Mutex } from '@livekit/mutex'; import { + ClientInfo_Capability, DataPacket, - DataStream_ByteHeader, DataStream_Chunk, - DataStream_Header, - DataStream_OperationType, - DataStream_TextHeader, + 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 { numberToBigInt, splitUtf8 } from '../../utils'; +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 STREAM_CHUNK_SIZE = 15_000; +const textEncoder = new TextEncoder(); /** * Manages sending custom user data via data channels. @@ -35,9 +50,30 @@ export default class OutgoingDataStreamManager { protected log: StructuredLogger; - constructor(engine: RTCEngine, 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) { @@ -47,33 +83,114 @@ export default class OutgoingDataStreamManager { /** {@inheritDoc LocalParticipant.sendText} */ async sendText(text: string, options?: SendTextOptions): Promise { const streamId = crypto.randomUUID(); - const textInBytes = new TextEncoder().encode(text); + 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()); - const progresses = new Array(fileIds ? fileIds.length + 1 : 1).fill(0); + // 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; - const totalProgress = progresses.reduce((acc, val) => acc + val, 0); - options?.onProgress?.(totalProgress); + options?.onProgress?.(progresses.reduce((acc, val) => acc + val, 0) / parts); }; - const writer = await this.streamText({ - streamId, - totalSize: totalTextLength, - destinationIdentities: options?.destinationIdentities, - topic: options?.topic, - attachedStreamIds: fileIds, - attributes: options?.attributes, - }); + // 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)), + ), + ); - await writer.write(text); - // set text part of progress to 1 - handleProgress(1, 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(); + await writer.close(); + info = writer.info; + } if (options?.attachments && fileIds) { await Promise.all( @@ -81,6 +198,8 @@ export default class OutgoingDataStreamManager { this._sendFile(fileIds[idx], file, { topic: options.topic, mimeType: file.type, + destinationIdentities: options.destinationIdentities, + compress: options.compress, onProgress: (progress) => { handleProgress(progress, idx + 1); }, @@ -88,7 +207,164 @@ export default class OutgoingDataStreamManager { ), ); } - return writer.info; + 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); } /** @@ -96,6 +372,7 @@ export default class OutgoingDataStreamManager { */ async streamText(options?: StreamTextOptions): Promise { const streamId = options?.streamId ?? crypto.randomUUID(); + const destinationIdentities = options?.destinationIdentities; const info: TextStreamInfo = { id: streamId, @@ -109,43 +386,21 @@ export default class OutgoingDataStreamManager { : Encryption_Type.NONE, attachedStreamIds: options?.attachedStreamIds, }; - const header = new DataStream_Header({ - streamId, - mimeType: info.mimeType, - topic: info.topic, - timestamp: numberToBigInt(info.timestamp), - totalLength: numberToBigInt(info.size), - attributes: info.attributes, - contentHeader: { - case: 'textHeader', - value: new DataStream_TextHeader({ - version: options?.version, - attachedStreamIds: info.attachedStreamIds, - replyToStreamId: options?.replyToStreamId, - operationType: - options?.type === 'update' - ? DataStream_OperationType.UPDATE - : DataStream_OperationType.CREATE, - }), - }, - }); - const destinationIdentities = options?.destinationIdentities; - const packet = new DataPacket({ - destinationIdentities, - value: { - case: 'streamHeader', - value: header, - }, - }); - await this.engine.sendDataPacket(packet, DataChannelKind.RELIABLE); + 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({ - // Implement the sink async write(text) { - for (const textByteChunk of splitUtf8(text, STREAM_CHUNK_SIZE)) { + for (const textByteChunk of splitUtf8(text, STREAM_CHUNK_SIZE_BYTES)) { const chunk = new DataStream_Chunk({ content: textByteChunk, streamId, @@ -164,17 +419,7 @@ export default class OutgoingDataStreamManager { } }, async close() { - const trailer = new DataStream_Trailer({ - streamId, - }); - const trailerPacket = new DataPacket({ - destinationIdentities, - value: { - case: 'streamTrailer', - value: trailer, - }, - }); - await engine.sendDataPacket(trailerPacket, DataChannelKind.RELIABLE); + await sendStreamTrailer(streamId, destinationIdentities, engine); }, abort(err) { console.log('Sink error:', err); @@ -186,6 +431,8 @@ export default class OutgoingDataStreamManager { 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, () => @@ -201,25 +448,65 @@ export default class OutgoingDataStreamManager { return { id: streamId }; } - private async _sendFile(streamId: string, file: File, options?: SendFileOptions) { - const writer = await this.streamBytes({ - streamId, - totalSize: file.size, + /** + * 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, - destinationIdentities: options?.destinationIdentities, + 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 reader = file.stream().getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - await writer.write(value); + 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); } - await writer.close(); - return writer.info; + + return info; } async streamBytes(options?: StreamBytesOptions) { @@ -239,36 +526,21 @@ export default class OutgoingDataStreamManager { : Encryption_Type.NONE, }; - const header = new DataStream_Header({ - totalLength: numberToBigInt(info.size), - mimeType: info.mimeType, - streamId, - topic: info.topic, - timestamp: numberToBigInt(Date.now()), - attributes: info.attributes, - contentHeader: { - case: 'byteHeader', - value: new DataStream_ByteHeader({ - name: info.name, - }), - }, - }); - - const packet = new DataPacket({ - destinationIdentities, - value: { - case: 'streamHeader', - value: header, - }, - }); + const header = buildByteStreamHeader(info); + const packet = createStreamHeaderPacket(header, destinationIdentities); - await this.engine.sendDataPacket(packet, DataChannelKind.RELIABLE); + 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(); @@ -276,7 +548,7 @@ export default class OutgoingDataStreamManager { let byteOffset = 0; try { while (byteOffset < chunk.byteLength) { - const subChunk = chunk.slice(byteOffset, byteOffset + STREAM_CHUNK_SIZE); + const subChunk = chunk.slice(byteOffset, byteOffset + STREAM_CHUNK_SIZE_BYTES); const chunkPacket = new DataPacket({ destinationIdentities, value: { @@ -297,17 +569,7 @@ export default class OutgoingDataStreamManager { } }, async close() { - const trailer = new DataStream_Trailer({ - streamId, - }); - const trailerPacket = new DataPacket({ - destinationIdentities, - value: { - case: 'streamTrailer', - value: trailer, - }, - }); - await engine.sendDataPacket(trailerPacket, DataChannelKind.RELIABLE); + await sendStreamTrailer(streamId, destinationIdentities, engine); }, abort(err) { logLocal.error('Sink error:', err); @@ -319,3 +581,114 @@ export default class OutgoingDataStreamManager { 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); +} diff --git a/src/room/data-stream/outgoing/header-utils.ts b/src/room/data-stream/outgoing/header-utils.ts new file mode 100644 index 0000000000..301f38bcad --- /dev/null +++ b/src/room/data-stream/outgoing/header-utils.ts @@ -0,0 +1,87 @@ +import { + DataPacket, + DataStream_ByteHeader, + DataStream_CompressionType, + DataStream_Header, + DataStream_OperationType, + DataStream_TextHeader, +} from '@livekit/protocol'; +import type { ByteStreamInfo, StreamTextOptions, TextStreamInfo } from '../../types'; +import { numberToBigInt } from '../../utils'; + +/** The data-streams-v2 wire signals carried directly on the header: the compression flag and the + * inline single-packet payload. Both used to live in reserved header attributes; they are now + * first-class protobuf fields on `DataStream.Header`. */ +export interface StreamHeaderV2Fields { + /** Compression applied to the inline/chunked payload. Defaults to `NONE` when omitted. */ + compression?: DataStream_CompressionType; + /** The full payload smuggled into the header for single-packet (inline) sends. */ + inlineContent?: Uint8Array; +} + +/** Builds the `DataStream_Header` for a text stream from its info and stream options. */ +export function buildTextStreamHeader( + info: TextStreamInfo, + options?: Pick, + v2?: StreamHeaderV2Fields, +): DataStream_Header { + return new DataStream_Header({ + streamId: info.id, + mimeType: info.mimeType, + topic: info.topic, + timestamp: numberToBigInt(info.timestamp), + totalLength: numberToBigInt(info.size), + attributes: info.attributes, + compression: v2?.compression ?? DataStream_CompressionType.NONE, + inlineContent: v2?.inlineContent, + contentHeader: { + case: 'textHeader', + value: new DataStream_TextHeader({ + version: options?.version, + attachedStreamIds: info.attachedStreamIds, + replyToStreamId: options?.replyToStreamId, + operationType: + options?.type === 'update' + ? DataStream_OperationType.UPDATE + : DataStream_OperationType.CREATE, + }), + }, + }); +} + +/** Builds the `DataStream_Header` for a byte stream from its info. */ +export function buildByteStreamHeader( + info: ByteStreamInfo, + v2?: StreamHeaderV2Fields, +): DataStream_Header { + return new DataStream_Header({ + streamId: info.id, + mimeType: info.mimeType, + topic: info.topic, + timestamp: numberToBigInt(info.timestamp), + totalLength: numberToBigInt(info.size), + attributes: info.attributes, + compression: v2?.compression ?? DataStream_CompressionType.NONE, + inlineContent: v2?.inlineContent, + contentHeader: { + case: 'byteHeader', + value: new DataStream_ByteHeader({ + name: info.name, + }), + }, + }); +} + +/** Wraps a `DataStream_Header` in a `DataPacket` ready to be sent over a data channel. */ +export function createStreamHeaderPacket( + header: DataStream_Header, + destinationIdentities?: Array, +): DataPacket { + return new DataPacket({ + destinationIdentities, + value: { + case: 'streamHeader', + value: header, + }, + }); +} diff --git a/src/room/errors.ts b/src/room/errors.ts index 0344f498bc..087cc6f9a8 100644 --- a/src/room/errors.ts +++ b/src/room/errors.ts @@ -287,6 +287,12 @@ export enum DataStreamErrorReason { // Encryption type mismatch. EncryptionTypeMismatch = 8, + + // The serialized stream header packet (driven mainly by attributes) exceeds the MTU budget. + HeaderTooLarge = 9, + + // The stream's (decompressed) payload exceeds the maximum allowed size. + PayloadTooLarge = 10, } export class DataStreamError extends LivekitReasonedError { diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index fae8d8934f..1993a1d154 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -82,8 +82,10 @@ import { sourceToKind, } from '../track/utils'; import { + type ByteStreamInfo, type ChatMessage, type DataPublishOptions, + type SendBytesOptions, type SendFileOptions, type SendTextOptions, type StreamBytesOptions, @@ -1851,6 +1853,17 @@ export default class LocalParticipant extends Participant { return this.roomOutgoingDataStreamManager.sendFile(file, options); } + /** + * Sends the given bytes to participants in the room via the data channel. + * For files, consider using {@link sendFile}; for longer/incremental payloads, {@link streamBytes}. + * + * @param bytes The byte payload + * @param options.topic Topic identifier used to route the stream to appropriate handlers. + */ + async sendBytes(bytes: Uint8Array, options?: SendBytesOptions): Promise { + return this.roomOutgoingDataStreamManager.sendBytes(bytes, options); + } + /** * Stream bytes incrementally to participants in the room via the data channel. * For sending files, consider using {@link sendFile} instead. diff --git a/src/room/participant/RemoteParticipant.ts b/src/room/participant/RemoteParticipant.ts index 464d7c5d70..da6181fafd 100644 --- a/src/room/participant/RemoteParticipant.ts +++ b/src/room/participant/RemoteParticipant.ts @@ -1,8 +1,9 @@ -import type { - ParticipantInfo, - SubscriptionError, - UpdateSubscription, - UpdateTrackSettings, +import { + ClientInfo_Capability, + type ParticipantInfo, + type SubscriptionError, + type UpdateSubscription, + type UpdateTrackSettings, } from '@livekit/protocol'; import type { SignalClient } from '../../api/SignalClient'; import { DeferrableMap } from '../../utils/deferrable-map'; @@ -47,6 +48,16 @@ export default class RemoteParticipant extends Participant { **/ clientProtocol: number; + /** The client capabilities the remote participant advertises (e.g. deflate-raw compression + * support). Used to decide which peer-to-peer features can be used when sending to them. + * + * Differs from clientProtocol in that these are truely optional "additions" which can be used + * or not depending on client specific attributes rather than protocol level invariants. + * + * @internal + **/ + capabilities: Array; + private volumeMap: Map; private audioOutput?: AudioOutputOptions; @@ -72,6 +83,7 @@ export default class RemoteParticipant extends Participant { return new RemoteDataTrack(info, manager, { publisherIdentity: pi.identity }); }), pi.clientProtocol, + pi.capabilities, ); } @@ -95,6 +107,7 @@ export default class RemoteParticipant extends Participant { kind: ParticipantKind = ParticipantKind.STANDARD, remoteDataTracks: Array = [], clientProtocol: number = CLIENT_PROTOCOL_DEFAULT, + capabilities: Array = [], ) { super(sid, identity || '', name, metadata, attributes, loggerOptions, kind); this.signalClient = signalClient; @@ -108,6 +121,7 @@ export default class RemoteParticipant extends Participant { ); this.volumeMap = new Map(); this.clientProtocol = clientProtocol; + this.capabilities = capabilities; } protected addTrackPublication(publication: RemoteTrackPublication) { diff --git a/src/room/rpc/client/RpcClientManager.test.ts b/src/room/rpc/client/RpcClientManager.test.ts index 12230397ea..3ff496bd44 100644 --- a/src/room/rpc/client/RpcClientManager.test.ts +++ b/src/room/rpc/client/RpcClientManager.test.ts @@ -16,6 +16,8 @@ describe('RpcClientManager', () => { const outgoingDataStreamManager = new OutgoingDataStreamManager( {} as unknown as RTCEngine, log, + (_identity) => CLIENT_PROTOCOL_DEFAULT, + () => [], ); rpcClientManager = new RpcClientManager( @@ -132,19 +134,13 @@ describe('RpcClientManager', () => { describe('v2 -> v2', () => { let rpcClientManager: RpcClientManager; - let mockStreamTextWriter: { - write: ReturnType; - close: ReturnType; - }; + let sendTextMock: ReturnType; let mockOutgoingDataStreamManager: OutgoingDataStreamManager; beforeEach(() => { - mockStreamTextWriter = { - write: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - }; + sendTextMock = vi.fn().mockResolvedValue(undefined); mockOutgoingDataStreamManager = { - streamText: vi.fn().mockResolvedValue(mockStreamTextWriter), + sendText: sendTextMock, } as unknown as OutgoingDataStreamManager; rpcClientManager = new RpcClientManager( @@ -171,7 +167,8 @@ describe('RpcClientManager', () => { }); // Verify the data stream was used with correct attributes - expect(mockOutgoingDataStreamManager.streamText).toHaveBeenCalledWith( + expect(mockOutgoingDataStreamManager.sendText).toHaveBeenCalledWith( + 'request-payload', expect.objectContaining({ topic: RPC_REQUEST_DATA_STREAM_TOPIC, destinationIdentities: ['destination-identity'], @@ -182,8 +179,6 @@ describe('RpcClientManager', () => { }), }), ); - expect(mockStreamTextWriter.write).toHaveBeenCalledWith('request-payload'); - expect(mockStreamTextWriter.close).toHaveBeenCalled(); // No packet should have been emitted expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); @@ -213,7 +208,8 @@ describe('RpcClientManager', () => { }); // Verify the data stream was used with correct attributes - expect(mockOutgoingDataStreamManager.streamText).toHaveBeenCalledWith( + expect(mockOutgoingDataStreamManager.sendText).toHaveBeenCalledWith( + longPayload, expect.objectContaining({ topic: RPC_REQUEST_DATA_STREAM_TOPIC, destinationIdentities: ['destination-identity'], @@ -224,8 +220,6 @@ describe('RpcClientManager', () => { }), }), ); - expect(mockStreamTextWriter.write).toHaveBeenCalledWith(longPayload); - expect(mockStreamTextWriter.close).toHaveBeenCalled(); // No packet should have been emitted expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); @@ -337,14 +331,14 @@ describe('RpcClientManager', () => { }); it('should not drop ack and response that arrive before publish completes', async () => { - // Hold the publish path open by blocking writer.close() until we explicitly resolve it. - let resolveClose!: () => void; - const closeBlocked = new Promise((resolve) => { - resolveClose = resolve; + // Hold the publish path open by blocking sendText() until we explicitly resolve it. + let resolveSend!: () => void; + const sendBlocked = new Promise((resolve) => { + resolveSend = resolve; }); - mockStreamTextWriter.close = vi.fn().mockReturnValue(closeBlocked); + sendTextMock.mockReturnValue(sendBlocked); - // Start performRpc but don't await its return yet. The synchronous prefix runs streamText. + // Start performRpc but don't await its return yet. The synchronous prefix runs sendText. const performRpcPromise = rpcClientManager.performRpc({ destinationIdentity: 'destination-identity', method: 'test-method', @@ -352,11 +346,10 @@ describe('RpcClientManager', () => { responseTimeout: 200, }); - // streamText was called synchronously; pull the request id out of the attributes. - const streamTextCalls = (mockOutgoingDataStreamManager.streamText as ReturnType) - .mock.calls; - expect(streamTextCalls.length).toBe(1); - const requestId = streamTextCalls[0][0].attributes[RpcRequestAttrs.RPC_REQUEST_ID]; + // sendText was called synchronously; pull the request id out of the attributes. + const sendTextCalls = sendTextMock.mock.calls; + expect(sendTextCalls.length).toBe(1); + const requestId = sendTextCalls[0][1].attributes[RpcRequestAttrs.RPC_REQUEST_ID]; // Deliver ack and response BEFORE close() unblocks - the publish has not yet returned. rpcClientManager.handleIncomingRpcAck(requestId); @@ -367,7 +360,7 @@ describe('RpcClientManager', () => { ); // Now allow the publish path to complete. - resolveClose(); + resolveSend(); const [, completionPromise] = await performRpcPromise; await expect(completionPromise).resolves.toStrictEqual('response-payload'); diff --git a/src/room/rpc/client/RpcClientManager.ts b/src/room/rpc/client/RpcClientManager.ts index 019a3714b1..0b282e24f7 100644 --- a/src/room/rpc/client/RpcClientManager.ts +++ b/src/room/rpc/client/RpcClientManager.ts @@ -145,7 +145,7 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm ) { if (remoteClientProtocol >= CLIENT_PROTOCOL_DATA_STREAM_RPC) { // Send payload as a data stream - a "version 2" rpc request. - const writer = await this.outgoingDataStreamManager.streamText({ + await this.outgoingDataStreamManager.sendText(payload, { topic: RPC_REQUEST_DATA_STREAM_TOPIC, destinationIdentities: [destinationIdentity], attributes: { @@ -155,9 +155,6 @@ export default class RpcClientManager extends (EventEmitter as new () => TypedEm [RpcRequestAttrs.RPC_REQUEST_VERSION]: `${RPC_VERSION_V2}`, }, }); - - await writer.write(payload); - await writer.close(); return; } diff --git a/src/room/rpc/server/RpcServerManager.test.ts b/src/room/rpc/server/RpcServerManager.test.ts index a2a32a707a..e083712f67 100644 --- a/src/room/rpc/server/RpcServerManager.test.ts +++ b/src/room/rpc/server/RpcServerManager.test.ts @@ -2,7 +2,11 @@ import { RpcRequest } from '@livekit/protocol'; import { assert, beforeEach, describe, expect, it, vi } from 'vitest'; import log from '../../../logger'; import { subscribeToEvents } from '../../../utils/subscribeToEvents'; -import { CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT } from '../../../version'; +import { + CLIENT_PROTOCOL_DATA_STREAM_RPC, + CLIENT_PROTOCOL_DATA_STREAM_V2, + CLIENT_PROTOCOL_DEFAULT, +} from '../../../version'; import type RTCEngine from '../../RTCEngine'; import OutgoingDataStreamManager from '../../data-stream/outgoing/OutgoingDataStreamManager'; import { RPC_RESPONSE_DATA_STREAM_TOPIC, RpcError, RpcRequestAttrs } from '../utils'; @@ -17,6 +21,8 @@ describe('RpcServerManager', () => { const outgoingDataStreamManager = new OutgoingDataStreamManager( {} as unknown as RTCEngine, log, + (_identity) => CLIENT_PROTOCOL_DEFAULT, + () => [], ); rpcServerManager = new RpcServerManager( @@ -184,22 +190,17 @@ describe('RpcServerManager', () => { describe('v2 -> v2', () => { let rpcServerManager: RpcServerManager; let outgoingDataStreamManager: OutgoingDataStreamManager; - let mockStreamTextWriter: { - write: ReturnType; - close: ReturnType; - }; beforeEach(() => { - outgoingDataStreamManager = new OutgoingDataStreamManager({} as unknown as RTCEngine, log); - - mockStreamTextWriter = { - write: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - }; - vi.spyOn(outgoingDataStreamManager, 'streamText').mockResolvedValue( - mockStreamTextWriter as any, + outgoingDataStreamManager = new OutgoingDataStreamManager( + {} as unknown as RTCEngine, + log, + (_identity) => CLIENT_PROTOCOL_DATA_STREAM_V2, + () => [], ); + vi.spyOn(outgoingDataStreamManager, 'sendText').mockResolvedValue(undefined as any); + rpcServerManager = new RpcServerManager( log, outgoingDataStreamManager, @@ -243,15 +244,14 @@ describe('RpcServerManager', () => { // The response should have been sent via data stream, not packet expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); - expect(outgoingDataStreamManager.streamText).toHaveBeenCalledWith( + expect(outgoingDataStreamManager.sendText).toHaveBeenCalledWith( + 'response payload', expect.objectContaining({ topic: RPC_RESPONSE_DATA_STREAM_TOPIC, destinationIdentities: ['caller-identity'], attributes: { [RpcRequestAttrs.RPC_REQUEST_ID]: requestId }, }), ); - expect(mockStreamTextWriter.write).toHaveBeenCalledWith('response payload'); - expect(mockStreamTextWriter.close).toHaveBeenCalled(); }); it('should receive a large rpc request (> 15kb) and send a large response via data stream from a participant', async () => { @@ -277,15 +277,14 @@ describe('RpcServerManager', () => { // The response should have been sent via data stream, not packet expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); - expect(outgoingDataStreamManager.streamText).toHaveBeenCalledWith( + expect(outgoingDataStreamManager.sendText).toHaveBeenCalledWith( + new Array(20_000).fill('B').join(''), expect.objectContaining({ topic: RPC_RESPONSE_DATA_STREAM_TOPIC, destinationIdentities: ['caller-identity'], attributes: { [RpcRequestAttrs.RPC_REQUEST_ID]: requestId }, }), ); - expect(mockStreamTextWriter.write).toHaveBeenCalledWith(new Array(20_000).fill('B').join('')); - expect(mockStreamTextWriter.close).toHaveBeenCalled(); }); it('should register an RPC method handler', async () => { @@ -317,7 +316,7 @@ describe('RpcServerManager', () => { // Response goes via data stream, not packet expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); - expect(outgoingDataStreamManager.streamText).toHaveBeenCalled(); + expect(outgoingDataStreamManager.sendText).toHaveBeenCalled(); }); it('should catch and transform unhandled errors in the RPC method handler', async () => { @@ -414,7 +413,7 @@ describe('RpcServerManager', () => { const errorResponse = errorEvent.packet.value.value.value.value; expect(errorResponse.code).toStrictEqual(RpcError.ErrorCode.UNSUPPORTED_METHOD); - expect(outgoingDataStreamManager.streamText).not.toHaveBeenCalled(); + expect(outgoingDataStreamManager.sendText).not.toHaveBeenCalled(); expect(managerEvents.areThereBufferedEvents('sendDataPacket')).toBe(false); }); }); @@ -424,8 +423,10 @@ describe('RpcServerManager', () => { const outgoingDataStreamManager = new OutgoingDataStreamManager( {} as unknown as RTCEngine, log, + (_identity) => CLIENT_PROTOCOL_DEFAULT, + () => [], ); - const streamTextSpy = vi.spyOn(outgoingDataStreamManager, 'streamText'); + const sendTextSpy = vi.spyOn(outgoingDataStreamManager, 'sendText'); const rpcServerManager = new RpcServerManager( log, @@ -457,7 +458,7 @@ describe('RpcServerManager', () => { assert(ackEvent.packet.value.case === 'rpcAck'); // Response should be a v1 RpcResponse packet, not a data stream - expect(streamTextSpy).not.toHaveBeenCalled(); + expect(sendTextSpy).not.toHaveBeenCalled(); const responseEvent = await managerEvents.waitFor('sendDataPacket'); assert(responseEvent.packet.value.case === 'rpcResponse'); const rpcResponse = responseEvent.packet.value.value; diff --git a/src/room/rpc/server/RpcServerManager.ts b/src/room/rpc/server/RpcServerManager.ts index 33215c3d8a..a8f161d929 100644 --- a/src/room/rpc/server/RpcServerManager.ts +++ b/src/room/rpc/server/RpcServerManager.ts @@ -263,13 +263,11 @@ export default class RpcServerManager extends (EventEmitter as new () => TypedEm if (callerClientProtocol >= CLIENT_PROTOCOL_DATA_STREAM_RPC) { // Send response as a data stream - const writer = await this.outgoingDataStreamManager.streamText({ + await this.outgoingDataStreamManager.sendText(payload, { topic: RPC_RESPONSE_DATA_STREAM_TOPIC, destinationIdentities: [destinationIdentity], attributes: { [RpcRequestAttrs.RPC_REQUEST_ID]: requestId }, }); - await writer.write(payload); - await writer.close(); return; } diff --git a/src/room/types.ts b/src/room/types.ts index 1eb8121679..d12579afbc 100644 --- a/src/room/types.ts +++ b/src/room/types.ts @@ -21,6 +21,21 @@ export interface SendTextOptions { attachments?: Array; onProgress?: (progress: number) => void; attributes?: Record; + /** Whether to compress the payload (deflate-raw). Defaults to true. Compression is only applied + * when every recipient supports data streams v2 and the runtime can compress. */ + compress?: boolean; +} + +export interface SendBytesOptions { + topic?: string; + destinationIdentities?: Array; + attributes?: Record; + onProgress?: (progress: number) => void; + /** Whether to compress the payload (deflate-raw). Defaults to true. Compression is only applied + * when every recipient supports data streams v2 and the runtime can compress. */ + compress?: boolean; + name?: string; + mimeType?: string; } export interface StreamTextOptions { @@ -51,6 +66,9 @@ export type SendFileOptions = Pick< > & { onProgress?: (progress: number) => void; encryptionType?: Encryption_Type.NONE; + /** Whether to compress the payload (deflate-raw). Defaults to true. Compression is only applied + * when every recipient supports data streams v2 and the runtime can compress. */ + compress?: boolean; }; export type DataPublishOptions = { diff --git a/src/room/utils.ts b/src/room/utils.ts index 52a586479b..4585360107 100644 --- a/src/room/utils.ts +++ b/src/room/utils.ts @@ -781,6 +781,73 @@ export function splitUtf8(s: string, n: number): NonSharedUint8Array[] { return result; } +/** Wraps a byte array in a `ReadableStream` that yields it as a single chunk and then closes. */ +export function readableFromBytes(bytes: NonSharedUint8Array): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +/** + * Re-chunks a byte stream into pieces of exactly `chunkSize` bytes (the final piece may be + * smaller), coalescing or splitting the source's pieces as needed. Memory use is bounded to roughly + * `chunkSize` plus one source read, so it never buffers the whole stream β€” used to pack + * `CompressionStream`/`file.stream()` output into MTU-sized data-stream chunks. + */ +export async function* readBytesInChunks( + source: ReadableStream, + chunkSize: number, +): AsyncGenerator { + const reader = source.getReader(); + let buffer = new Uint8Array(0); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (value.byteLength === 0) { + continue; + } + const merged = new Uint8Array(buffer.byteLength + value.byteLength); + merged.set(buffer); + merged.set(value, buffer.byteLength); + buffer = merged; + while (buffer.byteLength >= chunkSize) { + yield buffer.slice(0, chunkSize); + buffer = buffer.slice(chunkSize); + } + } + if (buffer.byteLength > 0) { + yield buffer; + } + } finally { + reader.releaseLock(); + } +} + +/** Encodes a byte array as a base64 string (suitable for embedding binary data in a string field). */ +export function encodeBase64(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]!); + } + return btoa(binary); +} + +/** Decodes a base64 string (as produced by {@link encodeBase64}) back into a byte array. */ +export function decodeBase64(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + export function extractMaxAgeFromRequestHeaders(headers: Headers): number | undefined { const cacheControl = headers.get('Cache-Control'); if (cacheControl) { diff --git a/src/version.ts b/src/version.ts index ed123a1f06..75ad4d28d6 100644 --- a/src/version.ts +++ b/src/version.ts @@ -8,7 +8,11 @@ export const CLIENT_PROTOCOL_DEFAULT = 0; /** Replaces RPC v1 protocol with a v2 data streams based one to support unlimited request / * response payload length. */ export const CLIENT_PROTOCOL_DATA_STREAM_RPC = 1; +/** "Data streams v2": the client knows how to receive a single-packet data stream (a stream whose + * entire payload is smuggled into the header packet, with no chunk/trailer packets). Senders only + * use the single-packet optimization when the recipient advertises at least this protocol. */ +export const CLIENT_PROTOCOL_DATA_STREAM_V2 = 2; /** The client protocol version indicates what level of support that the client has for * client <-> client api interactions. */ -export const clientProtocol = CLIENT_PROTOCOL_DATA_STREAM_RPC; +export const clientProtocol = CLIENT_PROTOCOL_DATA_STREAM_V2;