Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
def3c98
refactor: rename constant
1egoman Jun 4, 2026
9f467c4
fix: make rpc use datastream.sendText, NOT streamText
1egoman Jun 4, 2026
632f551
fix: convert rpc tests from streamText -> sendText
1egoman Jun 4, 2026
b90d2ee
feat: add initial support for single packet data streams via hacky me…
1egoman Jun 4, 2026
cb4a54a
feat: add backwards compatibility back in for old multi packet v1 dat…
1egoman Jun 4, 2026
2d7936f
feat: check all participants in room before broadcasting a v2 data st…
1egoman Jun 4, 2026
a18007c
feat: add initial data stream benchmark app
1egoman Jun 5, 2026
578dbd8
feat: add missing constants file
1egoman Jun 5, 2026
7c35a52
feat: add initial data stream compression implementation
1egoman Jun 5, 2026
aac267b
feat: migrate compression to get rid of "pump" and compress per packe…
1egoman Jun 5, 2026
c7acc6b
fix: swap else if -> switch/case
1egoman Jun 5, 2026
c406a2e
feat: add initial take at streaming through CompressionStream for data
1egoman Jun 8, 2026
38b2a0c
feat: commit hand written data streams compression approach
1egoman Jun 9, 2026
3f605f1
feat: try out using fflate to share a single compression stream acros…
1egoman Jun 10, 2026
09e2fa1
refactor: move to deflate-raw for all compression, move to Compressio…
1egoman Jun 10, 2026
b8aaf48
fix: get rid of singleWrite mode on streamText
1egoman Jun 10, 2026
fe33ba5
fix: clean up code
1egoman Jun 11, 2026
3ca6f5f
feat: remove compression from streamText, in practice this doesn't re…
1egoman Jun 11, 2026
e918ca2
feat: add compression into bytestreams too
1egoman Jun 12, 2026
6d09260
refactor: move around outgoing data stream single packet / compressio…
1egoman Jun 12, 2026
087fe22
fix: reduce needless diff churn
1egoman Jun 12, 2026
234d378
feat: add max header size breaking change
1egoman Jun 12, 2026
f79abfc
feat: advertise new CAP_COMPRESSION_DEFLATE_RAW capability to SFU
1egoman Jun 15, 2026
7e342e4
feat: add proper OutgoingDataStreamManager tests
1egoman Jun 15, 2026
8d9ec44
feat: add initial IncomingDataStreamManager tests
1egoman Jun 16, 2026
18fe70c
fix: ensure that compression only used when remote participants suppo…
1egoman Jun 16, 2026
1932f77
fix: fiz zlib Z_SYNC_FLUSH test
1egoman Jun 16, 2026
209e654
feat: add a few more IncomingDataStreamManager tests
1egoman Jun 16, 2026
82b1f4c
fix: reformat code
1egoman Jun 17, 2026
c744642
fix: remove unused import
1egoman Jun 17, 2026
01dfa70
fix: remove Z_SYNC_FLUSH test, it was not really testing the right thing
1egoman Jun 17, 2026
bead054
fix: add participant disconnect test
1egoman Jun 17, 2026
2455a65
fix: add sendFile v1 outgoing data stream manager tests
1egoman Jun 17, 2026
33a3ce4
feat: port over data streams implementation to use newly added proto …
1egoman Jun 23, 2026
5464880
feat: commit initial data streams spec
1egoman Jun 23, 2026
a7780de
fix: remove unused tests
1egoman Jun 23, 2026
0611aba
fix: remove earlier drafts mention
1egoman Jun 25, 2026
78fe015
fix: remove more out of date E2EE stuff and note about utf-8 chunk bo…
1egoman Jun 25, 2026
c551b4a
fix: update misleading comments
1egoman Jun 25, 2026
00ba872
fix: add sendBytes method as the main entrypoint for sending inline b…
1egoman Jun 25, 2026
cc53c78
fix: make sendFile optional in spec
1egoman Jun 25, 2026
9cc7a28
fix: run npm run format
1egoman Jun 25, 2026
66f2811
fix: address remaining lint errors
1egoman Jun 25, 2026
72ed4f4
fix: add missing changeset
1egoman Jun 25, 2026
e4b07a5
fix: add log when unknown compression scheme encountered
1egoman Jun 29, 2026
2b034a7
fix: convert intermedite streams to transformstream
1egoman Jun 29, 2026
2280836
fix: port compression to use TransformStream
1egoman Jun 29, 2026
dc69bf7
feat: add zero length single packet data stream tests
1egoman Jul 1, 2026
1536956
fix: swap break for return
1egoman Jul 1, 2026
21426e2
fix: ensure data stream payload will only ever be compressed once
1egoman Jul 1, 2026
7f48881
fix: address lint error
1egoman Jul 1, 2026
b432685
feat: add name / mimeType to new sendBytes method
1egoman Jul 1, 2026
1edcb68
docs: update CompressedStreamState docstring
1egoman Jul 1, 2026
a3ffb95
feat: add compressedStream.lengthLargerThan to allow for partial stre…
1egoman Jul 1, 2026
27f4afd
Revert "feat: add compressedStream.lengthLargerThan to allow for part…
1egoman Jul 7, 2026
9dca376
refactor: rename CompressedStreamState -> MaybeCollectedStream
1egoman Jul 7, 2026
4a6e2bb
feat: adjust progress event to report results more reliably in all co…
1egoman Jul 17, 2026
3d3e26c
fix: address flipped stream controllers check
1egoman Jul 17, 2026
f45fadc
fix: make sure textStreamControllers always cleaned up
1egoman Jul 17, 2026
2c23c34
fix: ensure destinationIdentities / compress forwarded along to attac…
1egoman Jul 17, 2026
fc53b23
feat: add max payload byte size limit to data streams
1egoman Jul 20, 2026
52734f1
fix: surface an abnormal data stream trailer close reason as an Abnor…
1egoman Jul 20, 2026
902b960
feat: add new test cases for incoming / outgoing data stream handling
1egoman Jul 20, 2026
c8e0790
fix: adjust uint8array cast to get ci to pass
1egoman Jul 20, 2026
a0e4921
feat: lift NonSharedUint8Array casts further up and use NonSharedUint…
1egoman Jul 22, 2026
0d41037
fix: upgrade @livekit/protocol to 1.50.4
1egoman Jul 22, 2026
f291fd8
fix: adjust changeset to minor
1egoman Jul 23, 2026
c3fd1ab
fix: remove data streams spec and benchmark before merging
1egoman Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slick-geese-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'livekit-client': minor
---

