From 1546ceb73bcb8164e6833564c519254497fa28f7 Mon Sep 17 00:00:00 2001 From: James Hugman Date: Tue, 28 Jul 2026 19:32:15 +0100 Subject: [PATCH 1/3] Enable set_ws_client and set_http_client injectable interfaces --- .github/workflows/tests.yml | 8 +++++++- Cargo.lock | 2 ++ livekit-net/Cargo.toml | 13 +++++++++++++ livekit-net/src/lib.rs | 5 +++++ livekit-net/src/transport.rs | 5 +++++ livekit-net/src/types.rs | 3 +++ livekit-uniffi/Cargo.toml | 1 + livekit-uniffi/src/lib.rs | 2 ++ 8 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 84fb21427..222850de0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -170,4 +170,10 @@ jobs: shell: bash run: | cargo test --verbose --target ${{ matrix.target }} -p livekit-net --features native-tokio - cargo test --verbose --target ${{ matrix.target }} -p livekit-api --features signal-client-tokio \ No newline at end of file + cargo test --verbose --target ${{ matrix.target }} -p livekit-api --features signal-client-tokio + + - name: Build livekit-net uniffi feature (foreign transport bindings) + shell: bash + run: | + cargo build --verbose --target ${{ matrix.target }} -p livekit-net --features uniffi + cargo build --verbose --target ${{ matrix.target }} -p livekit-net --features uniffi,native-tokio \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 9984098ea..74b8e890e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4045,6 +4045,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-tungstenite", + "uniffi", "url", ] @@ -4079,6 +4080,7 @@ dependencies = [ "futures-util", "livekit-api", "livekit-datatrack", + "livekit-net", "livekit-protocol", "log", "once_cell", diff --git a/livekit-net/Cargo.toml b/livekit-net/Cargo.toml index 965f78f8c..e1d170993 100644 --- a/livekit-net/Cargo.toml +++ b/livekit-net/Cargo.toml @@ -9,6 +9,12 @@ repository.workspace = true [features] default = [] +# UniFFI bindings: exposes the transport seam across the FFI so a host can +# implement WsClient/HttpClient and register it. Orthogonal to the native +# backends — enable alongside a native-* backend for a host-overridable +# transport that falls back to native. +uniffi = ["dep:uniffi"] + # Native backend bundles — each pairs the net libraries with a runtime. native-tokio = ["__native", "__native-tokio", "tokio"] native-async = ["__native", "__native-async", "async"] @@ -50,6 +56,13 @@ __native-async = ["dep:async-tungstenite", "dep:isahc", "dep:tokio", "dep:future [dependencies] async-trait = "0.1" +# Optional: scaffolding for the `uniffi` feature. `scaffolding-ffi-buffer-fns` +# matches livekit-datatrack/livekit-uniffi so the ABI is consistent when these +# crates are combined into one cdylib. No runtime feature: the exported traits +# are `with_foreign` (host-driven futures) and the exported setters are sync, so +# the crate stays runtime-agnostic. +uniffi = { workspace = true, features = ["scaffolding-ffi-buffer-fns"], optional = true } + # Direct path dep (not workspace inheritance) so default-features = false is # honored: cargo ignores a member's default-features override on an inherited dep, # which would drag in livekit-runtime's default `tokio` and trip its one-runtime diff --git a/livekit-net/src/lib.rs b/livekit-net/src/lib.rs index 74fc509c5..2aba6edf8 100644 --- a/livekit-net/src/lib.rs +++ b/livekit-net/src/lib.rs @@ -25,6 +25,9 @@ pub use types::{Header, HttpResponse, TransportError}; use std::sync::{Arc, OnceLock}; +#[cfg(feature = "uniffi")] +uniffi::setup_scaffolding!(); + /// Render a URL for logging with secrets stripped: userinfo (`user:password@`) /// and the query string (which can carry an access token). Keeps scheme, host, /// port, and path. @@ -45,12 +48,14 @@ static HTTP: OnceLock> = OnceLock::new(); /// /// Independent of [`set_http_client`]: a consumer that only needs HTTP (e.g. a /// token source) can register that alone, and vice versa. +#[cfg_attr(feature = "uniffi", uniffi::export)] pub fn set_ws_client(c: Arc) { let _ = WS.set(c); } /// Register the process-wide HTTP client. Call once at startup, before the first /// request. A later call is ignored (first registration wins). +#[cfg_attr(feature = "uniffi", uniffi::export)] pub fn set_http_client(c: Arc) { let _ = HTTP.set(c); } diff --git a/livekit-net/src/transport.rs b/livekit-net/src/transport.rs index fa39e9d40..91bbdef04 100644 --- a/livekit-net/src/transport.rs +++ b/livekit-net/src/transport.rs @@ -17,6 +17,7 @@ use std::sync::Arc; /// A single open WebSocket connection. Control frames (ping/pong, close handshake) /// are the implementation's own responsibility; only binary application frames cross here. +#[cfg_attr(feature = "uniffi", uniffi::export(with_foreign))] #[async_trait::async_trait] pub trait WsConnection: Send + Sync + 'static { /// Send one binary application frame. @@ -31,12 +32,14 @@ pub trait WsConnection: Send + Sync + 'static { /// /// A record wrapper, not a bare `Arc`: uniffi 0.31 cannot /// lift a trait object returned from an async `with_foreign` method. +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] pub struct WsConnectResult { pub connection: Arc, } /// A host- or Rust-provided WebSocket transport. Opens the LiveKit signalling /// WebSocket; knows nothing about LiveKit/protobuf. +#[cfg_attr(feature = "uniffi", uniffi::export(with_foreign))] #[async_trait::async_trait] pub trait WsClient: Send + Sync { /// Open a WebSocket. `url` is the full ws(s):// URL including query string. @@ -51,6 +54,7 @@ pub trait WsClient: Send + Sync { } /// The HTTP method for an [`HttpClient::request`]. +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HttpMethod { Get, @@ -64,6 +68,7 @@ pub enum HttpMethod { /// Implementors provide the single [`request`](HttpClient::request) primitive; /// [`HttpClientExt`] layers `get`/`post` on top, so adding verbs never widens the /// implementation (or, for foreign impls, the FFI) surface. +#[cfg_attr(feature = "uniffi", uniffi::export(with_foreign))] #[async_trait::async_trait] pub trait HttpClient: Send + Sync { /// Perform one HTTP request, sending `body` if present. diff --git a/livekit-net/src/types.rs b/livekit-net/src/types.rs index d05871c42..3dc744bc9 100644 --- a/livekit-net/src/types.rs +++ b/livekit-net/src/types.rs @@ -15,6 +15,7 @@ use std::fmt; /// A single HTTP/WebSocket request header. +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[derive(Debug, Clone)] pub struct Header { pub name: String, @@ -22,6 +23,7 @@ pub struct Header { } /// The result of an HTTP request performed by the transport. +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[derive(Debug, Clone)] pub struct HttpResponse { pub status: u16, @@ -31,6 +33,7 @@ pub struct HttpResponse { } /// Errors a transport implementation may return. Mapped onto `SignalError` by the caller. +#[cfg_attr(feature = "uniffi", derive(uniffi::Error))] #[derive(Debug, Clone)] pub enum TransportError { Timeout, diff --git a/livekit-uniffi/Cargo.toml b/livekit-uniffi/Cargo.toml index 635acab64..081f61d28 100644 --- a/livekit-uniffi/Cargo.toml +++ b/livekit-uniffi/Cargo.toml @@ -15,6 +15,7 @@ publish = false livekit-protocol = { workspace = true } livekit-api = { workspace = true, default-features = false, features = ["access-token"] } livekit-datatrack = { workspace = true, features = ["uniffi"] } +livekit-net = { workspace = true, features = ["uniffi"] } uniffi = { workspace = true, features = ["scaffolding-ffi-buffer-fns", "tokio"] } log = { workspace = true } tokio = { workspace = true, features = ["sync", "rt-multi-thread"] } diff --git a/livekit-uniffi/src/lib.rs b/livekit-uniffi/src/lib.rs index 94f2cc0d0..0a9ad72ec 100644 --- a/livekit-uniffi/src/lib.rs +++ b/livekit-uniffi/src/lib.rs @@ -30,4 +30,6 @@ pub mod common; /// Global async runtime. pub mod runtime; +extern crate livekit_net; + uniffi::setup_scaffolding!(); From 66a66b8a9400b86bc8e13954493eefa11c07344c Mon Sep 17 00:00:00 2001 From: James Hugman Date: Wed, 29 Jul 2026 14:17:51 +0100 Subject: [PATCH 2/3] feat(livekit-net): add transport self-test + registration probes --- .changeset/livekit_net_self_test_ffi.md | 6 ++ livekit-net/src/lib.rs | 34 +++++++++++ livekit-net/tests/self_test.rs | 76 +++++++++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 .changeset/livekit_net_self_test_ffi.md create mode 100644 livekit-net/tests/self_test.rs diff --git a/.changeset/livekit_net_self_test_ffi.md b/.changeset/livekit_net_self_test_ffi.md new file mode 100644 index 000000000..2b96ed1b8 --- /dev/null +++ b/.changeset/livekit_net_self_test_ffi.md @@ -0,0 +1,6 @@ +--- +livekit-net: patch +livekit-uniffi: patch +--- + +Add `self_test_http_get` / `self_test_ws_echo` / `has_http_client` / `has_ws_client` UniFFI exports so foreign hosts can exercise the transport seam end-to-end. diff --git a/livekit-net/src/lib.rs b/livekit-net/src/lib.rs index 2aba6edf8..d38e184dc 100644 --- a/livekit-net/src/lib.rs +++ b/livekit-net/src/lib.rs @@ -60,6 +60,40 @@ pub fn set_http_client(c: Arc) { let _ = HTTP.set(c); } +/// Self-test: GET `url` via the registered HTTP client; returns the full response +/// (status + headers + body) so callers can assert the whole struct round-trips the FFI. +/// Errors if no client is registered or the transport fails. +#[cfg_attr(feature = "uniffi", uniffi::export)] +pub async fn self_test_http_get(url: String) -> Result { + let c = + http_client().ok_or_else(|| TransportError::Other("no http client registered".into()))?; + c.request(HttpMethod::Get, url, Vec::new(), None).await +} + +/// Self-test: connect, send `payload`, receive one frame, close; return the echoed bytes. +/// Errors if no client is registered, the transport fails, or the peer closes first. +#[cfg_attr(feature = "uniffi", uniffi::export)] +pub async fn self_test_ws_echo(url: String, payload: Vec) -> Result, TransportError> { + let c = ws_client().ok_or_else(|| TransportError::Other("no ws client registered".into()))?; + let conn = c.connect(url, Vec::new(), 5_000).await?.connection; + conn.send(payload).await?; + let got = conn.recv().await?.ok_or(TransportError::Closed)?; + conn.close().await; + Ok(got) +} + +/// Test probe: whether a process-wide HTTP client is registered. +#[cfg_attr(feature = "uniffi", uniffi::export)] +pub fn has_http_client() -> bool { + http_client().is_some() +} + +/// Test probe: whether a process-wide WebSocket client is registered. +#[cfg_attr(feature = "uniffi", uniffi::export)] +pub fn has_ws_client() -> bool { + ws_client().is_some() +} + /// Resolve the process-wide WebSocket client. /// /// Returns the explicitly registered client if any; otherwise, on native builds, diff --git a/livekit-net/tests/self_test.rs b/livekit-net/tests/self_test.rs new file mode 100644 index 000000000..a394aacb7 --- /dev/null +++ b/livekit-net/tests/self_test.rs @@ -0,0 +1,76 @@ +// Copyright 2026 LiveKit, Inc. (Apache-2.0) +use livekit_net::{ + has_http_client, has_ws_client, self_test_http_get, self_test_ws_echo, set_http_client, + set_ws_client, Header, HttpMethod, HttpResponse, TransportError, WsClient, WsConnectResult, + WsConnection, +}; +use std::sync::{Arc, Mutex}; + +struct EchoConn { + buf: Mutex>>, +} + +#[async_trait::async_trait] +impl WsConnection for EchoConn { + async fn send(&self, frame: Vec) -> Result<(), TransportError> { + *self.buf.lock().unwrap() = Some(frame); + Ok(()) + } + async fn recv(&self) -> Result>, TransportError> { + Ok(self.buf.lock().unwrap().take()) + } + async fn close(&self) {} +} + +struct EchoWsClient; + +#[async_trait::async_trait] +impl WsClient for EchoWsClient { + async fn connect( + &self, + _url: String, + _headers: Vec
, + _timeout_ms: u64, + ) -> Result { + Ok(WsConnectResult { connection: Arc::new(EchoConn { buf: Mutex::new(None) }) }) + } +} + +struct CannedHttpClient; + +#[async_trait::async_trait] +impl livekit_net::HttpClient for CannedHttpClient { + async fn request( + &self, + _method: HttpMethod, + _url: String, + _headers: Vec
, + _body: Option>, + ) -> Result { + Ok(HttpResponse { + status: 201, + headers: vec![Header { name: "x-test".into(), value: "1".into() }], + body: b"hello".to_vec(), + }) + } +} + +#[tokio::test] +async fn self_tests_round_trip_through_registered_clients() { + // Own test binary ⇒ fresh OnceLock; nothing registered yet. + assert!(!has_http_client()); + assert!(!has_ws_client()); + + set_http_client(Arc::new(CannedHttpClient)); + set_ws_client(Arc::new(EchoWsClient)); + assert!(has_http_client()); + assert!(has_ws_client()); + + let resp = self_test_http_get("http://example/x".into()).await.unwrap(); + assert_eq!(resp.status, 201); + assert_eq!(resp.body, b"hello"); + assert_eq!(resp.headers.len(), 1); + + let echoed = self_test_ws_echo("ws://example/x".into(), b"ping".to_vec()).await.unwrap(); + assert_eq!(echoed, b"ping"); +} From 52d21c05c84bc1481574967106b3184ae85ed35e Mon Sep 17 00:00:00 2001 From: James Hugman Date: Tue, 4 Aug 2026 15:50:49 +0100 Subject: [PATCH 3/3] fix(livekit-net): report registration, not resolvability, in probes has_http_client/has_ws_client resolved through the native fallback, so on any native build they were a constant true and could not tell a host whether its set_*_client call took effect. Read the OnceLocks directly. Also adds the transitive changeset bumps for livekit, livekit-api and livekit-ffi. --- .changeset/livekit_net_self_test_ffi.md | 3 +++ livekit-net/src/lib.rs | 16 ++++++++++++---- livekit-net/tests/self_test.rs | 4 +++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.changeset/livekit_net_self_test_ffi.md b/.changeset/livekit_net_self_test_ffi.md index 2b96ed1b8..64f9f4b1e 100644 --- a/.changeset/livekit_net_self_test_ffi.md +++ b/.changeset/livekit_net_self_test_ffi.md @@ -1,6 +1,9 @@ --- livekit-net: patch livekit-uniffi: patch +livekit-api: patch +livekit: patch +livekit-ffi: patch --- Add `self_test_http_get` / `self_test_ws_echo` / `has_http_client` / `has_ws_client` UniFFI exports so foreign hosts can exercise the transport seam end-to-end. diff --git a/livekit-net/src/lib.rs b/livekit-net/src/lib.rs index d38e184dc..4b895b654 100644 --- a/livekit-net/src/lib.rs +++ b/livekit-net/src/lib.rs @@ -82,16 +82,24 @@ pub async fn self_test_ws_echo(url: String, payload: Vec) -> Result, Ok(got) } -/// Test probe: whether a process-wide HTTP client is registered. +/// Test probe: whether a host has explicitly registered an HTTP client via +/// [`set_http_client`]. +/// +/// Reports registration, not resolvability: on native builds [`http_client`] +/// still yields the built-in client when this returns `false`. #[cfg_attr(feature = "uniffi", uniffi::export)] pub fn has_http_client() -> bool { - http_client().is_some() + HTTP.get().is_some() } -/// Test probe: whether a process-wide WebSocket client is registered. +/// Test probe: whether a host has explicitly registered a WebSocket client via +/// [`set_ws_client`]. +/// +/// Reports registration, not resolvability: on native builds [`ws_client`] still +/// yields the built-in client when this returns `false`. #[cfg_attr(feature = "uniffi", uniffi::export)] pub fn has_ws_client() -> bool { - ws_client().is_some() + WS.get().is_some() } /// Resolve the process-wide WebSocket client. diff --git a/livekit-net/tests/self_test.rs b/livekit-net/tests/self_test.rs index a394aacb7..497a0c15e 100644 --- a/livekit-net/tests/self_test.rs +++ b/livekit-net/tests/self_test.rs @@ -57,7 +57,9 @@ impl livekit_net::HttpClient for CannedHttpClient { #[tokio::test] async fn self_tests_round_trip_through_registered_clients() { - // Own test binary ⇒ fresh OnceLock; nothing registered yet. + // Own test binary ⇒ fresh OnceLock, so nothing is registered yet. The probes + // report registration only, so a native build's built-in fallback doesn't + // show up here. assert!(!has_http_client()); assert!(!has_ws_client());