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:
25
core/Cargo.lock
generated
25
core/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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.)
|
||||
|
||||
101
core/src/iroh.rs
101
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<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}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
21
rendezvous/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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" };
|
||||
|
||||
Reference in New Issue
Block a user