Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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,854 changes: 1,779 additions & 75 deletions Cargo.lock

Large diffs are not rendered by default.

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

[iroh]
# 32 byte secret key for the iroh endpoint. Defines the endpoint ID of the iroh endpoint.
# The matching endpoint id is set in the justfile as a default for the `pub-iroh` command.
# Can also be set via MOQ_IROH_SECRET env variable if you don't want to put secrets into the config file.
# Alternatively, set `secret_key_path` to a path to load the secret key from a file.
# If the file does not exist, a random key will be generated and written to the path.
secret_key = "b724c6a37a004ae3d14c3efed84ccc145ee78f884e40f1b47063bdfd479ebed1"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should this be checked in?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually it shouldn't because if it is different people doing dev will connect to each other haha. Will remove and add instructions instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed

47 changes: 31 additions & 16 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -128,23 +128,31 @@ 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='bbb' url="http://localhost:4443/anon" *args:
# Download the sample media.
just download "{{name}}"

# Pre-build the binary so we don't queue media while compiling.
cargo build --bin hang
# Publish the media with the hang cli.
just ffmpeg-cmaf "dev/{{name}}.fmp4" | cargo run --bin hang -- publish --url "{{url}}" --name "{{name}}" fmp4 {{args}}

# 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 -- publish --url "{{url}}" --name "{{name}}" fmp4 {{args}}
# Publish a video file using ffmpeg to a relay server over iroh
# NOTE: The default url (iroh endpoint id) matches the secret key set in dev/relay.toml
pub-iroh name='bbb' url='iroh://a73123fce41108f024a196a399edadbba8060be166c779aa50bf4731931492d3' *args:
cargo build --bin hang
# Publish the media with the hang cli.
just ffmpeg-cmaf "dev/{{name}}.fmp4" | cargo run --bin hang -- publish --url "{{url}}" --name "anon/{{name}}" fmp4 {{args}}

# Generate and ingest an HLS stream from a video file.
pub-hls name relay="http://localhost:4443/anon":
Expand Down Expand Up @@ -235,20 +243,27 @@ 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:
serve name="bbb":
# 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 -- serve --listen "[::]:4443" --tls-generate "localhost" --name "{{name}}" fmp4

serve-iroh name="bbb":
# 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
just ffmpeg-cmaf "dev/{{name}}.fmp4" | \
cargo run --bin hang -- serve --iroh --listen "[::]:4443" --tls-generate "localhost" --name "{{name}}" fmp4 \

# 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 @@ -29,3 +29,7 @@ tokio = { workspace = true, features = ["full"] }
tower-http = { version = "0.6", features = ["cors", "fs"] }
tracing = "0.1"
url = "2"

[features]
default = ["iroh"]
iroh = ["moq-native/iroh"]
32 changes: 28 additions & 4 deletions rs/hang-cli/src/client.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,38 @@
use crate::Publish;

use hang::moq_lite;
use moq_native::web_transport_quinn::generic;
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 client(
config: moq_native::ClientConfig,
#[cfg(feature = "iroh")] iroh: moq_native::iroh::EndpointConfig,
url: Url,
name: String,
publish: Publish,
) -> anyhow::Result<()> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

heh I think it's time we made a struct for this

Copy link
Contributor Author

Choose a reason for hiding this comment

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

After the latest refactor there's no new args needed here anymore.

tracing::info!(%url, %name, "connecting");
let session = client.connect(url).await?;
match url.scheme() {
#[cfg(feature = "iroh")]
"iroh" => {
Copy link
Contributor Author

@Frando Frando Dec 20, 2025

Choose a reason for hiding this comment

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

URL format is tbd, as implemented you pass https://moq-relay.example.org for a QUIC connection, and iroh://<endpoint-id-in-hex> for an iroh connection. The iroh:// scheme is not well-defined elsewhere yet. Once I add h3 support to web-transport-iroh, it might make sense to support iroh+moql:// for raw moq-lite over iroh (direct QUIC, no WebTransport/h3) and iroh+h3:// for moq-lite wrapped in WebTransport over iroh (which would allow setting headers for auth on the request).

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think iroh:// is fine because we'll have ALPN to detect if HTTP/3 and WebTransport is supported.

let client = iroh.init_client().await?;
let session = client.connect(url).await?;
run_import_session(session, name, publish).await?;
client.close().await;
Ok(())
}
_ => {
let client = config.init()?;
let session = client.connect(url).await?;
run_import_session(session, name, publish).await
}
}
}
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