Add support for data streams v2
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ export type {
TranscriptionSegment,
ChatMessage,
SendTextOptions,
SendBytesOptions,
ByteStreamInfo,
} from './room/types';
export * from './version';
export {
Expand Down
5 changes: 4 additions & 1 deletion src/room/Room.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
44 changes: 38 additions & 6 deletions src/room/Room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ import {
getEmptyAudioStreamTrack,
isBrowserSupported,
isCloud,
isCompressionStreamSupported,
isLocalAudioTrack,
isLocalParticipant,
isReactNative,
Expand Down Expand Up @@ -268,7 +269,13 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
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
Expand Down Expand Up @@ -971,11 +978,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
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,
Expand Down Expand Up @@ -2505,10 +2508,39 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
}
}

/** The client capabilities this SDK advertises to other participants in its `ClientInfo`. */
private getClientInfoCapabilities(
roomOptions: InternalRoomOptions,
): Array<ClientInfo_Capability> {
const capabilities: Array<ClientInfo_Capability> = [];
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<ClientInfo_Capability> => {
return this.remoteParticipants.get(identity)?.capabilities ?? [];
};

private getAllRemoteParticipantIdentities = () => {
return Array.from(this.remoteParticipants.keys());
};

private registerRpcDataStreamHandler() {
this.incomingDataStreamManager.registerTextStreamHandler(
RPC_REQUEST_DATA_STREAM_TOPIC,
Expand Down
117 changes: 117 additions & 0 deletions src/room/data-stream/compression.ts
Original file line number Diff line number Diff line change
@@ -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<BufferSource>`
* (a wider element type than `Uint8Array`), and `WritableStream<W>` is covariant in `W`, so neither
* is structurally a `ReadableWritablePair<Uint8Array, Uint8Array>` 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<NonSharedUint8Array> {
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<NonSharedUint8Array> {
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<NonSharedUint8Array>,
maxByteLength?: number,
): Promise<NonSharedUint8Array> {
const reader = stream.getReader();
const chunks: Array<NonSharedUint8Array> = [];
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;
}
18 changes: 18 additions & 0 deletions src/room/data-stream/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading