From 3d4af095b1d618de9a731080270cc14c6edaacc1 Mon Sep 17 00:00:00 2001 From: Patrick Ecord Date: Tue, 23 Jun 2026 20:08:45 -0500 Subject: [PATCH] =?UTF-8?q?wasm:=20browser=E2=86=94native=20clipboard=20sy?= =?UTF-8?q?nc=20works=20over=20iroh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two root causes kept the wasm web client from syncing with native peers, both fixed here: 1. Gossip island — a node that registered with the rendezvous first joined its gossip topic alone and never pulled later-joining peers into its active view (its single subscribe_and_join was done, so a browser that appeared afterwards had a live magicsock path but no gossip neighbor). The re-register loop now seeds peer relay addrs into the MemoryLookup and calls GossipSender::join_peers each round, so neighbors form both ways. 2. Engine::send panicked on wasm — directly_covered() calls Instant::now(), which traps ("time not implemented") on wasm32-unknown-unknown, so send threw before ever reaching the gossip broadcast (0 Broadcast commands). web-time backs Instant with Performance.now() on wasm; native keeps std::time and is unaffected (web-time isn't linked there). Supporting connectivity: the rendezvous now stores + returns each peer's iroh relay URL (PeerInfo{id,relay}) so a browser — relay-only, can't resolve id→addr via discovery — can dial natives via a seeded MemoryLookup; CORS added so the browser's fetch to the rendezvous is allowed. Verified live, cross-relay: text typed in a browser tab lands on the Mac clipboard and vice-versa, with the wasm engine running as a relay-only iroh peer. Browser-console tracing capped at INFO; demo send wrapped in try/catch. Co-Authored-By: Claude Opus 4.8 --- core/Cargo.lock | 25 ++++++++++ core/Cargo.toml | 8 ++++ core/src/iroh.rs | 101 +++++++++++++++++++++++++++++++++-------- core/src/lib.rs | 4 ++ core/src/wasm.rs | 17 +++++++ core/web/index.html | 10 +++- rendezvous/Cargo.lock | 21 +++++++++ rendezvous/Cargo.toml | 2 + rendezvous/src/main.rs | 36 ++++++++++----- 9 files changed, 191 insertions(+), 33 deletions(-) diff --git a/core/Cargo.lock b/core/Cargo.lock index ca5dfb7..fcbda01 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -706,6 +706,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -4851,6 +4861,7 @@ dependencies = [ "base64", "bytes", "chacha20poly1305", + "console_error_panic_hook", "futures-util", "getrandom 0.2.17", "getrandom 0.4.3", @@ -4865,9 +4876,12 @@ dependencies = [ "serde_json", "sha2 0.11.0", "tokio", + "tracing", + "tracing-wasm", "uniffi", "wasm-bindgen", "wasm-bindgen-futures", + "web-time", "webrtc", ] @@ -5220,6 +5234,17 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/core/Cargo.toml b/core/Cargo.toml index c3dbb7f..ee41df6 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -61,6 +61,14 @@ getrandom_02 = { package = "getrandom", version = "0.2", features = ["js"] } wasm-bindgen-futures = "0.4" wasm-bindgen = "0.2" js-sys = "0.3" +# Drop-in Instant for the browser: std::time::Instant::now() panics ("time not +# implemented") on wasm32-unknown-unknown. web_time backs it with Performance.now() +# (and re-exports std on native, so native is unaffected — it isn't even linked there). +web-time = "1" +# Browser diagnostics: surface Rust panics + iroh's tracing logs in the console. +console_error_panic_hook = "0.1" +tracing-wasm = "0.2" +tracing = "0.1" # 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.) diff --git a/core/src/iroh.rs b/core/src/iroh.rs index da64dbe..cb51b9d 100644 --- a/core/src/iroh.rs +++ b/core/src/iroh.rs @@ -16,7 +16,10 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; use futures_util::TryStreamExt; -use iroh::{endpoint::presets, protocol::Router, Endpoint, EndpointId}; +use iroh::{ + address_lookup::MemoryLookup, endpoint::presets, protocol::Router, Endpoint, EndpointAddr, + EndpointId, RelayUrl, +}; use iroh_blobs::{store::mem::MemStore, BlobsProtocol, Hash}; use iroh_gossip::{ api::{Event, GossipSender}, @@ -88,15 +91,33 @@ fn env_bootstrap_ids() -> Vec { .collect() } -/// Register this node with the rendezvous (cfg.server) under our room and get -/// back the current peers to bootstrap the gossip topic. Zero-config: the room -/// is a hash, content never touches the rendezvous. No-op if server isn't a URL. -async fn rendezvous_register(server: &str, room: &str, my_id: &str) -> Vec { +/// {id, relay} JSON from the rendezvous → an EndpointAddr (id + optional relay). +/// The relay url is what lets a browser peer dial without discovery. +fn peer_to_addr(p: &serde_json::Value) -> Option { + let id = p["id"].as_str()?.parse::().ok()?; + let mut addr = EndpointAddr::new(id); + if let Some(relay) = p["relay"].as_str().filter(|s| !s.is_empty()) { + if let Ok(url) = relay.parse::() { + addr = addr.with_relay_url(url); + } + } + Some(addr) +} + +/// Register this node (id + its relay url) with the rendezvous under our room and +/// get back the current peers as full addresses to bootstrap the gossip topic. +/// Zero-config: the room is a hash, content never touches the rendezvous. +async fn rendezvous_register( + server: &str, + room: &str, + my_id: &str, + my_relay: &str, +) -> Vec { if !(server.starts_with("http://") || server.starts_with("https://")) { return Vec::new(); } let url = format!("{}/rendezvous/register", server.trim_end_matches('/')); - let body = serde_json::json!({ "room": room, "id": my_id }); + let body = serde_json::json!({ "room": room, "id": my_id, "relay": my_relay }); // Bounded — a stuck rendezvous must never wedge the transport. On wasm the // fetch backend has no Builder::timeout (the browser bounds it instead). #[cfg(not(target_arch = "wasm32"))] @@ -122,11 +143,7 @@ async fn rendezvous_register(server: &str, room: &str, my_id: &str) -> Vec().ok()) - .collect() - }) + .map(|a| a.iter().filter_map(peer_to_addr).collect()) .unwrap_or_default() } @@ -171,7 +188,15 @@ impl Transport for IrohTransport { 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 { + // A static address book — we seed it with peers' relay addresses from + // the rendezvous so we can dial them without discovery (browsers can't + // resolve id→address otherwise: "Not enough addresses"). + let lookup = MemoryLookup::new(); + let endpoint = match Endpoint::builder(presets::N0) + .address_lookup(lookup.clone()) + .bind() + .await + { Ok(e) => e, Err(e) => { eprintln!("tethercore[iroh]: bind failed: {e}"); @@ -180,8 +205,14 @@ impl Transport for IrohTransport { }; endpoint.online().await; let my_id = endpoint.id().to_string(); + let my_relay = endpoint + .addr() + .relay_urls() + .next() + .map(|u| u.to_string()) + .unwrap_or_default(); *this.id.lock().unwrap() = Some(my_id.clone()); - eprintln!("tethercore[iroh]: online id={my_id}"); + eprintln!("tethercore[iroh]: online id={my_id} relay={my_relay}"); let gossip = Gossip::builder().spawn(endpoint.clone()); let store = MemStore::new(); @@ -198,21 +229,47 @@ impl Transport for IrohTransport { // Bootstrap: env override (tests) + the rendezvous (zero-config). eprintln!("tethercore[iroh]: transport ready; registering with rendezvous {}", this.cfg.server); - let mut boot = env_bootstrap_ids(); - boot.extend(rendezvous_register(&this.cfg.server, &this.cfg.room, &my_id).await); + let mut addrs: Vec = + env_bootstrap_ids().into_iter().map(EndpointAddr::new).collect(); + addrs.extend( + rendezvous_register(&this.cfg.server, &this.cfg.room, &my_id, &my_relay).await, + ); + // Seed peer addresses so the dial works without discovery. + for a in &addrs { + lookup.add_endpoint_info(a.clone()); + } + let boot: Vec = addrs.iter().map(|a| a.id).collect(); eprintln!("tethercore[iroh]: bootstrapping with {} peer(s)", boot.len()); // Keep re-registering so late-joining devices can bootstrap off us - // (rendezvous TTL is 90s; refresh well under it). + // (rendezvous TTL is 90s; refresh well under it). Crucially, this also + // actively pulls newly-seen peers into our gossip active view: a node + // that registered first bootstrapped *alone* (empty subscribe_and_join), + // so without re-joining it would have a live magicsock path to a + // late-joiner but never form a gossip neighbor — messages would go + // nowhere. Seeding the lookup + join_peers each round fixes that. { let server = this.cfg.server.clone(); let room = this.cfg.room.clone(); let id = my_id.clone(); + let relay = my_relay.clone(); let running = running.clone(); + let lookup = lookup.clone(); + let sender_slot = this.sender.clone(); spawner.spawn(async move { while running.load(Ordering::SeqCst) { - n0_future::time::sleep(std::time::Duration::from_secs(30)).await; - let _ = rendezvous_register(&server, &room, &id).await; + n0_future::time::sleep(std::time::Duration::from_secs(15)).await; + let peers = rendezvous_register(&server, &room, &id, &relay).await; + for a in &peers { + lookup.add_endpoint_info(a.clone()); + } + let ids: Vec = peers.iter().map(|a| a.id).collect(); + if !ids.is_empty() { + let sender = sender_slot.lock().unwrap().clone(); + if let Some(sender) = sender { + let _ = sender.join_peers(ids).await; + } + } } }); } @@ -252,12 +309,14 @@ impl Transport for IrohTransport { } } Ok(Some(Event::NeighborUp(id))) => { + eprintln!("tethercore[iroh]: neighbor up {id}"); let _ = this.events.send(MeshEvent::DirectUp(id.to_string())); } Ok(Some(Event::NeighborDown(id))) => { + eprintln!("tethercore[iroh]: neighbor down {id}"); let _ = this.events.send(MeshEvent::DirectDown(id.to_string())); } - Ok(Some(_)) => {} // Lagged, etc. + Ok(Some(_)) => {} // Lagged, etc. Ok(None) => break, // stream ended Err(e) => { eprintln!("tethercore[iroh]: recv error: {e}"); @@ -281,7 +340,9 @@ impl Transport for IrohTransport { Err(_) => return, }; rt.spawn(async move { - let _ = sender.broadcast(body.into()).await; + if let Err(e) = sender.broadcast(body.into()).await { + eprintln!("tethercore[iroh]: broadcast failed: {e}"); + } }); } } diff --git a/core/src/lib.rs b/core/src/lib.rs index be92b6c..3c97735 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -22,7 +22,11 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +#[cfg(not(target_arch = "wasm32"))] use std::time::{Duration, Instant}; +// On wasm, std's Instant::now() panics; web_time backs it with Performance.now(). +#[cfg(target_arch = "wasm32")] +use web_time::{Duration, Instant}; #[cfg(not(target_arch = "wasm32"))] use futures_util::StreamExt; // only the native SSE stream uses this diff --git a/core/src/wasm.rs b/core/src/wasm.rs index 5742846..22142dd 100644 --- a/core/src/wasm.rs +++ b/core/src/wasm.rs @@ -32,6 +32,7 @@ impl WasmEngine { /// identity/secret (same on all your devices). #[wasm_bindgen(constructor)] pub fn new(server: String, room: String, from: String, account: String) -> WasmEngine { + init_diagnostics(); let inner = Engine::new(server, room, from, "web".into(), "Web".into(), account); WasmEngine { inner } } @@ -65,6 +66,22 @@ pub fn room_for_account(account: String) -> String { crate::room_for_account(account) } +/// Route Rust panics + iroh's tracing logs to the browser console (once). +/// Capped at INFO: iroh's DEBUG/TRACE (per-packet NAT probes, relay pings, the +/// harmless "Not enough addresses" holepunch-upgrade misses) is a firehose; INFO +/// keeps warnings/errors and high-level connection events. Bump to DEBUG when +/// debugging the gossip mesh. +fn init_diagnostics() { + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + console_error_panic_hook::set_once(); + let mut cfg = tracing_wasm::WASMLayerConfigBuilder::new(); + cfg.set_max_level(tracing::Level::INFO); + tracing_wasm::set_as_global_default_with_config(cfg.build()); + }); +} + /// 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 { diff --git a/core/web/index.html b/core/web/index.html index e907008..81f657a 100644 --- a/core/web/index.html +++ b/core/web/index.html @@ -47,6 +47,7 @@ const from = "web-" + Math.random().toString(36).slice(2, 10); engine = new WasmEngine(server, room, from, account); + window.engine = engine; // expose for debugging engine.start((text) => { $("feed").value += "← " + text + "\n"; $("feed").scrollTop = $("feed").scrollHeight; @@ -64,7 +65,14 @@ $("send").onclick = () => { const text = $("msg").value; - if (engine && text) { engine.send(text); $("feed").value += "→ " + text + "\n"; $("msg").value = ""; } + if (engine && text) { + try { + engine.send(text); + $("feed").value += "→ " + text + "\n"; $("msg").value = ""; + } catch (e) { + console.error("send failed:", e); + } + } }; $("msg").addEventListener("keydown", (e) => { if (e.key === "Enter") $("send").click(); }); diff --git a/rendezvous/Cargo.lock b/rendezvous/Cargo.lock index 78c8c18..b09bba4 100644 --- a/rendezvous/Cargo.lock +++ b/rendezvous/Cargo.lock @@ -74,6 +74,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + [[package]] name = "bytes" version = "1.12.0" @@ -430,6 +436,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tower-http", ] [[package]] @@ -474,6 +481,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "http", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" diff --git a/rendezvous/Cargo.toml b/rendezvous/Cargo.toml index 26927c3..c07187f 100644 --- a/rendezvous/Cargo.toml +++ b/rendezvous/Cargo.toml @@ -13,3 +13,5 @@ axum = "0.7" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "signal"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +# CORS so the browser (wasm) client's fetch to the rendezvous is allowed. +tower-http = { version = "0.6", features = ["cors"] } diff --git a/rendezvous/src/main.rs b/rendezvous/src/main.rs index 6777a75..e3b65c6 100644 --- a/rendezvous/src/main.rs +++ b/rendezvous/src/main.rs @@ -36,7 +36,9 @@ const TTL: Duration = Duration::from_secs(90); /// Cap members per room to blunt a junk-registration flood. const MAX_PER_ROOM: usize = 64; -type Rooms = Arc>>>; +// room → id → (relay_url, last_seen). The relay url lets a browser peer (which +// can't resolve id→address via discovery) dial the member directly. +type Rooms = Arc>>>; /// Shared state: the room table + an optional bearer token. When the token is /// non-empty, register/peers require `Authorization: Bearer `; when empty @@ -69,6 +71,8 @@ fn authorized(headers: &HeaderMap, token: &str) -> bool { struct Register { room: String, id: String, + #[serde(default)] + relay: String, // the member's iroh relay URL (so browsers can dial it) } #[derive(Deserialize)] @@ -76,20 +80,26 @@ struct PeersQuery { room: String, } -#[derive(Serialize)] -struct Peers { - peers: Vec, +#[derive(Serialize, Clone)] +struct PeerInfo { + id: String, + relay: String, } -fn live_peers(rooms: &Rooms, room: &str, exclude: Option<&str>) -> Vec { +#[derive(Serialize)] +struct Peers { + peers: Vec, +} + +fn live_peers(rooms: &Rooms, room: &str, exclude: Option<&str>) -> Vec { let now = Instant::now(); let mut map = rooms.lock().unwrap(); let members = map.entry(room.to_string()).or_default(); - members.retain(|_, seen| now.duration_since(*seen) < TTL); + members.retain(|_, (_, seen)| now.duration_since(*seen) < TTL); members - .keys() - .filter(|id| Some(id.as_str()) != exclude) - .cloned() + .iter() + .filter(|(id, _)| Some(id.as_str()) != exclude) + .map(|(id, (relay, _))| PeerInfo { id: id.clone(), relay: relay.clone() }) .collect() } @@ -105,9 +115,9 @@ async fn register( let now = Instant::now(); let mut map = st.rooms.lock().unwrap(); let members = map.entry(r.room.clone()).or_default(); - members.retain(|_, seen| now.duration_since(*seen) < TTL); + members.retain(|_, (_, seen)| now.duration_since(*seen) < TTL); if members.len() < MAX_PER_ROOM || members.contains_key(&r.id) { - members.insert(r.id.clone(), now); + members.insert(r.id.clone(), (r.relay.clone(), now)); } } Ok(Json(Peers { peers: live_peers(&st.rooms, &r.room, Some(&r.id)) })) @@ -137,7 +147,9 @@ async fn main() { .route("/rendezvous/register", post(register)) .route("/rendezvous/peers", get(peers)) .route("/healthz", get(|| async { "ok" })) - .with_state(state); + .with_state(state) + // Allow the browser (wasm) client to fetch us cross-origin. + .layer(tower_http::cors::CorsLayer::permissive()); let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)).await.unwrap(); let auth = if token.is_empty() { "OPEN (no token — set TETHER_RENDEZVOUS_TOKEN before public exposure)" } else { "token-gated" };