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:
- Accepts the raw QUIC connection via
endpoint.accept(). - Extracts the ALPN string from the TLS handshake metadata.
- Looks up the corresponding handler in the
ProtocolMap. - Spawns a new task that executes the handler's
acceptmethod.
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
- Bind the endpoint using your preferred transport preset (e.g.,
presets::N0). - Create the builder with
Router::builder(endpoint). - Register handlers by calling
.accept(alpn, handler)for each protocol. - Spawn the router with
.spawn()to start the accept loop and return aRouterhandle. - Optional: Set an
IncomingFilterviaRouterBuilder::incoming_filterto 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
Endpointis 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 aRouterBuilder. - 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →