wasm: wasm-bindgen surface — WasmEngine + roomForAccount for the browser

src/wasm.rs exposes the engine to JS (gated to wasm32 + iroh-transport): a
WasmEngine wrapping Arc<Engine> with new(server, room, from, account) / start(
on_message) / send / stop / irohId, plus roomForAccount so the browser derives
the same room as native clients. Inbound clipboards bridge to a JS callback via a
JsHandler.

MessageHandler's Send+Sync bound is relaxed on wasm (single JS thread; a
js_sys::Function isn't Send and the engine spawns via spawn_local where it isn't
needed). Adds wasm-bindgen + js-sys (wasm-only deps).

Native (UniFFI) + wasm both compile clean. Next: generate the JS bindings with
wasm-bindgen-cli and a browser smoke test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 17:54:53 -05:00
parent 39ec4bc216
commit ad026e8871
4 changed files with 98 additions and 1 deletions

2
core/Cargo.lock generated
View File

@@ -4857,6 +4857,7 @@ dependencies = [
"iroh",
"iroh-blobs",
"iroh-gossip",
"js-sys",
"mdns-sd",
"n0-future",
"reqwest 0.12.28",
@@ -4865,6 +4866,7 @@ dependencies = [
"sha2 0.11.0",
"tokio",
"uniffi",
"wasm-bindgen",
"wasm-bindgen-futures",
"webrtc",
]

View File

@@ -59,6 +59,8 @@ reqwest = { version = "0.12", default-features = false, features = ["json"] }
getrandom = "0.4.3"
getrandom_02 = { package = "getrandom", version = "0.2", features = ["js"] }
wasm-bindgen-futures = "0.4"
wasm-bindgen = "0.2"
js-sys = "0.3"
# Browser iroh: default-features off (drops native portmapper/apple-datapath),
# but keep tls-ring — ring works on wasm and presets::N0 needs it. (Matches n0's
# own browser-chat example.)

View File

@@ -67,6 +67,9 @@ mod iroh;
mod lan;
#[cfg(not(target_arch = "wasm32"))]
mod rtc;
// The browser's wasm-bindgen surface (iroh-only build).
#[cfg(all(target_arch = "wasm32", feature = "iroh-transport"))]
mod wasm;
use crypto::Crypto;
@@ -101,13 +104,23 @@ pub struct Message {
/// Implemented on the foreign side (Swift/Kotlin) or natively (desktop agent).
/// Callbacks fire on the engine's runtime thread — marshal to the UI thread
/// on the consumer side.
#[cfg_attr(not(target_arch = "wasm32"), uniffi::export(callback_interface))]
// Native: Send + Sync (engine runs on a tokio thread pool) + a UniFFI callback.
// wasm: single JS thread, so no Send/Sync — a JS callback (js_sys::Function)
// isn't Send, and the engine spawns via spawn_local where it isn't needed.
#[cfg(not(target_arch = "wasm32"))]
#[uniffi::export(callback_interface)]
pub trait MessageHandler: Send + Sync {
fn on_message(&self, msg: Message);
fn on_status(&self, connected: bool);
/// A file/image received P2P (RTC data channel), reassembled from chunks.
fn on_file(&self, name: String, mime: String, data: Vec<u8>);
}
#[cfg(target_arch = "wasm32")]
pub trait MessageHandler {
fn on_message(&self, msg: Message);
fn on_status(&self, connected: bool);
fn on_file(&self, name: String, mime: String, data: Vec<u8>);
}
/// A tether device discovered on the local network (mDNS). Surfaced to the UI
/// for an AirDrop-style "nearby devices" list. `room` lets the UI offer to pair

80
core/src/wasm.rs Normal file
View File

@@ -0,0 +1,80 @@
//! wasm-bindgen surface — the browser's entry point to tethercore.
//!
//! On native the engine is exposed via UniFFI (Swift/Kotlin); in the browser it's
//! the same `Engine`, wrapped for JS here. A browser peer is relay-only (no
//! holepunch in the sandbox), but otherwise identical: same gossip topic, same
//! E2E, same rendezvous bootstrap.
//!
//! JS usage:
//! import init, { WasmEngine, roomForAccount } from "./tethercore.js";
//! await init();
//! const room = roomForAccount("you@example.com");
//! const e = new WasmEngine("https://rendezvous.example", room, deviceId, "you@example.com");
//! e.start((text) => { /* inbound clipboard */ });
//! e.send("hello from the browser");
use std::sync::Arc;
use wasm_bindgen::prelude::*;
use crate::{Engine, Message, MessageHandler};
/// The clipboard engine, for the browser.
#[wasm_bindgen]
pub struct WasmEngine {
inner: Arc<Engine>,
}
#[wasm_bindgen]
impl WasmEngine {
/// `server` = rendezvous URL, `room` = account-derived room (see
/// `roomForAccount`), `from` = a stable device id, `account` = the shared
/// identity/secret (same on all your devices).
#[wasm_bindgen(constructor)]
pub fn new(server: String, room: String, from: String, account: String) -> WasmEngine {
let inner = Engine::new(server, room, from, "web".into(), "Web".into(), account);
WasmEngine { inner }
}
/// Start syncing. `on_message` is invoked with each inbound clipboard's text.
pub fn start(&self, on_message: js_sys::Function) {
self.inner.start(Box::new(JsHandler { on_message }));
}
/// Publish clipboard text to the room.
pub fn send(&self, text: String) {
self.inner.send(text);
}
/// Stop syncing.
pub fn stop(&self) {
self.inner.stop();
}
/// This node's iroh EndpointId once online (for debugging), else `null`.
#[wasm_bindgen(js_name = irohId)]
pub fn iroh_id(&self) -> Option<String> {
self.inner.iroh_id()
}
}
/// `account` → account-derived room id. Re-exported so the browser derives the
/// SAME room as the native clients.
#[wasm_bindgen(js_name = roomForAccount)]
pub fn room_for_account(account: String) -> String {
crate::room_for_account(account)
}
/// Bridges inbound clipboards to a JS callback. Single-threaded (the engine runs
/// on the JS event loop), so the `js_sys::Function` never crosses a thread.
struct JsHandler {
on_message: js_sys::Function,
}
impl MessageHandler for JsHandler {
fn on_message(&self, m: Message) {
let _ = self.on_message.call1(&JsValue::NULL, &JsValue::from_str(&m.text));
}
fn on_status(&self, _connected: bool) {}
fn on_file(&self, _name: String, _mime: String, _data: Vec<u8>) {}
}