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:

  • ProtocolHandler trait (lines 27-44): Defines the asynchronous accept method that receives a fully handshaked Connection. It also provides optional hooks for on_accepting (early connection handling) and shutdown (cleanup).
  • RouterBuilder::accept (lines 84-92): Registers your handler for a specific ALPN. The builder stores handlers in an internal ProtocolMap.
  • 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 the ProtocolHandler trait definition (lines 27-44), RouterBuilder implementation with the accept registration method (lines 84-92), and the Router::spawn accept loop (lines 99-122).
  • iroh/src/endpoint.rs: Defines the Endpoint struct 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 async accept method to handle bi-directional streams; optionally implement on_accepting for early connection control and shutdown for 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 be Clone and may handle multiple concurrent connections.
  • Reference the source: The trait definition and routing logic are located in iroh/src/protocol.rs lines 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:

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 →