From 2ec5d892aa286e24c7f00c1aec0136c6bdbf0a32 Mon Sep 17 00:00:00 2001 From: Patrick Ecord Date: Tue, 23 Jun 2026 17:28:37 -0500 Subject: [PATCH] =?UTF-8?q?wasm:=20tethercore=20compiles=20for=20wasm32=20?= =?UTF-8?q?=E2=80=94=20runtime=20Spawner=20+=20n0-future=20timers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web client is real: `tethercore` with the full iroh stack now builds for wasm32-unknown-unknown, producing tethercore.wasm. - Runtime abstraction: a `Spawner` replaces the bare tokio runtime Handle across the Transport trait + Engine + all transports — tokio Handle on native, the browser event loop (wasm-bindgen-futures::spawn_local) on wasm. The engine no longer builds a tokio multi-thread runtime on wasm (there are no OS threads). - IrohTransport's internal tasks (re-register loop, blob fetch) route through the Spawner; its 30s timer uses n0-future (iroh's cross-platform async) instead of tokio::time, which isn't available on wasm. - iroh on wasm: default-features=false + tls-ring (ring builds for wasm with a wasm-capable clang) + iroh-gossip net; getrandom uses its wasm_js backend. - build-web.sh documents the toolchain (Homebrew LLVM clang for ring's C, the getrandom rustflag) and produces the .wasm. Native (default + iroh + all clients) is byte-unaffected and verified clean throughout. Remaining for a running web app: wasm-bindgen JS bindings + a browser shim (relay-only in-browser). Co-Authored-By: Claude Opus 4.8 --- core/Cargo.lock | 1 + core/Cargo.toml | 6 +++- core/build-web.sh | 24 ++++++++++++++ core/src/iroh.rs | 17 +++++----- core/src/lan.rs | 8 ++--- core/src/lib.rs | 79 +++++++++++++++++++++++++++++++++++++---------- core/src/rtc.rs | 8 ++--- 7 files changed, 109 insertions(+), 34 deletions(-) create mode 100755 core/build-web.sh diff --git a/core/Cargo.lock b/core/Cargo.lock index 4c5e159..f9173ba 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -4858,6 +4858,7 @@ dependencies = [ "iroh-blobs", "iroh-gossip", "mdns-sd", + "n0-future", "reqwest 0.12.28", "serde", "serde_json", diff --git a/core/Cargo.toml b/core/Cargo.toml index 00ce060..b32ad41 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -25,9 +25,13 @@ bytes = "1.12.0" chacha20poly1305 = "0.10.1" base64 = "0.22.1" +# n0-future: iroh's cross-platform async (works native + wasm) — used for the +# IrohTransport's timers so it doesn't depend on tokio's time on wasm. +n0-future = { version = "0.3", optional = true } + [features] default = [] -iroh-transport = ["dep:iroh", "dep:iroh-gossip", "dep:iroh-blobs"] +iroh-transport = ["dep:iroh", "dep:iroh-gossip", "dep:iroh-blobs", "dep:n0-future"] # ── Native (desktop/mobile): legacy transports + UniFFI + full tokio ── # The hand-rolled SSE/RTC/LAN stack and its OS-socket deps live here; none of it diff --git a/core/build-web.sh b/core/build-web.sh new file mode 100755 index 0000000..8a064c4 --- /dev/null +++ b/core/build-web.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Builds tethercore for the browser (wasm32). The core compiles to wasm with the +# iroh Mesh (relay-only in-browser). The NEXT step — wasm-bindgen JS bindings + +# a browser shim for the web app — is still TODO; this just produces the .wasm. +# +# Requires: +# rustup target add wasm32-unknown-unknown +# Homebrew LLVM (`brew install llvm`) for a wasm-capable clang — ring's build +# script compiles C for wasm and Apple's clang has no wasm backend. +set -euo pipefail +cd "$(dirname "$0")" + +# A clang/llvm-ar that can target wasm (override with LLVM_PREFIX if elsewhere). +LLVM="${LLVM_PREFIX:-/opt/homebrew/opt/llvm}" +export CC_wasm32_unknown_unknown="$LLVM/bin/clang" +export AR_wasm32_unknown_unknown="$LLVM/bin/llvm-ar" +# getrandom has no OS rng in the browser — use its wasm_js backend. +export RUSTFLAGS="${RUSTFLAGS:-} --cfg getrandom_backend=\"wasm_js\"" + +rustup target add wasm32-unknown-unknown >/dev/null 2>&1 || true +echo "▸ building tethercore for wasm32 (iroh, relay-only in-browser)…" +cargo build --target wasm32-unknown-unknown --features iroh-transport --lib "$@" +echo "✅ target/wasm32-unknown-unknown/debug/tethercore.wasm" +echo " next: wasm-bindgen bindings + a JS shim for the browser app." diff --git a/core/src/iroh.rs b/core/src/iroh.rs index 1da0089..da64dbe 100644 --- a/core/src/iroh.rs +++ b/core/src/iroh.rs @@ -24,9 +24,7 @@ use iroh_gossip::{ proto::TopicId, }; use serde::{Deserialize, Serialize}; -use tokio::runtime::Handle; - -use crate::{Config, EventTx, MeshEvent, Message, Transport}; +use crate::{Config, EventTx, MeshEvent, Message, Spawner, Transport}; /// Blob handles needed by `send_file` / blob downloads, set once online. #[derive(Clone)] @@ -169,8 +167,9 @@ impl Transport for IrohTransport { true // iroh chooses direct-vs-relay itself; treat as a direct peer channel } - fn start(self: Arc, rt: Handle, running: Arc) { + fn start(self: Arc, rt: Spawner, running: Arc) { let this = self; + let spawner = rt.clone(); // for tasks spawned inside the transport loop rt.spawn(async move { let endpoint = match Endpoint::bind(presets::N0).await { Ok(e) => e, @@ -210,9 +209,9 @@ impl Transport for IrohTransport { let room = this.cfg.room.clone(); let id = my_id.clone(); let running = running.clone(); - tokio::spawn(async move { + spawner.spawn(async move { while running.load(Ordering::SeqCst) { - tokio::time::sleep(std::time::Duration::from_secs(30)).await; + n0_future::time::sleep(std::time::Duration::from_secs(30)).await; let _ = rendezvous_register(&server, &room, &id).await; } }); @@ -245,7 +244,7 @@ impl Transport for IrohTransport { if let Ok(ann) = serde_json::from_str::(&m.text) { if let Some(blobs) = this.blobs.lock().unwrap().clone() { let events = this.events.clone(); - tokio::spawn(Self::fetch_blob(blobs, events, ann)); + spawner.spawn(Self::fetch_blob(blobs, events, ann)); } } } else { @@ -270,7 +269,7 @@ impl Transport for IrohTransport { }); } - fn publish(&self, rt: &Handle, msg: Message) { + fn publish(&self, rt: &Spawner, msg: Message) { // Clipboard + receipts ride gossip; other kinds are local-only. if !(msg.kind.is_empty() || msg.kind == "clipboard" || msg.kind == "receipt") { return; @@ -289,7 +288,7 @@ impl Transport for IrohTransport { /// Add the (already-sealed) bytes to the local blob store and broadcast a /// blob announcement over gossip; peers fetch the bytes P2P by hash. - fn send_file(&self, rt: &Handle, _id: String, name: String, mime: String, data: Vec) { + fn send_file(&self, rt: &Spawner, _id: String, name: String, mime: String, data: Vec) { let blobs = self.blobs.lock().unwrap().clone(); let sender = self.sender.lock().unwrap().clone(); let cfg = self.cfg.clone(); diff --git a/core/src/lan.rs b/core/src/lan.rs index de54565..509fce1 100644 --- a/core/src/lan.rs +++ b/core/src/lan.rs @@ -21,7 +21,7 @@ use serde_json::{json, Value}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; use tokio::net::{TcpListener, TcpStream}; -use tokio::runtime::Handle; +use crate::Spawner; use tokio::sync::Mutex; use crate::{Config, EventTx, MeshEvent, Message, Peer, Transport}; @@ -77,7 +77,7 @@ impl Transport for LanTransport { true } - fn start(self: Arc, rt: Handle, running: Arc) { + fn start(self: Arc, rt: Spawner, running: Arc) { let this = self; rt.spawn(async move { if let Err(e) = this.run(running).await { @@ -86,7 +86,7 @@ impl Transport for LanTransport { }); } - fn publish(&self, rt: &Handle, msg: Message) { + fn publish(&self, rt: &Spawner, msg: Message) { let peers = self.peers.clone(); rt.spawn(async move { let body = match serde_json::to_vec(&msg) { @@ -101,7 +101,7 @@ impl Transport for LanTransport { }); } - fn send_file(&self, rt: &Handle, id: String, name: String, mime: String, data: Vec) { + fn send_file(&self, rt: &Spawner, id: String, name: String, mime: String, data: Vec) { let peers = self.peers.clone(); let events = self.events.clone(); rt.spawn(async move { diff --git a/core/src/lib.rs b/core/src/lib.rs index 8b944ef..bb84a5a 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -24,11 +24,41 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use futures_util::StreamExt; +#[cfg(not(target_arch = "wasm32"))] +use futures_util::StreamExt; // only the native SSE stream uses this use serde::{Deserialize, Serialize}; -use tokio::runtime::Handle; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; +/// Cross-target task spawner. Native wraps a tokio runtime `Handle`; wasm uses +/// the browser event loop via `spawn_local`. This is what lets the engine + +/// transports compile on `wasm32` (no OS threads / tokio runtime there) — call +/// `rt.spawn(fut)` the same way on both. Futures are `Send` on native (thread +/// pool) and `?Send` on wasm (single JS thread). +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone)] +pub(crate) struct Spawner(tokio::runtime::Handle); +#[cfg(not(target_arch = "wasm32"))] +impl Spawner { + pub(crate) fn spawn(&self, fut: F) + where + F: std::future::Future + Send + 'static, + { + self.0.spawn(fut); + } +} +#[cfg(target_arch = "wasm32")] +#[derive(Clone)] +pub(crate) struct Spawner; +#[cfg(target_arch = "wasm32")] +impl Spawner { + pub(crate) fn spawn(&self, fut: F) + where + F: std::future::Future + 'static, + { + wasm_bindgen_futures::spawn_local(fut); + } +} + mod crypto; #[cfg(feature = "iroh-transport")] mod iroh; @@ -319,17 +349,19 @@ trait Transport: Send + Sync { } /// Spawn the inbound loop on `rt`; emit traffic/link/file events off the /// `EventTx` held since construction. Runs until `running` clears. - fn start(self: Arc, rt: Handle, running: Arc); + fn start(self: Arc, rt: Spawner, running: Arc); /// Publish an outbound message. Fire-and-forget. - fn publish(&self, rt: &Handle, msg: Message); + fn publish(&self, rt: &Spawner, msg: Message); /// Send a file/image. Only direct transports implement this; others no-op /// (files are never relayed). - fn send_file(&self, _rt: &Handle, _id: String, _name: String, _mime: String, _data: Vec) {} + fn send_file(&self, _rt: &Spawner, _id: String, _name: String, _mime: String, _data: Vec) {} } #[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Object))] pub struct Engine { cfg: Config, + // Native owns a tokio runtime; wasm uses the browser event loop (no field). + #[cfg(not(target_arch = "wasm32"))] rt: Arc, running: Arc, transports: Vec>, @@ -356,6 +388,19 @@ pub struct Engine { iroh_id: Arc>>, } +// Internal (non-FFI) helpers — kept out of the uniffi::export impl so the +// non-FFI `Spawner` return type doesn't leak into the bindings. +impl Engine { + /// A task spawner for this engine — tokio runtime on native, the JS event + /// loop on wasm. + fn spawner(&self) -> Spawner { + #[cfg(not(target_arch = "wasm32"))] + return Spawner(self.rt.handle().clone()); + #[cfg(target_arch = "wasm32")] + return Spawner; + } +} + #[cfg_attr(not(target_arch = "wasm32"), uniffi::export)] impl Engine { /// `server` is the bus base URL, e.g. "http://192.168.11.233:8765". @@ -373,6 +418,8 @@ impl Engine { name: String, account: String, ) -> Arc { + // Native runs the engine on a tokio runtime; wasm uses the JS event loop. + #[cfg(not(target_arch = "wasm32"))] let rt = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() @@ -411,6 +458,7 @@ impl Engine { ]; Arc::new(Self { cfg, + #[cfg(not(target_arch = "wasm32"))] rt: Arc::new(rt), running: Arc::new(AtomicBool::new(false)), transports, @@ -446,11 +494,11 @@ impl Engine { let n = self.file_counter.fetch_add(1, Ordering::SeqCst); let id = format!("{:016x}", n); let data = self.crypto.seal(&data); // E2E: chunks carry ciphertext - let rt = self.rt.handle(); + let rt = self.spawner(); for t in &self.transports { // Direct transports only — files are never relayed over SSE. if t.direct() { - t.send_file(rt, id.clone(), name.clone(), mime.clone(), data.clone()); + t.send_file(&rt, id.clone(), name.clone(), mime.clone(), data.clone()); } } } @@ -507,13 +555,13 @@ impl Engine { let receipts = self.receipts.clone(); let cfg = self.cfg.clone(); let transports = self.transports.clone(); - let rt = self.rt.clone(); + let spawner = self.spawner(); let direct = self.direct.clone(); let present = self.present.clone(); let discovered = self.discovered.clone(); let transfers = self.transfers.clone(); let crypto = self.crypto.clone(); - self.rt.spawn(async move { + self.spawner().spawn(async move { // File de-dup: the same file can arrive over both RTC and LAN. let mut file_seen: VecDeque<(String, Instant)> = VecDeque::new(); while let Some(ev) = rx.recv().await { @@ -585,12 +633,11 @@ impl Engine { ts: 0, }; let covered = directly_covered(&direct, &present); - let h = rt.handle(); for t in &transports { if covered && t.is_relay() { continue; // keep the ack off the relay when P2P covers } - t.publish(h, ack.clone()); + t.publish(&spawner, ack.clone()); } } let h = handler.lock().unwrap().clone(); @@ -627,7 +674,7 @@ impl Engine { } for t in &self.transports { - t.clone().start(self.rt.handle().clone(), self.running.clone()); + t.clone().start(self.spawner(), self.running.clone()); } } @@ -647,12 +694,12 @@ impl Engine { ts: 0, // server stamps authoritative ts }; let covered = self.directly_covered(); - let rt = self.rt.handle(); + let rt = self.spawner(); for t in &self.transports { if covered && t.is_relay() { continue; // direct channels reach everyone → keep content off the relay } - t.publish(rt, msg.clone()); + t.publish(&rt, msg.clone()); } } @@ -728,7 +775,7 @@ impl Transport for SseTransport { true } - fn start(self: Arc, rt: Handle, running: Arc) { + fn start(self: Arc, rt: Spawner, running: Arc) { let this = self; rt.spawn(async move { let min = Duration::from_millis(500); @@ -759,7 +806,7 @@ impl Transport for SseTransport { }); } - fn publish(&self, rt: &Handle, msg: Message) { + fn publish(&self, rt: &Spawner, msg: Message) { let http = self.http.clone(); let cfg = self.cfg.clone(); rt.spawn(async move { diff --git a/core/src/rtc.rs b/core/src/rtc.rs index e29c330..154d8f6 100644 --- a/core/src/rtc.rs +++ b/core/src/rtc.rs @@ -18,7 +18,7 @@ use std::time::Duration; use bytes::Bytes; use futures_util::StreamExt; use serde_json::{json, Value}; -use tokio::runtime::Handle; +use crate::Spawner; use tokio::sync::Mutex; use webrtc::api::media_engine::MediaEngine; @@ -485,7 +485,7 @@ impl Transport for RtcTransport { true } - fn start(self: Arc, rt: Handle, running: Arc) { + fn start(self: Arc, rt: Spawner, running: Arc) { // Presence chirp — announce ourselves so peers initiate. { let this = self.clone(); @@ -531,7 +531,7 @@ impl Transport for RtcTransport { }); } - fn publish(&self, rt: &Handle, msg: Message) { + fn publish(&self, rt: &Spawner, msg: Message) { // The "tether" data channel carries raw clipboard text only; receipts // and other typed messages go over SSE/LAN. if !(msg.kind.is_empty() || msg.kind == "clipboard") { @@ -551,7 +551,7 @@ impl Transport for RtcTransport { /// Stream a file over each open data channel as binary frames: /// M · C<16-byte id>… · E<16-byte id> /// The channel is reliable+ordered, so chunks reassemble in arrival order. - fn send_file(&self, rt: &Handle, id: String, name: String, mime: String, data: Vec) { + fn send_file(&self, rt: &Spawner, id: String, name: String, mime: String, data: Vec) { let peers = self.peers.clone(); let events = self.events.clone(); rt.spawn(async move {