Skip to content
Draft
1 change: 1 addition & 0 deletions .changes/connect-room-options-ignored
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="fixed" "Room.connect no longer ignores the roomOptions argument passed to it"
1 change: 1 addition & 0 deletions .changes/data-stream-options
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
minor type="added" "ConnectOptions.dataStream with maxPayloadByteLength, bounding the payload a single incoming data stream may deliver"
1 change: 1 addition & 0 deletions .changes/data-streams-v2
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
minor type="changed" "Data streams are now backed by the Rust core (livekit-uniffi) on native platforms, adding data streams v2: single-packet inline sends, deflate-raw compression and MTU-bounded headers. Adds LocalParticipant.sendBytes, a compress option, Participant.capabilities and ClientProtocolVersion.v2. Web keeps the existing Dart implementation and interoperates as a pre-v2 peer."
11 changes: 10 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,16 @@ Web/native divergence is handled with conditional imports (e.g. `track/processor

`lib/src/uniffi/` wraps `livekit_uniffi`, a Dart package generated from the `livekit-uniffi` crate in the sibling `rust-sdks` repo. It reaches Rust through Dart's Native Assets: the package's `hook/build.dart` bundles a `cdylib` into the host app and the generated bindings call into it with `@Native`. This is why the SDK requires Flutter >= 3.38 / Dart >= 3.10.

There is no dynamic library to load on the web, so `uniffi.dart` splits native/web the same way the rest of the SDK does. **`uniffi_io.dart` is the only file allowed to import `package:livekit_uniffi/...`** — importing it from anywhere reachable on web pulls `dart:ffi` into a web compile and breaks `flutter build web`/`--wasm`. Guard calls with `LiveKitUniffi.isAvailable`.
There is no dynamic library to load on the web, so `uniffi.dart` splits native/web the same way the rest of the SDK does. **Only `uniffi_io.dart` and files under `lib/src/data_stream/` whose names end in `_native.dart` (plus `ffi_bridged.dart`) may import `package:livekit_uniffi/...`** — importing it from anywhere reachable on web pulls `dart:ffi` into a web compile and breaks `flutter build web`/`--wasm`. No generated uniffi type may appear in a public API signature; convert at the boundary (`data_stream/ffi_bridged.dart`). Guard calls with `LiveKitUniffi.isAvailable`.

### Data streams

`lib/src/data_stream/` has two implementations behind one interface (`data_streams.dart`, conditional import): `data_streams_native.dart` delegates to the Rust core, which implements **data streams v2** (inline single-packet sends, deflate-raw compression, UTF-8-aware chunking, MTU-bounded headers); `data_streams_web.dart` is the original Dart v1 code, kept because the cdylib can't run in a browser. Web advertises `ClientProtocolVersion.v1` and no capabilities, so v2 senders fall back to uncompressed multi-packet for it.

Two things to know when touching the native path:

- **The core's push delegates cannot be used from Dart.** uniffi compiles a callback interface to `Pointer.fromFunction`, which is only valid on the isolate's thread, and the core invokes those delegates from its tokio runtime — the VM aborts with `Cannot invoke native callback outside an isolate`, which is not catchable. The managers are therefore built through the crate's `polled*` adapters (`livekit-uniffi/src/data_stream/polled.rs`), which implement the delegates *in Rust*, buffer into a channel, and expose an `async fn next_*` we await. `RemoteParticipantRegistryDelegate` is the one callback we implement directly, and it is safe: it is only called synchronously inside a `send*` future, which uniffi polls from the calling (Dart) thread.
- **Whoever awaits a uniffi object is the only thing that may dispose it.** Freeing the Rust handle while a `next()`/`nextPackets()` is in flight is a use-after-free that surfaces as a SIGBUS with no Dart stack. Hence readers are disposed by their pump rather than from a subscription's `onCancel`, and `dispose()` calls `close()` on the queues to wake their pumps instead of releasing them directly.

### Local development loop

Expand Down
2 changes: 2 additions & 0 deletions lib/livekit_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export 'src/connection_check/events.dart';
export 'src/constants.dart';
export 'src/core/room.dart';
export 'src/core/room_preconnect.dart';
export 'src/data_stream/errors.dart';
export 'src/data_stream/stream_reader.dart';
export 'src/data_stream/stream_writer.dart';
export 'src/e2ee/e2ee_manager.dart';
Expand Down Expand Up @@ -69,6 +70,7 @@ export 'src/track/remote/remote.dart';
export 'src/track/remote/video.dart';
export 'src/track/track.dart';
export 'src/json/agent_attributes.dart';
export 'src/types/client_capability.dart';
export 'src/types/data_stream.dart';
export 'src/types/audio_encoding.dart';
export 'src/types/other.dart';
Expand Down
28 changes: 6 additions & 22 deletions lib/src/core/engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1024,29 +1024,13 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
identity: dp.participantIdentity,
),
);
} else if (dp.whichValue() == lk_models.DataPacket_Value.streamHeader) {
// Data Stream Header
} else if (dp.whichValue() == lk_models.DataPacket_Value.streamHeader ||
dp.whichValue() == lk_models.DataPacket_Value.streamChunk ||
dp.whichValue() == lk_models.DataPacket_Value.streamTrailer) {
// Data stream header / chunk / trailer, forwarded whole — see EngineDataStreamPacketEvent.
events.emit(
EngineDataStreamHeaderEvent(
header: dp.streamHeader,
identity: dp.participantIdentity,
encryptionType: encryptionType,
),
);
} else if (dp.whichValue() == lk_models.DataPacket_Value.streamChunk) {
// Data Stream Chunk
events.emit(
EngineDataStreamChunkEvent(
chunk: dp.streamChunk,
identity: dp.participantIdentity,
encryptionType: encryptionType,
),
);
} else if (dp.whichValue() == lk_models.DataPacket_Value.streamTrailer) {
// Data Stream trailer
events.emit(
EngineDataStreamTrailerEvent(
trailer: dp.streamTrailer,
EngineDataStreamPacketEvent(
packet: dp,
identity: dp.participantIdentity,
encryptionType: encryptionType,
),
Expand Down
Loading