How to Use the iroh Router to Multiplex Multiple Protocols on a Single Endpoint
Use Router::builder(endpoint) to register multiple protocol handlers by their ALPN identifiers, then call .spawn() to start a single accept loop that automatically routes incoming QUIC connections to the correct handler based on the handshake negotiation.
The iroh crate from the n0-computer/iroh repository provides a high-performance networking stack for building distributed applications. To serve multiple distinct application protocols over one QUIC endpoint, you use the Router to multiplex traffic based on Application-Layer Protocol Negotiation (ALPN) bytes exchanged during the initial handshake.
Understanding the Router Architecture
iroh::protocol::Router acts as a thin, asynchronous dispatch layer that sits atop a single Endpoint. When a peer connects, the router extracts the ALPN string from the QUIC handshake and forwards the connection to the corresponding protocol handler registered in an internal ProtocolMap.
How ALPN-Based Dispatch Works
During the QUIC handshake, the client and server negotiate an application protocol using ALPN bytes. The router leverages this to route traffic without manual inspection of the connection stream. In iroh/src/protocol.rs, the handle_connection function (lines 642-648) implements this logic:
- Accepts the raw QUIC connection via
endpoint.accept(). - Extracts the ALPN bytes from the handshake metadata.
- Looks up the matching handler in the
ProtocolMap. - Spawns a task to run the handler's
acceptoron_acceptingcallback.
The Accept Loop and Task Management
The router runs a single asynchronous accept loop in a dedicated task. This loop manages all protocol handlers using a JoinSet, ensuring that if any handler panics, the router aborts the entire loop to prevent undefined states. The endpoint remains owned by the router, enabling clean shutdown semantics that cancel pending tasks and close connections in one step.
Step-by-Step Implementation
To multiplex protocols, you construct a RouterBuilder, register each protocol with its unique ALPN, and spawn the router into a background task.
Building the Router
Start by binding an endpoint and creating a builder. In iroh/src/protocol.rs, the Router::builder function initializes the RouterBuilder struct:
let endpoint = Endpoint::bind(presets::N0).await?;
let builder = Router::builder(endpoint);
Registering Protocol Handlers
For each protocol you wish to expose, call RouterBuilder::accept with the ALPN bytes and a handler implementing the ProtocolHandler trait. According to the source at lines 85-92, each call inserts a new entry into the internal map:
const ECHO_ALPN: &[u8] = b"/iroh/example/echo/1";
const TIME_ALPN: &[u8] = b"/iroh/example/time/1";
let builder = Router::builder(endpoint)
.accept(ECHO_ALPN, Echo) // First protocol
.accept(TIME_ALPN, Time); // Second protocol
Handlers must implement ProtocolHandler, which requires an accept method that receives the Connection object. You may wrap handlers in Arc<...> or Box<...> if needed.
Spawning the Accept Loop
Call spawn() to install the ALPN list on the endpoint and start the accept loop. As implemented in iroh/src/protocol.rs at lines 99-107, this returns a cloneable Router handle:
let router = builder.spawn();
Once spawned, the router begins listening for incoming connections and automatically dispatches them based on the negotiated ALPN.
Complete Example: Echo and Time Protocols
The following example demonstrates running two distinct protocols on a single endpoint. The Echo protocol mirrors data back to the client, while the Time protocol returns the current UNIX timestamp.
use iroh::{
Endpoint,
endpoint::{Connection, presets},
protocol::{AcceptError, ProtocolHandler, Router},
};
use n0_error::{Result, StdResultExt};
use std::time::{SystemTime, UNIX_EPOCH};
const ECHO_ALPN: &[u8] = b"/iroh/example/echo/1";
const TIME_ALPN: &[u8] = b"/iroh/example/time/1";
#[tokio::main]
async fn main() -> Result<()> {
// Build and spawn the router with both protocols
let router = start_server().await?;
// Wait for the endpoint to become reachable
router.endpoint().online().await;
// Connect to both protocols on the same endpoint
connect_and_use(router.endpoint().addr(), ECHO_ALPN, b"ping").await?;
connect_and_use(router.endpoint().addr(), TIME_ALPN, b"").await?;
// Graceful shutdown
router.shutdown().await.anyerr()?;
Ok(())
}
async fn start_server() -> Result<Router> {
let endpoint = Endpoint::bind(presets::N0).await?;
let router = Router::builder(endpoint)
.accept(ECHO_ALPN, Echo)
.accept(TIME_ALPN, Time)
.spawn();
Ok(router)
}
#[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(())
}
}
#[derive(Debug, Clone)]
struct Time;
impl ProtocolHandler for Time {
async fn accept(&self, conn: Connection) -> Result<(), AcceptError> {
let (mut send, _) = conn.accept_bi().await?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
send.write_all(format!("now={}\n", now).as_bytes()).await?;
send.finish()?;
conn.closed().await;
Ok(())
}
}
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!("ALPN {}: {}", std::str::from_utf8(alpn)?, String::from_utf8_lossy(&resp));
conn.close(0u32.into(), b"done");
client.close().await;
Ok(())
}
This pattern scales to any number of protocols—simply continue chaining .accept(alpn, handler) calls before .spawn().
Graceful Shutdown
To shut down the router without dropping active connections, call Router::shutdown. As implemented in iroh/src/protocol.rs at lines 404-452, this method:
- Cancels the main accept loop.
- Signals all registered protocol handlers to shut down.
- Waits for all handler tasks to complete.
- Closes the underlying endpoint.
router.shutdown().await?;
This ensures that in-flight protocol operations complete before the network resources are released.
Summary
- Single endpoint, multiple protocols: The
Routerallows oneEndpointto serve any number of application protocols distinguished by ALPN bytes. - Registration: Use
Router::builder(endpoint).accept(alpn, handler)to map ALPN identifiers toProtocolHandlerimplementations. - Spawn: Calling
.spawn()starts the internal accept loop that multiplexes connections based on the QUIC handshake. - Source locations: Core logic resides in
iroh/src/protocol.rs, specifically theRouterBuilder::acceptmethod (lines 85-92),RouterBuilder::spawn(lines 99-107), andhandle_connectiondispatch (lines 642-648). - Cleanup: Use
router.shutdown().awaitfor graceful termination that properly cleans up all handler tasks and the endpoint.
Frequently Asked Questions
What is ALPN and why is it required for the Router?
ALPN (Application-Layer Protocol Negotiation) is a TLS extension used by QUIC to advertise supported protocols during the handshake. The Router uses these bytes to identify which handler should receive a connection without reading application data first. Without ALPN, the router would have no mechanism to determine which protocol implementation should handle the connection.
How many protocols can a single Router instance handle?
There is no hardcoded limit within the Router itself. You may register any number of protocols using consecutive .accept() calls on the builder. Each registration inserts a new entry into the internal ProtocolMap, which is consulted for every incoming connection. Performance depends only on your endpoint's capacity to handle concurrent QUIC connections.
What happens if a client connects with an unregistered ALPN?
If a client negotiates an ALPN that was not registered with the router, the router will reject the connection during the handshake phase. The connection never reaches your application code, as the dispatch logic in handle_connection only forwards connections with matching ALPN entries found in the ProtocolMap.
Can I filter connections before they reach the protocol handlers?
Yes. The RouterBuilder supports an optional IncomingFilter set via RouterBuilder::incoming_filter. This filter runs after the handshake completes but before the connection is dispatched to the handler, allowing you to reject, retry, or ignore connections based on custom logic such as IP allowlists or rate limiting.
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 →