How to Use the iroh Router to Compose Multiple Protocols on a Single Endpoint

The iroh Router multiplexes any number of application-level protocols over a single QUIC Endpoint by dispatching incoming connections to registered handlers based on their ALPN identifier.

The iroh::protocol::Router in the n0-computer/iroh repository provides a zero-cost abstraction for running multiple protocols concurrently on one network address. By leveraging Application-Layer Protocol Negotiation (ALPN) bytes exchanged during the QUIC handshake, the Router eliminates the need for separate ports or complex manual connection routing.

Architecture of the Protocol Router

The Router operates as a thin, high-performance layer above the raw QUIC Endpoint. It maintains an internal ProtocolMap that associates ALPN byte strings with their respective handlers, then runs a dedicated asynchronous task to accept and dispatch connections.

The ALPN Dispatch Loop

Inside iroh/src/protocol.rs, the handle_connection function (lines 642-648) implements the core dispatch logic:

  1. Accepts the raw QUIC connection via endpoint.accept().
  2. Extracts the ALPN string from the TLS handshake metadata.
  3. Looks up the corresponding handler in the ProtocolMap.
  4. Spawns a new task that executes the handler's accept method.

If any handler panics, the Router aborts the entire loop using a JoinSet, preventing the system from entering a half-initialized state.

Protocol Registration

Before spawning, you register handlers using RouterBuilder::accept (lines 85-92 in iroh/src/protocol.rs). Each call inserts a new entry into the internal map:

Router::builder(endpoint)
    .accept(b"/my/proto/1", handler_one)
    .accept(b"/my/proto/2", handler_two)

The handler must implement the ProtocolHandler trait, typically wrapped in an Arc or Box for thread-safe sharing.

Building a Multi-Protocol Router

Constructing a Router that serves multiple protocols follows a strict builder pattern. You initialize the router with an existing Endpoint, register one or more protocol handlers, and then spawn the accept loop.

Step-by-Step Implementation

  1. Bind the endpoint using your preferred transport preset (e.g., presets::N0).
  2. Create the builder with Router::builder(endpoint).
  3. Register handlers by calling .accept(alpn, handler) for each protocol.
  4. Spawn the router with .spawn() to start the accept loop and return a Router handle.
  5. Optional: Set an IncomingFilter via RouterBuilder::incoming_filter to reject connections before the handshake completes.

Complete Rust Example

Below is a complete example demonstrating an Echo protocol and a Time protocol running simultaneously on the same endpoint. This pattern mirrors the official examples in iroh/examples/echo.rs and iroh/examples/incoming-filter.rs.

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

/// ALPN identifiers for the two protocols.
const ECHO_ALPN: &[u8] = b"/iroh/example/echo/1";
const TIME_ALPN: &[u8] = b"/iroh/example/time/1";

#[tokio::main]
async fn main() -> Result<()> {
    // ---- Server side -------------------------------------------------------
    let router = start_server().await?;

    // Wait until the endpoint is reachable (e.g. NAT hole‑punched).
    router.endpoint().online().await;

    // ---- Client side -------------------------------------------------------
    // Connect to the same endpoint twice, once for each protocol.
    connect_and_use(router.endpoint().addr(), ECHO_ALPN, b"ping").await?;
    connect_and_use(router.endpoint().addr(), TIME_ALPN, b"").await?;

    // Clean shutdown – all handler tasks finish gracefully.
    router.shutdown().await.anyerr()?;
    Ok(())
}

// ---------------------------------------------------------------------------
// Server side: build a router that knows *both* protocols.
async fn start_server() -> Result<Router> {
    let endpoint = Endpoint::bind(presets::N0).await?;
    let router = Router::builder(endpoint)
        .accept(ECHO_ALPN, Echo)   // first protocol
        .accept(TIME_ALPN, Time)   // second protocol
        .spawn();                  // start the accept loop
    Ok(router)
}

