- 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
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:
- Hub behind NAT — spokes dial by
NodeIdthrough relay servers, no public IP needed - Spoke push — hub can initiate connections to spokes by
NodeId(impossible with WS without polling) - P2P spoke-to-spoke — direct spoke-to-spoke communication without routing through hub
- Cryptographic identity — Ed25519
NodeIddoubles 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:
- Write a minimal Rust NAPI crate wrapping
iroh-gossip::Gossip.subscribe() + broadcast() - Contribute gossip to
@rayhanadev/iroh - Use hub as a relay point (hub receives once, fans out to each spoke's
IrohEventTargetindividually)
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
- Binding stability —
@rayhanadev/irohhas 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. - NAPI under Deno — NAPI-RS
.nodebinaries need testing under Deno 2.x. Since we're building with tsup for npm, the runtime is Node.js. - Datagram support —
sendDatagram/readDatagramcould be used for fire-and-forget events (no response expected). Not needed for hub-spoke but could be useful for broadcast. Deferred.