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:
1
core/Cargo.lock
generated
1
core/Cargo.lock
generated
@@ -4858,6 +4858,7 @@ dependencies = [
|
||||
"iroh-blobs",
|
||||
"iroh-gossip",
|
||||
"mdns-sd",
|
||||
"n0-future",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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
|
||||
|
||||
24
core/build-web.sh
Executable file
24
core/build-web.sh
Executable 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."
|
||||
@@ -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<Self>, rt: Handle, running: Arc<AtomicBool>) {
|
||||
fn start(self: Arc<Self>, rt: Spawner, running: Arc<AtomicBool>) {
|
||||
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::<BlobAnnounce>(&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<u8>) {
|
||||
fn send_file(&self, rt: &Spawner, _id: String, name: String, mime: String, data: Vec<u8>) {
|
||||
let blobs = self.blobs.lock().unwrap().clone();
|
||||
let sender = self.sender.lock().unwrap().clone();
|
||||
let cfg = self.cfg.clone();
|
||||
|
||||
@@ -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<Self>, rt: Handle, running: Arc<AtomicBool>) {
|
||||
fn start(self: Arc<Self>, rt: Spawner, running: Arc<AtomicBool>) {
|
||||
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<u8>) {
|
||||
fn send_file(&self, rt: &Spawner, id: String, name: String, mime: String, data: Vec<u8>) {
|
||||
let peers = self.peers.clone();
|
||||
let events = self.events.clone();
|
||||
rt.spawn(async move {
|
||||
|
||||
@@ -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<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;
|
||||
#[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<Self>, rt: Handle, running: Arc<AtomicBool>);
|
||||
fn start(self: Arc<Self>, rt: Spawner, running: Arc<AtomicBool>);
|
||||
/// 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<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))]
|
||||
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<tokio::runtime::Runtime>,
|
||||
running: Arc<AtomicBool>,
|
||||
transports: Vec<Arc<dyn Transport>>,
|
||||
@@ -356,6 +388,19 @@ pub struct Engine {
|
||||
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)]
|
||||
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<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()
|
||||
.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<Self>, rt: Handle, running: Arc<AtomicBool>) {
|
||||
fn start(self: Arc<Self>, rt: Spawner, running: Arc<AtomicBool>) {
|
||||
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 {
|
||||
|
||||
@@ -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<Self>, rt: Handle, running: Arc<AtomicBool>) {
|
||||
fn start(self: Arc<Self>, rt: Spawner, running: Arc<AtomicBool>) {
|
||||
// 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<json meta> · C<16-byte id><chunk>… · 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<u8>) {
|
||||
fn send_file(&self, rt: &Spawner, id: String, name: String, mime: String, data: Vec<u8>) {
|
||||
let peers = self.peers.clone();
|
||||
let events = self.events.clone();
|
||||
rt.spawn(async move {
|
||||
|
||||
Reference in New Issue
Block a user