How to Implement a Custom ProtocolHandler in Iroh: A Complete Guide

To implement a custom ProtocolHandler in Iroh, create a type that implements the ProtocolHandler trait—specifically the accept method—and register it with a Router using a unique ALPN byte string that identifies your protocol.

The Iroh library provides a Rust-native framework for building peer-to-peer applications over QUIC. A custom ProtocolHandler lets you define application logic that runs when remote peers connect using a specific Application-Layer Protocol Negotiation (ALPN) identifier. This article covers the complete implementation process based on the current Iroh source code.

Understanding the ProtocolHandler Architecture

Iroh's protocol system decouples connection management from application logic. The architecture revolves around three core components: the Router, the ProtocolMap, and the trait interfaces defined in iroh/src/protocol.rs.

The Router and ALPN Routing

The Router owns an Endpoint and maintains a ProtocolMap that associates ALPN byte strings with boxed protocol handlers. When a peer connects, the router's accept loop (handle_connection in iroh/src/protocol.rs) validates the ALPN, looks up the corresponding handler, and dispatches the connection to your implementation.

The RouterBuilder configures this mapping via the .accept(alpn, handler) method before spawning the router with .spawn(). Each ALPN must be unique across the application to prevent routing conflicts.

The ProtocolHandler Trait Interface

The ProtocolHandler trait in iroh/src/protocol.rs defines the contract between your application code and Iroh's connection handler. The trait requires Send + Sync + 'static bounds and a Debug implementation. The core methods are:

  • accept – The mandatory method that handles the connection after it is fully established.
  • on_accepting – Optional hook called during the 0-RTT or early data phase before full acceptance.
  • shutdown – Optional cleanup method invoked during router shutdown.

Internally, Iroh uses DynProtocolHandler, a dyn-compatible version that returns boxed futures, allowing the router to store diverse handler types as trait objects in the ProtocolMap.

Implementing the ProtocolHandler Trait

Creating a custom handler requires defining a struct that implements the trait's required methods. The implementation pattern follows a zero-sized struct approach for stateless protocols, or you can include fields for stateful handlers.

Required Methods and Trait Bounds

Every handler must implement the accept method, which receives an established Connection and returns a Result<(), AcceptError>. The method signature is:

async fn accept(&self, connection: Connection) -> Result<(), AcceptError>;

The trait bounds require your type to be Send + Sync + 'static, ensuring thread safety across the Tokio runtime. You must also derive or implement Debug.

Optional Lifecycle Hooks

You can optionally implement on_accepting to intercept connections during the early acceptance phase. This is useful for 0-RTT validation or rejecting connections before full handshake completion. The shutdown method allows you to clean up resources or notify peers when the router is stopping.

Registering Handlers with the Router

Registration binds your handler to a specific ALPN byte string. The router uses this string to dispatch incoming connections to the correct handler.

Building the Router

The registration flow begins with Router::builder(endpoint) and chains the .accept() method for each protocol you support. Here is the pattern from iroh/src/protocol.rs:

let endpoint = Endpoint::bind(presets::N0).await?;
let router = Router::builder(endpoint)
    .accept(b"/my/custom/1", MyHandler)
    .spawn();

The ALPN bytes (b"/my/custom/1" in this example) must match exactly what connecting clients use in their endpoint.connect() call.

ProtocolMap and ALPN Keys

The ProtocolMap stored inside the router is a HashMap keyed by ALPN bytes containing Box<dyn DynProtocolHandler>. When a connection arrives, the router extracts the ALPN from the TLS handshake and calls protocol_map.get(&alpn) to retrieve your handler. If no handler matches, the connection is rejected.

Handling Connection Lifecycle

Understanding when Iroh calls your handler methods helps implement proper resource management and error handling.

The Accept Flow

When a peer connects using your registered ALPN, the router executes the following sequence:

  1. Calls handler.on_accepting (if implemented) to validate the incoming connection
  2. Upon successful acceptance, calls handler.accept(connection) in a new Tokio task
  3. Your handler receives the Connection object and can open streams using connection.accept_bi() or connection.accept_uni()

The accept future runs independently, allowing long-running protocols without blocking the router's accept loop.

Graceful Shutdown

When you call router.shutdown().await, Iroh invokes the shutdown method on all registered handlers concurrently. This gives each protocol a chance to close connections cleanly before the router aborts remaining tasks and drops the endpoint. Always await router.shutdown() before dropping the endpoint to prevent connection leaks.

Complete Working Example

The following example from iroh/examples/echo.rs demonstrates a minimal echo protocol that accepts bidirectional streams and echoes back any received data:

use iroh::{
    Endpoint, EndpointAddr,
    endpoint::{Connection, presets},
    protocol::{AcceptError, ProtocolHandler, Router},
};
use n0_error::{Result, StdResultExt};

const ALPN: &[u8] = b"iroh-example/echo/0";

#[tokio::main]
async fn main() -> Result<()> {
    let router = start_accept_side().await?;
    router.endpoint().online().await;
    connect_side(router.endpoint().addr()).await?;
    router.shutdown().await.anyerr()?;
    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)
}

#[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 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(())
}

This example shows the complete lifecycle: handler definition, router registration, connection acceptance with bidirectional streams, and graceful shutdown.

Summary

  • Implement ProtocolHandler – Define a type with Debug that implements the accept method to handle incoming connections.
  • Register with ALPN – Use Router::builder(endpoint).accept(alpn, handler) to bind your handler to a specific protocol identifier.
  • Handle lifecycle – Optionally implement on_accepting for early validation and shutdown for cleanup; always await router.shutdown() for clean termination.
  • Thread safety – Ensure your handler meets Send + Sync + 'static bounds as required by the trait definition in iroh/src/protocol.rs.

Frequently Asked Questions

What is the ALPN string used for in Iroh?

The ALPN (Application-Layer Protocol Negotiation) byte string identifies which protocol a peer wants to use when connecting. In Iroh, the Router inspects the ALPN from the TLS handshake and dispatches the connection to the corresponding ProtocolHandler. Both client and server must agree on the same ALPN bytes for the connection to succeed.

Do I need to implement on_accepting and shutdown?

No. The ProtocolHandler trait provides default implementations for on_accepting and shutdown. You only need to implement on_accepting if you require early connection validation or 0-RTT handling. Implement shutdown if your protocol needs to clean up resources or notify peers before the router closes. The only mandatory method is accept.

Can I use custom transports with ProtocolHandler?

Yes. The ProtocolHandler trait is transport-agnostic. As shown in iroh/examples/custom-transport.rs, you can use custom transports with your handler by configuring the Endpoint with a custom transport before passing it to Router::builder. The handler itself works with the Connection abstraction regardless of the underlying transport.

How does Iroh handle multiple protocols?

Iroh supports multiple protocols simultaneously by storing each registration in a ProtocolMap. You can chain multiple .accept() calls on the RouterBuilder, each with a unique ALPN and handler. The router listens on a single endpoint and automatically routes connections to the appropriate handler based on the ALPN string provided by the connecting peer.

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 →