Add a comment explaining the lifecycle difference between iroh and quinn clients.

The iroh branch explicitly calls client.close().await because iroh endpoints maintain active relay server connections that require graceful shutdown. The default branch uses quinn, which handles UDP socket cleanup via Drop without requiring explicit close. Consider adding a brief comment clarifying this architectural difference to prevent confusion during maintenance.

🤖 Prompt for AI Agents
In rs/hang-cli/src/client.rs around lines 7 to 30, add a short comment
explaining why the iroh branch explicitly calls client.close().await but the
default/quinn branch does not: note that iroh endpoints maintain active relay
server connections that require an explicit graceful shutdown, whereas the
quinn/UDP client relies on Drop to clean up sockets, so no explicit close is
needed; place the comment above the match or directly above each branch to make
the lifecycle difference clear to future maintainers.


async fn run_import_session<S>(session: S, name: String, publish: Publish) -> anyhow::Result<()>
where
S: generic::Session,
{
// Create an origin producer to publish to the broadcast.
let origin = moq_lite::Origin::produce();
origin.producer.publish_broadcast(&name, publish.consume());
Expand Down
57 changes: 55 additions & 2 deletions rs/hang-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ pub enum Command {
#[command(flatten)]
config: moq_native::ServerConfig,

/// Optionally enable serving via iroh.
#[cfg(feature = "iroh")]
#[clap(long)]
iroh: bool,

/// Configuration for the iroh endpoint.
#[cfg(feature = "iroh")]
#[command(flatten)]
iroh_config: moq_native::iroh::EndpointConfig,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does this work in clap?

#[cfg(feature = "iroh")]
#[clap(long, flatten)]
iroh: Option<moq_native::iroh::EndpointConfig>,

I don't remember. Would be nice to avoid the boolean flag if possible. At the very least, clap should enforce one is not set without the other.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It does, but would still not allow to optionally enable iroh support. All fields in EndpointConfig are optional, because we can legitimately create an iroh endpoint without any configuration. So if we want to make binding the iroh endpoint and serving via it optional, I fear a separate boolean option is the only way. We could say that the serve command always serves via iroh, too, if the iroh feature is enabled. However, I think having it configurable is better.

I did not add the configuration for the publish command, because there the iroh endpoint will just be unused if not using an iroh URL. Ideally, parsing the URL could happen before even binding the endpoint, however that would need some more changes to moq-native which I wasn't sure about yet.


/// The name of the broadcast to serve.
#[arg(long)]
name: String,
Expand All @@ -42,6 +52,13 @@ pub enum Command {
#[command(flatten)]
config: moq_native::ClientConfig,

/// Configuration for the iroh endpoint.
///
/// It will be used for URLs with the iroh:// scheme.
#[cfg(feature = "iroh")]
#[command(flatten)]
iroh_config: moq_native::iroh::EndpointConfig,

/// The URL of the MoQ server.
///
/// The URL must start with `https://` or `http://`.
Expand Down Expand Up @@ -79,7 +96,43 @@ async fn main() -> anyhow::Result<()> {
publish.init().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,
#[cfg(feature = "iroh")]
iroh,
#[cfg(feature = "iroh")]
iroh_config,
..
} => {
server(
config,
#[cfg(feature = "iroh")]
iroh.then_some(iroh_config),
name,
dir,
publish,
)
.await
}
Command::Publish {
config,
#[cfg(feature = "iroh")]
iroh_config,
url,
name,
..
} => {
client(
config,
#[cfg(feature = "iroh")]
iroh_config,
url,
name,
publish,
)
.await
}
}
}
36 changes: 32 additions & 4 deletions rs/hang-cli/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@ use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::{http::Method, routing::get, Router};
use hang::moq_lite;
#[cfg(feature = "iroh")]
use moq_native::iroh::EndpointConfig;
use moq_native::web_transport_quinn::generic::Session;
use std::future::Future;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::pin::Pin;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;

use crate::Publish;

pub async fn server(
config: moq_native::ServerConfig,
#[cfg(feature = "iroh")] iroh_config: Option<EndpointConfig>,
name: String,
public: Option<PathBuf>,
publish: Publish,
Expand All @@ -25,6 +31,22 @@ pub async fn server(
.context("invalid listen address")?;

let server = config.init()?;
tracing::info!(addr = ?server.local_addr(), "listening");

// Init iroh server if enabled.
#[cfg(feature = "iroh")]
let iroh_fut = if let Some(iroh_config) = iroh_config {
let server = iroh_config.init_server().await?;
tracing::info!(endpoint_id = %server.endpoint().id(), "iroh listening");
Box::pin(accept(server, name.clone(), publish.consume())) as Pin<Box<dyn Future<Output = _>>>
} else {
Box::pin(std::future::pending::<anyhow::Result<()>>())
};

// tokio::select! does not support feature flags on match arms, thus we set the future to pending
// if the iroh feature is disabled.
#[cfg(not(feature = "iroh"))]
let iroh_fut = Box::pin(std::future::pending::<anyhow::Result<()>>()) as Pin<Box<dyn Future<Output = _>>>;

// Get the first certificate's fingerprint.
// TODO serve all of them so we can support multiple signature algorithms.
Expand All @@ -35,20 +57,19 @@ pub async fn server(

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

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

tracing::info!(addr = ?server.local_addr(), "listening");

while let Some(session) = server.accept().await {
let id = conn_id;
conn_id += 1;
Expand Down Expand Up @@ -80,8 +101,15 @@ async fn run_session(
// Create an origin producer to publish to the broadcast.
let origin = moq_lite::Origin::produce();
origin.producer.publish_broadcast(&name, consumer);
match session {
moq_native::Session::Quinn(session) => run_session_inner(id, session, origin.consumer).await,
#[cfg(feature = "iroh")]
moq_native::Session::Iroh(session) => run_session_inner(id, session, origin.consumer).await,
}
}

let session = moq_lite::Session::accept(session, origin.consumer, None)
async fn run_session_inner<S: Session>(id: u64, session: S, consumer: moq_lite::OriginConsumer) -> anyhow::Result<()> {
let session = moq_lite::Session::accept(session, consumer, None)
.await
.context("failed to accept session")?;

Expand Down
3 changes: 3 additions & 0 deletions rs/moq-native/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ categories = ["multimedia", "network-programming", "web-programming"]
default = ["aws-lc-rs"]
aws-lc-rs = ["rustls/aws-lc-rs", "rcgen/aws_lc_rs", "quinn/rustls-aws-lc-rs"]
ring = ["rustls/ring", "rcgen/ring", "quinn/rustls-ring"]
iroh = ["dep:web-transport-iroh"]

[dependencies]
anyhow = { version = "1", features = ["backtrace"] }
Expand Down Expand Up @@ -42,6 +43,8 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
web-transport-quinn = { workspace = true }
web-transport-iroh = { git = "https://github.com/n0-computer/iroh-live", optional = true }
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Pin the git dependency to a specific commit or tag.

The web-transport-iroh dependency uses a git source without a commit hash or tag. This can lead to non-reproducible builds and unexpected breakage if the upstream repository changes.

🔎 Recommended fix: Add commit or tag specification
-web-transport-iroh = { git = "https://github.com/n0-computer/iroh-live", optional = true }
+web-transport-iroh = { git = "https://github.com/n0-computer/iroh-live", rev = "COMMIT_HASH", optional = true }

Or use a tag if available:

-web-transport-iroh = { git = "https://github.com/n0-computer/iroh-live", optional = true }
+web-transport-iroh = { git = "https://github.com/n0-computer/iroh-live", tag = "v0.x.x", optional = true }

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

🤖 Prompt for AI Agents
In rs/moq-native/Cargo.toml around line 46, the web-transport-iroh git
dependency is declared without a commit hash or tag which makes builds
non-reproducible; update the dependency to pin it to a specific commit or tag by
finding the desired commit SHA (or release tag) in the upstream repo
(https://github.com/n0-computer/iroh-live) and adding either rev =
"<commit-sha>" or tag = "<tag-name>" to the dependency specification in
Cargo.toml, then run cargo update and verify the build.

rand = "0.9.2"

[dev-dependencies]
anyhow = "1"
Expand Down
Loading
Loading