How to Implement Custom Protocol Handlers Using the ProtocolHandler Trait in iroh
The iroh library routes incoming QUIC connections based on Application-Layer Protocol Negotiation (ALPN) strings, enabling you to implement custom protocol handlers by defining a struct that implements the ProtocolHandler trait and registering it with a Router for your specific ALPN identifier.
The iroh networking stack (n0-computer/iroh) provides a modular architecture for building peer-to-peer applications over QUIC. By implementing the ProtocolHandler trait, you define how your application handles incoming connections, processes bidirectional streams, and manages protocol-specific lifecycle events. This approach separates transport concerns from application logic, allowing the Router to manage connection dispatch while your handler implements the protocol semantics.
Core Architecture Components
Router and ProtocolMap
At the heart of iroh's protocol system is the Router, which owns an Endpoint and maintains a ProtocolMap to dispatch incoming connections. As implemented in iroh/src/protocol.rs, the RouterBuilder configures this mapping via the .accept(alpn, handler) method, associating ALPN byte strings with boxed protocol handlers before calling .spawn() to create the running router instance.
The ProtocolMap stores Box<dyn DynProtocolHandler> instances keyed by ALPN identifiers. When a peer connects, the router looks up the handler using get(&alpn) and delegates connection management to the corresponding implementation.
DynProtocolHandler and Connection Flow
DynProtocolHandler represents the dyn-compatible version of the ProtocolHandler trait, returning boxed futures to enable trait object usage. According to the source code in iroh/src/protocol.rs, the connection handling flow follows this sequence:
- The router's
handle_connectionloop receives anIncomingconnection and validates the ALPN - The router calls
handler.on_accepting(optional hook for early validation or 0-RTT handling) - Upon success, the router spawns a Tokio task to run
handler.accept(connection), allowing long-running protocols to execute concurrently - During shutdown, the router invokes each handler's
shutdownmethod concurrently before aborting remaining tasks
Implementing the ProtocolHandler Trait
To create a custom protocol handler, define a type (typically a zero-size struct) and implement the ProtocolHandler trait with the following requirements:
- Send + Sync + 'static bounds for thread-safe sharing across async tasks
- Debug implementation for logging and diagnostics
acceptmethod – the required async fn that receives aConnectionand implements your protocol logic
Optional Lifecycle Hooks
on_accepting – Override this method to perform early validation before the connection is fully accepted, or to handle 0-RTT data. The default implementation simply resolves the Accepting future.
shutdown – Implement this method to clean up resources, close lingering connections, or persist state when the router initiates graceful shutdown. The router awaits all shutdown futures concurrently.
Method Signature
use iroh::protocol::{ProtocolHandler, AcceptError};
use iroh::endpoint::Connection;
#[derive(Debug, Clone)]
struct MyHandler;
impl ProtocolHandler for MyHandler {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
// Protocol implementation: open streams, read/write data
Ok(())
}
}
Registering and Running Protocol Handlers
ALPN Registration
Register your handler with the router using the builder pattern. The ALPN bytes must be unique across your application:
use iroh::{Endpoint, protocol::Router};
use iroh::endpoint::presets;
let endpoint = Endpoint::bind(presets::N0).await?;
let router = Router::builder(endpoint)
.accept(b"/my/custom/1", MyHandler)
.spawn();
The router spawns background tasks to accept incoming connections and dispatch them to the appropriate handler based on the ALPN negotiation.
Graceful Shutdown
Always await router.shutdown().await before dropping the endpoint to ensure all protocol handlers receive the shutdown signal and can close connections cleanly:
router.shutdown().await?;
Complete Implementation Examples
Echo Protocol Example
This minimal implementation from iroh/examples/echo.rs demonstrates a complete handler that accepts bidirectional streams and echoes data back to the client:
use iroh::{
Endpoint, EndpointAddr,
endpoint::{Connection, presets},
protocol::{AcceptError, ProtocolHandler, Router},
};
use n0_error::{Result, StdResultExt};
const ALPN: &[u8] = b"iroh-example/echo/0";
#[derive(Debug, Clone)]
struct Echo;
impl ProtocolHandler for Echo {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let (mut send, mut recv) = connection.accept_bi().await?;
tokio::io::copy(&mut recv, &mut send).await?;
send.finish()?;
connection.closed().await;
Ok(())
}
}
async fn start_accept_side() -> Result<Router> {
let endpoint = Endpoint::bind(presets::N0).await?;
let router = Router::builder(endpoint).accept(ALPN, Echo).spawn();
Ok(router)
}
async fn connect_side(addr: EndpointAddr) -> Result<()> {
let endpoint = Endpoint::bind(presets::N0).await?;
let conn = endpoint.connect(addr, ALPN).await?;
let (mut send, mut recv) = conn.open_bi().await.anyerr()?;
send.write_all(b"Hello, world!").await.anyerr()?;
send.finish().anyerr()?;
let response = recv.read_to_end(1000).await.anyerr()?;
assert_eq!(&response, b"Hello, world!");
conn.close(0u32.into(), b"bye!");
endpoint.close().await;
Ok(())
}
Custom Transport Integration
The example in iroh/examples/custom-transport.rs shows how protocol handlers work with custom transports and path selection:
use iroh::{
Endpoint, SecretKey,
endpoint::{Builder, Connection, presets},
protocol::{AcceptError, ProtocolHandler, Router},
test_utils::test_transport::{TEST_TRANSPORT_ID, TestNetwork, TestTransport},
};
use n0_error::Result;
const ALPN: &[u8] = b"iroh-example/echo/0";
#[derive(Debug, Clone)]
struct Echo;
impl ProtocolHandler for Echo {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let (mut send, mut recv) = connection.accept_bi().await?;
tokio::io::copy(&mut recv, &mut send).await?;
send.finish()?;
connection.closed().await;
Ok(())
}
}
#[tokio::main]
async fn main() -> Result<()> {
let network = TestNetwork::new();
let s1 = SecretKey::from([0u8; 32]);
let s2 = SecretKey::from([1u8; 32]);
let t1 = network.create_transport(s1.public())?;
let ep1 = Endpoint::builder().secret_key(s1).bind().await?;
let t2 = network.create_transport(s2.public())?;
let ep2 = Endpoint::builder().secret_key(s2).bind().await?;
let server = Router::builder(ep2).accept(ALPN, Echo).spawn();
let conn = ep1.connect(s2.public(), ALPN).await?;
// Use connection...
server.shutdown().await?;
Ok(())
}
Summary
- Implement
ProtocolHandlerby defining the requiredacceptmethod and optionallyon_acceptingandshutdownfor lifecycle management. - Register handlers using
Router::builder(endpoint).accept(alpn_bytes, handler).spawn()to map ALPN identifiers to your implementation. - Connection dispatch occurs automatically via
ProtocolMapiniroh/src/protocol.rs, which looks up handlers based on the ALPN string negotiated during the QUIC handshake. - Graceful shutdown requires awaiting
router.shutdown()to ensure all handlers cleanup resources before the endpoint closes. - Trait requirements include
Send + Sync + 'staticandDebug, with theacceptmethod running in its own Tokio task to support long-running protocols.
Frequently Asked Questions
What is the difference between ProtocolHandler and DynProtocolHandler?
ProtocolHandler is the generic trait you implement for your custom protocols, while DynProtocolHandler is the dyn-compatible object-safe version used internally by the ProtocolMap. As defined in iroh/src/protocol.rs, DynProtocolHandler wraps your concrete type in a box and returns boxed futures, enabling the router to store heterogeneous handler types in a single map keyed by ALPN strings.
How does ALPN routing work in iroh?
When a peer initiates a QUIC connection, the TLS handshake includes an ALPN identifier. The router's handle_connection method extracts this string and queries the ProtocolMap using get(&alpn). If a matching handler exists, the router calls your implementation; otherwise, the connection is rejected. This mechanism allows multiple protocols to coexist on a single endpoint.
Can a single router instance handle multiple protocol handlers?
Yes. The RouterBuilder accepts multiple registrations via repeated .accept() calls, allowing you to serve different protocols on the same endpoint. Each handler operates independently with its own ALPN identifier, and the router dispatches connections to the appropriate handler based on the negotiated protocol string.
How do I handle errors within the accept method?
The accept method returns Result<(), AcceptError>. If your protocol encounters an unrecoverable error, return Err(AcceptError::other(e)) or the appropriate variant. The router logs the error and closes the connection. For recoverable errors within your protocol logic (such as stream read failures), handle them internally within the accept future before returning Ok(()) when the protocol completes normally.
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 →