Custom Protocols

Add native ALPN handlers or compose one host-owned Iroh router without duplicating OpenRTC lifecycle

Most applications do not need this page. Use an avenue handle and channels.register() for ordinary application messages.

A Rust-native host may go further: it can add a custom ALPN handler or compose Iroh Docs, Blobs, Gossip, and OpenRTC on the same endpoint. The extension boundary is deliberately narrow: there must still be one endpoint and one ALPN router, and accepted OpenRTC connections must return to the OpenRTC client that owns admission, peer sessions, replacement, retry, and readiness.

Register a native ALPN plugin

ProtocolPlugin declares the ALPN and supplies its handler. Register it before binding the endpoint so the ALPN is advertised during negotiation.

use anyhow::Result;
use async_trait::async_trait;
use iroh::{
    endpoint::Connection,
    protocol::{AcceptError, DynProtocolHandler, ProtocolHandler},
};
use openrtc::protocol_registry::{ProtocolPlugin, RuntimeContext};

#[derive(Debug)]
struct MyHandler;

impl ProtocolHandler for MyHandler {
    async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
        // Handle only example/my-app/1 traffic here.
        drop(connection);
        Ok(())
    }
}

struct MyPlugin;

#[async_trait]
impl ProtocolPlugin for MyPlugin {
    fn name(&self) -> &'static str { "my-app" }
    fn alpn(&self) -> Option<Vec<u8>> { Some(b"example/my-app/1".to_vec()) }
    fn has_router_handler(&self) -> bool { true }
    fn router_handler(&self) -> Option<Box<dyn DynProtocolHandler>> {
        Some(Box::new(MyHandler))
    }
    async fn on_runtime_ready(&self, context: RuntimeContext) -> Result<()> {
        tracing::debug!(node_id = %context.node_id, "custom protocol ready");
        Ok(())
    }
}

Use the typed registration method so a session-adjacent plugin cannot accidentally be treated as an ALPN handler:

use openrtc::runtime_manager::RuntimeManager;
use std::sync::Arc;

let runtime = RuntimeManager::new(client.clone());
runtime.register_alpn_protocol(Arc::new(MyPlugin)).await?;

The registry rejects duplicate plugin names and duplicate ALPN values. register_session_plugin() is the corresponding seam for code that reacts to an existing OpenRTC session without adding another ALPN.

Registration declares the protocol; it does not create a second router. A plugin with a router_handler() belongs in the combined-router bootstrap below and can be added with runtime.apply_registered_protocols(builder).

Compose a Plutonium-style router

Products that already own an Iroh protocol suite can install one combined router. Plutonium uses this pattern for its product protocol plus Iroh Docs, Blobs, and Gossip.

The bootstrap order matters:

  1. Collect every extra ALPN before endpoint bind.
  2. Ask OpenRTC to initialize the endpoint without starting its internal router.
  3. Build one router over that endpoint.
  4. Forward the OpenRTC base ALPN to Client::handle_incoming_connection().
  5. Add the product handlers and spawn the router once.
use anyhow::Result;
use iroh::{
    endpoint::Connection,
    protocol::{AcceptError, ProtocolHandler, Router},
};
use openrtc::{client::Client, native_node::PlutoniumProtocol};
use std::sync::Arc;

#[derive(Clone)]
struct OpenRtcHandler(Arc<Client>);

impl ProtocolHandler for OpenRtcHandler {
    async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
        if let Err(error) = self.0.handle_incoming_connection(connection).await {
            tracing::warn!(%error, "OpenRTC rejected an incoming connection");
        }
        Ok(())
    }
}

let extra_alpns = vec![
    iroh_blobs::ALPN.to_vec(),
    iroh_docs::ALPN.to_vec(),
    iroh_gossip::ALPN.to_vec(),
];

client
    .init_iroh_without_internal_router(secret_key, extra_alpns)
    .await?;
let endpoint = client.get_endpoint().await?;

let router = Router::builder(endpoint)
    .accept(PlutoniumProtocol::ALPN, OpenRtcHandler(client.clone()))
    .accept(iroh_blobs::ALPN, blobs)
    .accept(iroh_docs::ALPN, docs)
    .accept(iroh_gossip::ALPN, gossip)
    .spawn();

The handler values (blobs, docs, and gossip) are created by their owning Iroh crates. Keep the returned router alive for the lifetime of the host and shut it down during normal application teardown.

If the host uses ProtocolPlugin objects, include runtime.protocol_registry().extra_alpns().await in extra_alpns before endpoint initialization, then call runtime.apply_registered_protocols(builder) before spawn() instead of adding those handlers by hand. The base OpenRTC forwarding handler is still required.

Ownership rules

  • Register ALPNs before endpoint bind; adding them afterward cannot change negotiation.
  • Run one accept loop. Never start OpenRTC's internal router and a host router on the same endpoint.
  • Send accepted OpenRTC base-ALPN connections to handle_incoming_connection().
  • Let OpenRTC remain the owner of discovery, presence, admission, peer generations, replacement, retry, and settled readiness.
  • Keep product authorization and payload semantics in the product protocol.

Browser Docs, Blobs, and Gossip

Browser/WASM cannot install an arbitrary Rust ProtocolHandler, but the shipping WASM endpoint can enable the upstream Iroh Docs, Blobs, and Gossip handlers on its existing router. The product supplies one durable BrowserProtocolStore adapter, normally backed by IndexedDB; OpenRTC hydrates the Rust actors before exposing them and serves persisted blob ranges without creating a second endpoint.

import { runtimeFromCapability, type BrowserProtocolStore } from 'openrtc/runtime';

const space = await rtc.spaces.join('design-doc');
const runtime = runtimeFromCapability(space).advanced.runtime;
const protocolStore: BrowserProtocolStore = appIndexedDbIrohStore();

await runtime.initIrohProtocols(protocolStore);
const doc = await runtime.createIrohNamespace();
const receipt = await runtime.putIrohBytes(
  doc.namespaceId,
  new TextEncoder().encode('title'),
  new TextEncoder().encode('OpenRTC architecture'),
);
await protocolStore.acknowledgeOutbox(receipt.operationId);

const ticket = await runtime.shareIrohNamespace(doc.namespaceId, { writable: false });
const rows = await runtime.queryIrohNamespace(doc.namespaceId);

The adapter contract groups identity, author, namespace, signed-entry, outbox, blob chunk, Bao outboard, staging, delete, and flush operations. Make each transaction atomic. Use one writer per browser profile (Web Locks plus a BroadcastChannel handoff is the recommended host policy), fail closed on quota/corruption/schema mismatch, and call flushIrohProtocols() before a controlled shutdown. OpenRTC deliberately does not hide those durability decisions behind an uninspectable database.

Arbitrary custom ALPN handlers remain native-only. Browser application protocols that do not need Docs/Blobs/Gossip should use avenue handles and registered named channels.

Next steps

Avenues

Choose the public capability handle before extending the runtime.