// ---------------------------------------------------------------------------
// Simple echo handler – identical to the example in the repo.
#[derive(Debug, Clone)]
struct Echo;
impl ProtocolHandler for Echo {
    async fn accept(&self, conn: Connection) -> Result<(), AcceptError> {
        let (mut send, mut recv) = conn.accept_bi().await?;
        tokio::io::copy(&mut recv, &mut send).await?;
        send.finish()?;
        conn.closed().await;
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// A tiny “time” protocol: on each connection it writes the current UNIX
// timestamp and then closes.
#[derive(Debug, Clone)]
struct Time;
impl ProtocolHandler for Time {
    async fn accept(&self, conn: Connection) -> Result<(), AcceptError> {
        use std::time::{SystemTime, UNIX_EPOCH};
        let (mut send, _) = conn.accept_bi().await?;
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let payload = format!("now={now}\n");
        send.write_all(payload.as_bytes()).await?;
        send.finish()?;
        conn.closed().await;
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// Client helper – opens a connection, sends optional data, prints the response.
async fn connect_and_use(addr: EndpointAddr, alpn: &[u8], outbound: &[u8]) -> Result<()> {
    let client = Endpoint::bind(presets::N0).await?;
    let conn = client.connect(addr, alpn).await?;
    let (mut send, mut recv) = conn.open_bi().await?;
    if !outbound.is_empty() {
        send.write_all(outbound).await?;
    }
    send.finish()?;
    let resp = recv.read_to_end(1024).await?;
    println!("Response for ALPN {}: {}", std::str::from_utf8(alpn)?, String::from_utf8_lossy(&resp));
    conn.close(0u32.into(), b"done");
    client.close().await;
    Ok(())
}

In this implementation, the client connects to the same EndpointAddr twice using different ALPN strings. The Router automatically routes each connection to the appropriate handler without additional configuration.

Graceful Shutdown and Error Handling

The Router provides clean lifecycle management through the shutdown method (lines 404-452 in iroh/src/protocol.rs). When you call router.shutdown().await:

  • The accept loop is cancelled immediately.
  • All pending handler tasks are notified and awaited via the internal JoinSet.
  • The underlying Endpoint is closed.

This ensures that no connections are dropped mid-handshake and that all protocol handlers complete their current work before the application exits.

Summary

  • Initialize the Router with Router::builder(endpoint) to obtain a RouterBuilder.
  • Register each protocol using .accept(alpn, handler), ensuring every ALPN identifier is unique.
  • Spawn the router with .spawn() to start the ALPN-based dispatch loop and expose all registered protocols on the single endpoint.
  • Shutdown gracefully by awaiting router.shutdown() to ensure all handler tasks and the endpoint close cleanly.

Frequently Asked Questions

What is the maximum number of protocols the iroh Router can handle?

The Router stores protocols in a standard ProtocolMap (a hash map), so the practical limit depends on available memory and the uniqueness of your ALPN identifiers. Each protocol requires a distinct ALPN byte sequence, and there is no hardcoded limit in the implementation.

How does the Router handle connections with unknown ALPN identifiers?

According to the dispatch logic in handle_connection within iroh/src/protocol.rs, if the ALPN extracted from the handshake does not match any registered handler, the connection is rejected. You can also configure an IncomingFilter via RouterBuilder::incoming_filter to inspect and reject connections before the handshake completes.

Can I add protocols to a running Router?

No. The ALPN list is fixed when you call spawn() (lines 99-107 in iroh/src/protocol.rs). The method installs the ALPN list on the endpoint and starts the accept loop. To add new protocols, you must create a new RouterBuilder, register all desired protocols, and spawn a new Router instance.

Does each protocol handler run in its own task?

Yes. When handle_connection dispatches a connection, it spawns the handler's accept method in a new asynchronous task managed by a JoinSet. This isolation ensures that blocking operations or panics in one handler do not impact the main accept loop or other running protocols.

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 →