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:

  1. The router's handle_connection loop receives an Incoming connection and validates the ALPN
  2. The router calls handler.on_accepting (optional hook for early validation or 0-RTT handling)
  3. Upon success, the router spawns a Tokio task to run handler.accept(connection), allowing long-running protocols to execute concurrently
  4. During shutdown, the router invokes each handler's shutdown method 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
  • accept method – the required async fn that receives a Connection and 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 ProtocolHandler by defining the required accept method and optionally on_accepting and shutdown for 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 ProtocolMap in iroh/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 + 'static and Debug, with the accept method 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →