How to Implement Custom Application Protocol Handlers in iroh

Custom protocol handlers in iroh are implemented by creating a Rust type that implements the ProtocolHandler trait and registering it with the router via RouterBuilder::accept() using a unique ALPN identifier.

iroh routes incoming QUIC connections using Application-Layer Protocol Negotiation (ALPN) strings, the same extensibility mechanism used by HTTP/3 and SSH. This allows you to extend the n0-computer/iroh networking stack with application-specific protocols. Below is a complete guide based on the actual source implementation showing how to register custom handlers and manage protocol lifecycles.

Architecture of the Protocol Handler System

The protocol handler system consists of three core components: the trait definition, the registration API, and the connection dispatch loop.

The ProtocolHandler Trait

At the heart of the system is the ProtocolHandler trait defined in iroh/src/protocol.rs#L27-L31. This trait defines the async callbacks the router invokes for each matching connection:

#[async_trait]
pub trait ProtocolHandler: Send + Sync + Debug + 'static {
    async fn accept(&self, conn: Connection) -> Result<(), AcceptError>;
    async fn on_accepting(&self, conn: Connecting) -> Result<Connection> { ... }
    async fn shutdown(&self) -> Result<()> { ... }
}

Only the accept method is required. The on_accepting hook optionally intercepts the connection during the handshake phase, and shutdown enables cleanup when the router terminates.

Router Registration and ALPN Management

Handlers register with the router through RouterBuilder::accept located at iroh/src/protocol.rs#L84-L92. This method maps an ALPN byte string to your handler implementation:

impl RouterBuilder {
    pub fn accept<H: ProtocolHandler>(mut self, alpn: impl AsRef<[u8]>, handler: H) -> Self {
        let alpn = alpn.as_ref().to_vec();
        self.protocols.insert(alpn, Arc::new(handler));
        self
    }
}

When you call Router::spawn(), the router automatically invokes Endpoint::set_alpns (defined in iroh/src/endpoint.rs#L49-L59) to advertise all registered protocols to connecting peers.

Connection Dispatch Flow

The acceptance loop in iroh/src/protocol.rs#L124-L142 extracts the ALPN from the TLS handshake and dispatches to the matching handler:

  1. The router waits for incoming QUIC connections
  2. Upon connection, it reads the ALPN negotiated during the TLS handshake
  3. It looks up the ALPN in the internal ProtocolMap
  4. If found, it calls handler.on_accepting() (default returns the connection), then handler.accept()
  5. If no handler matches, the connection is closed immediately

Implementing a Custom Protocol Handler

Creating a custom protocol requires three steps: defining the handler struct, implementing the trait, and registering with the router.

Step 1: Define the Handler Struct

Create a struct that will hold your protocol state. Handlers must be Send + Sync and 'static:

use std::sync::Arc;
use iroh::protocol::ProtocolHandler;

#[derive(Debug, Clone)]
struct EchoProtocol;

Step 2: Implement the ProtocolHandler Trait

Implement the required accept method and optionally on_accepting or shutdown. Here is the echo protocol implementation from iroh/examples/echo.rs:

use iroh::endpoint::Connection;
use iroh::protocol::AcceptError;
use n0_error::Result;

impl ProtocolHandler for EchoProtocol {
    async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
        let (mut send, mut recv) = connection.accept_bi().await?;
        
        // Echo everything back to the peer
        tokio::io::copy(&mut recv, &mut send).await?;
        
        // Graceful stream closure
        send.finish()?;
        connection.closed().await;
        Ok(())
    }
}

Step 3: Register with the Router

Bind an endpoint and register your handler with a unique ALPN string:

use iroh::{endpoint::{Endpoint, presets}, protocol::Router};

#[tokio::main]
async fn main() -> Result<()> {
    // Bind using production-grade defaults
    let endpoint = Endpoint::bind(presets::N0).await?;
    
    // Define a unique ALPN identifier for your protocol
    const ECHO_ALPN: &[u8] = b"/iroh/echo/1";
    
    // Build router, register handler, and spawn accept loop
    let router = Router::builder(endpoint)
        .accept(ECHO_ALPN, EchoProtocol)
        .spawn();
    
    println!("Server running at: {}", router.endpoint().addr());
    
    // Shutdown handling
    router.shutdown().await?;
    Ok(())
}

Complete Example: Echo Protocol

Here is a complete, runnable example demonstrating both server and client:

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

#[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<()> {
    // Server setup
    let endpoint = Endpoint::bind(presets::N0).await?;
    const ECHO_ALPN: &[u8] = b"/iroh/echo/1";
    
    let router = Router::builder(endpoint)
        .accept(ECHO_ALPN, Echo)
        .spawn();
    
    println!("Server address: {}", router.endpoint().addr());
    
    // Client demonstration
    let client = Endpoint::bind(presets::N0).await?;
    let conn = client.connect(router.endpoint().addr(), ECHO_ALPN).await?;
    let (mut send, mut recv) = conn.accept_bi().await?;
    
    // Send payload
    let payload = b"hello iroh!";
    send.write_all(payload).await?;
    send.finish()?;
    
    // Receive echo
    let mut echoed = Vec::new();
    tokio::io::copy(&mut recv, &mut echoed).await?;
    println!("Echoed back: {}", String::from_utf8_lossy(&echoed));
    
    // Cleanup
    router.shutdown().await?;
    client.close().await;
    Ok(())
}

Key Implementation Details

ALPN String Selection

Choose ALPN values that follow the format /iroh/<protocol-name>/<version> to avoid collisions with built-in protocols. The ALPN is a byte sequence, not a Unicode string, though UTF-8 is conventional.

Thread Safety and Shared State

Handlers are wrapped in Arc and must be thread-safe. For shared mutable state, wrap data in Arc<Mutex<T>> or use atomic types. The accept method runs in a spawned task, so long-running operations do not block other connections.

Graceful Shutdown Handling

Implement the optional shutdown method if your protocol requires cleanup. The router calls this on all registered handlers before aborting pending connections during router.shutdown().await:

async fn shutdown(&self) -> Result<()> {
    // Close open streams, flush buffers, etc.
    Ok(())
}

Summary

  • Implement ProtocolHandler by defining the accept method to handle incoming bi-directional streams
  • Register with RouterBuilder::accept() using a unique ALPN byte string at iroh/src/protocol.rs#L84-L92
  • The router automatically advertises registered ALPNs via Endpoint::set_alpns at iroh/src/endpoint.rs#L49-L59
  • Connection dispatch occurs in the accept loop at iroh/src/protocol.rs#L124-L142 based on TLS ALPN negotiation
  • Optional hooks include on_accepting for handshake interception and shutdown for resource cleanup

Frequently Asked Questions

What is the minimum required implementation for a protocol handler?

You must implement only the accept method of the ProtocolHandler trait. This method receives a Connection object and returns Result<(), AcceptError>. The on_accepting and shutdown methods have default implementations that simply pass through the connection or return immediately, respectively.

How does iroh route incoming connections to the correct handler?

The router extracts the ALPN protocol identifier from the TLS handshake during connection establishment. It looks up this byte string in an internal ProtocolMap (populated via RouterBuilder::accept), then dispatches the connection to the corresponding handler's accept method. If no handler matches the ALPN, the connection is closed.

Can I modify the connection before the handler accepts it?

Yes. Override the on_accepting method in your ProtocolHandler implementation. This method receives a Connecting object and returns a Result<Connection>, allowing you to inspect or reject the connection before the main accept logic runs. The default implementation simply awaits the connection and returns it unchanged.

How do I handle cleanup when shutting down a protocol?

Implement the optional shutdown method in your protocol handler. When you call Router::shutdown(), the router invokes this method on all registered handlers before aborting pending connections. Use this to close open streams, flush buffers, or signal background tasks to terminate gracefully.

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 →