Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
cf57c18
feat: iroh support
Frando Dec 17, 2025
ecb6473
refactor: clean up the iroh integration, add to serve cli too
Frando Dec 19, 2025
4479711
fixup
Frando Dec 20, 2025
ae10087
chore: clippy
Frando Dec 20, 2025
3396963
fix: address AI review
Frando Dec 20, 2025
0a6e925
chore: cargo sort
Frando Dec 20, 2025
c4995e0
refactor: update web-transport-iroh and streamline things
Frando Jan 3, 2026
32725d8
address review, update web-transport-iroh, add docs
Frando Jan 5, 2026
c1b9a4f
deps: update to web-transport-iroh 0.1.0
Frando Jan 5, 2026
45fc066
fix: support all iroh url schemes in native client
Frando Jan 5, 2026
a1d7905
Merge remote-tracking branch 'origin/main' into feat/iroh
Frando Jan 5, 2026
68f79ad
fixup
Frando Jan 5, 2026
ca3ce89
refactor: integrate iroh directly into moq-native Client/Server
Frando Jan 6, 2026
6e341a9
refactor: embed iroh config in ClientConfig/ServerConfig
Frando Jan 6, 2026
cb770d0
cleanup and docs
Frando Jan 6, 2026
9535833
fix: convert h3+iroh urls to https
Frando Jan 6, 2026
8e44da9
docs
Frando Jan 6, 2026
a90f5dd
docs fixes
Frando Jan 6, 2026
8b17e3d
Some iroh configuration nits.
kixelated Jan 7, 2026
3cb0569
Make iroh a default feature for moq-relay/hang-cli
kixelated Jan 8, 2026
f47b6e1
refactor: preserve iroh.enabled setting from relay config file
Frando Jan 9, 2026
0b642a1
docs: adapt README and justfile for changed iroh config
Frando Jan 9, 2026
68bc45f
Merge remote-tracking branch 'origin/main' into feat/iroh
Frando Jan 9, 2026
8c47525
refactor: make iroh enabled an Option<bool>
Frando Jan 9, 2026
3a089de
Get rid of `pub-iroh`
kixelated Jan 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,849 changes: 1,772 additions & 77 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ moq-token = { version = "0.5", path = "rs/moq-token" }
serde = { version = "1", features = ["derive"] }
tokio = "1.48"
web-async = { version = "0.1.1", features = ["tracing"] }
web-transport-iroh = "0.1.1"
web-transport-quinn = "0.10"
web-transport-trait = "0.3"
web-transport-ws = "0.2"
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,45 @@ just web # Terminal 3: Start web server

There are more commands: check out the [justfile](justfile), [rs/justfile](rs/justfile), and [js/justfile](js/justfile).

## Iroh support

