How to Implement a Custom ProtocolHandler in iroh
To implement a custom protocol in iroh, create a type that implements the ProtocolHandler trait and register it with a RouterBuilder using a unique ALPN identifier.
The iroh crate from n0-computer/iroh routes incoming QUIC connections by ALPN (Application-Layer Protocol Negotiation). When you implement a custom ProtocolHandler, you define how your application processes bi-directional streams after the QUIC handshake completes. This pattern allows you to build peer-to-peer protocols on top of iroh’s transport layer without managing low-level networking code.
Understanding the ProtocolHandler Architecture
At runtime, iroh maintains a dispatch table that maps ALPN byte sequences to handler instances. The core components live in iroh/src/protocol.rs:
ProtocolHandlertrait (lines 27-44): Defines the asynchronousacceptmethod that receives a fully handshakedConnection. It also provides optional hooks foron_accepting(early connection handling) andshutdown(cleanup).RouterBuilder::accept(lines 84-92): Registers your handler for a specific ALPN. The builder stores handlers in an internalProtocolMap.Router::spawn(lines 99-122): Starts the accept loop, updates the endpoint’s ALPN advertisement list, and launches the demultiplexing task that routes connections to handlers.handle_connection(lines 25-46): The internal function that looks up handlers by ALPN and executes the protocol logic.
When a peer connects using your ALPN, the router automatically invokes your handler’s accept method with the established connection.
Steps to Implement a Custom ProtocolHandler
1. Define a Unique ALPN Identifier
Choose a byte slice that uniquely identifies your protocol. Use a URI-style string to avoid collisions:
const MY_ALPN: &[u8] = b"/my-app/protocol/1";
2. Create a Handler Struct
Define a struct to hold any protocol-specific state, such as connection counters or configuration:
#[derive(Debug, Clone)]
struct MyProtocolHandler {
state: Arc<Mutex<ProtocolState>>,
}
3. Implement the ProtocolHandler Trait
Provide an async accept method that implements your wire protocol. This is where you accept bidirectional streams, read data, and send responses:
use iroh::protocol::{ProtocolHandler, AcceptError};
use iroh::endpoint::Connection;
impl ProtocolHandler for MyProtocolHandler {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
// Accept a bidirectional stream (similar to TCP)
let (mut send, mut recv) = connection.accept_bi().await?;
// Your protocol logic here
tokio::io::copy(&mut recv, &mut send).await?;
// Graceful stream closure
send.finish()?;
connection.closed().await;
Ok(())
}
async fn shutdown(&self) {
// Optional: clean up resources when the router shuts down
}
}
4. Register with the RouterBuilder
Bind your handler to the ALPN when constructing the router:
use iroh::protocol::Router;
let router = Router::builder(endpoint.clone())
.accept(MY_ALPN, MyProtocolHandler::new())
.spawn();
5. Spawn and Run
The spawn method starts the background task that listens for incoming connections and dispatches them to your handler.
Complete Implementation Examples
Minimal Echo Protocol
This example from the iroh test suite demonstrates a handler that echoes received bytes back to the sender:
// Located in iroh tests (conceptually similar to iroh/tests/echo implementations)
#[derive(Debug, Clone)]
struct Echo;
const ECHO_ALPN: &[u8] = b"/iroh/echo/1";
impl ProtocolHandler for Echo {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let (mut send, mut recv) = connection.accept_bi().await?;
// Echo data back to the peer
tokio::io::copy(&mut recv, &mut send).await?;
send.finish()?;
connection.closed().await;
Ok(())
}
}
// Registration:
let router = Router::builder(endpoint.clone())
.accept(ECHO_ALPN, Echo)
.spawn();
Production Handler with State Management
For real applications, you typically need shared state and graceful shutdown handling:
use std::sync::{Arc, Mutex};
use iroh::{
endpoint::{self, presets, Connection},
protocol::{AcceptError, ProtocolHandler, Router},
Endpoint,
};
#[derive(Debug, Default)]
struct TimestampProtocol {
request_count: Arc<Mutex<u64>>,
}
const TIMESTAMP_ALPN: &[u8] = b"/my-app/timestamp/1";
impl ProtocolHandler for TimestampProtocol {
async fn accept(&self, conn: Connection) -> Result<(), AcceptError> {
let (mut send, mut recv) = conn.accept_bi().await?;
// Read incoming data
let mut buf = vec![0u8; 1024];
let n = recv.read(&mut buf).await?;
// Update state
let mut counter = self.request_count.lock().unwrap();
*counter += 1;
// Send timestamped response
let reply = format!("Request #{} received at {:?}", *counter, std::time::SystemTime::now());
send.write_all(reply.as_bytes()).await?;
send.finish()?;
conn.closed().await;
Ok(())
}
async fn shutdown(&self) {
let count = *self.request_count.lock().unwrap();
eprintln!("TimestampProtocol shutting down. Total requests: {}", count);
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Bind endpoint using minimal preset
let endpoint = Endpoint::bind(presets::Minimal).await?;
// Configure router with our handler
let router = Router::builder(endpoint.clone())
.accept(TIMESTAMP_ALPN, TimestampProtocol::default())
.spawn();
println!("Listening on {}", endpoint.addr());
// Keep alive until Ctrl-C
tokio::signal::ctrl_c().await?;
router.shutdown().await?;
Ok(());
}
Connecting from a Peer Client
To connect to your custom protocol from another iroh node:
let remote_endpoint = Endpoint::bind(presets::Minimal).await?;
let conn = remote_endpoint.connect(router.endpoint().addr(), MY_ALPN).await?;
let (mut send, mut recv) = conn.open_bi().await?;
send.write_all(b"Hello").await?;
send.finish()?;
The peer specifies the same ALPN byte sequence; iroh negotiates the protocol during the QUIC handshake and routes the connection to your handler’s accept method.
Key Source Files in n0-computer/iroh
iroh/src/protocol.rs: Contains theProtocolHandlertrait definition (lines 27-44),RouterBuilderimplementation with theacceptregistration method (lines 84-92), and theRouter::spawnaccept loop (lines 99-122).iroh/src/endpoint.rs: Defines theEndpointstruct and low-level QUIC connection handling used by protocol handlers.iroh/tests/patchbay/*.rs: Integration tests demonstrating various protocol handler implementations, including echo servers and multi-protocol routers.iroh/README.md: High-level overview of iroh’s architecture and quick-start examples.
Summary
- ALPN drives dispatch: iroh uses Application-Layer Protocol Negotiation to route incoming QUIC connections to the appropriate handler.
- Implement
ProtocolHandler: Provide an asyncacceptmethod to handle bi-directional streams; optionally implementon_acceptingfor early connection control andshutdownfor cleanup. - Register with
RouterBuilder: Use.accept(alpn, handler)to bind your implementation to a specific protocol identifier. - Manage state safely: Wrap shared state in
Arc<Mutex<_>>or similar structures since handlers must beCloneand may handle multiple concurrent connections. - Reference the source: The trait definition and routing logic are located in
iroh/src/protocol.rslines 27-122.
Frequently Asked Questions
What is ALPN and why does iroh use it for ProtocolHandler dispatch?
ALPN (Application-Layer Protocol Negotiation) is a TLS extension that allows peers to negotiate which protocol to use during the initial handshake. According to the iroh source code in iroh/src/protocol.rs, iroh registers ALPN identifiers with the QUIC endpoint; when a remote peer connects using a matching ALPN, the router automatically dispatches the connection to your registered ProtocolHandler. This eliminates manual protocol detection and allows a single iroh node to host multiple protocols simultaneously on different ALPN identifiers.
Can a single iroh node handle multiple protocols at once?
Yes. A RouterBuilder accepts multiple .accept() calls with different ALPN byte sequences. As implemented in iroh/src/protocol.rs lines 84-92, the builder stores each handler in a ProtocolMap. When Router::spawn runs (lines 99-122), it updates the endpoint’s ALPN list to advertise all registered protocols. Incoming connections are demultiplexed based on the ALPN negotiated during the QUIC handshake.
How do I handle graceful shutdown in my ProtocolHandler?
Implement the optional shutdown method on the ProtocolHandler trait. This async method is invoked when you call router.shutdown().await. Use it to close persistent connections, flush buffers, or persist state to disk. The default implementation in iroh/src/protocol.rs is empty, so you only need to override it if your protocol requires cleanup logic.
What is the difference between accept and on_accepting in the ProtocolHandler trait?
accept is the primary method that receives a fully established Connection after the QUIC handshake completes; implement your main protocol logic here. on_accepting is an optional early hook that runs before the handshake finishes (as shown in the trait definition at lines 27-44 in iroh/src/protocol.rs). It is useful for 0-RTT scenarios or making early reject decisions based on the connecting peer’s address before committing resources to the connection.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →