Files
pubsub/docs/architecture/iroh-transport.md
glm-5.1 e60f0a1aa0 Mark Iroh adapters as deferred pending fork of iroh-ts
Update peer dep from @rayhanadev/iroh to @alkdev/iroh. Document
platform strategy: Linux native + WASM fallback, Windows/macOS
deferred. Fix outdated single-import reference in iroh-transport.md
to split spoke/hub imports. Resolve binding stability and NAPI/Deno
R&D items as covered by the fork path.
2026-05-08 03:59:35 +00:00

6.8 KiB

status, last_updated
status last_updated
draft 2026-05-08

Iroh Transport

P2P QUIC event target using iroh. More complex than the other transports due to NAT traversal, crypto identity, and byte-stream framing.

Import: @alkdev/pubsub/event-target-iroh-spoke and @alkdev/pubsub/event-target-iroh-hub (not yet implemented) Peer dep: @alkdev/iroh (optional, NAPI-RS native addon — pending fork of iroh-ts)

Why Iroh

WebSocket requires the hub to have a publicly reachable address. Iroh solves:

  1. Hub behind NAT — spokes dial by NodeId through relay servers, no public IP needed
  2. Spoke push — hub can initiate connections to spokes by NodeId (impossible with WS without polling)
  3. P2P spoke-to-spoke — direct spoke-to-spoke communication without routing through hub
  4. Cryptographic identity — Ed25519 NodeId doubles as spoke authentication

Iroh Binding

Using @alkdev/iroh (pending fork of @rayhanadev/iroh) as the NAPI-RS binding. The original @rayhanadev/iroh has one author and no tests — we plan to fork it with the following platform strategy:

  • Linux: Native NAPI-RS .node binary (primary target)
  • WASM: Fallback for other environments (browser, Deno)
  • Windows/macOS: Deferred. Cross-compilation from Linux is possible with NAPI but tricky; macOS CI licensing is dubious. Adding these platforms is a future accessibility concern.

The API surface we use is small (10 methods):

Method Purpose
Endpoint.create() / createWithOptions({ alpns }) Create QUIC endpoint
Endpoint.connect(nodeId, alpn) Connect to a peer by public key
Endpoint.accept() Accept incoming connection
Endpoint.nodeId() Get our public key identity
Connection.openBi() Open bidirectional stream (spoke side)
Connection.acceptBi() Accept bidirectional stream (hub side)
SendStream.writeAll(data) Send data on stream
RecvStream.readExact(len) Read exact bytes from stream
Connection.remoteNodeId() Get peer's public key
Connection.sendDatagram() / readDatagram() Unreliable datagrams

Not exposed (not critical): Endpoint.watchAddr(), Connection.close_reason(), Connection.stats().

Protocol

Single bidirectional QUIC stream per connection. Length-prefixed JSON messages.

Framing

QUIC streams are byte streams (no message boundaries). We use 4-byte big-endian length prefix:

[4 bytes: length N][N bytes: JSON payload]

RecvStream.readExact(4) reads the length, then readExact(N) reads the payload. This is trivial with iroh's readExact() API.

Message Format

All transports use the EventEnvelope format:

{ "type": "call.responded", "id": "uuid-123", "payload": { "output": 42 } }

On the wire, this serializes as the JSON payload after the length prefix. When received, it maps to new CustomEvent("call.responded:uuid-123", { detail: envelope }).

Two-Sided Design

Unlike Redis and WebSocket, Iroh has distinct hub and spoke connection patterns:

Spoke Side

const conn = await endpoint.connect(hubNodeId, "alkhub/1");
const eventTarget = await createSpokeIrohEventTarget(conn);

Spoke opens the bidirectional stream with openBi(). The event target wraps the SendStream and RecvStream.

Hub Side

const conn = await endpoint.accept();
const eventTarget = await createHubIrohEventTarget(conn);

Hub accepts the connection, then accepts the stream with acceptBi(). Same TypedEventTarget interface on both sides.

Why Two Factories?

The connection initiator (spoke) calls openBi(). The listener (hub) calls acceptBi(). Both get SendStream + RecvStream — the framing and event handling are identical. The split is about connection establishment, not event handling. Could be unified as createIrohEventTarget(sendStream, recvStream) with separate helpers for connection, but the two-factory pattern makes the hub/spoke asymmetry explicit.

Identity

Connection.remoteNodeId() returns the peer's Ed25519 public key. This is cryptographic identity — no separate API key exchange needed for authentication. The hub can verify that a connection comes from an expected spoke by checking its NodeId.

This is strictly better than WebSocket's token-in-URL or first-message approach. It's also harder to revoke — disabling a spoke requires a denylist of NodeIds rather than rotating a token.

Connection Startup

On connection, both sides exchange the operations they expose (same hub.register pattern as WebSocket). The NodeId serves as identity — no separate API key exchange.

Reconnection

Same pattern as WebSocket — detect connection failure, reconnect, re-register. QUIC handles multipath better than TCP but the application still needs reconnection logic.

Detection: RecvStream.readExact() throws on connection close. The event target should propagate this as an error event or let the caller handle it.

Browser Limitations

Iroh in browsers is relay-only (no UDP hole punching from browser sandbox). This means:

  • Browser spokes always route through relay servers
  • WebSocketEventTarget is the right browser transport today (native, no extra deps)
  • IrohEventTarget for browsers would use the WASM build over relay — future option

Multi-Node (Future)

For 1:N fan-out, iroh-gossip is the right tool. No TS binding exists yet. Options:

  1. Write a minimal Rust NAPI crate wrapping iroh-gossip::Gossip.subscribe() + broadcast()
  2. Contribute gossip to @rayhanadev/iroh
  3. Use hub as a relay point (hub receives once, fans out to each spoke's IrohEventTarget individually)

For now, 1:1 connections are sufficient. The hub fans out to multiple spokes by dispatching to each spoke's IrohEventTarget individually — same pattern as WebSocketEventTarget on the hub side.

Comparison with WebSocketEventTarget

Aspect WebSocket Iroh
Connection new WebSocket(url) endpoint.connect(nodeId, alpn)
Accept Hono WS upgrade endpoint.accept()
Identity API key/token Ed25519 NodeId (cryptographic, mutual)
NAT traversal Requires reverse proxy / tunnel Built-in (relay + hole punching)
Framing WS frames (built-in) QUIC stream (length-prefix needed)
Hub behind NAT Not possible without tunneling Yes
Browser Yes (native) Limited (WASM build, relay-only)
Native addon No Yes (NAPI-RS)

Open Questions

  1. Fork of iroh-ts@rayhanadev/iroh has one author and no tests. We plan to fork as @alkdev/iroh with Linux native + WASM fallback. Windows and macOS native builds deferred. Mitigation: the API surface we use is small (10 methods) and the binding is thin.
  2. Datagram supportsendDatagram/readDatagram could be used for fire-and-forget events (no response expected). Not needed for hub-spoke but could be useful for broadcast. Deferred.