The `moq-native` and `moq-relay` crates optionally support connecting via [iroh](https://github.com/n0-computer/iroh). The iroh integration is disabled by default, to use it enable the `iroh` feature.

When the iroh feature is enabled, you can connect to iroh endpoints with these URLs:

* `iroh://<ENDPOINT_ID>`: Connect via moq-lite over raw QUIC.
* `moql+iroh://<ENDPOINT_ID>`: Connect via moq-lite over raw QUIC (same as above)
* `moqt+iroh://<ENDPOINT_ID>`: Connect via IETF MoQ over raw QUIC
* `h3+iroh://<ENDPOINT_ID>/optional/path?with=query`: Connect via WebTransport over HTTP/3.

`ENDPOINT_ID` must be the hex-encoded iroh endpoint id. It is currently not possible to set direct addresses or iroh relay URLs. The iroh integration in moq-native uses iroh's default discovery mechanisms to discover other endpoints by their endpoint id.

You can run a demo like this:

```sh
# Terminal 1: Start a relay server
just relay --iroh-enabled
# Copy the endpoint id printed at "iroh listening"

# Terminal 2: Publish via moq-lite over raw iroh QUIC
#
# Replace ENDPOINT_ID with the relay's endpoint id.
#
# We set an `anon/` prefix to match the broadcast name the web ui expects
# Because moq-lite does not have headers if using raw QUIC, only the hostname
# in the URL can be used.
just pub bbb iroh://ENDPOINT_ID/anon --iroh-enabled
# Alternatively you can use WebTransport over HTTP/3 over iroh,
# which allows to set a path prefix in the URL:
just pub bbb h3+iroh://ENDPOINT_ID/anon --iroh-enabled

# Terminal 3: Start web server
just web
```

Then open [localhost:5173](http://localhost:5173) and watch BBB, pushed from terminal 1 via iroh to the relay running in terminal 2, from where the browser fetches it over regular WebTransport.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix terminal numbering in the explanation.

The description incorrectly states "pushed from terminal 1 via iroh to the relay running in terminal 2". Terminal 1 runs the relay, and terminal 2 publishes. The correct flow is: terminal 2 (publisher) → terminal 1 (relay) → browser.

📝 Proposed fix
-Then open [localhost:5173](http://localhost:5173) and watch BBB, pushed from terminal 1 via iroh to the relay running in terminal 2, from where the browser fetches it over regular WebTransport.
+Then open [localhost:5173](http://localhost:5173) and watch BBB, pushed from terminal 2 via iroh to the relay running in terminal 1, from where the browser fetches it over regular WebTransport.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Then open [localhost:5173](http://localhost:5173) and watch BBB, pushed from terminal 1 via iroh to the relay running in terminal 2, from where the browser fetches it over regular WebTransport.
Then open [localhost:5173](http://localhost:5173) and watch BBB, pushed from terminal 2 via iroh to the relay running in terminal 1, from where the browser fetches it over regular WebTransport.
🤖 Prompt for AI Agents
In @README.md at line 203, The README sentence "Then open [localhost:5173] ...
pushed from terminal 1 via iroh to the relay running in terminal 2" has the
terminal numbers reversed; update that sentence (the line beginning "Then open
[localhost:5173]") to describe the correct flow: terminal 2 (publisher) →
terminal 1 (relay) → browser, e.g., "pushed from terminal 2 via iroh to the
relay running in terminal 1, from where the browser fetches it over regular
WebTransport."


`just serve` serves a video via iroh alongside regular QUIC (it enables the `iroh` feature). This repo currently does not provide a native viewer, so you can't subscribe to it directly. However, you can use the [watch example from iroh-live](https://github.com/n0-computer/iroh-live/blob/main/iroh-live/examples/watch.rs) to view a video published via `hang-native`.

## License

Expand Down
11 changes: 11 additions & 0 deletions dev/relay.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,14 @@ listen = "[::]:4443"
[auth]
# Allow anonymous access to everything.
public = ""

[iroh]
# You can optionally enable iroh support for P2P connections.
# Clients can connect using --iroh-enabled and a `iroh://` URL.
enabled = false

# Path to persist the iroh secret key.
# If the file does not exist, a random key will be generated and written to the path.
# This gives the relay a persistent iroh endpoint id. The secret key can alternatively be set
# via MOQ_IROH_SECRET environment variable.
secret = "./dev/relay-iroh-secret.key"
41 changes: 22 additions & 19 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ dev:
"sleep 1 && just pub bbb http://localhost:4443/anon" \
"sleep 2 && just web http://localhost:4443/anon"


# Run a localhost relay server without authentication.
relay:
relay *args:
# Run the relay server overriding the provided configuration file.
TOKIO_CONSOLE_BIND=127.0.0.1:6680 cargo run --bin moq-relay -- dev/relay.toml
TOKIO_CONSOLE_BIND=127.0.0.1:6680 cargo run --bin moq-relay -- dev/relay.toml {{args}}

# Run a cluster of relay servers
cluster:
Expand Down Expand Up @@ -128,23 +129,26 @@ download-url name:
*) echo "unknown" && exit 1 ;; \
esac

# Convert an h264 input file to CMAF (fmp4) format to stdout.
ffmpeg-cmaf input output='-' *args:
ffmpeg -hide_banner -v quiet \
-stream_loop -1 -re \
-i "{{input}}" \
-c copy \
-f mp4 -movflags cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame {{args}} {{output}}

# Publish a video using ffmpeg to the localhost relay server
# NOTE: The `http` means that we perform insecure certificate verification.
# Switch it to `https` when you're ready to use a real certificate.
pub name url="http://localhost:4443/anon" *args:
pub name url="http://localhost:4443/anon" prefix="" *args:
# Download the sample media.
just download "{{name}}"

# Pre-build the binary so we don't queue media while compiling.
cargo build --bin hang

# Run ffmpeg and pipe the output to hang
ffmpeg -hide_banner -v quiet \
-stream_loop -1 -re \
-i "dev/{{name}}.fmp4" \
-c copy \
-f mp4 -movflags cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame \
- | TOKIO_CONSOLE_BIND=127.0.0.1:6681 cargo run --bin hang -- publish --url "{{url}}" --name "{{name}}" fmp4 {{args}}
# Publish the media with the hang cli.
just ffmpeg-cmaf "dev/{{name}}.fmp4" |\
cargo run --bin hang -- \
publish --url "{{url}}" --name "{{prefix}}{{name}}" {{args}} fmp4

# Generate and ingest an HLS stream from a video file.
pub-hls name relay="http://localhost:4443/anon":
Expand Down Expand Up @@ -241,20 +245,19 @@ sub name url='http://localhost:4443/anon':
@echo "Install and use hang-gst directly for GStreamer functionality"

# Publish a video using ffmpeg directly from hang to the localhost
serve name:
# To also serve via iroh, pass --iroh-enabled as last argument.
serve name *args:
# Download the sample media.
just download "{{name}}"

# Pre-build the binary so we don't queue media while compiling.
cargo build --bin hang

# Run ffmpeg and pipe the output to hang
ffmpeg -hide_banner -v quiet \
-stream_loop -1 -re \
-i "dev/{{name}}.fmp4" \
-c copy \
-f mp4 -movflags cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame \
- | cargo run --bin hang -- serve --listen "[::]:4443" --tls-generate "localhost" --name "{{name}}"
just ffmpeg-cmaf "dev/{{name}}.fmp4" |\
cargo run --bin hang -- \
{{args}} serve --listen "[::]:4443" --tls-generate "localhost" \
--name "{{name}}" fmp4
Comment on lines +248 to +260
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clarify the comment about argument placement.

The comment states "pass --iroh-enabled as last argument" but the placement of {{args}} at line 259 (before the serve subcommand) means these are global flags to the hang binary, not arguments at the end of the command line.

For example, just serve bbb --iroh-enabled expands to:

cargo run --bin hang -- --iroh-enabled serve --listen "[::]:4443" ...

The wording "last argument" typically suggests the flag appears at the end of the command, which could confuse users.

📝 Suggested comment clarification
-# Publish a video using ffmpeg directly from hang to the localhost
-# To also serve via iroh, pass --iroh-enabled as last argument.
+# Publish a video using ffmpeg directly from hang to the localhost
+# To also serve via iroh, pass --iroh-enabled (e.g., just serve bbb --iroh-enabled).
🤖 Prompt for AI Agents
In @justfile around lines 248 - 260, The comment "pass --iroh-enabled as last
argument" is misleading because {{args}} is expanded before the serve subcommand
in the serve justfile target, making them global flags to the hang binary;
update the comment to state that flags in {{args}} are global and must come
before the serve subcommand (e.g., "pass --iroh-enabled before the subcommand
since {{args}} is inserted prior to 'serve'"), or if the intent was truly to
allow trailing arguments, move the {{args}} placeholder to after the final
positional/flags (after fmp4) so arguments are appended at the end.


# Run the web server
web url='http://localhost:4443/anon':
Expand Down
4 changes: 4 additions & 0 deletions rs/hang-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ categories = ["multimedia", "network-programming", "web-programming"]
[[bin]]
name = "hang"
path = "src/main.rs"

[features]
default = ["iroh"]
iroh = ["moq-native/iroh"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
Expand Down
4 changes: 1 addition & 3 deletions rs/hang-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ use crate::Publish;
use hang::moq_lite;
use url::Url;

pub async fn client(config: moq_native::ClientConfig, url: Url, name: String, publish: Publish) -> anyhow::Result<()> {
let client = config.init()?;

pub async fn run_client(client: moq_native::Client, url: Url, name: String, publish: Publish) -> anyhow::Result<()> {
// Create an origin producer to publish to the broadcast.
let origin = moq_lite::Origin::produce();
origin.producer.publish_broadcast(&name, publish.consume());
Expand Down
37 changes: 35 additions & 2 deletions rs/hang-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
mod client;
mod publish;
mod server;
mod web;

use client::*;
use publish::*;
use server::*;
use web::*;

use clap::{Parser, Subcommand};
use std::path::PathBuf;
Expand All @@ -15,6 +17,11 @@ pub struct Cli {
#[command(flatten)]
log: moq_native::Log,

/// Iroh configuration
#[command(flatten)]
#[cfg(feature = "iroh")]
iroh: moq_native::iroh::EndpointConfig,

#[command(subcommand)]
command: Command,
}
Expand Down Expand Up @@ -84,8 +91,34 @@ async fn main() -> anyhow::Result<()> {
// Initialize the broadcast from stdin before starting any client/server.
publish.init().await?;

#[cfg(feature = "iroh")]
let iroh = cli.iroh.bind().await?;

match cli.command {
Command::Serve { config, dir, name, .. } => server(config, name, dir, publish).await,
Command::Publish { config, url, name, .. } => client(config, url, name, publish).await,
Command::Serve { config, dir, name, .. } => {
let web_bind = config.bind.unwrap_or("[::]:443".parse().unwrap());

#[allow(unused_mut)]
let mut server = config.init()?;
#[cfg(feature = "iroh")]
server.with_iroh(iroh);

let web_tls = server.tls_info();

tokio::select! {
res = run_server(server, name, publish.consume()) => res,
res = run_web(web_bind, web_tls, dir) => res,
res = publish.run() => res,
}
}
Command::Publish { config, url, name, .. } => {
#[allow(unused_mut)]
let mut client = config.init()?;

#[cfg(feature = "iroh")]
client.with_iroh(iroh);

run_client(client, url, name, publish).await
}
}
}
85 changes: 3 additions & 82 deletions rs/hang-cli/src/server.rs
Original file line number Diff line number Diff line change
@@ -1,50 +1,14 @@
use anyhow::Context;
use axum::handler::HandlerWithoutStateExt;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::{http::Method, routing::get, Router};
use hang::moq_lite;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;

use crate::Publish;

pub async fn server(
config: moq_native::ServerConfig,
pub async fn run_server(
mut server: moq_native::Server,
name: String,
public: Option<PathBuf>,
publish: Publish,
consumer: moq_lite::BroadcastConsumer,
) -> anyhow::Result<()> {
let mut listen = config.bind.unwrap_or("[::]:443".parse().unwrap());
listen = tokio::net::lookup_host(listen)
.await
.context("invalid listen address")?
.next()
.context("invalid listen address")?;

let server = config.init()?;

#[cfg(unix)]
// Notify systemd that we're ready.
let _ = sd_notify::notify(true, &[sd_notify::NotifyState::Ready]);

let tls_info = server.tls_info();

tokio::select! {
res = accept(server, name, publish.consume()) => res,
res = publish.run() => res,
res = web(listen, tls_info, public) => res,
}
}

async fn accept(
mut server: moq_native::Server,
name: String,
consumer: moq_lite::BroadcastConsumer,
) -> anyhow::Result<()> {
let mut conn_id = 0;

tracing::info!(addr = ?server.local_addr(), "listening");
Expand Down Expand Up @@ -85,46 +49,3 @@ async fn run_session(

session.closed().await.map_err(Into::into)
}

// Initialize the HTTP server (but don't serve yet).
async fn web(
bind: SocketAddr,
tls_info: Arc<RwLock<moq_native::TlsInfo>>,
public: Option<PathBuf>,
) -> anyhow::Result<()> {
async fn handle_404() -> impl IntoResponse {
(StatusCode::NOT_FOUND, "Not found")
}

let fingerprint_handler = move || async move {
// Get the first certificate's fingerprint.
// TODO serve all of them so we can support multiple signature algorithms.
tls_info
.read()
.expect("tls_info read lock poisoned")
.fingerprints
.first()
.expect("missing certificate")
.clone()
};

let mut app = Router::new()
.route("/certificate.sha256", get(fingerprint_handler))
.layer(CorsLayer::new().allow_origin(Any).allow_methods([Method::GET]));

// If a public directory is provided, serve it.
// We use this for local development to serve the index.html file and friends.
if let Some(public) = public.as_ref() {
tracing::info!(public = %public.display(), "serving directory");

let public = ServeDir::new(public).not_found_service(handle_404.into_service());
app = app.fallback_service(public);
} else {
app = app.fallback_service(handle_404.into_service());
}

let server = axum_server::bind(bind);
server.serve(app.into_make_service()).await?;

Ok(())
}
59 changes: 59 additions & 0 deletions rs/hang-cli/src/web.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
use anyhow::Context;
use axum::handler::HandlerWithoutStateExt;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::{http::Method, routing::get, Router};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;

// Initialize the HTTP server (but don't serve yet).
pub async fn run_web(
bind: SocketAddr,
tls_info: Arc<RwLock<moq_native::TlsInfo>>,
public: Option<PathBuf>,
) -> anyhow::Result<()> {
let listen = tokio::net::lookup_host(bind)
.await
.context("invalid listen address")?
.next()
.context("invalid listen address")?;

async fn handle_404() -> impl IntoResponse {
(StatusCode::NOT_FOUND, "Not found")
}

let fingerprint_handler = move || async move {
// Get the first certificate's fingerprint.
// TODO serve all of them so we can support multiple signature algorithms.
tls_info
.read()
.expect("tls_info read lock poisoned")
.fingerprints
.first()
.expect("missing certificate")
.clone()
};
Comment on lines +28 to +38
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential panic if no certificates are configured.

The fingerprint_handler uses .expect("missing certificate") which will panic if fingerprints is empty. While the server likely won't start without certificates, this could cause a runtime panic if the TLS configuration changes dynamically or during edge cases.

Consider returning a proper HTTP error instead:

🔎 Proposed fix
 	let fingerprint_handler = move || async move {
 		// Get the first certificate's fingerprint.
 		// TODO serve all of them so we can support multiple signature algorithms.
-		tls_info
+		match tls_info
 			.read()
 			.expect("tls_info read lock poisoned")
 			.fingerprints
 			.first()
-			.expect("missing certificate")
 			.clone()
+		{
+			Some(fp) => (StatusCode::OK, fp).into_response(),
+			None => (StatusCode::SERVICE_UNAVAILABLE, "no certificate available").into_response(),
+		}
 	};

Committable suggestion skipped: line range outside the PR's diff.


let mut app = Router::new()
.route("/certificate.sha256", get(fingerprint_handler))
.layer(CorsLayer::new().allow_origin(Any).allow_methods([Method::GET]));

// If a public directory is provided, serve it.
// We use this for local development to serve the index.html file and friends.
if let Some(public) = public.as_ref() {
tracing::info!(public = %public.display(), "serving directory");

let public = ServeDir::new(public).not_found_service(handle_404.into_service());
app = app.fallback_service(public);
} else {
app = app.fallback_service(handle_404.into_service());
}

let server = axum_server::bind(listen);
server.serve(app.into_make_service()).await?;

Ok(())
}
Loading
Loading