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
parent 1135b7d27b
commit 3d4af095b1
9 changed files with 191 additions and 33 deletions

25
core/Cargo.lock generated
View File

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

View File

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

View File

@@ -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<EndpointId> {
.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<EndpointId> {
/// {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<EndpointAddr> {
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://")) {
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<Endpo
};
v["peers"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|p| p.as_str()?.parse::<EndpointId>().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<EndpointAddr> =
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());
// 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<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))) => {
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}");
}
});
}
}

View File

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

View File

@@ -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 {

View File

@@ -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(); });
</script>

21
rendezvous/Cargo.lock generated
View File

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

View File

@@ -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"] }

View File

@@ -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<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
/// non-empty, register/peers require `Authorization: Bearer <token>`; 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<String>,
#[derive(Serialize, Clone)]
struct PeerInfo {
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 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" };