De-risks the Iroh evaluation before any integration. examples/iroh_spike.rs
stands up two iroh 1.0 endpoints in-process, each identified by a public-key
EndpointId, and round-trips bytes over a QUIC bidirectional stream:
- accept side via Router + ProtocolHandler (≈ DirectUp + inbound MeshEvents)
- connect side via endpoint.connect(addr, ALPN) (≈ the LAN/RTC dial path)
- connection.remote_id() gives a durable per-device public key — identity is
the transport, so iroh subsumes the keypair/Noise layer from ARCHITECTURE §3
iroh is a dev-dependency only (not in the shipped lib). The API maps cleanly
onto the Transport/MeshEvent seam, so an eventual IrohTransport slots in without
touching the Sync/App tiers.
Verified: `cargo run --example iroh_spike` → "✅ iroh round-trip: 49 bytes
echoed P2P (QUIC, key-addressed)". Roadmap updated with milestones 2–4
(cross-network relay via homelab supernode, account→EndpointId discovery,
IrohTransport wrap + A/B vs RTC+LAN).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
94 lines
4.1 KiB
Rust
94 lines
4.1 KiB
Rust
//! Iroh spike — does iroh 1.0 give us the Mesh for free?
|
|
//! cargo run --example iroh_spike
|
|
//!
|
|
//! Proves, in-process, the three things a tether Mesh transport needs:
|
|
//! 1. an Endpoint identified by a public key (durable device identity — the
|
|
//! thing ARCHITECTURE.md §3 wants, here for free as `EndpointId`),
|
|
//! 2. an accept side (QUIC + relay holepunch handled by iroh),
|
|
//! 3. a connect side that dials by address and round-trips bytes.
|
|
//!
|
|
//! Mapping onto our contract (so the integration is obvious):
|
|
//! - `ProtocolHandler::accept(conn)` ≈ a peer became directly reachable
|
|
//! → emit `MeshEvent::DirectUp(remote_id)`, then read frames and emit
|
|
//! `MeshEvent::Message` / `MeshEvent::File` just like rtc.rs/lan.rs do.
|
|
//! - `endpoint.connect(addr, ALPN)` ≈ the LAN/RTC dial path (lower id dials).
|
|
//! - `connection.remote_id()` ≈ a stable per-device public key — no
|
|
//! separate keypair/Noise layer needed; identity is the transport.
|
|
//!
|
|
//! What this spike does NOT yet prove: cross-network holepunch via a relay
|
|
//! (needs two machines), and discovery (mapping an account/room → EndpointId).
|
|
//! Those are the next milestones if iroh earns the integration.
|
|
|
|
use iroh::{
|
|
endpoint::{presets, Connection},
|
|
protocol::{AcceptError, ProtocolHandler, Router},
|
|
Endpoint, EndpointAddr,
|
|
};
|
|
|
|
const ALPN: &[u8] = b"tether/iroh-spike/0";
|
|
const PAYLOAD: &[u8] = b"tether clipboard sync, carried over iroh 1.0 QUIC";
|
|
|
|
/// Turn any Display error into anyhow, so we don't pull in iroh's n0-error crate
|
|
/// just for a spike.
|
|
fn ae<E: std::fmt::Display>(e: E) -> anyhow::Error {
|
|
anyhow::anyhow!("{e}")
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
// ── accept side ──────────────────────────────────────────────────────────
|
|
let server = Endpoint::bind(presets::N0).await.map_err(ae)?;
|
|
let server_id = server.id();
|
|
let router = Router::builder(server).accept(ALPN, Echo).spawn();
|
|
|
|
// Wait until the endpoint has a reachable address to hand out.
|
|
router.endpoint().online().await;
|
|
let server_addr: EndpointAddr = router.endpoint().addr();
|
|
println!("[iroh] accept side online — id={server_id}");
|
|
|
|
// ── connect side ─────────────────────────────────────────────────────────
|
|
let client = Endpoint::bind(presets::N0).await.map_err(ae)?;
|
|
println!("[iroh] connect side — id={}", client.id());
|
|
|
|
let conn = client.connect(server_addr, ALPN).await.map_err(ae)?;
|
|
let (mut send, mut recv) = conn.open_bi().await.map_err(ae)?;
|
|
send.write_all(PAYLOAD).await.map_err(ae)?;
|
|
send.finish().map_err(ae)?;
|
|
|
|
// Echo handler sends the same bytes back.
|
|
let echoed = recv.read_to_end(64 * 1024).await.map_err(ae)?;
|
|
conn.close(0u32.into(), b"done");
|
|
client.close().await;
|
|
router.shutdown().await.map_err(ae)?;
|
|
|
|
if echoed == PAYLOAD {
|
|
println!(
|
|
"✅ iroh round-trip: {} bytes echoed P2P (QUIC, key-addressed)\n payload={:?}",
|
|
echoed.len(),
|
|
String::from_utf8_lossy(&echoed)
|
|
);
|
|
Ok(())
|
|
} else {
|
|
anyhow::bail!("echo mismatch: got {:?}", String::from_utf8_lossy(&echoed))
|
|
}
|
|
}
|
|
|
|
/// Minimal accepting protocol: echo the first stream's bytes back. In the real
|
|
/// transport this body would emit MeshEvents instead of echoing.
|
|
#[derive(Debug, Clone)]
|
|
struct Echo;
|
|
|
|
impl ProtocolHandler for Echo {
|
|
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
|
let remote = connection.remote_id();
|
|
println!("[iroh] accepted connection from {remote}");
|
|
let (mut send, mut recv) = connection.accept_bi().await?;
|
|
// ≈ read frames → emit MeshEvent::Message/File. Here: echo straight back.
|
|
let n = tokio::io::copy(&mut recv, &mut send).await?;
|
|
println!("[iroh] echoed {n} byte(s) back to {remote}");
|
|
send.finish()?;
|
|
connection.closed().await;
|
|
Ok(())
|
|
}
|
|
}
|