wasm: browser↔native clipboard sync works over iroh

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 20:08:45 -05:00
co-authored by Claude Opus 4.8
parent 1135b7d27b
commit 3d4af095b1
9 changed files with 191 additions and 33 deletions
+25
View File
@@ -706,6 +706,16 @@ dependencies = [
"memchr", "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]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.9.6" version = "0.9.6"
@@ -4851,6 +4861,7 @@ dependencies = [
"base64", "base64",
"bytes", "bytes",
"chacha20poly1305", "chacha20poly1305",
"console_error_panic_hook",
"futures-util", "futures-util",
"getrandom 0.2.17", "getrandom 0.2.17",
"getrandom 0.4.3", "getrandom 0.4.3",
@@ -4865,9 +4876,12 @@ dependencies = [
"serde_json", "serde_json",
"sha2 0.11.0", "sha2 0.11.0",
"tokio", "tokio",
"tracing",
"tracing-wasm",
"uniffi", "uniffi",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-time",
"webrtc", "webrtc",
] ]
@@ -5220,6 +5234,17 @@ dependencies = [
"tracing-log", "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]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
+8
View File
@@ -61,6 +61,14 @@ getrandom_02 = { package = "getrandom", version = "0.2", features = ["js"] }
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
js-sys = "0.3" 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), # 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 # but keep tls-ring — ring works on wasm and presets::N0 needs it. (Matches n0's
# own browser-chat example.) # own browser-chat example.)
+81 -20
View File
@@ -16,7 +16,10 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex as StdMutex}; use std::sync::{Arc, Mutex as StdMutex};
use futures_util::TryStreamExt; 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_blobs::{store::mem::MemStore, BlobsProtocol, Hash};
use iroh_gossip::{ use iroh_gossip::{
api::{Event, GossipSender}, api::{Event, GossipSender},
@@ -88,15 +91,33 @@ fn env_bootstrap_ids() -> Vec<EndpointId> {
.collect() .collect()
} }
/// Register this node with the rendezvous (cfg.server) under our room and get /// {id, relay} JSON from the rendezvous → an EndpointAddr (id + optional relay).
/// back the current peers to bootstrap the gossip topic. Zero-config: the room /// The relay url is what lets a browser peer dial without discovery.
/// is a hash, content never touches the rendezvous. No-op if server isn't a URL. fn peer_to_addr(p: &serde_json::Value) -> Option<EndpointAddr> {
async fn rendezvous_register(server: &str, room: &str, my_id: &str) -> Vec<EndpointId> { let id = p["id"].as_str()?.parse::<EndpointId>().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::<RelayUrl>() {
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<EndpointAddr> {
if !(server.starts_with("http://") || server.starts_with("https://")) { if !(server.starts_with("http://") || server.starts_with("https://")) {
return Vec::new(); return Vec::new();
} }
let url = format!("{}/rendezvous/register", server.trim_end_matches('/')); 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 // Bounded — a stuck rendezvous must never wedge the transport. On wasm the
// fetch backend has no Builder::timeout (the browser bounds it instead). // fetch backend has no Builder::timeout (the browser bounds it instead).
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -122,11 +143,7 @@ async fn rendezvous_register(server: &str, room: &str, my_id: &str) -> Vec<Endpo
}; };
v["peers"] v["peers"]
.as_array() .as_array()
.map(|a| { .map(|a| a.iter().filter_map(peer_to_addr).collect())
a.iter()
.filter_map(|p| p.as_str()?.parse::<EndpointId>().ok())
.collect()
})
.unwrap_or_default() .unwrap_or_default()
} }
@@ -171,7 +188,15 @@ impl Transport for IrohTransport {
let this = self; let this = self;
let spawner = rt.clone(); // for tasks spawned inside the transport loop 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 { // 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, Ok(e) => e,
Err(e) => { Err(e) => {
eprintln!("tethercore[iroh]: bind failed: {e}"); eprintln!("tethercore[iroh]: bind failed: {e}");
@@ -180,8 +205,14 @@ impl Transport for IrohTransport {
}; };
endpoint.online().await; endpoint.online().await;
let my_id = endpoint.id().to_string(); 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()); *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 gossip = Gossip::builder().spawn(endpoint.clone());
let store = MemStore::new(); let store = MemStore::new();
@@ -198,21 +229,47 @@ impl Transport for IrohTransport {
// Bootstrap: env override (tests) + the rendezvous (zero-config). // Bootstrap: env override (tests) + the rendezvous (zero-config).
eprintln!("tethercore[iroh]: transport ready; registering with rendezvous {}", this.cfg.server); eprintln!("tethercore[iroh]: transport ready; registering with rendezvous {}", this.cfg.server);
let mut boot = env_bootstrap_ids(); let mut addrs: Vec<EndpointAddr> =
boot.extend(rendezvous_register(&this.cfg.server, &this.cfg.room, &my_id).await); 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<EndpointId> = addrs.iter().map(|a| a.id).collect();
eprintln!("tethercore[iroh]: bootstrapping with {} peer(s)", boot.len()); eprintln!("tethercore[iroh]: bootstrapping with {} peer(s)", boot.len());
// Keep re-registering so late-joining devices can bootstrap off us // 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 server = this.cfg.server.clone();
let room = this.cfg.room.clone(); let room = this.cfg.room.clone();
let id = my_id.clone(); let id = my_id.clone();
let relay = my_relay.clone();
let running = running.clone(); let running = running.clone();
let lookup = lookup.clone();
let sender_slot = this.sender.clone();
spawner.spawn(async move { spawner.spawn(async move {
while running.load(Ordering::SeqCst) { while running.load(Ordering::SeqCst) {
n0_future::time::sleep(std::time::Duration::from_secs(30)).await; n0_future::time::sleep(std::time::Duration::from_secs(15)).await;
let _ = rendezvous_register(&server, &room, &id).await; let peers = rendezvous_register(&server, &room, &id, &relay).await;
for a in &peers {
lookup.add_endpoint_info(a.clone());
}
let ids: Vec<EndpointId> = 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))) => { Ok(Some(Event::NeighborUp(id))) => {
eprintln!("tethercore[iroh]: neighbor up {id}");
let _ = this.events.send(MeshEvent::DirectUp(id.to_string())); let _ = this.events.send(MeshEvent::DirectUp(id.to_string()));
} }
Ok(Some(Event::NeighborDown(id))) => { Ok(Some(Event::NeighborDown(id))) => {
eprintln!("tethercore[iroh]: neighbor down {id}");
let _ = this.events.send(MeshEvent::DirectDown(id.to_string())); let _ = this.events.send(MeshEvent::DirectDown(id.to_string()));
} }
Ok(Some(_)) => {} // Lagged, etc. Ok(Some(_)) => {} // Lagged, etc.
Ok(None) => break, // stream ended Ok(None) => break, // stream ended
Err(e) => { Err(e) => {
eprintln!("tethercore[iroh]: recv error: {e}"); eprintln!("tethercore[iroh]: recv error: {e}");
@@ -281,7 +340,9 @@ impl Transport for IrohTransport {
Err(_) => return, Err(_) => return,
}; };
rt.spawn(async move { 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}");
}
}); });
} }
} }
+4
View File
@@ -22,7 +22,11 @@
use std::collections::{HashMap, HashSet, VecDeque}; use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
#[cfg(not(target_arch = "wasm32"))]
use std::time::{Duration, Instant}; 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"))] #[cfg(not(target_arch = "wasm32"))]
use futures_util::StreamExt; // only the native SSE stream uses this use futures_util::StreamExt; // only the native SSE stream uses this
+17
View File
@@ -32,6 +32,7 @@ impl WasmEngine {
/// identity/secret (same on all your devices). /// identity/secret (same on all your devices).
#[wasm_bindgen(constructor)] #[wasm_bindgen(constructor)]
pub fn new(server: String, room: String, from: String, account: String) -> WasmEngine { 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); let inner = Engine::new(server, room, from, "web".into(), "Web".into(), account);
WasmEngine { inner } WasmEngine { inner }
} }
@@ -65,6 +66,22 @@ pub fn room_for_account(account: String) -> String {
crate::room_for_account(account) 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 /// 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. /// on the JS event loop), so the `js_sys::Function` never crosses a thread.
struct JsHandler { struct JsHandler {
+9 -1
View File
@@ -47,6 +47,7 @@
const from = "web-" + Math.random().toString(36).slice(2, 10); const from = "web-" + Math.random().toString(36).slice(2, 10);
engine = new WasmEngine(server, room, from, account); engine = new WasmEngine(server, room, from, account);
window.engine = engine; // expose for debugging
engine.start((text) => { engine.start((text) => {
$("feed").value += "← " + text + "\n"; $("feed").value += "← " + text + "\n";
$("feed").scrollTop = $("feed").scrollHeight; $("feed").scrollTop = $("feed").scrollHeight;
@@ -64,7 +65,14 @@
$("send").onclick = () => { $("send").onclick = () => {
const text = $("msg").value; 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(); }); $("msg").addEventListener("keydown", (e) => { if (e.key === "Enter") $("send").click(); });
</script> </script>
+21
View File
@@ -74,6 +74,12 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.12.0" version = "1.12.0"
@@ -430,6 +436,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"tower-http",
] ]
[[package]] [[package]]
@@ -474,6 +481,20 @@ dependencies = [
"tracing", "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]] [[package]]
name = "tower-layer" name = "tower-layer"
version = "0.3.3" version = "0.3.3"
+2
View File
@@ -13,3 +13,5 @@ axum = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "signal"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "signal"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
# CORS so the browser (wasm) client's fetch to the rendezvous is allowed.
tower-http = { version = "0.6", features = ["cors"] }
+24 -12
View File
@@ -36,7 +36,9 @@ const TTL: Duration = Duration::from_secs(90);
/// Cap members per room to blunt a junk-registration flood. /// Cap members per room to blunt a junk-registration flood.
const MAX_PER_ROOM: usize = 64; const MAX_PER_ROOM: usize = 64;
type Rooms = Arc<Mutex<HashMap<String, HashMap<String, Instant>>>>; // 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<Mutex<HashMap<String, HashMap<String, (String, Instant)>>>>;
/// Shared state: the room table + an optional bearer token. When the token is /// Shared state: the room table + an optional bearer token. When the token is
/// non-empty, register/peers require `Authorization: Bearer <token>`; when empty /// non-empty, register/peers require `Authorization: Bearer <token>`; when empty
@@ -69,6 +71,8 @@ fn authorized(headers: &HeaderMap, token: &str) -> bool {
struct Register { struct Register {
room: String, room: String,
id: String, id: String,
#[serde(default)]
relay: String, // the member's iroh relay URL (so browsers can dial it)
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -76,20 +80,26 @@ struct PeersQuery {
room: String, room: String,
} }
#[derive(Serialize)] #[derive(Serialize, Clone)]
struct Peers { struct PeerInfo {
peers: Vec<String>, id: String,
relay: String,
} }
fn live_peers(rooms: &Rooms, room: &str, exclude: Option<&str>) -> Vec<String> { #[derive(Serialize)]
struct Peers {
peers: Vec<PeerInfo>,
}
fn live_peers(rooms: &Rooms, room: &str, exclude: Option<&str>) -> Vec<PeerInfo> {
let now = Instant::now(); let now = Instant::now();
let mut map = rooms.lock().unwrap(); let mut map = rooms.lock().unwrap();
let members = map.entry(room.to_string()).or_default(); 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 members
.keys() .iter()
.filter(|id| Some(id.as_str()) != exclude) .filter(|(id, _)| Some(id.as_str()) != exclude)
.cloned() .map(|(id, (relay, _))| PeerInfo { id: id.clone(), relay: relay.clone() })
.collect() .collect()
} }
@@ -105,9 +115,9 @@ async fn register(
let now = Instant::now(); let now = Instant::now();
let mut map = st.rooms.lock().unwrap(); let mut map = st.rooms.lock().unwrap();
let members = map.entry(r.room.clone()).or_default(); 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) { 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)) })) 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/register", post(register))
.route("/rendezvous/peers", get(peers)) .route("/rendezvous/peers", get(peers))
.route("/healthz", get(|| async { "ok" })) .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 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" }; let auth = if token.is_empty() { "OPEN (no token — set TETHER_RENDEZVOUS_TOKEN before public exposure)" } else { "token-gated" };