Files
pubsub/docs/architecture/iroh-transport.md
glm-5.1 de7fc88f99 Simplify to transport-only: remove call protocol, add EventEnvelope, expand stream operators
- Remove src/call.ts (PendingRequestMap, CallEventSchema, CallError) — call protocol belongs in @alkdev/operations
- Add EventEnvelope type ({ type, id, payload }) as the cross-platform serialization contract
- Simplify createPubSub: replace PubSubPublishArgsByKey tuple model with PubSubEventMap; publish(type, id, payload) and subscribe(type, id) use explicit id for topic scoping
- Update Redis adapter to serialize/deserialize full EventEnvelope
- Expand operators: add take, reduce, toArray, batch, dedupe, window, flat, groupBy, chain, join
- Remove @alkdev/typebox runtime dependency (was only used by call.ts)
- Remove ./call sub-path export from package.json and tsup config
- Update all architecture docs to reflect transport-only scope, add Worker adapter, remove call protocol references
- Remove docs/architecture/call-protocol.md
- Update AGENTS.md with new source layout and transport-only principle
2026-05-01 19:40:25 +00:00

6.5 KiB

status, last_updated
status last_updated
draft 2026-05-01

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 (not yet implemented) Peer dep: @rayhanadev/iroh (optional, NAPI-RS native addon)

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 @rayhanadev/iroh (v0.1.1) as the NAPI-RS binding. Community binding, one author, no tests. It has everything needed for hub-spoke 1:1 bidirectional streams:

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. Binding stability@rayhanadev/iroh has one author and no tests. If it breaks, we may need to fork or write our own NAPI wrapper. Mitigation: the API surface we use is small (10 methods) and the binding is thin.
  2. NAPI under Deno — NAPI-RS .node binaries need testing under Deno 2.x. Since we're building with tsup for npm, the runtime is Node.js.
  3. 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.