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:
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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
80
core/src/wasm.rs
Normal 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>) {}
|
||||
}
|
||||
Reference in New Issue
Block a user