wasm: tethercore compiles for wasm32 — runtime Spawner + n0-future timers

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 17:28:37 -05:00
parent 7859184a48
commit 2ec5d892aa
7 changed files with 109 additions and 34 deletions

1
core/Cargo.lock generated
View File

@@ -4858,6 +4858,7 @@ dependencies = [
"iroh-blobs", "iroh-blobs",
"iroh-gossip", "iroh-gossip",
"mdns-sd", "mdns-sd",
"n0-future",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -25,9 +25,13 @@ bytes = "1.12.0"
chacha20poly1305 = "0.10.1" chacha20poly1305 = "0.10.1"
base64 = "0.22.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] [features]
default = [] 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 ── # ── 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 # The hand-rolled SSE/RTC/LAN stack and its OS-socket deps live here; none of it

24
core/build-web.sh Executable file
View File

@@ -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."

View File

@@ -24,9 +24,7 @@ use iroh_gossip::{
proto::TopicId, proto::TopicId,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::runtime::Handle; use crate::{Config, EventTx, MeshEvent, Message, Spawner, Transport};
use crate::{Config, EventTx, MeshEvent, Message, Transport};
/// Blob handles needed by `send_file` / blob downloads, set once online. /// Blob handles needed by `send_file` / blob downloads, set once online.
#[derive(Clone)] #[derive(Clone)]
@@ -169,8 +167,9 @@ impl Transport for IrohTransport {
true // iroh chooses direct-vs-relay itself; treat as a direct peer channel true // iroh chooses direct-vs-relay itself; treat as a direct peer channel
} }
fn start(self: Arc<Self>, rt: Handle, running: Arc<AtomicBool>) { fn start(self: Arc<Self>, rt: Spawner, running: Arc<AtomicBool>) {
let this = self; let this = self;
let spawner = rt.clone(); // for tasks spawned inside the transport loop
rt.spawn(async move { rt.spawn(async move {
let endpoint = match Endpoint::bind(presets::N0).await { let endpoint = match Endpoint::bind(presets::N0).await {
Ok(e) => e, Ok(e) => e,
@@ -210,9 +209,9 @@ impl Transport for IrohTransport {
let room = this.cfg.room.clone(); let room = this.cfg.room.clone();
let id = my_id.clone(); let id = my_id.clone();
let running = running.clone(); let running = running.clone();
tokio::spawn(async move { spawner.spawn(async move {
while running.load(Ordering::SeqCst) { 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; let _ = rendezvous_register(&server, &room, &id).await;
} }
}); });
@@ -245,7 +244,7 @@ impl Transport for IrohTransport {
if let Ok(ann) = serde_json::from_str::<BlobAnnounce>(&m.text) { if let Ok(ann) = serde_json::from_str::<BlobAnnounce>(&m.text) {
if let Some(blobs) = this.blobs.lock().unwrap().clone() { if let Some(blobs) = this.blobs.lock().unwrap().clone() {
let events = this.events.clone(); let events = this.events.clone();
tokio::spawn(Self::fetch_blob(blobs, events, ann)); spawner.spawn(Self::fetch_blob(blobs, events, ann));
} }
} }
} else { } 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. // Clipboard + receipts ride gossip; other kinds are local-only.
if !(msg.kind.is_empty() || msg.kind == "clipboard" || msg.kind == "receipt") { if !(msg.kind.is_empty() || msg.kind == "clipboard" || msg.kind == "receipt") {
return; return;
@@ -289,7 +288,7 @@ impl Transport for IrohTransport {
/// Add the (already-sealed) bytes to the local blob store and broadcast a /// Add the (already-sealed) bytes to the local blob store and broadcast a
/// blob announcement over gossip; peers fetch the bytes P2P by hash. /// 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<u8>) { fn send_file(&self, rt: &Spawner, _id: String, name: String, mime: String, data: Vec<u8>) {
let blobs = self.blobs.lock().unwrap().clone(); let blobs = self.blobs.lock().unwrap().clone();
let sender = self.sender.lock().unwrap().clone(); let sender = self.sender.lock().unwrap().clone();
let cfg = self.cfg.clone(); let cfg = self.cfg.clone();

View File

@@ -21,7 +21,7 @@ use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
use tokio::net::{TcpListener, TcpStream}; use tokio::net::{TcpListener, TcpStream};
use tokio::runtime::Handle; use crate::Spawner;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::{Config, EventTx, MeshEvent, Message, Peer, Transport}; use crate::{Config, EventTx, MeshEvent, Message, Peer, Transport};
@@ -77,7 +77,7 @@ impl Transport for LanTransport {
true true
} }
fn start(self: Arc<Self>, rt: Handle, running: Arc<AtomicBool>) { fn start(self: Arc<Self>, rt: Spawner, running: Arc<AtomicBool>) {
let this = self; let this = self;
rt.spawn(async move { rt.spawn(async move {
if let Err(e) = this.run(running).await { 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(); let peers = self.peers.clone();
rt.spawn(async move { rt.spawn(async move {
let body = match serde_json::to_vec(&msg) { 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<u8>) { fn send_file(&self, rt: &Spawner, id: String, name: String, mime: String, data: Vec<u8>) {
let peers = self.peers.clone(); let peers = self.peers.clone();
let events = self.events.clone(); let events = self.events.clone();
rt.spawn(async move { rt.spawn(async move {

View File

@@ -24,11 +24,41 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; 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 serde::{Deserialize, Serialize};
use tokio::runtime::Handle;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; 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<F>(&self, fut: F)
where
F: std::future::Future<Output = ()> + 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<F>(&self, fut: F)
where
F: std::future::Future<Output = ()> + 'static,
{
wasm_bindgen_futures::spawn_local(fut);
}
}
mod crypto; mod crypto;
#[cfg(feature = "iroh-transport")] #[cfg(feature = "iroh-transport")]
mod iroh; mod iroh;
@@ -319,17 +349,19 @@ trait Transport: Send + Sync {
} }
/// Spawn the inbound loop on `rt`; emit traffic/link/file events off the /// Spawn the inbound loop on `rt`; emit traffic/link/file events off the
/// `EventTx` held since construction. Runs until `running` clears. /// `EventTx` held since construction. Runs until `running` clears.
fn start(self: Arc<Self>, rt: Handle, running: Arc<AtomicBool>); fn start(self: Arc<Self>, rt: Spawner, running: Arc<AtomicBool>);
/// Publish an outbound message. Fire-and-forget. /// 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 /// Send a file/image. Only direct transports implement this; others no-op
/// (files are never relayed). /// (files are never relayed).
fn send_file(&self, _rt: &Handle, _id: String, _name: String, _mime: String, _data: Vec<u8>) {} fn send_file(&self, _rt: &Spawner, _id: String, _name: String, _mime: String, _data: Vec<u8>) {}
} }
#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Object))] #[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Object))]
pub struct Engine { pub struct Engine {
cfg: Config, cfg: Config,
// Native owns a tokio runtime; wasm uses the browser event loop (no field).
#[cfg(not(target_arch = "wasm32"))]
rt: Arc<tokio::runtime::Runtime>, rt: Arc<tokio::runtime::Runtime>,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
transports: Vec<Arc<dyn Transport>>, transports: Vec<Arc<dyn Transport>>,
@@ -356,6 +388,19 @@ pub struct Engine {
iroh_id: Arc<Mutex<Option<String>>>, iroh_id: Arc<Mutex<Option<String>>>,
} }
// 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)] #[cfg_attr(not(target_arch = "wasm32"), uniffi::export)]
impl Engine { impl Engine {
/// `server` is the bus base URL, e.g. "http://192.168.11.233:8765". /// `server` is the bus base URL, e.g. "http://192.168.11.233:8765".
@@ -373,6 +418,8 @@ impl Engine {
name: String, name: String,
account: String, account: String,
) -> Arc<Self> { ) -> Arc<Self> {
// 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() let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2) .worker_threads(2)
.enable_all() .enable_all()
@@ -411,6 +458,7 @@ impl Engine {
]; ];
Arc::new(Self { Arc::new(Self {
cfg, cfg,
#[cfg(not(target_arch = "wasm32"))]
rt: Arc::new(rt), rt: Arc::new(rt),
running: Arc::new(AtomicBool::new(false)), running: Arc::new(AtomicBool::new(false)),
transports, transports,
@@ -446,11 +494,11 @@ impl Engine {
let n = self.file_counter.fetch_add(1, Ordering::SeqCst); let n = self.file_counter.fetch_add(1, Ordering::SeqCst);
let id = format!("{:016x}", n); let id = format!("{:016x}", n);
let data = self.crypto.seal(&data); // E2E: chunks carry ciphertext let data = self.crypto.seal(&data); // E2E: chunks carry ciphertext
let rt = self.rt.handle(); let rt = self.spawner();
for t in &self.transports { for t in &self.transports {
// Direct transports only — files are never relayed over SSE. // Direct transports only — files are never relayed over SSE.
if t.direct() { 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 receipts = self.receipts.clone();
let cfg = self.cfg.clone(); let cfg = self.cfg.clone();
let transports = self.transports.clone(); let transports = self.transports.clone();
let rt = self.rt.clone(); let spawner = self.spawner();
let direct = self.direct.clone(); let direct = self.direct.clone();
let present = self.present.clone(); let present = self.present.clone();
let discovered = self.discovered.clone(); let discovered = self.discovered.clone();
let transfers = self.transfers.clone(); let transfers = self.transfers.clone();
let crypto = self.crypto.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. // File de-dup: the same file can arrive over both RTC and LAN.
let mut file_seen: VecDeque<(String, Instant)> = VecDeque::new(); let mut file_seen: VecDeque<(String, Instant)> = VecDeque::new();
while let Some(ev) = rx.recv().await { while let Some(ev) = rx.recv().await {
@@ -585,12 +633,11 @@ impl Engine {
ts: 0, ts: 0,
}; };
let covered = directly_covered(&direct, &present); let covered = directly_covered(&direct, &present);
let h = rt.handle();
for t in &transports { for t in &transports {
if covered && t.is_relay() { if covered && t.is_relay() {
continue; // keep the ack off the relay when P2P covers 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(); let h = handler.lock().unwrap().clone();
@@ -627,7 +674,7 @@ impl Engine {
} }
for t in &self.transports { 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 ts: 0, // server stamps authoritative ts
}; };
let covered = self.directly_covered(); let covered = self.directly_covered();
let rt = self.rt.handle(); let rt = self.spawner();
for t in &self.transports { for t in &self.transports {
if covered && t.is_relay() { if covered && t.is_relay() {
continue; // direct channels reach everyone → keep content off the 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 true
} }
fn start(self: Arc<Self>, rt: Handle, running: Arc<AtomicBool>) { fn start(self: Arc<Self>, rt: Spawner, running: Arc<AtomicBool>) {
let this = self; let this = self;
rt.spawn(async move { rt.spawn(async move {
let min = Duration::from_millis(500); 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 http = self.http.clone();
let cfg = self.cfg.clone(); let cfg = self.cfg.clone();
rt.spawn(async move { rt.spawn(async move {

View File

@@ -18,7 +18,7 @@ use std::time::Duration;
use bytes::Bytes; use bytes::Bytes;
use futures_util::StreamExt; use futures_util::StreamExt;
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::runtime::Handle; use crate::Spawner;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use webrtc::api::media_engine::MediaEngine; use webrtc::api::media_engine::MediaEngine;
@@ -485,7 +485,7 @@ impl Transport for RtcTransport {
true true
} }
fn start(self: Arc<Self>, rt: Handle, running: Arc<AtomicBool>) { fn start(self: Arc<Self>, rt: Spawner, running: Arc<AtomicBool>) {
// Presence chirp — announce ourselves so peers initiate. // Presence chirp — announce ourselves so peers initiate.
{ {
let this = self.clone(); 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 // The "tether" data channel carries raw clipboard text only; receipts
// and other typed messages go over SSE/LAN. // and other typed messages go over SSE/LAN.
if !(msg.kind.is_empty() || msg.kind == "clipboard") { 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: /// Stream a file over each open data channel as binary frames:
/// M<json meta> · C<16-byte id><chunk>… · E<16-byte id> /// M<json meta> · C<16-byte id><chunk>… · E<16-byte id>
/// The channel is reliable+ordered, so chunks reassemble in arrival order. /// 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<u8>) { fn send_file(&self, rt: &Spawner, id: String, name: String, mime: String, data: Vec<u8>) {
let peers = self.peers.clone(); let peers = self.peers.clone();
let events = self.events.clone(); let events = self.events.clone();
rt.spawn(async move { rt.spawn(async move {