--- url: https://doc.moq.dev/setup.md description: Run the MoQ demo locally or against the public relay --- # Quick Start The default demo starts a relay, publishes a test video, and opens the web player. Everything runs on your machine. ```bash git clone https://github.com/moq-dev/moq cd moq ``` ## With Nix (recommended) Install [Nix](https://nixos.org/download.html) with flakes enabled, then: ```bash nix develop --command just ``` The dev shell pins every tool the repository uses. With [nix-direnv](https://github.com/nix-community/nix-direnv), entering the directory loads the shell and the command becomes `just`. ## Without Nix Install [Just](https://github.com/casey/just), [Rust](https://www.rust-lang.org/tools/install), [Bun](https://bun.sh/), and [FFmpeg](https://ffmpeg.org/download.html), then: ```bash just install just ``` Windows users should run `setup.bat` first; see [Development](/setup/dev#windows). ## What starts 1. [moq-relay](/bin/relay/) on `localhost:4443` with a generated certificate and anonymous access. 2. [moq-cli](/bin/cli) publishing Big Buck Bunny through the relay. 3. The [web demo](/bin/demo) at [localhost:5173](http://localhost:5173), where you can watch the stream or publish your camera. ## Skip the relay A public test relay runs at `https://cdn.moq.dev/anon`. Anything published there is public and discoverable, so pick a unique name and don't publish private media. ```bash # Publish a file, then open https://moq.dev/watch?name= ffmpeg -re -i video.mp4 -c copy -f mpegts - | \ moq --connect https://cdn.moq.dev/anon --broadcast .hang import ts ``` Every client, in every language, connects the same way: a relay URL whose path scopes [authentication](/bin/relay/auth), plus a broadcast name. Media broadcasts end in `.hang`. ## Next steps * [Install](/setup/install) the relay and CLI as packages instead of building them. * Publish from [OBS](/bin/obs), [GStreamer](/bin/gstreamer), or [RTMP/SRT/WebRTC](/bin/cli). * Embed a player with the [web components](/lib/js/) or pick a [library](/lib/). * [Deploy](/setup/prod) a relay with real TLS and authentication. --- --- url: https://doc.moq.dev/setup/install.md description: Install moq-relay, moq, and the plugins on Linux, macOS, and Windows --- # Install Two binaries and two plugins ship prebuilt: | Package | Binary | What it does | | --- | --- | --- | | `moq-relay` | `moq-relay` | The [relay server](/bin/relay/) | | `moq` | `moq` | The [media router](/bin/cli): publish, play, convert, gateways, and `moq auth` | | GStreamer plugin | `moqsink`, `moqsrc` | [GStreamer](/bin/gstreamer) elements | | OBS plugin | | [OBS Studio](/bin/obs) output and source | Package names match the executables everywhere except crates.io, where the crate is `moq-cli`. Existing Homebrew installs migrate through formula renames; apt upgrades use transitional packages, and dnf replaces the old packages. The `moqdev/moq-cli` Docker image and the `#moq-cli` flake package stop at 0.12.2 and now exit with an error naming `moqdev/moq` and `#moq`. Use `moq auth` for keys and tokens; installing `moq` includes it. ## Any platform ```bash # crates.io (needs a Rust toolchain) cargo install moq-relay moq-cli # Homebrew (macOS and Linux) brew install moq-dev/tap/moq-relay moq-dev/tap/moq # Nix (`release` builds from source; a release tag such as moq-relay-v0.12.4 uses the binary cache) nix run github:moq-dev/moq/release#moq-relay -- relay.toml nix run github:moq-dev/moq/release#moq -- --help # Docker (linux/amd64 and linux/arm64) docker run -p 4443:4443/udp -p 4443:4443/tcp -v "$PWD/relay.toml:/app/relay.toml:ro" moqdev/moq-relay /app/relay.toml docker run -i moqdev/moq --help ``` Static binaries for Linux (x86\_64, aarch64), macOS (Apple Silicon), and Windows (x64) are attached to every [GitHub release](https://github.com/moq-dev/moq/releases). The Nix cache at `kixelated.cachix.org` only holds tagged releases, so the `release` branch builds from source. ## Debian and Ubuntu Debian 12+ and Ubuntu 22.04+. The GStreamer plugin needs GStreamer 1.22, so it is available on Debian 12+ and Ubuntu 24.04+. ```bash curl -fsSL https://apt.moq.dev/moq-keyring.gpg | sudo tee /usr/share/keyrings/moq-keyring.gpg > /dev/null echo "deb [signed-by=/usr/share/keyrings/moq-keyring.gpg] https://apt.moq.dev stable main" | sudo tee /etc/apt/sources.list.d/moq.list sudo apt update sudo apt install moq-relay moq gstreamer1.0-moq ``` ## Fedora, RHEL, and openSUSE Fedora 39+, RHEL 9, Rocky 9, AlmaLinux 9. On openSUSE use `zypper addrepo`. ```bash sudo curl -fsSL https://rpm.moq.dev/moq.repo -o /etc/yum.repos.d/moq.repo sudo dnf install moq-relay moq gstreamer1-moq ``` Both repositories are signed with the project key, served at `https://apt.moq.dev/moq-keyring.gpg` and `https://rpm.moq.dev/moq-keyring.gpg`. ### Running the relay as a service The Linux packages install a systemd unit and a default config at `/etc/moq-relay/relay.toml`. Put the certificate, key, and JWK under `/var/lib/moq-relay/`, then: ```bash sudo systemctl enable --now moq-relay sudo journalctl -u moq-relay -f ``` The service runs as a dynamic user with `CAP_NET_BIND_SERVICE`, so port 443 works without root, and config edits survive upgrades. ## Windows ```powershell winget install moq-dev.moq-relay winget install moq-dev.moq ``` The OBS plugin ships as a zip for Windows x64 and macOS arm64 on the [`obs-moq` releases](https://github.com/moq-dev/moq/releases?q=obs-moq); see [OBS](/bin/obs). To build the repository itself on Windows, see [Development](/setup/dev#windows). ## Other * **Arch Linux**: a community-maintained `moq-relay-bin` PKGBUILD lives in the AUR. * **Air-gapped hosts**: use the release binaries. They link glibc 2.34+, so Alpine and other musl distributions should use the Docker image or build from source. * **From source**: `cargo build --release -p moq-relay` (or `-p moq-cli`) in a checkout. --- --- url: https://doc.moq.dev/setup/dev.md description: Build, test, and debug the MoQ repository --- # Development The repository uses [Just](https://github.com/casey/just) as its command runner. Run commands inside the Nix dev shell (`nix develop`) so your tools match CI. `just check` and `just fix` refuse to run outside it; set `MOQ_ALLOW_HOST=1` to use the host toolchain anyway. | Command | Purpose | | --- | --- | | `just` | Start the local relay, test publisher, and web demo. | | `just --list` | List every recipe. | | `just fix` | Format and lint the packages this branch changed. | | `just check` | Compile, lint, and test the same scope. This is what CI runs. | | `just fix --all`, `just check --all` | The same, over every package. | | `just pub bbb ` | Publish Big Buck Bunny (also `tos`, `clock`, `gst`, `hls`). | | `just sub gst bbb ` | Play a broadcast through GStreamer. | | `just relay` | Run a local relay on its own. | | `just boy` | Run the [MoQ Boy](/bin/demo) demo. | Recipes default to the local relay at `http://localhost:4443`. Pass `https://cdn.moq.dev/anon` to use the public relay instead. The default BBB/TOS publishers and `just pub serve` use MPEG-TS, with one audio frame per PES to avoid batching latency. Use `just pub cmaf` only when testing fMP4/CMAF. BBB publishes the original 720p video and a pre-encoded 360p rendition at about 600 kbps. The player can switch between them as bandwidth or viewport size changes, without encoding while publishing. Consumers that only support one rendition get the 720p track. To reproduce the hosted SD asset, run `just pub encode-bbb-sd`, then `just pub upload bbb-sd.mp4` with access to the video bucket. The encode keeps the source frame timestamps and keyframes, and holds the final SD frame long enough to match the source audio's loop period. `just pub check-bbb` verifies both assets across three loops. Remove the local SD file before re-encoding. ## Debugging ```bash RUST_LOG=debug just # structured logs RUST_LOG=moq_net=trace just # one crate RUST_BACKTRACE=1 just # panic backtraces ``` The relay's [HTTP endpoints](/bin/relay/http) list announced broadcasts and fetch groups with `curl`, which is the quickest way to see what a relay holds. ## Windows Nix isn't available on Windows, so `setup.bat` installs the toolchain with winget: Git, Rust, Bun, Node, just, CMake, and the Visual Studio Build Tools. Run it from an Administrator terminal on a fresh machine, and re-run it after reopening the terminal if it reports tools missing from `PATH`. Run `just` recipes from **Git Bash**, not PowerShell or `cmd`: they need `bash` and `cygpath`. Only one `just dev` can run at a time on Windows, because the free-port probe needs `lsof`. If a rebuild fails with "Access is denied", a previous relay is still running: ```bat taskkill /IM moq-relay.exe /F taskkill /IM moq.exe /F ``` ## Before opening a pull request ```bash just fix just check ``` These diff the branch against its upstream (or `origin/main`) and run only the modules the diff reaches. The map from paths to modules lives in `sh/dispatch.sh`. Recipes stay thin: any logic (conditionals, loops, traps) lives in a script under `sh/`. See [CONTRIBUTING.md](https://github.com/moq-dev/moq/blob/main/CONTRIBUTING.md) for branch targeting, commit messages, and reviews, and [Agent setup](/setup/agent) if an AI coding agent is doing the work. --- --- url: https://doc.moq.dev/setup/prod.md description: Run moq-relay publicly with TLS, authentication, and host tuning --- # Production Deployment A public relay needs a reachable UDP port, a trusted certificate, and an explicit access policy. Start from the local config and change these: 1. Give the relay a stable hostname and forward its UDP port (QUIC and WebTransport). Forward TCP too if you enable HTTPS or the WebSocket fallback. 2. Install a publicly trusted certificate. Generated certificates and disabled verification are for development. 3. Configure [authentication](/bin/relay/auth). Leave nothing anonymous unless you mean to. 4. Keep the operational endpoints (`/metrics`, `/nodes`) on the `[internal]` listener, bound to loopback or a private network. 5. Raise Linux UDP socket buffers (below). ```toml [listen] bind = "[::]:443" [listen.tls] cert = "/etc/letsencrypt/live/relay.example.com/fullchain.pem" key = "/etc/letsencrypt/live/relay.example.com/privkey.pem" # HTTPS and WebSocket fallback on TCP. The same certificate works. [web.https] listen = "[::]:443" cert = "/etc/letsencrypt/live/relay.example.com/fullchain.pem" key = "/etc/letsencrypt/live/relay.example.com/privkey.pem" [auth] key = "/etc/moq/public.jwk" [internal] listen = "127.0.0.1:9101" ``` Certificate files are watched and reloaded for new connections. See the [configuration reference](/bin/relay/config) for every section. ## Socket buffers The relay asks for 8 MiB UDP buffers and logs a warning when the kernel clamps them. On Linux, raise the limits and persist them: ```bash printf 'net.core.rmem_max = 8388608\nnet.core.wmem_max = 8388608\n' | sudo tee /etc/sysctl.d/60-moq.conf sudo sysctl --system ``` macOS uses `kern.ipc.maxsockbuf`; Windows sizes each socket directly. ## Scaling out Connect relays into a [cluster](/bin/relay/cluster) to serve multiple regions or add redundancy. Clustering routes broadcasts between relays; how clients pick an entry relay (DNS, anycast, a load balancer) is up to you. `/health` is the liveness probe. If you would rather not run infrastructure, [moq.pro](https://moq.pro) hosts relays behind an API. ## Verify * Connect with [moq-cli](/bin/cli) from outside the network and publish with a test token. * Watch it with the [web player](/lib/js/watch) or `moq play`. * Check [`/health` and `/metrics`](/bin/relay/http), and confirm the startup log shows the expected listeners, certificate, and buffer sizes. --- --- url: https://doc.moq.dev/setup/upgrade.md description: >- Breaking changes between the 2026-09-17 releases and the 2026-09-23 release train, with the replacement for each --- # Upgrade This page walks from the 2026-09-17 releases (moq-relay 0.14.18, moq-cli 0.11.2, moq-net 0.2.22, @moq/net 0.3.5, moq-ffi 0.3.19) to the 2026-09-23 release train (moq-relay 0.15.1, moq-cli 0.12.1, moq-net 0.3.0, @moq/net 0.4.0, moq-ffi 0.4.1). Each crate's `CHANGELOG.md` has the full list; this page is the subset that breaks a working setup. A released flag, environment variable, or config key that was renamed is refused at startup with its replacement named, rather than ignored. Fix what the error lists and rerun. ## Wire Older protocol versions still negotiate, so relays and clients can be upgraded in any order, apart from [pattern-only token grants](#relay-and-cli) and two wire changes: * The lite 06 ALPN is `moq-lite-06`, not `moq-lite-06-wip`. An explicit `moq-lite-06-wip` in a version list is refused (#3941). * The hang catalog's root `timeline` entry is `archive`, and wall time moved to a root `clock: { wall, timescale }` (#3612, #3675). A new `moq export hls` finds no timeline in an old publisher's catalog, so upgrade publishers before the HLS gateway. ## Relay and CLI The `moq-relay` and `moq` flags split into `--listen-*` (accepting), `--connect-*` (dialing), and a shared `--quic-*` section. Environment variables follow the flag (`MOQ_SERVER_BIND` is `MOQ_LISTEN`). | Before | After | | --- | --- | | `--server-bind` | `--listen` | | `--server-*`, `--tls-cert`, `--tls-key`, `--tls-generate` | `--listen-*`, `--listen-tls-cert`, `--listen-tls-key`, `--listen-tls-generate` | | `--client-connect` | `--connect` | | `--client-*` | `--connect-*` | | `--client-failover-delay` | `--connect-race` | | `--client-reconnect=false` | `--connect-once` (inverted) | | `--tls-disable-verify`, `--client-tls-disable-verify` | `--connect-tls-insecure` | | `--server-quic-*`, `--client-quic-*` | `--quic-*`, applied to both directions | | TOML `[server]`, `[client]` | `[listen]`, `[connect]` | | TOML `[server.quic]`, `[client.quic]` | `[quic]` | | TOML `listen`, `connect`, `failover_delay`, `reconnect`, `disable_verify` | `bind`, `url`, `race`, `once` (inverted), `insecure` | | `--cluster-linger` | removed; a broadcast closes when its last publisher is lost | | `--cluster-connect host:port` | a full URL, `https://host/?jwt=TOKEN` | | `--cluster-mesh`, TOML `mesh` | removed; list every peer with `--cluster-connect` or `--cluster-connect-api` | | `moq --origin`, `--name`, `--latency-max` | `--hop`, `--broadcast`, `--max-age` | | `moq publish`, `moq subscribe` | `moq import`, `moq export` | | `moq token`, the `moq-token` binary | `moq auth` | Other changes to a deployment: * **Auth is one contract** (#3688). The relay asks an auth server per session (`--auth-url`) or applies a static anonymous grant (`--auth-public`); exactly one is required. `--auth-key`, `--auth-key-dir`, `--auth-api`, `--auth-api-mode`, `--auth-public-api`, `--auth-domain`, `--auth-mtls-tier`, and `--auth-tls-*` are gone: run `moq auth serve --key-dir ...` next to the relay and point `--auth-url` at it. The flag-by-flag mapping is in [Migrating from the relay flags](/bin/relay/auth#migrating-from-the-relay-flags). * **Grants are patterns, not prefixes.** `anon` is now exactly the broadcast `anon`; write `anon/**` for the subtree. This applies to `--auth-public`, TOML `public`, and the `[auth.public]` table, which is now `public_subscribe` / `public_publish`. A public or mTLS pattern with no wildcard refuses to start, naming the subtree to write, rather than pick one reading silently. The patterns are rooted at `/`, as in 0.14, so `anon/**` admits a client dialed at `/anon` and refuses one dialed outside `anon/`. Earlier 0.15 releases rooted them at the dialed path instead. * **Token grants are patterns.** JWT `publish` and `subscribe` claims are patterns, so a token granting `alice` covers only `alice`; sign `alice/**` instead. Existing `put`/`get` tokens and key scopes keep working as subtrees, and subtree-only grants are still signed in that form, so a `moq-token` deployment can upgrade issuers and verifiers in either order. Verifiers on the pattern-only `moq-auth` 0.1.0/0.1.1 or `@moq/auth` 0.1.x/0.2.0 refuse that form, so upgrade them before their issuers. Grants only a pattern can express need an upgraded verifier. * **Removed auth settings refuse to start.** 0.14's `[auth]` `key`, `key_dir`, `auth_api`, `domains`, `mtls_tier`, and `[auth.tls]`, and their flags and `MOQ_AUTH_*` variables, stop the relay with the replacement named rather than being ignored. * **Token claims.** `iss`, `sub`, and `jti` are ignored and `nbf` is enforced. Any other claim refuses the token with its name, `aud` and `cluster` included, where 0.14 ignored all but `aud`: an issuer adding app claims such as `user_id` must drop them. * **Every credential is evaluated or refused.** A relay on `--auth-public` refuses a session presenting a token, as 0.14 did, including peers sending `cluster.token`, and refuses to start with a client CA. `moq auth serve` refuses a session presenting both a JWT and a certificate, so a peer presents one or the other. * **`moq auth serve` never re-checks or expires by default**, as 0.14 never did. `--revalidate` needs `--expires`, and `--limit-*` needs `--revalidate`. * **mTLS admits nothing on its own.** A verified client certificate is reported to the auth server, which grants it. `moq auth serve --mtls-publish '**' --mtls-subscribe '**'` restores the old full access for every certificate the relay's client CA verifies, so keep that CA to cluster peers. * **`moq --listen` needs auth.** A CLI listener refuses to start without `--auth-url` or `--auth-public` instead of accepting everyone. * **Gossip discovery is removed.** A relay dials only the peers it lists or finds on the LAN, never a URL learned from an announcement, and no longer announces `.internal/origins`. The `/nodes` endpoint lists only peers this relay dialed. Until every relay that ran `--cluster-mesh` is upgraded, keep client grants off `.internal/`: an older relay still dials any URL announced there with `cluster.token`, and an upgraded peer still forwards it. * **Other 0.14 auth differences kept.** Peers identify by certificate or LAN path. An auth server's `root` alias may have any depth. A path in `--cluster-connect` is not refused, although it shifts the mesh frame. `moq auth serve --key` takes a file, not an https or JWKS URL. `moq auth sign --root` is the token root and JS `verify --root` the dialed path. `/.cluster*` roots are reserved. `--auth-public a,b` splits on commas. * **noq is the only QUIC stack** (#3811). The `quinn` and `quiche` cargo features and the backend setting are gone. * **Stats counters** are `*_started` / `*_ended` (`sessions_started`, `announces_ended`, ...). This release still writes the old `announced` / `*_closed` names beside them, so move consumers now. ## GStreamer * `moqsink` properties `estimated-send-bitrate` / `estimated-recv-bitrate` are `estimated-send-rate` / `estimated-recv-rate`, with no alias; a `gst-launch` line naming the old ones fails at runtime. ## Rust * **moq-native is moq-tokio** (#2896). moq-native 0.20.0 is a stub that fails to compile with the rename. `ClientConfig::default().init()?` is `moq_tokio::connect::Config::default().init(quic)?`, and `with_publisher(&origin).with_subscriber(origin)` is `with_origin(origin)`. Names sit under their modules (`connection::Goaway`, `connection::Monitor`, `transport::Session`; #3745). * **moq-token is moq-auth** (#3684). `Claims` holds `Patterns`, and `Claims::authorize` returns pattern residuals. * **Origins** (#3400, #3804). `Origin::random().produce()` is `moq_tokio::origin::spawn()`. `create_broadcast(path, route)` is `origin.publish(path, route)`, or `create_broadcast(path)` then `broadcast.announce(route)`. `with_root` plus `scope(prefixes)` is one `scope(root, &patterns)` returning `Result`. `origin::Info` is `origin::Config`. * **Announcements are prefix routes** (#3225, #3770). `announce::Update` is `{ prefix, route, kind, captures }`: skip `!update.kind.is_active()` and resolve the broadcast with `consumer.request_broadcast(&update.prefix)`. Serve a subtree on demand with `origin.dynamic(prefix, route)`. * **Tracks.** `with_latency_max` / `latency_max` is `with_max_age` / `max_age`. `write_datagram(Datagram)` is `insert_datagram(sequence, timestamp, payload)` (#3666); `append_datagram` is unchanged. `track::SubscriberControl` is `track::Control`, `track::GroupRequest` is `group::Request`, and `ConnectionStats` is `session::Stats` with `estimated_send_rate` / `estimated_recv_rate`. * **Oversized groups abort** with `GroupTooLarge` instead of shedding their head (#3585). * **Catalog edits are fallible** (#3644, #3813). moq-mux and moq-json `lock()` is `modify()?`, and a guard that fails to publish on drop aborts the track. `timeline::Config::wall` is the broadcast `Clock`. * **moq-json config** (#3718). `compression: bool` is a `Compression` enum; track-owning options are `producer::Config` / `consumer::Config`. * **moq-mux imports are typed.** `import::Init` splits into `AudioInit`, `VideoInit`, and `ContainerInit`, with typed formats instead of strings, and `Track::new` is `Track::audio` / `Track::video`. * **moq-relay embedding** (#3638). `Relay` fields are private: clone the handles you need, mount routes, then call `Relay::run`. `Cluster::with_cache` moved to `cluster::Options`. ## JavaScript The JavaScript packages have no changelog; this list follows the breaking PRs, so a minor rename may be missing. * **@moq/token is @moq/auth.** `sign` / `verify` are `Key.sign` / `Key.verify`, and claims are pattern unions (see [Token grants are patterns](#relay-and-cli)). * **One `Connection`** (#3614, #3636). `Connection.Reload` is `new Moq.Connection({ url })`, which pools one connection per relay. `closed` settles only on `close()`; the error that stopped retrying is `error`. * **Origins hold broadcasts** (#2705, #3225). `connection.publish(path, broadcast)` is `origin.createBroadcast(path)` then `broadcast.announce()`, and `connection.announced(prefix)` is `origin.announced(scope)` yielding `{ prefix, kind, route }`. `ignoreSelf` is gone: reflected announces are always dropped. * **Names mirror Rust** (#3710). `latencyMax` is `maxAge` (a `Time.Milli`), `writeDatagram` is `insertDatagram`, and `RemoteError` is `Error.Stream` / `Error.Session`. * **@moq/watch** (#3396, #3817). `latency`, `latency-min`, `latency-max`, and `jitter` are `delay` and `buffer`, which need a unit (`delay="100ms"`, `buffer="30s"`). `reload` is `announced`. `Watch.Broadcast({ connection })` is `Watch.Player({ origin: connection.origin, ... })`. * **@moq/publish** takes `origin: connection.origin` instead of a `connection` signal. * **@moq/json and @moq/binary** take one options object (#3640): `new Json.Snapshot.Consumer({ track })` instead of `(track, config)`. * **@moq/hang** reads the catalog `archive` entry instead of `timeline`. ## Bindings Python (`moq-rs` 0.5.0), Swift (0.5.0), Kotlin (`dev.moq:moq` 0.5.0), and Go wrap moq-ffi 0.4. These are the moq-ffi names; each wrapper follows them in its own casing: * **Go module path** is `moq.dev/moq` (was `github.com/moq-dev/moq-go/moq`). * **Publish split by kind.** `publish_media*` is `publish_audio`, `publish_video`, or `publish_container`, each taking its own init. The raw encoder paths that used to be `publish_audio` / `publish_video` are `encode_audio` / `encode_video`. * **Durations are microseconds** (`max_age_us`, `MoqBackoff.initial_us`), and rate estimates are `estimated_send_rate` / `estimated_recv_rate`. * **Setters are fallible** (#3642). Client and server configuration setters return an error (`Busy` during connect or listen) instead of dropping the value. `set_tls_disable_verify(bool)` is `set_tls_verify(bool)`. * **Announcements.** `MoqAnnounced` is `MoqAnnounceConsumer` and `MoqAnnouncement` is `MoqAnnounceUpdate`; `MoqBroadcastRequest::abort` is `reject`, and `MoqOriginOptions` is `MoqOriginConfig`. * **`MoqAudioCodec`** is an `opus()` object (#3671). * **Errors.** `MoqError::Protocol` carries a `MoqProtocolError` (scope, wire code, kind) instead of a flattened message. * **Track and group `finish()`** keeps the handle open so a later `abort()` can still run. C (libmoq 0.6): * The 41 `moq_client_*` setters are one zero-initializable `moq_client_config`, whose durations are `_us`. * `moq_publish_media` is `moq_publish_audio`, `moq_publish_video`, or `moq_publish_container`; the raw encoders are `moq_encode_audio*` / `moq_encode_video*`. Formats are enums instead of strings. * `moq_announced` is `moq_announce_update`, `moq_origin_consume_announced` is `moq_origin_announced_broadcast`, and `moq_broadcast_request_abort` is `moq_broadcast_request_reject`. --- --- url: https://doc.moq.dev/setup/agent.md description: Teach your AI coding agent how to build with MoQ --- # Agent Setup Using an AI coding agent like Claude Code, Cursor, or Codex? Teach it MoQ by pasting this prompt: ``` Follow the instructions at https://doc.moq.dev/setup/agent/prompt.md ``` The agent will fetch the instructions and install the [MoQ skill](https://github.com/moq-dev/moq/tree/main/skills/moq) into your project. The skill tells the agent what MoQ is and to read this site as markdown (see below) instead of hallucinating an API. ## Manual Installation Prefer to run it yourself? The skill installs with the [skills CLI](https://github.com/vercel-labs/skills), which supports Claude Code, Cursor, Codex, OpenCode, Windsurf, GitHub Copilot, and more: ```bash npx -y skills add moq-dev/moq --yes ``` Add `--global` for a user-level install, or `-a ` to target a specific agent. ## What's in the Skill? The skill is a single markdown file, [`skills/moq/SKILL.md`](https://github.com/moq-dev/moq/blob/main/skills/moq/SKILL.md), that your agent loads when a task involves MoQ. It's deliberately short: what MoQ is, a pointer to this site, and the few facts that don't change, so it never goes stale as the packages evolve. Contributions welcome; it's just markdown. ## Reading the Docs Every page on this site is also served as plain markdown: drop any trailing `/` and append `.md`, so `/bin/relay/` becomes `/bin/relay.md` and `/lib/js/watch` becomes `/lib/js/watch.md`. [`/llms.txt`](/llms.txt) indexes every page with a one-line description, and [`/llms-full.txt`](/llms-full.txt) bundles them all into one file, following the [llms.txt](https://llmstxt.org) convention. --- --- url: https://doc.moq.dev/concept.md description: How the MoQ stack fits together --- # Concepts MoQ is a stack of small layers. A relay only needs the middle one, which is what lets it forward media it can't parse. | Layer | Role | Implemented by | | --- | --- | --- | | Application | Your product: rooms, auth, custom tracks. | You | | [hang](/concept/hang) | Media: a catalog of tracks, codec config, timestamped frames. | `hang`, `@moq/hang` | | [moq-lite](/concept/moq-lite) | Generic live pub/sub: broadcasts, tracks, groups, frames. | `moq-net`, `@moq/net`, relays, CDNs | | [Transport](/concept/transport) | QUIC streams, via WebTransport in browsers, with a WebSocket fallback. | The browser, `moq-tokio` | ## The model in one paragraph A **broadcast** is a named, discoverable collection of **tracks** from one publisher. A **track** is a live sequence of **groups**, and each group is a sequence of **frames** delivered reliably and in order over its own QUIC stream (or, for a tiny single frame, as one unreliable datagram). Groups are independent: they can arrive out of order, be prioritized against each other, and be dropped under congestion without corrupting the rest. For video a group is a GoP starting at a keyframe; for audio it's a second or so of samples; for data it's whatever unit can be skipped as a whole. Subscribers pull. Nothing is transmitted until someone subscribes, duplicate subscriptions are merged at every relay on the way upstream, and a publisher that watches demand (the capture and transcode paths do) can skip encoding too. Each subscriber also chooses its own latency budget, so the same broadcast can be watched at 100 ms by one viewer and 10 s by another. ## Sections * [Transport](/concept/transport): why QUIC, and the WebTransport, WebSocket, raw QUIC, and iroh paths onto it. * [moq-lite](/concept/moq-lite): the pub/sub protocol, discovery, path patterns, subscriptions, and congestion behavior. * [hang](/concept/hang): the media catalog, containers, and how to extend both. * [Audio jitter](/concept/audio-jitter): how a receiver sizes its audio playout target from arrival timing. * [Stats](/concept/stats): the traffic counters a relay publishes as broadcasts, and how to read them. * [Standards](/concept/standard): how this relates to the IETF moq-transport, MSF, LOC, and this project's own drafts, including [e2ee](/draft/moq-e2ee). * [Use cases](/concept/use-case/): MoQ compared with HLS/DASH, RTMP/SRT, WebRTC, and used for AI. --- --- url: https://doc.moq.dev/concept/transport.md description: >- QUIC, WebTransport, the WebSocket fallback, raw QUIC, stream listeners, and iroh --- # Transport MoQ runs on QUIC. Every other transport on this page is a way to get a QUIC-shaped connection (independent streams, optional datagrams) from a particular environment. ## Why QUIC HTTP/1 serialized requests; HTTP/2 multiplexed them but kept one TCP byte stream, so one lost packet stalled everything. RTMP, HLS, and SRT have the same head-of-line problem: old frames block new ones during congestion, and latency climbs. QUIC ([RFC 9000](https://datatracker.ietf.org/doc/html/rfc9000)) gives each stream its own ordering and flow control, so a lost packet only holds back the stream it carried, plus: * **Partial reliability.** A stream can be reset mid-flight. MoQ resets a group once it is too old to matter. * **Prioritization.** The sender decides which stream's packet goes next, so new groups and audio starve old video rather than the other way around. * **Encryption, congestion control, and connection migration** for free, from a library every HTTP/3 stack already ships. "Just use UDP" is the road WebRTC and SRT took, and each had to reinvent all of that. MoQ spends its effort on media instead. ## WebTransport (browsers) [WebTransport](https://www.w3.org/TR/webtransport/) exposes QUIC streams to a web page over an HTTP/3 `CONNECT`. Chrome and Edge (97+) and Firefox (114+) support it. The TypeScript client is pickier than the browsers: it uses WebTransport on Firefox only from 153 (earlier releases allow too few incoming streams) and keeps Safari on the WebSocket fallback, since WebKit bugs stall long sessions even though Safari 26.4 ships the API. Native clients can use WebTransport too, but usually skip it. ## WebSocket fallback Where UDP is blocked or WebTransport is missing, clients race a WebSocket connection (`wss://`) and keep whichever wins. A small multiplexer, [qmux](/draft/qmux-websocket), carries MoQ streams over the socket. It works everywhere but cannot escape TCP head-of-line blocking, so priority and resets only help once bytes leave the TCP queue. The fallback is automatic in every client; the relay enables it with `[web.https]`. ## Raw QUIC (native) Native clients dial the relay directly with the MoQ ALPN, skipping the HTTP/3 handshake. The URL path and `?jwt=` token travel in the MoQ `SETUP` message instead of a `CONNECT` request, so the server sees the same thing either way. `moq-tokio` picks this automatically for `https://` URLs; use `moql://` or `moqt://` to force it. ## TCP and Unix sockets (local workers) For trusted processes on the same host, the relay can accept the qmux wire format over plain TCP or a Unix socket with no TLS. Gateways and stats publishers use this to avoid UDP and certificate setup. Both listeners authenticate through the same JWT and public-access rules as QUIC, and the Unix socket can additionally allowlist the connecting process by uid, gid, or pid. See [stream listeners](/bin/relay/auth#stream-listeners). ## iroh (peer-to-peer, experimental) [iroh](https://www.iroh.computer/) swaps "dial a hostname" for "dial a public key". Two native MoQ endpoints on the same network can exchange media with no relay, no DNS, and no certificate authority: the endpoint's key is its address and its TLS identity. ```bash moq --iroh-enabled --iroh-disable-relay \ --connect "iroh:///anon" --broadcast cam.hang play ``` Keep it on the local network. Across the internet iroh falls back to an n0 relay that forwards opaque packets, which is a TURN-shaped hop that cannot cache or fan out; a [MoQ relay](/bin/relay/) is the right tool there. `--iroh-disable-relay` pins traffic to direct addresses but also removes hole punching. Discovery still publishes the endpoint's addresses to n0's DNS whichever way it is configured. Browsers can't use iroh at all: WebTransport gives a page a connection, not an endpoint. The relay opts in with `[iroh] enabled = true` and a persisted `secret` so the endpoint id survives restarts. See the [config reference](/bin/relay/config#iroh). --- --- url: https://doc.moq.dev/concept/moq-lite.md description: The generic pub/sub layer, a simple forward-compatible subset of moq-transport --- # moq-lite moq-lite is the pub/sub protocol this project speaks. It is a deliberately small subset of the IETF [moq-transport](/concept/standard) draft, so it works against any moq-transport relay (including [Cloudflare](https://moq.dev/blog/first-cdn)) while staying simple enough to implement in an afternoon. The wire spec is [draft-lcurley-moq-lite](/draft/moq-lite). ## Terminology | moq-lite | Meaning | moq-transport name | | --- | --- | --- | | **Session** | One connection, publishing and subscribing at once. | Session | | **Origin** | The set of broadcasts visible to a session, scoped by the URL path. | (none) | | **Broadcast** | A named, discoverable collection of tracks from one publisher. | Namespace | | **Track** | A live sequence of groups, delivered out of order until closed. | Track | | **Group** | A sequence of frames delivered reliably and in order, on its own QUIC stream. | Group | | **Frame** | A sized chunk of bytes. | Object | | **Datagram** | One unreliable frame sent as a QUIC datagram instead of a group. | Datagram | ## Session setup A dedicated ALPN selects the wire version for moq-lite 03 and newer. The legacy `moql` ALPN negotiates moq-lite 01 or 02 via `SETUP`. In moq-lite 05 and newer, each side also sends a `SETUP` message with its capabilities. Rust and TypeScript speak moq-lite 01 through 06 and moq-transport drafts 14 through 22. Clients offer `moq-lite-06` first by default. moq-lite 07 is still in progress: it negotiates as `moq-lite-07-wip`, and only when both sides explicitly enable it. moq-lite 07 also switches every varint from QUIC's two-bit length prefix to moq-transport's leading-ones form, so values up to 127 take one byte instead of up to 63, and the range widens from 62 to 64 bits. Rust still refuses lite-07 values above 2^62-1 until its `VarInt` widens. ## Subscription completion On moq-lite 07, `SUBSCRIBE_END` counts the group streams opened for the subscription. Rust and TypeScript stop waiting for missing streams once that many headers have arrived; skipped group sequences add no wait. A stream whose header arrived is always read to its end before the subscription completes. A stream reset before its header arrived cannot be counted, so the subscriber still allows a grace period for late streams. The grace uses the subscription's nonzero effective maximum age, or one second when no maximum age is set. moq-lite 05 and 06 instead account for group sequences using received headers, datagrams, and `SUBSCRIBE_DROP`, from the start group the subscription last asked for. A lost datagram is not owed, but its hole waits out the grace like a lost stream. From moq-lite 06, an end that contradicts the groups received aborts the track: a group at or past `SUBSCRIBE_END`, or a `SUBSCRIBE_END` below a group already received. moq-lite 05 specified an inclusive end, so there it only drops that group or the early boundary. ## Group reads across failover Rust origin readers can keep reading an in-flight group after its source fails. A group reader waiting on a replacement copy subscribes to it from the frame it needs, even when the caller is not polling for the next group, and holds that subscription until the group ends. If that copy refuses the subscription, the read ends with an error. The copy's declared start says whether the group is still coming, so a newer group overtaking it on the wire does not end the wait. A copy that declares no start (a local track) and has advanced past the group ends the read with an error. This also applies when waiting for the group's completion; no FETCH is issued. A replacement that later drops the group it is serving still needs SUBSCRIBE\_DROP support to resolve that wait. ## Discovery A session can ask for announcements matching a path prefix. The peer replies with what it can serve today, then streams changes as they come and go. That is how a conference room learns who joined, how a player learns a stream came online without polling, and how [relay clusters](/bin/relay/cluster) discover each other. An announcement is a **route**: a claim that broadcasts at a path prefix, and every path beneath it, can be served. By convention a publisher announces each broadcast's exact path, so subscribers enumerate broadcasts by listing routes, but a service can announce one short prefix and serve whatever is requested beneath it, advertising capability without enumerating inventory. A route is always a prefix, on every wire version and on moq-transport alike; a service that serves only some of the paths beneath its prefix refuses the rest as they are requested. Each route carries the chain of relay identities it passed through, which is how forwarding loops are caught, and a cost, which is how a subscriber picks among several routes to the same broadcast. A hop of 0 is the anonymous mark and travels the chain unchanged; when it is the first hop, a relay puts a random ID, fresh per connection, in front of it to name the publisher. A route that passed through an anonymous hop at any depth ranks below every fully identified route, whatever the costs say; among anonymous routes, cost keeps ordering. On moq-lite 07, an announcement may copy the head of its path and the tail of its relay chain from one still live on the same stream, so many broadcasts from a few origins behind the same relays stop repeating those bytes. Rust compresses when it helps; TypeScript decodes it but always sends literally. A broadcast exists only while it is announced, for consumers in the same process and across a session alike: one that is created but never announced can be neither discovered nor requested. A broadcast published locally competes with remote routes to its path on cost like any other route, winning only a tie. Retracting a route (an unannounce, or the peer's `ANNOUNCE_END`) stops new requests from resolving through it but leaves subscriptions already in flight alone: each track runs to its own end or failure. On moq-lite 05 and newer, a clean end requires `SUBSCRIBE_END` before the publisher's FIN. A FIN without that declaration fails the subscription with `ProtocolViolation`; older moq-lite versions use FIN alone. moq-transport requires `PUBLISH_DONE` before FIN. moq-transport sessions behave the same when a namespace is withdrawn. A route update that changes its first hop, the original publisher, is not a retraction: subscriptions in flight drain the old publisher, and new requests resolve through the new one. ### Hidden broadcasts A path segment starting with `.` hides a route from discovery, the way a dotfile hides from `ls`. A platform publishes its own broadcasts there (relay stats under `.stats/`) without them turning up in an app that lists everything and plays what it finds. Only segments below the requested prefix count: listing the root skips `.stats/node`, but listing `.stats` shows `node`. A `.` elsewhere in a segment (`catalog.pro`) is part of the name. Hiding narrows discovery and nothing else. Subscribing to a hidden path by name works without asking, and tokens authorize it like any other path. To list hidden routes too, opt in per announce request: ```rust let announced = origin.consume().with_hidden(true).announced(); ``` ```typescript const announced = connection.announced(Path.Pattern.all(), { hidden: true }); ``` On the wire, moq-lite 07 (`moq-lite-07-wip`, opt-in only) carries the opt-in on each announce request, and moq-transport carries it as a `SUBSCRIBE_NAMESPACE` parameter once the peer's `SETUP` says it understands one ([hidden](/draft/moq-hidden)). An older MoQ Lite peer never opts in, so it never discovers hidden routes. IETF peers that omit the MoQ Hidden setup option receive all authorized namespaces, including dot-prefixed namespaces. Peers that declare it opt in per subscription; a prefix naming the dot segment itself also lists its children. Rust sessions always opt in on the wire and filter per local reader, so a relay mirrors everything and each consumer decides. ## Path patterns Rust's `moq_net::Pattern` and TypeScript's `Path.Pattern` from `@moq/net` describe sets of literal paths. Patterns match the whole path. `room` matches only `room`, while `room/**` matches `room` and every descendant. Segments are separated by `/`: | Segment | Matches | | --- | --- | | `room` | That literal segment. | | `*` | Exactly one nonempty segment. | | `camera-*` | One segment starting with `camera-`. | | `pre*suf` | One segment with that prefix and suffix, without overlapping them. | | `**` | Zero or more segments. | A pattern may contain at most 32 segments and one `**`. Leading, trailing, or repeated separators are invalid pattern syntax. The empty pattern matches the empty path. There is no escape syntax for a literal `*`. Construction normalizes adjacent `*` and `**`: `*/**` prints as `**/*`, so equivalent wildcard placements have the same identity. Patterns never travel as announcements. `origin.dynamic(prefix, route)` advertises a prefix: the call claims that `prefix` and every path beneath it *can* be served, not that any exist. A route is a capability, not an inventory: a subscriber must not treat a prefix as a concrete broadcast name. Use `publish(path, route)` when the path is known; use `dynamic` when the set of paths is not, and refuse the requests you will not serve. A pattern lives in two places: the token, which scopes what a session may publish and subscribe to, and a local filter a consumer applies to the prefixes it is told about. A subscriber watching under a root sees advertisements named relative to that root. The pattern scope filters which prefixes are visible without changing a route's prefix. When several routes advertise one prefix, each reader sees the best route its scope can use, so a cheaper route scoped elsewhere never hides it. Announce events carry the covered path, captures, and what happened to it: Rust `announce::Update { prefix, captures: Option>, route, kind }` and TypeScript `Announce.Update { prefix, captures, route, kind }`, where the kind is announced, updated (a reprice in place), or retracted. Captures are present when the announced prefix pins every wildcard in the most-specific matching scope member. The Rust consumer is a `Stream` and the TypeScript one an async iterable. Announcements are hints; requests are the authority. When a subscriber asks for a covered path the advertiser will not serve, the advertiser refuses that request rather than narrowing the claim, and no message narrows a route. Token scope is any pattern union; the session asks for each member's literal head on the prefix-only wire and filters locally. In Rust and TypeScript, `origin.scope(root, patterns)` narrows the handle's permissions and presents paths relative to `root`. Nested scopes intersect with their parent. In Rust, `origin.mount(at, target)` reads the subtree at `at` from `target` instead: a request for `at/rest` joins the one front at `target/rest`, announcements under `target` present under `at`, the handle's patterns still authorize `at/rest`, and nothing is published beneath `at`. Mounts never chain: a mount point that overlaps another mount's point or any target, its own included, is refused. A session receiving into that scoped origin asks for the literal heads of its allowed patterns, coalescing duplicate or nested heads. An unscoped origin still asks for the empty prefix, covering every namespace. These subscriptions include hidden routes; each local announcement reader decides whether to show them. ```typescript import { Path } from "@moq/net"; const scope = Path.Pattern.parse("room/**"); scope.matches("room/alice"); // true scope.contains(Path.Pattern.parse("room/camera-*")); // true scope.overlaps(Path.Pattern.parse("*/alice")); // true scope.rebase("room").toJSON(); // ["**"] Path.Pattern.parse("camera-*").rooted("room").text; // "room/camera-*" ``` `contains` asks whether every path matched by the other pattern is allowed by this one. `overlaps` asks whether they share any matching path. `rebase` returns the matching paths relative to a literal root; `rooted` places a pattern beneath that root and rejects results exceeding the segment limit. Rebasing can require several patterns: `**/a` rebased at `a` yields both `""` (the root itself) and `**/a` (deeper paths ending in `a`). `Patterns` is a union that removes members contained by another member and orders the remaining members canonically. Its containment check requires one member to cover the entire requested pattern; it does not combine several members to prove joint coverage. Equality compares those reduced members. Use `specificity` to rank structural constraints when selecting rules, and `contains` to check whether a rule stays within a scope. ## Subscriptions A subscriber names a broadcast and track. Delivery starts at the oldest group it can still use, which at the default budget is the latest one, so every group must begin at a point a fresh subscriber can decode from (a keyframe, a full JSON snapshot). Groups can be fetched by sequence number too, optionally bounded to a range of frames, which is how the [HLS gateway](/bin/hls) and the relay's [HTTP fetch](/bin/relay/http) serve history. Each subscription carries the knobs that decide behavior under congestion: | Knob | Effect | | --- | --- | | **Priority** (0..255) | Higher-priority tracks get bandwidth first. Audio above video, base layer above enhancement. | | **Order** | Which group to send first when several are pending. Newest first for live, oldest first for catch-up. | | **Max age** | How old a non-latest group may get before it is skipped. Zero means "live edge only", and raising it is also what asks for history. | Max age is measured on the media timeline, not the wall clock, so a backlog delivered as a burst is still old while a congestion stall never expires anything on its own. Both ends apply it: the publisher skips a group rather than sending it, and the subscriber skips it again as it reads, since the publisher only ever sees the most tolerant budget across its subscribers. Across a native route failover, the reader still judges buffered groups against the logical track's live edge, including groups it is draining from a retired route. A successor group with no timestamp leaves the preceding group's reach unbounded until its first frame arrives; if it is dropped first, the next group takes its place. A cached open group's prefix remains readable across repeated takeovers and idle resumes. The publisher declares a retention window per track, which bounds how far back a fetch or late subscriber can reach. Media tracks default to 30 seconds so a segmented egress can still find its segments. Put together, a conference might use: | Track | Priority | Order | Max age | | --- | --- | --- | --- | | audio | 100 | ascending | 500 ms | | video | 50 | descending | 2 s | Under light congestion video drops the tail of a group; under heavy congestion video stops and audio lags by at most 500 ms. No protocol change, just knobs. ## Datagrams Since moq-lite 05, a publisher can send a tiny single-frame group as a QUIC datagram: unreliable, unordered, under about 1200 bytes, and never retransmitted. It suits real-time audio and sensor data. There is no stream fallback, so a datagram that doesn't fit isn't delivered that way. ## What moq-lite leaves out Compared with moq-transport: no request IDs (a stream per request instead), no push (subscribers always ask), fetches within a single group only, no sub-groups (use a track per SVC layer), no gaps in object numbering, no per-object metadata (encode it in the payload), no pausing (unsubscribe instead), and UTF-8 names instead of byte arrays. When a peer negotiates moq-transport the implementation still enforces this simpler model, faking or refusing the rest. | Client | Relay | Works | | --- | --- | --- | | moq-lite | moq-lite | yes | | moq-lite | moq-transport | yes | | moq-transport | moq-lite | without moq-transport-only features | | moq-transport | moq-transport | depends on the implementations | ## Protocol errors Session close codes and stream reset codes use separate registries: session code 0 is a clean close, while stream code 0 is an internal error. Rust preserves received codes as `moq_net::Error::Session(SessionError)` or `Error::Stream(StreamError)`; JavaScript exposes `SessionError` and `StreamError`. Match the registry before interpreting the number. Native bindings expose scope, code, kind, and a diagnostic message; unknown and application codes retain their numeric value. Transport failures without a protocol code remain separate. A deliberate local close ends received tracks cleanly after their delivered groups; a peer close ends tracks and open group readers with the session error. ## Local read limits Group ranges name which groups a reader may deliver. In Rust, `set_groups(2..=5)` includes group 5, while `set_groups(2..5)` excludes it. TypeScript spells the endpoints explicitly: `reader.setGroups({ start: { included: 2 }, end: { included: 5 } })`. Changing these local limits preserves read progress. Raising the start skips lower groups; lowering it never rewinds the reader. Raising or removing the end cap makes unread buffered groups available again. These local limits do not change upstream demand; subscription preferences control that separately. The wire encoding is unchanged. --- --- url: https://doc.moq.dev/concept/hang.md description: The media layer, a WebCodecs-shaped catalog plus timestamped frames --- # hang hang is the media format on top of [moq-lite](/concept/moq-lite): a catalog track that describes the media tracks, and a container that gives each frame a timestamp. It is modeled on [WebCodecs](https://www.w3.org/TR/webcodecs/) so a browser can decode it directly. The spec is [draft-lcurley-moq-hang](/draft/moq-hang). Plaintext broadcast names end in `.hang` so a player knows which catalog to expect. End-to-end encrypted broadcasts live under `/`, where `` is derived from the credential and a semantic name such as `foo.hang`, and one epoch identifies each publisher run. The path exposes no format or protection marker; the payloads follow [moq-e2ee](/draft/moq-e2ee). ## Catalog `catalog.json` is a JSON track listing the renditions of each media kind and the decoder config for each. It updates live as tracks come and go, and a compressed twin (`catalog.json.z`) is published alongside it. Catalog readers refuse an update with more than 64 video, audio, and text renditions combined, before opening media subscriptions. The refusal covers the whole update. ```json { "video": { "renditions": { "hd": { "codec": "avc1.64001f", "description": "0164001f...", "codedWidth": 1280, "codedHeight": 720, "container": { "kind": "legacy" } } } }, "audio": { "renditions": { "en": { "codec": "opus", "sampleRate": 48000, "numberOfChannels": 2, "container": { "kind": "legacy" } } } } } ``` Each rendition extends the WebCodecs `VideoDecoderConfig` or `AudioDecoderConfig`. Every WebCodecs codec is fair game; H.264, H.265, VP8, VP9, AV1, AAC, and Opus are what the tools produce today. Properties shared by every video rendition (display size, rotation, flip) sit at the section root. A few things the catalog can express beyond decoder config: * **Labels.** Any rendition may carry a human-readable `label` for a track picker. The map key stays the track name used to subscribe, so labels need not be unique and renaming one doesn't rename the track. * **Renditions in another broadcast.** A rendition may point at a relative broadcast path, so a transcoder can publish a ladder that adds low rungs and references the source's original rendition without re-publishing its bytes. The path resolves against where the consumer found the catalog, so a reference that escapes above the root names nothing and the catalog is rejected. * **Jitter.** A rendition can say how far its frames fell behind the media clock before the publisher flushed them, in whole milliseconds rounded up. Encoders report the spread of lateness above each rendition's own recent minimum, so a constant encoder delay is not jitter; container imports estimate batch spans without counting ingest delay. It describes the publisher, never the network, only grows over the life of a stream, and a player sizes its buffer to at least this much. A `0` is read as absent. * **Delay.** A rendition can also say how far its frames reach the transport behind the broadcast's earliest rendition, measured the same way from each rendition's minimum lateness, so a video encoder running 200 ms behind audio advertises `delay: 200` on video. It follows the same rules as jitter. A player holds the largest `delay + jitter` among the renditions it subscribes to, and never subtracts one rendition's `delay` from another's. * **Stalled renditions.** A publisher can flag a rendition as temporarily bad so players prefer another one without the track disappearing. First-party video publishers set this flag after more than three frame intervals of source silence or encoding lag while subscribed, and clear it after three on-time completed frames or when idle. Browser and native capture poll while waiting; FLV and MPEG-TS importers observe video silence as container data arrives. The shared detector is `hang::catalog::stalled::Detector` in Rust and `Catalog.Stalled.Detector` in JavaScript. It is a playback diagnostic, not an authorization or routing signal. * **Archive.** A broadcast may advertise an `archive` entry naming its timeline track (a small index of each complete aligned segment) and, if recorded, the replay MoQ path, object-store URL, and format version. The timeline is what lets the [HLS gateway](/bin/hls) build playlists without subscribing to media. A reader that cannot decode the `archive` entry, such as one from a different version, ignores it and loses the timeline and replay, but live media still plays. * **Clock.** The optional root `clock` maps PTS zero to wall time so every media track and the archive index share one fixed epoch after timescale conversion. It is independent of `archive`, so a live-only publisher can expose wall-clock timing without creating a segment index. * **Extensions.** The root is a loose object. Applications add their own sections (`scte35`, for example) next to the ones hang defines, optionally naming a track that carries the data. Every library exposes a way to write your section without clobbering the built-in ones, and readers ignore what they don't know. ## Text Captions and subtitles are their own tracks in a `text` section, not part of the video bitstream, so the relay stays media-agnostic and a viewer downloads only the language it picked. Renditions work like audio: usually one per language, with `lang` and `label` driving the picker. There is no WebCodecs decoder for text, so a consumer parses each cue itself. `format` says how (`vtt`, `ttml`, or `utf8`) and `role` is `subtitle` (dialogue) or `caption` (all audio). Each frame's timestamp is the cue's start time on the same media clock, so cues schedule against the same playhead. ## Data tracks Not everything in a broadcast is media: a chat log, a telemetry feed, a thumbnail, a serialized game state. The `json` and `binary` sections list these as plain tracks, split by whether a generic consumer can parse the payload. ```json { "json": { "tracks": { "chat": { "mode": "stream", "compression": "deflate" }, "status": { "mode": "snapshot" } } }, "binary": { "tracks": { "thumbnail": { "mode": "snapshot", "mime": "image/jpeg" } } } } ``` Unlike the media sections these are not renditions: each entry is a distinct track, not an alternative to choose between. `mode` is required and says how groups compose the frames, because reading an append log as a latest-value document would silently discard everything but the last payload: * `snapshot` is lossy. Each group supersedes the previous one, so a consumer reads only the newest. A JSON track may follow the first frame with merge-patch deltas. * `stream` is an ordered log: one payload per frame, all in a single group that is never rolled. Retention is still bounded by the group cache, and a consumer that falls behind fails the read rather than silently resuming mid-log. The rest is descriptive: `compression` (`deflate`, the same group-scoped `deflate-raw` the catalog uses), `schema` on a JSON track, `mime` on a binary one, `bitrate`, `jitter`, and `delay` with the same meaning as for media, plus the optional `broadcast` reference. A publisher measures `jitter` and `delay` only from payloads stamped with their capture time on the broadcast clock. A consumer that doesn't recognize a `mode` or `compression` ignores that track and round-trips it verbatim. In Rust the catalog owns the lifetime: `catalog.json_stream(track, config)` (or `json_snapshot` / `binary_snapshot` / `binary_stream`) writes the entry and retracts it when the producer drops. Read the config from `catalog.json.tracks` or `catalog.binary.tracks`, then pair its name and config with `moq_mux::catalog::Entry::new` to subscribe. In C, `moq_publish_json_*` and `moq_publish_binary_*` do the same, retracting on `_finish`. In the browser, read the same map, subscribe by name, and hand the track to `@moq/json` or `@moq/binary`. An application with its own per-track fields can list a data track in its own root section instead, flattening the JSON or binary entry beside those fields so there is one entry per track. Name the section with a namespaced key such as `com.example.mavlink`. A generic consumer only finds tracks in `json` and `binary`. ## Container The `container.kind` on each rendition says how frames are framed: | Kind | Frame layout | Use | | --- | --- | --- | | `legacy` | varint microsecond timestamp + codec payload | The default. Cheapest. | | `cmaf` | `moof` + `mdat` | fMP4 passthrough for HLS/DASH interop; ~100 bytes per frame. | | `loc` | small property block + payload | The IETF [LOC](/concept/standard#loc) container. | A consumer skips renditions with a kind it doesn't recognize and carries them through when republishing the catalog. A Legacy or LOC video publisher can close the last frame's duration with an empty codec payload whose timestamp is that frame's exclusive end. Consumers treat it as metadata and never pass it to a decoder. This lets a group close immediately without waiting for the next frame. Audio has codec-defined durations, and CMAF carries sample durations directly, so neither needs per-group duration markers. Audio retains its separate terminal marker before codec drain packets, allowing consumers to discard encoder padding beyond the source endpoint. Empty payloads on data tracks remain data, including empty text cues. ## Groups and keyframes A video group is a GoP: it begins with a keyframe and holds the frames that depend on it. That alignment is what makes MoQ's congestion behavior safe. A relay can drop a whole group, a viewer can join at any group boundary, and the decoder never sees a frame whose reference is missing. Audio groups are independent too and typically hold about a second. The `description` field carries out-of-band codec setup (an `avcC` box for H.264). When it is absent, the parameter sets ride inline before each keyframe, which is what `avc3`/`hev1` tracks do. Decoders should handle both. ## Your own format hang is a convention, not a requirement. If you control both ends, publish whatever frames you like on raw tracks; the relay never looks inside them. The [MoQ Boy](/bin/demo) demo mixes hang media tracks with JSON status and command tracks on the same broadcast. --- --- url: https://doc.moq.dev/concept/audio-jitter.md description: How a receiver sizes its audio playout target from arrival timing --- # Audio jitter A receiver cannot play audio the instant it arrives. Frames leave the publisher in bursts and cross a network that delivers them unevenly, so a playhead that starts on the first frame runs dry on the first gap. The buffer it holds ahead of the playhead is the **playout target**, and this page says how to compute it. This document is normative. `@moq/hang` and `moq-audio` each implement it, and neither is the reference for the other: parity is held by the [conformance corpus](#conformance-corpus), one set of arrival traces with the target series both must produce. Nothing here is negotiated on the wire, so a receiver that chooses a different target is still conformant. It will just sound worse. The target is a *measurement*, not a round trip. `max(20ms, 1.25 x minRtt)` sizes a buffer for one retransmit, which describes how long the network takes to recover a loss and says nothing about how unevenly a publisher emits frames. A sender flushing 100 ms of media at once needs 100 ms of buffer on a perfect network, and the round trip cannot see it. ## The observation point Every frame is observed **as it comes off the transport, before any group can be skipped**. A target derived from what survives the age budget only ever confirms the budget it was cut to. That point is one await in each implementation, resolving once per container frame: | | Observation point | | --- | --- | | Browser | `Group.Consumer.readFrame()`, inside the container consumer's per-group loop | | Native | `container::Consumer::read()`, awaited by `moq-audio`'s decode consumer | Both sit below the decoder and above nothing: no reordering, no gap handling, no age budget. A frame dropped by the relay is never observed, which is correct. A frame the container will later discard *is* observed, which is also correct: it arrived, and when it arrived is the measurement. ## Units and clocks Two implementations that disagree here produce different targets from the same trace while both look right, so this is fixed rather than left to taste. * **Arrival** is the receiver's monotonic clock in **milliseconds**, as `f64`. `performance.now()` in the browser, `Instant` natively. Never the wall clock: a step adjustment would read as a burst of jitter. * **Media** is the frame's container timestamp converted to **milliseconds**, as `f64`, using the track's declared timescale. Convert once, at the observation point, as `ticks * 1000 / timescale` in `f64`. The legacy hang container is microseconds; CMAF and Matroska carry their own scale. * **Every value below is `f64` milliseconds.** No fixed point and no integer rounding. WebRTC's Q15 arithmetic exists for 2010-era DSPs and buys nothing here except a class of cross-language parity bug. Arithmetic is IEEE-754 and evaluated in the order written, so both languages get bit-identical results. Do not reassociate the sums, do not use compensated summation, and do not accumulate in a wider type. ## The estimator ``` observe(arrival, media): if media <= newest: return # reordered, excluded entirely newest = media drop history entries with entry.media < media - WINDOW push (arrival, media) onto history ref = entry in history minimising (entry.arrival - entry.media) delay = max(0, (arrival - ref.arrival) - (media - ref.media)) if first observation: origin = arrival; interval = 0; peak = delay; return index = floor((arrival - origin) / RESAMPLE) if index <= interval: peak = max(peak, delay); return fold(bucket of peak) fold(nothing) for each elapsed empty interval, at most IDLE_MAX interval = index; peak = delay fold(bucket): count += 1 forget = min(FORGET, count / (count + RAMP)) histogram = forget * histogram + (1 - forget) * (bucket or PRIOR) target(): measured = upper edge of the first bucket whose cumulative weight exceeds QUANTILE return max(measured, advertised) + frame ``` ### Relative arrival delay `delay` is measured against the **fastest frame in the recent window**, not against the previous frame. This is the whole design in one line, and it is what an inter-arrival estimator structurally cannot do: a queue filling at 2 ms per frame reads as a hundred tiny inter-arrival observations, where this reads as a delay climbing to 200 ms. The window is **media time**, not arrival time. Pruning `entry.media < media - WINDOW` is what makes a timestamp jump self-correcting: everything before it leaves the window at once, the new frame becomes its own reference, and the delay measured across the jump is zero rather than the size of the jump. A tune-in that reads a frame from a stale group and then the live edge needs no special case, and neither does a group the relay dropped. `WINDOW` is also the horizon over which a build-up is visible. A queue that fills over ten seconds is measured as the part of it that happened in the last two. ### Reordered arrivals are excluded, not measured A frame whose media timestamp is not newer than the newest already observed is **reordered**, and it is dropped: it updates no state, joins no history, and produces no delay. This is not a refinement. When a newer group arrives before an older one, `arrival - ref.arrival` is positive while `media - ref.media` is negative, so the media-time gap between them is *added* to the delay. That is exactly the failure this whole design exists to avoid, a media-time distance leaking into a delay measurement, and it arrives through the front door: the container consumer takes groups off the transport in delivery order and sorts afterwards, so out-of-order observation is ordinary input rather than an edge case. Reordering is a real cost, and a receiver that wanted to price it would weigh late arrivals against the loss of playing without them. That is WebRTC's `ReorderOptimizer` and SpeexDSP's `compute_opt_delay`, which reached the same shape independently. It is deliberately out of scope here: ship the underrun estimator first. ### One observation per interval, the maximum The histogram takes **one observation per `RESAMPLE` interval, the largest delay in it**. Easy to miss and behaviourally significant for two reasons. It decorrelates the observations. A hundred frames stuck behind one queue are one event, not a hundred, and weighting them as a hundred would let frame rate set the time constant. It turns the forget factor into a wall-clock time constant, which is the only form worth stating: the histogram halves a sample's weight after `RESAMPLE * ln(0.5) / ln(FORGET)`, about **20 s**, and retains 1% of it after about **135 s**. The forget factor decays **once per resampled observation, not once per arrival**. Applying a per-observation factor per arrival is the mistake the #3517 branch makes, and the resulting number reads plausible either way. ### The histogram An empirical distribution of `BUCKETS` buckets `BUCKET` wide, holding total weight 1. Each observation is an exponential update: ``` histogram = forget * histogram + (1 - forget) * x ``` where `x` is all weight on the observed bucket. Weight stays normalised for free, so there is no separate renormalisation step and no running total. A delay at or beyond `BUCKETS * BUCKET` lands in the last bucket rather than being discarded. WebRTC discards it, which makes a genuine multi-second spread indistinguishable from none; the target is bounded by its own ceiling instead. The cold start is seeded with a decaying prior, `PRIOR[i] = 0.5^(i+1)`. Its 95th percentile falls in bucket 4, so a receiver that has observed nothing at all targets **100 ms plus one frame**. That number is a consequence of the prior, not a chosen default. `RAMP` holds the forget factor below `FORGET` for the first observations, so early evidence overwrites the prior instead of being averaged into it. It reaches `FORGET` after about 116 observations, roughly a minute, and has no effect after that. Without it a receiver crawls toward the truth for a minute; with it, the corpus's evenly paced trace converges in under two seconds. ### Reading the target `measured` is the **upper edge** of the first bucket whose cumulative weight exceeds `QUANTILE`. The upper edge, because the delays in that bucket sit somewhere inside it and covering the whole bucket is what actually covers the quantile. A quantile of an empirical distribution, rather than a mean plus k deviations, because network delay is heavy-tailed and one-sided. A Gaussian assumption sizes that tail wrong in both directions. ## The frame duration One frame duration is added on top of `measured`. The buffer swings by the spread the quantile measures, so without it the bottom of the swing lands on an empty ring. **Frame duration is a codec property. It is never learned from observed timestamps.** Deriving it from timestamp arithmetic makes a tune-in artifact indistinguishable from a real frame duration, which is precisely how the #3517 branch reaches a 14.56 s target. A receiver takes the first of these that applies: 1. The container's own per-sample duration, when it carries one. CMAF does. 2. The codec's in-band duration. An Opus packet states its own duration in its TOC byte (RFC 6716 §3.1), at 48 kHz regardless of the encoder's internal bandwidth, which covers every duration Opus can pick from 2.5 to 60 ms. This is available at the observation point, needs no catalog change, and `rs/moq-mux` already has the parser. 3. A codec constant over the catalog's sample rate: 1024 samples for AAC-LC, 2048 for AAC-HE, 1152 or 576 for MP3 depending on the MPEG version. 4. Nothing. The term is zero and the target is the measured part alone. A new catalog field was considered and rejected. `AudioConfig` carries `sampleRate` and `bitrate` and concedes in its own comment that frame duration "depends on the codec, sample rate, etc"; adding one would mean the full cross-package sync, in `rs/hang`, `js/hang`, this page, and the hang draft, to carry a number the payload already states. ### What is not in the frame duration The browser's AudioWorklet renders in fixed 128-sample blocks, and `js/watch/src/audio/config.ts` currently adds that block to the per-codec frame duration, so 48 kHz Opus reads as 23 ms rather than 20 ms. **That block is not part of the target.** It is a property of the render backend, not of the network, and native has no worklet at all. If one language's step carried it and the other's did not, the two would produce different target series from the same trace and the corpus could not hold. It belongs in the **render slack**: the margin the playback ring keeps above the target so a render callback never arrives to find exactly zero samples. In the browser that is the worklet quantum; natively it is the backend's device period. Each side sizes its own, above the shared target, and neither is this page's business. ## The advertised flush span, and how it composes A hang rendition may advertise `jitter`: the largest delay the publisher has seen between a frame being ready and handing it to the transport. It is described in [hang](/concept/hang) and specified in [draft-lcurley-moq-hang](/draft/moq-hang). The two quantities share a word and get confused for each other, so plainly: | | Advertised `jitter` | The estimate here | | --- | --- | --- | | Measured by | the publisher, once | each receiver, continuously | | Measured at | the transport handoff | the receiver's transport | | Covers | encoder latency, packet packing, reorder, segmentation | all of that, plus the network | | Is | a declared lower bound | an observation | **They compose as a maximum, and the reason matters.** Both are the same formula, lateness relative to a windowed minimum, evaluated one hop apart. The receiver measures from the frame's media timestamp to its own arrival, so the publisher's flush delay is already inside every number on this page: a publisher flushing 100 ms of media at once produces arrivals whose relative delay reaches 100 ms less one frame, on a perfect network, with no help from the catalog. So the advertised value is a **floor**, not an addend: ``` target = max(measured, advertised) + frame ``` Adding them double-counts the publisher, and doubles the buffer for exactly the publishers that need it most. Taking the maximum on the grounds that they "describe the same quantity" reaches the right operation by the wrong argument: they are the same quantity measured at different points, and the receiver's point strictly contains the publisher's. The floor earns its place by being correct *immediately*, before the estimator has seen a full flush cycle, which is the one thing a measurement cannot be. Note that `js/watch` today adds the two rather than taking the maximum, which over-buffers a bursty publisher by its own flush span. ## Across renditions Every track plays from one clock, anchored on the earliest lateness any track has shown. The estimator measures each track against its own fastest frame, so a constant offset between tracks cancels out of `measured`: a video encoder that flushes 200 ms behind the audio encoder produces the same video target as one that does not, and its frames would all arrive 200 ms late for the shared clock. The catalog `delay` field carries that offset: how far a rendition's minimum flush lateness trails the broadcast's earliest rendition, measured by the publisher and never lowered. It is an addend, not a floor, because nothing on this page contains it. The shared playout is the largest requirement among the renditions actually subscribed: ``` playout = max over subscribed renditions of (delay + target) ``` A receiver **must not** subtract one rendition's `delay` from another's. Each is a lifetime maximum taken against a sliding baseline, so two values need not share an origin: if the earliest rendition later drifts behind another, their difference understates the real spread. The maximum never under-buffers, because the subscribed renditions' spread is measured from an earliest baseline no earlier than the broadcast's, so it never exceeds the largest `delay` among them. The cost is over-buffering by the smallest subscribed `delay` when the broadcast's earliest rendition is not subscribed. The playout is recomputed when a rendition is subscribed, unsubscribed, or its catalog entry rises, so dropping a slow rendition lowers latency. The shared reference itself only ever moves earlier, so once the earliest subscribed track leaves, the clock stays anchored to it until playback re-anchors; that errs on the safe side. ## Rise, fall, startup, and the ceiling There is **no separate rise or fall limiter**. The histogram is the only dynamic, and a second rate limit on top of it would be two knobs describing one behaviour. The asymmetry a playout target needs falls out of the shape rather than being dialled in. An underrun is an audible glitch and excess buffer is latency nobody can hear, so the target should rise readily and fall grudgingly, and it does: * **Rising** is driven by the maximum-over-interval resampling. A single late arrival defines its whole interval, so a burst is weighted as heavily as the hundreds of punctual frames around it. * **Falling** is driven by the forget factor alone, over the 20 s half-life above. Old mass in the tail has to decay out before the quantile can move down, and nothing can make it decay faster. * **A single bad observation does not move the target at all.** It adds at most `1 - forget` weight, which past the startup ramp is 1.7%, well under the 5% the 95th percentile needs. It takes a few intervals of genuine lateness before the target responds, which is the correct answer to a one-off straggler. The #3517 branch needed an explicit rise clamp and a one-frame-per-second fall limiter because it had an unbounded instant-rise path that bypassed the histogram entirely: `percentile() + step` was published the moment it rose, and `step` was learned from observed timestamps. Removing that path removes the need for the limiters. **The ceiling is structural.** `measured` cannot exceed `BUCKETS * BUCKET`, so nothing the receiver observes can carry the target past `BUCKETS * BUCKET + frame`, about 2 s. There is no magic number to clamp against, which is the specific failure of the #3517 branch: it clamped the percentile and then added an unbounded frame term on top. The advertised floor is the one term that can exceed that bound, and it is deliberately not clamped to it. The ceiling is a property of the histogram's size; the floor is a number the publisher declared about itself, so clamping the floor to the ceiling would under-buffer precisely the publisher that was honest about a long flush. Policy limits sit above the algorithm. A receiver's own configured maximum and the track's retention window both cap what it may hold, and neither is the estimator's business. ## Idle intervals An interval with no arrival in it still folds, seeded with the **prior** rather than with an observed bucket: ``` histogram = forget * histogram + (1 - forget) * PRIOR ``` This is the only honest answer to "does the wall-clock memory survive a pause". Skipping empty intervals freezes a stale estimate indefinitely. Decaying without seeding un-normalises the histogram and the quantile falls through to the ceiling. Seeding with the prior relaxes the estimate back toward cold start at the same time constant as everything else, which is what an evidence-free interval should do, and it raises the target rather than lowering it, so the error is on the safe side. `IDLE_MAX` caps how many intervals are folded at once. It exists only to bound the work: after 1024 folds the retained weight is about 2e-8, so the cap changes no output an `f64` can represent. The startup ramp is not restarted. A receiver coming back from a pause is not cold, and re-converging at the base rate is correct. ## Fixed targets An explicit delay **fixes the target and disables adaptation**. It is taken literally: no advertised floor, no frame term, no estimate. ``` target = delay ``` An impossible fixed target is refused, not silently adjusted. A negative delay, or one beyond the track's retention window, is an error at configuration time. Supported or refused, not warned about and continued. In the browser this is the numeric `delay` setting; natively it is `decode::Options::delay`, where `None` selects the estimator. The two spell the same thing the same way on purpose. It is distinct from `max_age`, which is the live-edge skip budget and not a buffer. ## Constants Every value comes from WebRTC's NetEq, whose algorithm lives in `underrun_optimizer`, `histogram`, and `packet_arrival_history` under `modules/audio_coding/neteq/`, not in the `delay_manager.cc` the name suggests. The two exceptions are marked. | Constant | Value | Origin | | --- | --- | --- | | `WINDOW` | 2000 ms | `PacketArrivalHistory` window size | | `BUCKET` | 20 ms | `Histogram` bucket size | | `BUCKETS` | 100 | `UnderrunOptimizer` bucket count | | `QUANTILE` | 0.95 | NetEq's default delay histogram quantile | | `FORGET` | 0.983 | NetEq's underrun forget factor. The widely repeated 0.97 is a transitional value, and 0.9993 is the *reorder* factor, which the #3517 branch uses by mistake | | `RESAMPLE` | 500 ms | NetEq's resample interval | | `RAMP` | 2 | NetEq's start forget weight | | `PRIOR[i]` | `0.5^(i+1)` | NetEq's histogram seed | | `IDLE_MAX` | 1024 intervals | Ours. A work bound with no effect on output | | Out-of-range delays | clamped, not dropped | Ours. See [the histogram](#the-histogram) | ## Implementing from this page WebRTC is BSD-3 plus a patents grant, and copying its source would carry a third license and its disclaimer into an `MIT OR Apache-2.0` repository. Implement from the description above, citing NetEq as the design's source in a comment. SpeexDSP's `jitter.c` is BSD-3 and safe to read. Asterisk's `jitterbuf.c` (GPLv2) and FreeSWITCH's `switch_jitterbuffer.c` (MPL 1.1) are not. No crate was adopted. The `neteq` crate is the only viable candidate and takes a percentile over per-packet deltas each clamped at zero, which cannot see a build-up, the one property this design exists for. It is also a whole NetEq, sample-rate-locked ring buffers and WSOLA and packet loss concealment, where this is a few hundred lines of estimator whose shape would then be its. Neither `webrtc-rs` nor `str0m` implements playout at all, GStreamer's `rtpjitterbuffer` solves clock skew rather than target sizing and is LGPL, and no browser-usable port exists. ## Conformance corpus `doc/concept/audio-jitter/` holds one JSON file per case: an arrival trace and the target series it must produce. Both implementations read these files directly, so neither is graded against the other. ```json { "name": "tunein", "about": "One frame from a stale group, then the live edge 14.5 s later, ...", "frame": 20, "arrival": [0, 10, 30], "media": [0, 14500, 14520], "target": [[0, 120], [26, 80]] } ``` `arrival` and `media` are parallel arrays in **delivery order**, the order the receiver observes them, so `media` is deliberately out of order in the reordering case. `advertised` and `delay` are present only where the case uses them. `target` lists `[frame index, target]` at every frame where the target differs from the previous frame's, starting at frame 0, so an implementation folds the trace one frame at a time and compares its target at each listed index. Tolerance is 1e-6 ms, which the `f64` rules above make generous. | Case | Holds the implementation to | | --- | --- | | `paced` | An even sender converges to one bucket plus one frame, and the startup ramp gets it there in under two seconds | | `flush` | The measured term follows a flush span, and tracks it when the span doubles | | `advertised` | The advertised span floors the target instead of adding to it | | `buildup` | A queue filling at 2 ms per frame is measured against the fastest recent arrival | | `reorder` | Out-of-order media timestamps never reach the delay measurement | | `idle` | A 30 s gap relaxes the histogram toward the prior | | `tunein` | A stale frame then the live edge 14.5 s later leaves the target near the frame duration. This is the #3517 regression | | `spike` | A recurring stall raises the target, and it decays once the stalls stop | | `fixed` | An explicit delay ignores the arrivals entirely | `reference.ts` beside them generated the numbers. It is **not normative and not shipped**: this page is. When the two disagree, this page wins, the reference is corrected, and the corpus is regenerated with `bun doc/concept/audio-jitter/corpus.ts`. An implementation that imports the reference has proved nothing, so none of them may; `biome.jsonc` forbids the import outside this directory rather than leaving it to review. `corpus.test.ts` re-runs the reference and fails when the checked-in files no longer match it, which is what stops a file being edited by hand to make a failing implementation pass. It compares rather than rewrites; regenerating is the `bun doc/concept/audio-jitter/corpus.ts` command above. It runs in `just check`. --- --- url: https://doc.moq.dev/concept/stats.md description: The stats broadcasts a relay publishes, their tracks, and both JSON encodings --- # Stats A relay publishes its traffic counters as ordinary MoQ broadcasts, so any subscriber can read them: a dashboard, a billing meter, an aggregator in another region. This page is the wire contract for those broadcasts, enough to read them in any language. [`moq-stats`](https://docs.rs/moq-stats) is the Rust producer and consumer, and the relay's [`[stats]`](/bin/relay/config#stats) section turns it on. ## Broadcasts Each node publishes under a prefix, `.stats` by default, which [moq-lite](/concept/moq-lite) hides from announce listings that don't ask for it. Traffic under the prefix is never counted, so serving stats doesn't generate more stats. ```text /node/ depth 0: one broadcast per node //node/ depth N: one broadcast per group per node ``` * `` tells relays sharing a cluster apart. It may span several segments (`sjc/1`), and is omitted along with its slash when unset: `/node`. * `` is the first `depth` segments of each broadcast path (for traffic) or auth root (for sessions), so a consumer can scope an announce to one tenant. A path shorter than `depth` groups under all of its segments. * The literal `node` segment leaves room for sibling categories under the same prefix, so a consumer skips any path without `node` where it expects one. A group segment literally named `node` is ambiguous; don't use one. At depth 0 the broadcast stays announced for the producer's life. At depth 1 or more, a group's broadcast is announced while that group has entries, and for a linger (five minutes by default) after its last one leaves. A group that returns within the linger keeps its broadcast, so viewer churn doesn't unannounce and re-announce it across the mesh; while it lingers empty, its tracks hold `{}`. Once the linger elapses with the group still empty, the broadcast is unannounced. Group numbers keep increasing across recreated tracks, group broadcasts, and restarts; they may have gaps. A producer's first group is the wall clock in microseconds, so a clock stepped back across a restart parks subscribers until the new run passes their cached groups. A recreated compressed track starts a new group with a full snapshot, never a delta whose compression state belonged to its previous writer. ## Tracks Traffic is split by **tier**, an arbitrary label (a billing class, a region) the relay takes from the auth grant or `--cluster-tier`. Each tier has three tracks, each in two encodings: | Track | Frame keyed by | Entry | | --- | --- | --- | | `publisher.json` | broadcast path | [Traffic](#traffic) this node sent (egress) | | `subscriber.json` | broadcast path | [Traffic](#traffic) this node received (ingress) | | `sessions.json` | auth root | [Presence](#presence) of connected sessions | The default tier is unprefixed. A named tier prefixes each name with its label and a slash: tier `region/sjc` publishes `region/sjc/publisher.json`. Appending `.z` selects the [compressed](#compressed) encoding of the same track: `publisher.json.z`. The default tier's six tracks always exist. A named tier's are created on its first recorded traffic, but a subscriber may ask for them earlier: any name of the shape `[/]{publisher,subscriber,sessions}.json[.z]` is accepted and held open with `{}` until the tier records. Any other name is refused. ## Frames Every frame is a JSON object mapping a key (broadcast path or auth root) to an entry. A traffic entry appears from its first nonzero counter until the relay drops its counters, even while idle: the last viewer can leave while the publisher still holds the path, and a returning viewer resumes the same counters. So a key missing from a frame had no counters, and one that returns starts from zero. A sessions entry appears while a session is connected and on the tick its last one disconnects. A track with no entries holds `{}`. The producer drains its counters every interval (one second by default) and writes only when a track's frame changed, so silence means nothing moved, not that the producer is gone. The track ends when the producer does, or at depth 1 or more when its group's broadcast is unannounced; the group may return later as a new broadcast. ### Traffic ```json { "acme/live": { "announces_started": 1, "announces_ended": 0, "announced_bytes": 9, "broadcasts_started": 3, "broadcasts_ended": 1, "subscriptions_started": 6, "subscriptions_ended": 2, "fetches": 0, "bytes": 1048576, "frames": 900, "groups": 30, "datagrams": 0, "stale": { "bytes": 0, "frames": 0, "groups": 0, "datagrams": 0 }, "announced": 1, "announced_closed": 0, "broadcasts": 3, "broadcasts_closed": 1, "subscriptions": 6, "subscriptions_closed": 2 } } ``` | Field | Counts | | --- | --- | | `announces_started` / `announces_ended` | Announces and unannounces of the broadcast. | | `announced_bytes` | The broadcast name's length, summed over each announce and unannounce. Not part of `bytes`. | | `broadcasts_started` / `broadcasts_ended` | On `publisher.json`, a session's first subscription to the broadcast and its last one closing. Started minus ended is the viewer count. `subscriber.json` leaves both at zero: ingress does not count viewers. | | `subscriptions_started` / `subscriptions_ended` | Track subscriptions opened and closed. | | `fetches` | One-shot group fetches requested, including ones that found nothing. Their payload counts in `bytes`, `frames`, and `groups`. | | `bytes` / `frames` / `groups` | Payload delivered. | | `datagrams` | Groups delivered as an unreliable datagram. A subset of `groups`. | | `stale` | Payload skipped because it aged past a subscriber's latency budget, with the same four fields. Disjoint from the top-level payload counters. | The last six fields are legacy spellings of the `*_started` and `*_ended` counters, still written so an older consumer reads a newer relay. A reader should prefer the canonical name and fall back to the legacy one. ### Presence ```json { "acme": { "sessions_started": 12, "sessions_ended": 10, "sessions": 12, "sessions_closed": 10 } } ``` `sessions_started` and `sessions_ended` count connects and disconnects under an auth root on the tier, whether or not any data flows. `sessions` and `sessions_closed` are their legacy spellings. A session moved to a new tier ends on the old one and starts on the new. ### Counters Every counter is a cumulative, monotonic unsigned integer. A rate is the difference between two frames divided by the time between them, and a live count is started minus ended. A frame never shows ended above started. A counter going **down** means the relay restarted or the entry was dropped and re-created. Treat it as the start of a fresh segment rather than a negative rate. A reader ignores unknown fields, so a newer relay can add counters, and defaults a missing field to zero, so it can read an older relay. ## Encodings Both encodings carry identical frames; pick by bandwidth. Only the track name says which one a track uses: the payload has no marker. ### Plain On a `.json` track each changed frame is its own group holding one frame, the full object as UTF-8 JSON. A reader takes the newest group. ### Compressed A `.json.z` track is a [moq-json](/lib/rs/moq-json) snapshot track with compression on. Stats frames change little between ticks, so it costs a fraction of the plain track's bytes. * **Groups.** A group's first frame is the full object. Each later frame is an [RFC 7396](https://www.rfc-editor.org/rfc/rfc7396) merge patch against the value so far: it carries only the changed counters, and `null` removes a dropped entry. The producer starts a new group once the patches outgrow eight times the snapshot's compressed size, or after 256 frames. * **DEFLATE.** Each group's frames form one raw DEFLATE stream, sync flushed per frame with the trailing `00 00 ff ff` stripped, as [moq-flate](/draft/moq-flate) specifies. The window starts cold at every group and never spans two. To read one, jump to the newest group, inflate and parse its first frame, then inflate and apply each following frame as a merge patch, in order. A reader missing a frame abandons the group and waits for the next. --- --- url: https://doc.moq.dev/concept/standard.md description: How this project relates to the IETF moq-transport, MSF, and LOC drafts --- # Standards The [IETF MoQ working group](https://datatracker.ietf.org/group/moq/about/) standardizes Media over QUIC. This project tracks that work and interoperates with it, while shipping a simpler profile you can use today. | Spec | Scope | Here | | --- | --- | --- | | [moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) | The IETF pub/sub protocol | Drafts 14 through 22 negotiated by ALPN; [moq-lite](/concept/moq-lite) is a forward-compatible subset | | [MSF](https://datatracker.ietf.org/doc/draft-ietf-moq-msf/) | The IETF catalog format | Read and written; broadcasts ending in `.msf` select it | | [LOC](https://datatracker.ietf.org/doc/draft-ietf-moq-loc/) | The IETF low-overhead container | Supported as a hang container kind | | [moq-lite](/draft/moq-lite), [hang](/draft/moq-hang), [e2ee](/draft/moq-e2ee), and friends | This project's own drafts | Normative for the implementation, published to the datatracker from [`drafts/`](https://github.com/moq-dev/moq/tree/main/drafts) | ## moq-transport moq-transport is the full protocol: namespaces (broadcasts) that several publishers may share, sub-groups for layered codecs, object-level metadata and gaps, `FETCH` for ranges of history, joining fetches, `PUBLISH` push, and pausing. moq-lite keeps the parts a CDN can implement without conflicts and maps everything else to "not supported" or a harmless equivalent. The [moq-lite page](/concept/moq-lite#what-moq-lite-leaves-out) lists the differences. On draft 19 and later, a requester can FIN its request stream while a Rust publisher keeps its `SUBSCRIBE` or `SUBSCRIBE_NAMESPACE` active; cancellation uses `RESET_STREAM` or `STOP_SENDING`. From draft 17, a subscription `REQUEST_UPDATE` can change subscriber priority; other changes are refused with `NOT_SUPPORTED` and end the subscription with `UPDATE_FAILED`. Drafts 14 to 18 retain FIN cancellation. Rust and JavaScript subscribers accept object extension blocks up to 64 KiB. This is an implementation limit, not a limit in the IETF draft. A larger declared block stops its subgroup stream with `MALFORMED_TRACK` before reading the block; other groups and the session stay open. Rust and JavaScript read an incoming padding stream (draft-18 and later) to the end and discard it, without sending `STOP_SENDING`. A unidirectional stream type the negotiated draft does not define, or a `SUBGROUP_HEADER` type it marks invalid, closes the session with `PROTOCOL_VIOLATION`, as the draft requires. An IETF publisher declares the track's default priority in `SUBSCRIBE_OK` or `PUBLISH` when that draft carries track properties. Groups without a priority flag inherit it. If the property is absent, the IETF wire default of 128 maps to model priority 127, where higher values are served first. A track that never sets a priority is 127 as well, so it goes out as 128 on IETF and 127 on moq-lite. On drafts 14–22, the Rust publisher answers a standalone `FETCH` within one group from the cache. A relay fetches a missing group upstream with a `FETCH` of that one whole group, and an upstream refusal is the refusal the fetcher sees. A range touching several groups is refused with `NOT_SUPPORTED`, as is a draft-20+ `LOCATION_FILTER` that ends at Largest Object instead of an absolute End Location. On draft-20 and later, `FETCH_OK` reports the inclusive requested end, capped at Largest Object when known. An empty range gets an empty fetch stream; a start past Largest Object is refused with `INVALID_RANGE`. A standalone `FETCH` carries no timestamps, since no `SUBSCRIBE_OK` declared a timescale for it. On drafts 14–19, the Rust publisher also serves relative and absolute joining `FETCH` requests for `NextObject` subscriptions, for the subscription group's saved prefix only, while the subscription delivers later objects. One reaching back to earlier groups is refused with `NOT_SUPPORTED`. Draft-20 uses subscription fills instead. JavaScript publishing refuses every `FETCH` with `NOT_SUPPORTED`; Rust and JavaScript subscribers request unfiltered delivery on older drafts because they do not issue joining fetches. Other publishers may replay a cached backlog for that filter; selecting the next group instead would leave static tracks waiting for a group that never arrives. A moq-lite datagram is a single-frame group, so on moq-transport it travels as an `OBJECT_DATAGRAM` at object 0 whose Group ID is the sequence, and a relay forwards it without renumbering. A datagram carrying any other Object ID, or a status other than Normal, is dropped. JavaScript does not yet carry datagrams on moq-transport. A client may present one credential in its `SETUP` with the `AUTHORIZATION TOKEN` option. The server reads a value (`USE_VALUE`, or `REGISTER`, which it treats as a value since it advertises no token cache) and hands its Token Type and bytes to the application unverified; a relay forwards them to its [auth server](/bin/relay/auth#the-contract). An alias reference (`DELETE`, `USE_ALIAS`) closes the session with `PROTOCOL_VIOLATION`, a structure that does not decode with `KEY_VALUE_FORMATTING_ERROR`, and a second token is refused. An `AUTHORIZATION TOKEN` parameter on a request is read and ignored: the session's credential is what authorizes it. A legal request that is not served is refused on its own with `NOT_SUPPORTED`, leaving the session open: a `SUBSCRIBE` with `FORWARD=0`, a `SUBSCRIBE` or `FETCH` carrying Range Filters (no `MAX_FILTER_RANGES` is advertised), a `FETCH` carrying `FILL_TIMEOUT` (Timed-Out gaps are not written), `TRACK_STATUS`, `SUBSCRIBE_TRACKS` (draft-18 and later), and the `FETCH` forms above. `NEW_GROUP_REQUEST` is ignored, as the draft allows a publisher without dynamic groups to do. A parameter the negotiated draft does not define still closes the session with `PROTOCOL_VIOLATION`, as the draft requires. Several project drafts extend the IETF wire without breaking it, since `SETUP` ignores unknown parameters: [cluster](/draft/moq-cluster) routing hop lists, [solicit](/draft/moq-solicit) to make announcements opt-in, [hidden](/draft/moq-hidden) to keep `.`-named namespaces out of discovery, and [probe](/draft/moq-probe) for bandwidth estimation. [moq-e2ee](/draft/moq-e2ee) is not a transport extension: it encrypts application payloads so relays still forward named tracks they cannot read. ## MSF The MoQ Streaming Format is a catalog, playing the role HLS playlists and SDP do elsewhere. It overlaps with the [hang catalog](/concept/hang) and the two will likely converge. The tools track draft-01 and hide the version on the wire, so draft-00 catalogs still decode and init data always arrives inline. The `stalled` rendition hint is shared between the two formats. ## LOC The Low Overhead Container carries a timestamp and a few properties per frame with none of CMAF's per-frame `moof` cost. It is close to hang's `legacy` container and is selectable per track (`container=loc` in the [GStreamer plugin](/bin/gstreamer)). ## Interop testing `moq-cli` speaks every listed draft, picks the newest one the relay also supports, and prints it in the logs. Publish a test pattern and play it back: ```bash ffmpeg -re -f lavfi -i testsrc=size=1280x720:rate=30 -f lavfi -i sine=frequency=440 \ -c:v libx264 -preset ultrafast -tune zerolatency -g 60 -c:a aac \ -f mpegts -pes_payload_size 0 -muxdelay 0 - \ | moq --connect https://relay.example.com --broadcast test.hang import ts moq --connect https://relay.example.com --broadcast test.hang export ts | ffplay - ``` Add `--connect-tls-insecure` for a self-signed relay on your own test network (it accepts any certificate, so never point it at a remote relay) and `RUST_LOG=info,moq_net=debug` to see the negotiated version. Behavior worth knowing when pointing another implementation at ours: we announce every namespace we can offer unsolicited *and* ask for every prefix we may discover; set the solicit `SETUP` option to make us wait to be asked. Single-track `PUBLISH` offers are declined; announce a namespace and serve the resulting `SUBSCRIBE`s instead. --- --- url: https://doc.moq.dev/concept/use-case.md description: Where MoQ fits, compared with what it replaces --- # Use Cases One protocol covers contribution, distribution, conferencing, and data. That is the point: sharing an implementation across all four is where the economies of scale come from. | Use case | Replaces | Why MoQ fits | | --- | --- | --- | | [Distribution](/concept/use-case/distribution) | HLS, DASH | Sub-second latency at CDN scale, with the viewer choosing its buffer. | | [Contribution](/concept/use-case/contribution) | RTMP, SRT | Pull-based delivery, optional on-demand encoding, and simple redundant ingest. | | [Conferencing](/concept/use-case/conferencing) | WebRTC | Bidirectional on one session, browser control of the pipeline, no SDP or TURN. | | [AI](/concept/use-case/ai) | WebRTC, WebSockets | Adjustable reliability, on-demand inference, media and prompts on one connection. | --- --- url: https://doc.moq.dev/concept/use-case/distribution.md description: Why MoQ beats segmented HTTP delivery on latency without giving up scale --- # MoQ vs HLS/DASH HLS and DASH won distribution because HTTP is stateless and cacheable, so ordinary CDNs fan it out. Their weakness is latency: a few seconds for LL-HLS and LL-DASH on a good network, and much more for conventional segments, with the exact figure set by the encoder, CDN, and player. ## The problem A conventional player downloads whole segments sequentially over TCP (the low-latency variants split them into parts, which helps but keeps the shape). When the network degrades, the current segment queues, the next one can't start, and the player can't switch renditions until the boundary. Bufferbloat sets in, playback freezes, and the player grows its buffer to avoid a repeat. Conventional segments are also published only once complete, which is why smaller segments only help so much. ## What MoQ does instead Segments become groups, each on its own QUIC stream, streamed as they're produced. When a new group starts during congestion it gets a higher priority than the stalled one, so the player sees the fresh group arrive (possibly at a lower rendition) while the old one starves. Past the viewer's latency budget, the old group is skipped: a stutter and a jump forward, like conferencing, instead of a buffering spinner. Audio outranks video, so sound stays continuous. The viewer still controls the buffer. A subscription that asks for a 10 s budget behaves like HLS; one that asks for 100 ms behaves like a video call. Same protocol, same relay, same broadcast. ## Why not HTTP/3? HTTP prioritization only works reliably on HTTP/2+ and only the client can open a request, which makes publishing awkward. MoQ uses WebTransport to get QUIC streams without HTTP semantics, but keeps HTTP's economics: an HTTP/3 CDN already runs a production QUIC stack, so adding MoQ is a small step. For the devices that will never speak QUIC, hang carries CMAF and the [HLS gateway](/bin/hls) serves any broadcast as HLS from the relay's cache. --- --- url: https://doc.moq.dev/concept/use-case/contribution.md description: Pull-based contribution, on-demand encoding, and redundant ingest --- # MoQ vs RTMP/SRT Contribution protocols push: RTMP from OBS to Twitch, SRT from a studio encoder, WHIP from a browser. Pushing means nothing is optional. A publisher offering 360p and 1080p encodes and uploads both whether or not anything downstream wants the second one. ## Pull changes the economics A MoQ viewer's first act is subscribing to the catalog. It then subscribes to the renditions it wants, and that subscription travels upstream (merging with duplicates) until one copy reaches the publisher. No subscribers, no transmission, and a publisher can go further and not encode either. That matters for long-tail content: hundreds of security cameras uploading 360p, with 1080p pulled only when someone zooms in. It matters for AI too, where a captions track backed by an expensive model runs only while someone has captions on. ## Redundant ingest for free Because tracks are only pulled where they're needed, a publisher can open several connections that might be used. Primary and secondary ingest is two connections and no business logic: subscriptions ride the primary until it fails, then move. Two encoders sharing a Hop ID become interchangeable sources that relays fail over between at a group boundary; see [redundant publishers](/bin/cli#redundant-publishers). ## One protocol both ways Contribution and distribution are the same problem with the arrows flipped: client to server versus server to client, 1:1 versus 1:N. One protocol for both means one implementation to optimize, one relay to deploy, and QUIC's congestion control (this project's relay defaults to BBR) instead of a bespoke UDP stack. Existing encoders still work: the [OBS plugin](/bin/obs) publishes MoQ directly, and [moq-cli](/bin/cli) accepts RTMP, SRT, and WHIP pushes. --- --- url: https://doc.moq.dev/concept/use-case/conferencing.md description: How MoQ compares with WebRTC for real-time conferencing --- # MoQ vs WebRTC WebRTC is the integrated real-time communications stack browsers ship, so it is what conferencing uses in a tab. It is less dominant than it looks: Zoom, Teams, and Discord run custom RTP stacks in their native apps and keep WebRTC for browser compatibility, and the one vendor that controls `libwebrtc` gets its features first. ## What MoQ offers * **Publish and subscribe on one session.** Each participant is a broadcast; peers discover each other through announcements on a path prefix. No SDP offer/answer, no renegotiation when someone joins. * **Latency you choose.** WebRTC decides when to drop. A MoQ subscriber sets its own budget per track, so audio can be near-lossless at 500 ms while video stays at the live edge. * **Scale from the same relay.** A relay that serves a two-person call also serves a ten-thousand-viewer webinar. There is no SFU/CDN split. * **Full pipeline control in the browser.** WebCodecs frames and `AudioData` samples are yours: render to a texture, run a model, apply effects. * **No STUN/TURN.** Clients dial the relay over WebTransport like any HTTPS server. Native peers on a LAN can go [peer-to-peer over iroh](/concept/transport#iroh-peer-to-peer-experimental). ## Interop WebRTC clients still fit. The [WebRTC gateway](/bin/rtc) accepts WHIP publishers and serves WHEP players, bridging them into the same broadcasts the MoQ clients use. --- --- url: https://doc.moq.dev/concept/use-case/ai.md description: Voice agents, streamed generation, and on-demand inference over MoQ --- # MoQ for AI Real-time AI has needs WebRTC wasn't designed for, and MoQ lines up with them. ## Reliability you can tune Inference is slow and expensive. If you spend 300 ms and real money on a response, you want a say in how the answer survives packet loss. WebRTC favors low playout latency and leaves recovery to the implementation; MoQ lets the application pick: audio near-lossless with a latency budget, video skippable, prompts fully reliable, all on one connection. ## Faster than real time A TTS model emits a whole sentence in a burst with timestamps in the future. WebRTC keeps buffering and playout inside its media engine; MoQ exposes the timestamped burst to the application, and the player paces it. In the browser, set a latency ceiling on [``](/lib/js/watch#buffered-playback) to buffer ahead and call `reset()` on an interruption. The [Pipecat](https://github.com/pipecat-ai/pipecat) voice-agent framework ships a MoQ transport built on this. ## Inference on demand Announcements and catalog metadata can advertise tracks before their frames exist. Media frames are transmitted only once someone subscribes to the track, and a publisher can defer encoding until then too. A `captions` track backed by Whisper runs only while a viewer has captions on. Object detection can consume a 360p 10 fps rendition while the full-resolution track stays idle until a human asks for it. ## Media and data together Send prompts, tool calls, transcripts, or vertex data as their own tracks next to the audio. The relay treats them all the same, and a JSON snapshot track gives late joiners the current state in one step. ## In practice Server-side agents use [Rust](/lib/rs/) with hardware codecs, or [Python](/lib/py/) where the ML stack lives. Browsers use [`@moq/publish`](/lib/js/publish) for the microphone and [`@moq/watch`](/lib/js/watch) for playback. Background reading: [WebRTC is the problem](https://moq.dev/blog/webrtc-is-the-problem). --- --- url: https://doc.moq.dev/draft.md --- # Internet-Drafts The IETF Internet-Drafts for the protocols implemented in this repository. These pages are generated from the [kramdown-rfc](https://github.com/cabo/kramdown-rfc) sources under `drafts/`; the submitted versions live on the [IETF datatracker](https://datatracker.ietf.org/person/kixelated@gmail.com). * [MoQ Cluster Extension](/draft/moq-cluster) (`draft-lcurley-moq-cluster`) * [MoQ End-to-End Encryption Profile](/draft/moq-e2ee) (`draft-lcurley-moq-e2ee`) * [DEFLATE Compressed Tracks for MoQ](/draft/moq-flate) (`draft-lcurley-moq-flate`) * [Media over QUIC - Hang](/draft/moq-hang) (`draft-lcurley-moq-hang`) * [MoQ Hidden Extension](/draft/moq-hidden) (`draft-lcurley-moq-hidden`) * [Media over QUIC - Lite](/draft/moq-lite) (`draft-lcurley-moq-lite`) * [MoQ MPEG-TS Catalog Extension](/draft/moq-mpegts) (`draft-lcurley-moq-mpegts`) * [MoQ Probe Extension](/draft/moq-probe) (`draft-lcurley-moq-probe`) * [MoQ Solicit Extension](/draft/moq-solicit) (`draft-lcurley-moq-solicit`) * [MoQ Object Timestamp Extension](/draft/moq-timestamp) (`draft-lcurley-moq-timestamp`) * [Media over QUIC - Use Cases](/draft/moq-use-cases) (`draft-lcurley-moq-use-cases`) * [QMux over WebSocket](/draft/qmux-websocket) (`draft-lcurley-qmux-websocket`) --- --- url: https://doc.moq.dev/draft/moq-cluster.md description: >- This document defines a clustering extension for MoQ Transport draft-ietf-moq-transport, used to build a mesh of relays. --- # MoQ Cluster Extension ::: info Rendered from the Internet-Draft source in this repository. Submitted versions are on the [IETF datatracker](https://datatracker.ietf.org/doc/draft-lcurley-moq-cluster/). ::: ## Abstract This document defines a clustering extension for MoQ Transport [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/), used to build a mesh of relays. Each namespace advertisement carries the list of Hop IDs it has passed through, starting with the original publisher, and the accumulated cost of that path. A receiver uses the list to detect loops and to tell which advertisements come from the same publisher, and the cost to choose between paths. Each endpoint declares its own Hop ID at setup, so a peer never advertises or serves it a path that already passed through it. ## Note to Readers This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it. Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything. ## Conventions and Definitions The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119](https://www.rfc-editor.org/rfc/rfc2119) [RFC8174](https://www.rfc-editor.org/rfc/rfc8174) when, and only when, they appear in all capitals, as shown here. **Upstream** and **downstream** are relative to the flow of an advertisement, not to the endpoints: the peer that sends an advertisement is upstream, the one that receives it is downstream. The same pair of relays can be upstream of each other for different namespaces. ## Introduction [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) is designed to deliver content through a mesh of relays but does not say how to build one, and the base protocol does not carry enough information to do so. Relays that simply forward PUBLISH\_NAMESPACE to each other break down: advertisements loop forever, and a relay that hears one namespace from two peers has no basis for choosing where to send a SUBSCRIBE. This extension adds two parameters to PUBLISH\_NAMESPACE and NAMESPACE. HOP\_PATH lists every endpoint an advertisement has passed through, starting with the original publisher, which breaks loops and lets paths be compared. ROUTE\_COST is the accumulated price of the path: the publisher seeds it, and each hop adds the RELAY\_COST its upstream declared at setup, so an unpriced mesh ranks by hop count. A relay that already carries a namespace advertises a lower cost, steering subscribers toward its warm copy. Each endpoint also declares its own Hop ID at setup, so a peer can leave it out of every path it advertises or serves to it, even across several connections between the same two relays. An advertisement is one path, so a relay forwards only the best path it knows per namespace and serves a subscription from one source at a time ([Several Publishers of One Namespace](#publishers)). ## Setup Negotiation ### Hop ID {#hop-id} The extension is negotiated during SETUP ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 10.3). An endpoint offers it by declaring its own Hop ID: ``` HOP_ID Setup Option { Option Key (vi64) = 0x40B54 Hop ID (vi64) } ``` Negotiation is per session; a relay MUST NOT assume that because one session negotiated the extension, another did. On a session that did, every PUBLISH\_NAMESPACE and NAMESPACE MUST carry HOP\_PATH, NAMESPACE takes the extended form in [Namespace Advertisements](#namespace), and a receiver MUST close the session with a PROTOCOL\_VIOLATION if either arrives without HOP\_PATH. ### Relay Cost {#relay-cost} An endpoint MAY declare what it charges for sending content: ``` RELAY_COST Setup Option { Option Key (vi64) = 0x40B56 Option Value (vi64) } ``` The value prices the sender's own egress, so each endpoint declares its own and the two need not match, as OSPF prices each router's own output interfaces ([RFC 2328, Section 9](https://www.rfc-editor.org/rfc/rfc2328#section-9)). A receiver adds it to the ROUTE\_COST of every advertisement that peer forwards ([Accumulating Cost](#accumulating)). Absent means 1, so an unpriced mesh ranks by hop count. 0 is distinct from absent: it makes the link free, which is how to describe two relays in the same datacenter. A declared cost is an assertion, not an instruction: a receiver MAY charge a locally configured value instead, so a peer cannot make itself cheap by saying so. The cost is one dimensionless integer, as in every deployed routing metric: RIP's hop count ([RFC 2453, Section 3.5](https://www.rfc-editor.org/rfc/rfc2453#section-3.5)), OSPF's interface cost, and IS-IS's default metric, whose delay, expense, and error metrics went unimplemented ([RFC 5305, Section 3](https://www.rfc-editor.org/rfc/rfc5305#section-3)), as did OSPF's per-type-of-service metrics ([RFC 2178, Appendix G.10](https://www.rfc-editor.org/rfc/rfc2178)). A deployment that weighs latency, hop count, and price folds them into the one value. Like BGP's MULTI\_EXIT\_DISC ([RFC 4271, Section 5.1.4](https://www.rfc-editor.org/rfc/rfc4271#section-5.1.4)), the value only means something within the deployment that chose its units, so a trust boundary clamps or replaces it ([Security Considerations](#security)). ## Hop IDs {#hop-ids} A **Hop ID** is a variable-length integer naming one endpoint in a path. Hop IDs SHOULD be unique among the endpoints an advertisement can traverse. An endpoint MAY pick one at random, since collisions in a 64-bit space are unlikely, or use a configured identifier that survives restarts. Loops and origins are detected by comparing Hop IDs for equality, so two endpoints sharing one are indistinguishable. Redundant publishers of interchangeable content MAY share one deliberately, so the mesh treats their paths as failover options for the same content ([Path Selection](#selection)). ### The Reserved Hop ID 0 {#zero} **0 means "no identity"** and is reserved. It stands for an endpoint that did not negotiate this extension, and an endpoint MAY declare it to withhold its identity. Since any number of endpoints can be 0, it identifies nothing: * **Loop detection**: 0 in a HOP\_PATH is never a loop. A receiver whose own Hop ID is 0 cannot detect loops through itself and MUST NOT discard an advertisement merely because the path contains 0. * **Origin identity**: an advertisement whose first entry is 0 has an unknown publisher, which a relay names by putting a stamp of its own in front ([Stamping an Unknown Publisher](#stamping)). A receiver MUST NOT treat two unstamped ones as interchangeable ([Path Selection](#selection)). * **Filtering**: a peer that declared 0 gave the receiver nothing to filter that session on. The receiver MAY assign an ID of its own ([Assigned Identities](#assigned)) as local selection state and MUST NOT write it into HOP\_PATH. Duplicate *non-zero* Hop IDs in one HOP\_PATH are a loop; duplicate zeros are not. Declaring 0 trades loop detection and failover for anonymity, except against a receiver that assigns an identity of its own. ### Assigned Identities {#assigned} A receiver MAY assign a Hop ID to a peer that declared none, whether by declaring 0 or by not negotiating the extension. It uses that ID as local selection state: as what it filters that session on, including for advertisements that arrived carrying their own HOP\_PATH. The ID is the receiver's own, not the peer's, and MUST NOT be forwarded. An advertisement that arrives with its own HOP\_PATH already names the sender there, as 0 if withheld. An unnamed publisher is stamped instead ([Stamping an Unknown Publisher](#stamping)), which is not an assigned identity. An assigned ID MUST NOT be shared between peers not known to be the same endpoint. Sharing one suppresses each one's advertisements to the other, so two unrelated peers would starve each other of routes. A peer the receiver authenticated, or dialed and therefore chose, SHOULD get one stable ID, so its reconnects and redundant sessions are filtered as one peer; a fresh ID per connection would make one peer look like several. An anonymous accepted session cannot be correlated with anything, so it SHOULD get a distinct ID per session: not an identity, but enough to keep routes learned from it from being advertised back to it, which is the loop 0 cannot prevent. ### Stamping an Unknown Publisher {#stamping} A relay that records an advertisement whose first HOP\_PATH entry is 0 MUST insert a random non-zero Hop ID in front of it, picked once for the session the advertisement arrived on. One that arrived with no HOP\_PATH ([Bridging](#bridging)) becomes that stamp followed by 0. Nothing on the wire says whether an unnamed publisher that reconnects is the same one, so a fresh stamp per session makes its reconnect a change of publisher downstream ([Updating an Advertisement](#updating)), while an update on the same session keeps the stamp and applies in place. The 0 behind the stamp keeps the path ranked below fully identified ones ([Path Selection](#selection)), since the unnamed hop may hide any depth. A stamp names a session, not a peer: it MUST NOT be reused across sessions or taken as the peer's identity. ## Namespace Advertisements {#namespace} HOP\_PATH and ROUTE\_COST are Key-Value-Pair parameters ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 2.5). PUBLISH\_NAMESPACE ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 10.15) already carries parameters. NAMESPACE ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 10.16) does not, and a subscriber-driven mesh propagates advertisements as NAMESPACE, so this extension appends a parameter block to it: ``` NAMESPACE Message (Cluster) { Type (vi64) = 0x8, Length (16), Track Namespace Suffix (..), Number of Parameters (vi64), Parameters (..) ... } ``` The added fields are encoded exactly as in PUBLISH\_NAMESPACE. Negotiating this extension enables the block on every NAMESPACE, with a parameter count of 0 when it is empty; when another extension defines the same block an endpoint appends one block holding the parameters of both, not two blocks. An endpoint MUST NOT append the block when nothing negotiated it, and MUST NOT include HOP\_PATH or ROUTE\_COST unless this extension is. NAMESPACE\_DONE ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 10.17) carries no state from this extension. An advertisement claims capability, not inventory: namespaces beneath the advertised one can be served, not that any exists. A publisher that serves only some of them advertises the covering namespace and refuses the requests it will not serve ([Path Selection](#selection)). ### HOP\_PATH Parameter {#hop-path} HOP\_PATH is the ordered list of Hop IDs an advertisement has passed through, from the original publisher to the peer sending it: ``` HOP_PATH Parameter { Type (vi64) = 0x40B57 Length (vi64) Hop ID (vi64) ... } ``` The list always has at least one entry, the original publisher, 0 if unknown ([The Reserved Hop ID 0](#zero)). A receiver MUST close the session with a PROTOCOL\_VIOLATION if the list is empty, if the entries do not exactly fill `Length`, or if a non-zero Hop ID appears twice. ### ROUTE\_COST Parameter {#route-cost} ROUTE\_COST is the marginal cost of subscribing through this advertisement: the price of the transfers a new subscription would cause. ``` ROUTE_COST Parameter { Type (vi64) = 0x40B58 Value (vi64) } ``` It is OPTIONAL and absent means 0. Costs still accumulate across a mesh that sends none, because each receiver adds the RELAY\_COST of the link it received over ([Accumulating Cost](#accumulating)). The original publisher seeds the value with its production cost: 0 for content it already produces, higher for content it would have to start on demand, such as a standby transcoder advertising everything it could serve. A standby seed only ranks last if no live path can accumulate past it, which is a property of the deployment, not of the number. A deployment relying on standby ordering within one specificity tier ([Path Selection](#selection)) MUST bound the charged links on an admitted path by H and each link's cost by C, including the receiving link, and MUST enforce both when admitting paths and links. Its live publishers MUST seed 0 and its standby publishers MUST seed above H \* C and below saturation; 2^32 is RECOMMENDED where H \* C < 2^32. Unknown, out-of-budget, and saturated routes are outside the guarantee: a receiver MUST NOT rank them above standby capacity on the guess that they already carry content. ## Relay Behavior A relay forwarding an advertisement MUST append its own Hop ID to the HOP\_PATH it received, so its ID is always the last entry. A received 0 is forwarded unchanged, behind a stamp when it is the first entry ([Stamping an Unknown Publisher](#stamping)). A relay MUST discard an advertisement whose HOP\_PATH already contains its own non-zero Hop ID: forwarding it would extend a loop, and subscribing through it would route the relay back to itself. This check catches loops of any length and is the only loop defense required. A conforming sender never sends one ([Path Selection](#selection)), so a receiver MAY close the session with a PROTOCOL\_VIOLATION instead; discarding is what keeps the mesh working when one member does not conform. ### Bridging {#bridging} An upstream that did not negotiate the extension sends no HOP\_PATH. The relay creates one holding its stamp for that session followed by 0 ([Stamping an Unknown Publisher](#stamping)), then appends its own Hop ID. The identity a receiver assigned that upstream ([Assigned Identities](#assigned)) is local selection state and MUST NOT appear in HOP\_PATH. ### Accumulating Cost {#accumulating} Before forwarding or acting on an advertisement, a relay MUST add the RELAY\_COST the sender declared ([Relay Cost](#relay-cost)) to the ROUTE\_COST it received. The addition MUST saturate rather than wrap, so an absurd value ranks last instead of overflowing to best. A relay actively carrying the namespace (a live subscription exists for at least one of its tracks) SHOULD advertise 0 instead: its ingress is already paid for, so another subscriber costs only the links below it. This is what lets a cluster converge on a warm copy. The discount applies only to the path it actually serves from; a standby path keeps its accumulated value, since serving from it means opening a fresh ingest. When it stops carrying the namespace it SHOULD restore the accumulated value, optionally after a grace period so brief churn does not flap routing. Two relays that each begin carrying the same namespace would each see the other's 0 as cheaper than its own source, and if both switched at once the namespace would have no source. Before re-parenting onto a 0-cost advertisement from another actively-carrying relay (one whose HOP\_PATH has two or more entries), a relay SHOULD apply a deterministic tie-break, such as comparing a hash of the namespace and each Hop ID, so exactly one side moves. Equal Hop IDs, including two relays that both declared 0, cannot be ordered, and neither side SHOULD move. Cheaper advertisements from anything else carry no such hazard and SHOULD be adopted at once. ### Updating an Advertisement {#updating} An endpoint updates a PUBLISH\_NAMESPACE with REQUEST\_UPDATE ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 9.5) on its request stream, carrying the HOP\_PATH or ROUTE\_COST that changed. An omitted parameter keeps its value, so a relay that starts carrying a namespace sends an explicit ROUTE\_COST of 0. The receiver answers REQUEST\_OK, or REQUEST\_ERROR and closes the stream, which withdraws the advertisement. NAMESPACE has no REQUEST\_UPDATE, so an endpoint updates one by re-sending it with new parameters on the same SUBSCRIBE\_NAMESPACE response stream. A receiver MUST NOT treat the repeat as a duplicate or a protocol violation. An advertisement lives as long as its stream, so an update on a new stream would leave two streams claiming one namespace. An endpoint MUST NOT open a second stream for an advertisement it already maintains on the session. An update replaces the old parameters atomically, so a receiver MUST NOT tear down subscriptions or drop cached state because one arrived. If the first HOP\_PATH entry is unchanged the content is continuous and subscriptions MAY resume on the new route at a group boundary. If the first entry changed, the publisher changed, and the endpoint still sends an ordinary update. The receiver keeps each subscription it is already serving on the old source until that source ends, MUST NOT resume or splice it onto the updated route, and serves new requests from the updated route without state cached from the old publisher. The expected update is a ROUTE\_COST change, which is how a relay signals that it started or stopped carrying the namespace. ## Path Selection {#selection} A receiver resolving a SUBSCRIBE, FETCH, or track-status request consults only the most specific advertisements covering it: the longest prefix. A refusal never falls through to a less specific tier. Within that tier, a receiver SHOULD prefer a HOP\_PATH that contains no 0 entry over one that does, then the lowest ROUTE\_COST, breaking ties toward the shorter HOP\_PATH and then toward the most recently received. This is advisory: a receiver MAY apply local policy, such as measured RTT, instead. NO\_CAPACITY ([IANA Considerations](#iana)) refuses a request the publisher could serve but has no capacity for now. It permits ONE re-resolution within the same tier, excluding the refusing advertiser: every route with its non-zero first Hop ID, or its session when that ID is 0. A receiver that has spent its retry, or has no other candidate, MUST refuse downstream with a code other than NO\_CAPACITY, so retries cannot compound hop by hop. Every other refusal, including an unrecognized code, is terminal. A receiver SHOULD NOT cache refusals. A relay MUST NOT advertise a namespace merely because it resolved it: the covering advertisement stays the only one until the publisher advertises the concrete namespace, which it SHOULD do once producing, so a later request finds the running content by its exact namespace instead of resolving a second producer. Two advertisements whose HOP\_PATH begins with the same non-zero Hop ID come from the same publisher and carry interchangeable content: a receiver MAY hold them as redundant paths and fail an active subscription over to the survivor at a group boundary. If the first entries differ, or either is 0, they are distinct publishers reusing a namespace ([Several Publishers of One Namespace](#publishers)). An endpoint MUST NOT advertise a path whose HOP\_PATH contains the Hop ID the peer declared: the peer could only discard it, and acting on it would form a loop. Of the paths that remain it SHOULD advertise the best, and advertises nothing when every path contains that Hop ID. Because selection is per session, a peer that the serving path runs through still receives the best standby, which is what lets it fail over if its own copy dies. An endpoint MUST select the source for a subscription by the same rule. If only excluded sources remain the subscription is unroutable, since serving it would hand the subscriber data that already flowed through itself. One rule for advertisement and dispatch keeps advertised paths truthful and prevents subscription cycles of any length. ## Several Publishers of One Namespace {#publishers} [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) lets several publishers advertise one namespace and leaves to the relay how it serves a SUBSCRIBE among them. Under this extension an advertisement is a path, so a session advertises a namespace at most once, a relay forwards only the best path it knows ([Path Selection](#selection)), and a subscription is served from one source at a time. A receiver MAY still hold paths to several publishers of one namespace and choose between them as it sees fit: serve from the cheapest and move to the next when it fails. A refusal moves to another publisher only as [Path Selection](#selection) allows: once, and only for NO\_CAPACITY. A relay that moves to another publisher MUST update its advertisement to the new path ([Updating an Advertisement](#updating)), so the first Hop ID downstream names the publisher that new subscriptions reach. Moving between distinct publishers is a discontinuity: their groups are not one sequence, so a subscriber sees an unrelated Location, and a FETCH that succeeds against one may fail against the other. Redundant publishers of the same content avoid this by sharing a Hop ID ([Hop IDs](#hop-ids)), which makes their paths interchangeable and lets a subscription fail over at a group boundary. Publishers that do not share one are treated as reusing a name. ## Security Considerations {#security} A Hop ID reveals nothing beyond what its operator encodes in it; a deployment that considers its identifiers sensitive can use random values or declare 0 ([The Reserved Hop ID 0](#zero)). Declaring 0 hides an identity from the mesh; a peer MAY assign one as local selection state ([Assigned Identities](#assigned)) and MUST NOT forward it. A HOP\_PATH does reveal how many hops an advertisement crossed, which hints at the size of a deployment; a relay MAY collapse its internal hops into one entry, or strip HOP\_PATH, before forwarding across a trust boundary. Because a relay only appends to HOP\_PATH, it cannot make a competing path look shorter than it is; the worst it can do is under-report its own upstream portion to win an advisory tie-break. ROUTE\_COST has no such protection: it is a single value the sender chooses, so a relay can advertise 0 for content it is not carrying and attract subscriptions it then has to fetch. Both cost only a suboptimal path choice, and the latter is self-limiting, since the traffic won this way must then be served. Implementations SHOULD bound the work started by requests beneath a broad advertisement, using NO\_CAPACITY when capacity is exhausted. A receiver MUST NOT make security decisions based on Hop IDs, and a deployment spanning a trust boundary SHOULD treat a peer's ROUTE\_COST as a hint to clamp or ignore rather than an accounting figure. ## IANA Considerations {#iana} This document requests the following registrations. High, distinctive values are requested to avoid the low ranges reserved by [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) and to minimize collisions with provisional registrations by other extensions. ### MOQT Setup Options This document requests two registrations in the "MOQT Setup Options" registry ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 15.4), whose policy is Specification Required. | Value | Name | Reference | |:--------|:-----------|:--------------| | 0x40B54 | HOP\_ID | This Document | | 0x40B56 | RELAY\_COST | This Document | ### MOQT Message Parameters This document requests two registrations in the "MOQT Message Parameters" registry ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 15.7). Both are carried in PUBLISH\_NAMESPACE, in REQUEST\_UPDATE of a PUBLISH\_NAMESPACE ([Updating an Advertisement](#updating)), and in the extended NAMESPACE message ([Namespace Advertisements](#namespace)). | Value | Name | Carried In | Reference | |:--------|:------------|:---------------------------------------------|:--------------| | 0x40B57 | HOP\_PATH | PUBLISH\_NAMESPACE, REQUEST\_UPDATE, NAMESPACE | This Document | | 0x40B58 | ROUTE\_COST | PUBLISH\_NAMESPACE, REQUEST\_UPDATE, NAMESPACE | This Document | The Key-Value-Pair parity is load-bearing: HOP\_PATH is odd, so its value is a length-prefixed byte string, while HOP\_ID, RELAY\_COST, and ROUTE\_COST are even, so their values are bare varints. ### MOQT Error Codes This document requests one registration in the "REQUEST\_ERROR Codes" registry. | Value | Name | Reference | |:--------|:------------|:--------------| | 0x40B5A | NO\_CAPACITY | This Document | ## Normative References * [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) ## Appendix A: Changelog ### moq-cluster-02 * Defined request resolution against the longest covering prefix and the NO\_CAPACITY refusal with its single re-resolution; any other refusal is terminal, including between several publishers of one namespace. * A relay does not advertise a namespace because it resolved it; the publisher advertises the concrete namespace once producing. * A change of original publisher is an ordinary update instead of a withdrawal and a new advertisement. Subscriptions already served drain the old source; new requests take the updated route. * A relay puts a random Hop ID, picked per session, in front of an advertisement whose first HOP\_PATH entry is 0, and writes that stamp followed by 0 for one with no HOP\_PATH. ### moq-cluster-01 * Assigned identities are local selection state and MUST NOT be forwarded. * Bridging an upstream that sent no HOP\_PATH writes 0 for that hop; a received 0 is forwarded unchanged. * Path selection prefers a HOP\_PATH with no 0 entry before comparing ROUTE\_COST. * Renamed the RELAY\_HOPS Setup Option to HOP\_ID and moved it to the even key 0x40B54, so its value is a bare varint rather than a length-prefixed one. * A PUBLISH\_NAMESPACE is updated with REQUEST\_UPDATE on its request stream instead of a repeated PUBLISH\_NAMESPACE; HOP\_PATH and ROUTE\_COST are registered for REQUEST\_UPDATE. A NAMESPACE is still re-sent on its stream. * A session advertises a namespace at most once and a subscription is served from one source at a time. A receiver chooses among several publishers of one namespace; moving between them is a discontinuity unless they share a Hop ID. * Named the routing protocols whose single per-direction metric RELAY\_COST follows. * Path selection consults the most specific advertisement first, the longest prefix; an advertisement is always a prefix, and a request beneath it that the advertiser will not serve is refused. Standby seeds are bounded by deployment limits. --- --- url: https://doc.moq.dev/draft/moq-e2ee.md description: >- This document specifies moq-e2ee-00, a versioned profile for end-to-end encryption of MoQ application payloads. --- # MoQ End-to-End Encryption Profile ::: info Rendered from the Internet-Draft source in this repository. Submitted versions are on the [IETF datatracker](https://datatracker.ietf.org/doc/draft-lcurley-moq-e2ee/). ::: ## Abstract This document specifies moq-e2ee-00, a versioned profile for end-to-end encryption of MoQ application payloads. Authorized publishers and subscribers share a 32-byte broadcast secret out of band. Each publisher instance mints an epoch and publishes under an opaque broadcast path ending in it. HKDF-SHA-256 derives opaque physical track names and per-track AES-128-GCM keys from the secret and the epoch; grouped frames and datagrams use separate key domains. Media frames and datagrams carry only ciphertext plus a 16-byte tag. The profile binds object identity through derivation and the nonce, not an on-wire header. ## Note to Readers This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it. Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything. ## Conventions and Definitions The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119](https://www.rfc-editor.org/rfc/rfc2119) [RFC8174](https://www.rfc-editor.org/rfc/rfc8174) when, and only when, they appear in all capitals, as shown here. ## Introduction MoQ relays forward named tracks of groups and frames ([draft-lcurley-moq-lite](/draft/moq-lite), [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/)) without parsing application payloads. This profile encrypts those payloads and hides the semantic broadcast and track names that would otherwise describe them, so a relay cannot recover content. It reuses AES-128-GCM and the 96-bit group/frame nonce shape of [draft-ietf-moq-secure-objects](https://datatracker.ietf.org/doc/draft-ietf-moq-secure-objects/) where those identities map, and specifies the moq-lite and datagram bindings that draft does not cover. The profile does not distribute keys, sign senders, pad payloads, or rotate a key inside an epoch. Applications that need those properties terminate this profile and run a different one. ## Relationship to Other Formats {#related} [draft-ietf-moq-secure-objects](https://datatracker.ietf.org/doc/draft-ietf-moq-secure-objects/) encrypts a MoQ Transport object under a per-track base key. Its nonce is a per-track salt XOR `uint64_be(group) || uint32_be(object)`, its AAD includes publisher priority and immutable properties, and the Key ID rides in those properties. The object payload is a length-prefixed plaintext plus an optional encrypted-properties list. This profile keeps AES-128-GCM, HKDF-SHA-256, a 96-bit nonce of `uint64_be(group) || uint32_be(frame)`, and the rule that an identity is used at most once. It differs where the models diverge: * One 32-byte broadcast secret authorizes every track. Per-track keys are derived, not supplied. * The Key ID is part of the out-of-band credential. No per-frame header or immutable property carries it. * The salt is not derived from the key. The publisher instance's epoch is an input to every derivation and the last segment of the broadcast path ([Epoch and Broadcast Path](#epoch)), so a restarted publisher derives new keys instead of needing a new Key ID. * The nonce is the identity counter itself, not a salt XOR. The derived key is already unique per credential, epoch, physical name, and domain. * The payload is ciphertext concatenated with the 16-byte tag, with no inner length prefix, encrypted properties, or padding. * moq-lite frame indices are implied by position in the group ([draft-lcurley-moq-lite](/draft/moq-lite) Section "Frame"), not an on-wire object ID. This profile still uses that index as the 32-bit nonce half, because it is the only canonical end-to-end frame identity on both moq-lite and MoQ Transport. * moq-lite datagrams share the group sequence namespace of the same track ([draft-lcurley-moq-lite](/draft/moq-lite) Section "Datagrams"). A grouped frame 0 and a datagram with that sequence would collide under one AES-GCM key, so datagrams use a separate key domain with frame ID zero. SFrame [RFC 9605](https://www.rfc-editor.org/rfc/rfc9605) is the cryptographic ancestor of [draft-ietf-moq-secure-objects](https://datatracker.ietf.org/doc/draft-ietf-moq-secure-objects/). This profile is not SFrame: it has no SFrame header, no CTR field on the wire, and no per-object KID. The experimental `moq-secure` format (https://github.com/cathode-ray-tube/moq-secure) is prior art only. Its independent counter, 17-byte per-frame header, ChaCha20-Poly1305 suite, signing lease, payload padding, and bytes-only processor are not compatibility requirements. ## Profile Version This document defines profile `moq-e2ee-00`. The profile is named out of band alongside the credential; an application MUST refuse a credential that names any other profile. A new profile is a new document; implementations MUST NOT fall back to plaintext or to an older profile because a catalog, announcement, or peer suggested one. ## Credential {#credential} The application supplies an immutable credential: ``` Credential { context (b) kid (u64) secret (32) } ``` **context**: Opaque bytes chosen by the application as the broadcast's end-to-end identity. Both ends MUST use identical bytes. The MoQ broadcast path is visible to relays and may be remounted under another prefix, so it is not this field unless the application copies it in. **kid**: Selects among credentials the application retains. It never changes in place; rotating the secret is a new kid. **secret**: Exactly 32 bytes from a cryptographically secure random generator. It MUST NOT be a password, passphrase, or other guessable input. `kid` MUST be in `0..=2^53-1` inclusive, the largest integer TypeScript can represent exactly. `context`, every `epoch`, and every semantic broadcast or track name MUST be at most 65535 bytes, the `bytes` encoding width. An implementation MUST refuse a credential outside those ranges (`identity`) or whose secret is not 32 bytes (`invalid_secret`). Applications distribute credentials over their own authenticated channel. MoQ announcements, catalogs, paths, and relay authorization MUST NOT carry the secret or authenticate it. Implementations MUST let the application retain more than one credential and select among them; they MUST NOT infer the kid from the transport. ## Epoch and Broadcast Path {#epoch} A generation is one credential under one epoch. Every derivation and every nonce is scoped to a generation. **epoch**: Opaque nonempty bytes minted by the publisher instance, containing no `/`. Each instance of a broadcast MUST mint an epoch that no other instance under the same credential has used or will use. Two instances MUST NOT share an epoch: they would derive the same keys and collide on nonces. The RECOMMENDED epoch is the lowercase text of a UUID version 7 ([RFC 9562](https://www.rfc-editor.org/rfc/rfc9562)): its leading 48-bit timestamp makes epochs sort by creation time and its random bits make collisions negligible. A protected broadcast is published at `/`, where `` is the 22-character base64url segment derived from the credential and the application's semantic broadcast name according to [Key Derivation](#derive), and `` is the epoch text. The opaque derivation does not include the epoch, so every instance of the same semantic broadcast shares a discovery prefix. The path carries no format or protection marker; for example, the semantic name `meeting.hang` appears only as an input to the opaque derivation. A plaintext consumer that opens the protected broadcast fails because it cannot find the plaintext catalog it expects, not because of a path naming rule. Subscribers discover instances by the `/` prefix and select the greatest epoch when epochs are UUID version 7 text, where greatest is newest. Opaque epochs carry no creation order, so any other epoch form needs an application rule for which instance is current. A subscriber that already knows the full path takes the epoch from its last segment. The epoch and the path are not secret and are not authenticated. A relay that presents a wrong epoch causes authentication failure; a relay that withholds a newer instance denies service. Neither can cause a nonce to repeat, because only the publisher instance chooses the epoch it encrypts under. A restart or replacement of a publisher is a new instance and mints a new epoch. Transport sequence numbers therefore restart freely without any coordination between instances. Ended instances remain readable at their own path for as long as relays or archives retain them. ## Canonical Encoding {#encoding} HKDF info fields use unique encodings, not varints. * `u16` / `u32` / `u64`: unsigned big-endian integers of that width. * `bytes`: `u16(length) || data`, length at most 65535. * ASCII labels are the UTF-8 bytes of the quoted string, with no length prefix of their own. Integers used as group, frame, or kid identities are refused before encoding if they fail [Bounds](#bounds). ## Key Derivation {#derive} Keys and physical names are derived with HKDF-SHA-256 [RFC 5869](https://www.rfc-editor.org/rfc/rfc5869). Let `salt` be the ASCII bytes of `"moq-e2ee-00"`. ``` prk = HKDF-Extract(salt, secret) ``` Opaque broadcast path material is 16 bytes: ``` path_info = "moq-e2ee-00 path" || bytes(context) || u64(kid) || bytes(semantic_broadcast) opaque = HKDF-Expand(prk, path_info, 16) ``` `semantic_broadcast` is the UTF-8 bytes of the application's semantic broadcast name. The opaque path segment is the unpadded base64url encoding of `opaque` ([RFC 4648](https://www.rfc-editor.org/rfc/rfc4648) Section 5): 22 ASCII characters. The epoch is deliberately absent from this derivation, so a subscriber can derive the prefix before discovering an instance. Physical track name material is 16 bytes: ``` name_info = "moq-e2ee-00 name" || bytes(context) || bytes(epoch) || u64(kid) || bytes(semantic_name) physical = HKDF-Expand(prk, name_info, 16) ``` `semantic_name` is the UTF-8 bytes of the application's track name (`catalog.json`, `video`, and so on). The physical track name is the unpadded base64url encoding of `physical` ([RFC 4648](https://www.rfc-editor.org/rfc/rfc4648) Section 5): 22 ASCII characters, which is a valid moq-lite track name. This function may hide any track-shaped name the application wants a relay not to read; it is not limited to media tracks. AEAD keys are 16 bytes, one per physical name and domain: ``` key_info = "moq-e2ee-00 key" || bytes(context) || bytes(epoch) || u64(kid) || bytes(physical_name) || domain key = HKDF-Expand(prk, key_info, 16) ``` `domain` is a single byte: `0x00` for grouped frames, `0x01` for datagrams. `physical_name` here is the 22-character ASCII string, not the raw 16-byte material. A given `(generation, physical_name, domain)` tuple has one key. Implementations MUST derive names from semantic names, then keys from the resulting physical names. A subscriber that learns a physical name from a decrypted catalog derives its key without ever knowing the semantic name. ## Object Identity {#identity} A protected object is the tuple `(generation, physical_name, domain, group, frame)`. ### Grouped Frames A grouped frame uses `domain = 0x00`, the group's sequence as `group`, and the frame index within that group as `frame`. moq-lite numbers frames from 0 in write order ([draft-lcurley-moq-lite](/draft/moq-lite)). On MoQ Transport, `frame` is the explicit Object ID, never its arrival ordinal. Publishers supporting both transports MUST assign contiguous Object IDs from zero so that each matches its moq-lite write-order index; relays MUST NOT renumber protected objects. An Object ID above `2^32-1` MUST be refused as `identity` before encryption or decryption. ### Datagrams A datagram uses `domain = 0x01`, its 64-bit sequence as `group`, and `frame = 0`. On MoQ Transport, a datagram is an Object at ID 0 in an OBJECT\_DATAGRAM whose Group ID is the sequence. Shared vectors cover grouped tracks on both transports and datagrams on moq-lite only. ### Nonce The 96-bit AES-GCM nonce is: ``` nonce = u64(group) || u32(frame) ``` AES-GCM's internal block counter is not `frame`. Implementations MUST call a standard AEAD API [RFC 5116](https://www.rfc-editor.org/rfc/rfc5116) with this nonce and an empty AAD. The empty AAD is deliberate: profile version, context, epoch, kid, physical name, and domain are bound by HKDF; group and frame are bound by the nonce. Rewritten timestamps and mutable routing properties are not authenticated. ## Payload Protection {#payload} Let `Nt = 16`. AES-128-GCM encrypts the application bytes with `key`, `nonce`, and empty AAD. The bytes placed in the MoQ frame or datagram payload are ciphertext concatenated with the 16-byte tag, in the [RFC 5116](https://www.rfc-editor.org/rfc/rfc5116) convention. There is no inner header. Within a generation a publisher MUST allocate group and datagram sequences monotonically and frame indices in write order, so an identity is encrypted at most once. Encrypting at an identity the same instance already used is `reuse` and MUST be refused. Relays and caches forward ciphertext unchanged, so replay from a cache never re-encrypts. ### Plaintext Ceiling Protected payload length is plaintext length plus `Nt`. A publisher MUST refuse plaintext that would make the protected payload exceed the transport payload limit for that object (`oversize`), before it touches the network. The interoperable grouped-frame payload cap matching current moq-net implementations is 32 MiB, so grouped plaintext MUST be at most `32 MiB - 16` bytes. moq-lite datagram bodies MUST remain at most 1200 bytes including Subscribe ID, Group Sequence, and Timestamp ([draft-lcurley-moq-lite](/draft/moq-lite) Section "Datagrams"). Those three varints are at most 24 bytes, so datagram plaintext MUST be at most `1200 - 24 - 16 = 1160` bytes; a publisher cannot observe the Subscribe ID each hop will encode and MUST NOT budget for a smaller header. ### Catalogs A catalog is a track like any other: published under the physical name derived from its semantic name, with each snapshot or delta protected at its own group and frame identity. Hang [draft-lcurley-moq-hang](/draft/moq-hang) `catalog.json` and `catalog.json.z` and MSF's `catalog` are semantic names; authorized clients derive those physical names from the generation, then learn the remaining opaque names from the decrypted catalog. A Hang rendition-map key in that catalog is the physical name of the track. If a representation is compressed, compression is applied to the catalog bytes before AEAD and reversed after decryption. Encrypting then compressing is forbidden: ciphertext does not compress, and the `.z` sibling would leak the uncompressed size ratio. ## Bounds {#bounds} Implementations MUST refuse non-integer identities (including NaN and infinities) and identities outside these bounds before encoding or AEAD: * `group` (grouped sequence or datagram sequence) and `kid`: `0..=2^53-1`. Above that is `identity`. * `frame`: `0..=2^32-1`. `2^32` and above is `identity`. * AEAD operations with one key: at most `2^24` invocations and at most `2^36` plaintext bytes (`2^32` 16-byte blocks). Exceeding either is `exhausted`. `2^53-1` is `Number.MAX_SAFE_INTEGER`. It is the strictest exact integer bound across current TypeScript and Rust implementations. The 32-bit frame width is the nonce field. The `2^24` invocation cap is the interoperable AES-GCM record limit from [draft-irtf-cfrg-aead-limits](https://datatracker.ietf.org/doc/draft-irtf-cfrg-aead-limits/). GCM authenticity also depends on total processed blocks, so `2^24` frames at the 32 MiB transport ceiling would be about `2^45` blocks; the `2^36`-byte cap is the matching total-block bound. Small records hit the invocation cap first; large records hit the byte cap first. A receiver counts failed opens too, since each is an AEAD invocation under that key. ## Failure Behavior {#failure} E2EE is an explicit per-broadcast mode. There is no plaintext fallback. Typed failures: * `invalid_secret`: secret is not 32 bytes. * `identity`: an integer is outside [Bounds](#bounds), a `bytes` field exceeds 65535, an epoch is empty or contains `/`, a physical name is not 22 base64url characters, or `domain` is not `0x00`/`0x01`. * `exhausted`: the next AEAD operation would exceed `2^24` uses of that key or `2^36` plaintext bytes under that key. * `reuse`: encrypting at an identity this instance already used. * `oversize`: plaintext plus tag exceeds the transport payload limit, or a ciphertext is shorter than `Nt` or larger than that limit. * `authentication`: AEAD open fails. Relocation across context, epoch, kid, physical name, domain, group, or frame is this failure. * `duplicate`: a receiver has already opened this datagram sequence inside its retained window. Operational, not a cryptographic event. Authentication failure on a grouped track MUST end that track with `authentication`. Authentication failure on a datagram MUST drop that datagram and emit `authentication`; the track continues. Grouped frames need no duplicate window: a transport delivers each frame of a group once, in order, at its index. Receivers SHOULD suppress datagram sequences they still retain; the window MUST be bounded, and 1024 sequences below the greatest opened is RECOMMENDED. The AEAD identity and epoch rules are the security boundary; a relay may still delay, reorder, suppress, or replay ciphertext outside a receiver's window. A late subscriber MAY start at any group the publisher still holds. Gaps are not errors. A receiver MUST NOT require group 0 or any prior identity before opening a later authentic object. ## Test Vectors {#vectors} Known-answer and negative vectors live in `moq-e2ee-00.json` beside this draft. Hex strings are octet sequences. The JSON is authoritative for primitive interop; an implementation of this profile MUST pass every vector. Each negative row specifies an `operation`, its inputs, and its expected typed error. Non-finite frame inputs use the strings `NaN`, `Infinity`, and `-Infinity`; group inputs in identity tests are decimal strings. Implementations whose types cannot represent an invalid input MUST reject it at their input boundary. The file covers opaque path derivation, key derivation, physical naming, grouped frames, a datagram at the fixed budget, the same identity under two epochs, relocation across every identity dimension, tag failure, malformed physical names, identity bounds, and oversize plaintext. The shared verifier is stateless. It does not verify `reuse`, `exhausted`, `duplicate`, or failure propagation. Each language core MUST test those lifecycle requirements, including monotonic allocation, per-key invocation and plaintext-byte accounting, bounded datagram suppression, and that a new instance under a new epoch authenticates while the old epoch does not. Passing the primitive vectors alone is not profile conformance. ## Security Considerations Relays, caches, recorders, and control planes are untrusted for content. Authorized endpoints that hold the broadcast secret are trusted. Sender authenticity against another endpoint that also holds the secret is not a goal of `moq-e2ee-00`. A relay can still observe the outer broadcast path including the opaque segment and epoch, opaque physical names, group and frame structure, timestamps, sizes, and traffic patterns. Padding and metadata-flow confidentiality are out of scope. The opaque segment hides the application's semantic broadcast name from a relay only while the application does not publish or otherwise expose the same name in plaintext. The epoch remains visible to every relay on the path. Nonce reuse under one key is catastrophic for AES-GCM. The profile prevents it by deriving every key from an epoch that only one publisher instance ever uses, allocating identities monotonically within that instance, separating datagram and grouped domains, and capping invocations and plaintext bytes per key. No state survives an instance: nothing needs to be persisted across restarts to stay safe. Empty AAD does not weaken the binding: every immutable end-to-end field is in the HKDF info or the nonce. Timestamps are excluded because relays rewrite them; a relay can therefore shift or reorder authentic objects in time within a receiver's tolerance. The opaque path segment is a deterministic function of the secret and semantic broadcast name; physical track names are deterministic functions of the secret and epoch. An attacker without the secret cannot predict them; an attacker with the secret can derive every name, which is intended. ## IANA Considerations This document requests no registrations. ## Normative References * [draft-lcurley-moq-lite](/draft/moq-lite) * [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) * [RFC 4648](https://www.rfc-editor.org/rfc/rfc4648) * [RFC 5116](https://www.rfc-editor.org/rfc/rfc5116) * [RFC 5869](https://www.rfc-editor.org/rfc/rfc5869) * [RFC 9562](https://www.rfc-editor.org/rfc/rfc9562) ## Informative References * [RFC 9605](https://www.rfc-editor.org/rfc/rfc9605) * [draft-ietf-moq-secure-objects](https://datatracker.ietf.org/doc/draft-ietf-moq-secure-objects/) * [draft-irtf-cfrg-aead-limits](https://datatracker.ietf.org/doc/draft-irtf-cfrg-aead-limits/) * [draft-lcurley-moq-hang](/draft/moq-hang) ## Changelog ### draft-lcurley-moq-e2ee-00 * Initial `moq-e2ee-00` profile: out-of-band credential, opaque broadcast path with a publisher-minted epoch as its last segment, HKDF physical names and keys, AES-128-GCM payloads, identity bounds, typed failures, and shared primitive vectors. ## Acknowledgments This document was drafted with the assistance of Grok, an AI assistant by xAI. --- --- url: https://doc.moq.dev/draft/moq-flate.md description: >- This document specifies how a MoQ Transport draft-ietf-moq-transport track carries DEFLATE-compressed payloads. --- # DEFLATE Compressed Tracks for MoQ ::: info Rendered from the Internet-Draft source in this repository. Submitted versions are on the [IETF datatracker](https://datatracker.ietf.org/doc/draft-lcurley-moq-flate/). ::: ## Abstract This document specifies how a MoQ Transport [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) track carries DEFLATE-compressed payloads. Each subgroup is one raw DEFLATE stream, sync flushed at each object boundary, so every object stays self-delimited while later objects compress against the earlier ones in the subgroup. Small repetitive payloads compress several times better than they do alone, and a dropped group costs nothing beyond itself because the window never spans one. Nothing is added to the wire: the application declares the track compressed, and a relay forwards it unchanged. ## Conventions and Definitions The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119](https://www.rfc-editor.org/rfc/rfc2119) [RFC8174](https://www.rfc-editor.org/rfc/rfc8174) when, and only when, they appear in all capitals, as shown here. ## Introduction A live track is usually many small payloads rather than a few large ones. DEFLATE [RFC 1951](https://www.rfc-editor.org/rfc/rfc1951) on one such payload alone is close to useless: the window starts cold, and below a few hundred bytes the block overhead can exceed the savings. The redundancy worth exploiting is between payloads: a JSON snapshot followed by its deltas, a telemetry record repeated at 50 Hz, successive subtitle cues. Compressing a whole track captures that redundancy but breaks on delivery. Groups are dropped, arrive out of order, and a subscriber joins at an arbitrary point, so a window spanning data it never received cannot be reconstructed. Scoping the window to a subgroup is the compromise: a drop costs nothing beyond itself, joining costs only the current group, and the transport never has to know. ## Compression Scope {#scope} Each subgroup is a separate DEFLATE stream: its object payloads share one window, in order, starting cold. [draft-lcurley-moq-lite](/draft/moq-lite) has no subgroups, so each group is one stream of frames. The application decides which subgroups a track uses; a consumer knows which to expect and decodes each one's objects in order. A publisher MUST NOT send a compressed track in datagrams, which are neither ordered nor reliable. An object with no payload is skipped, neither advancing nor resetting the window. ## Compressed Stream {#stream} A subgroup's payloads are compressed, in order, into one raw DEFLATE stream [RFC 1951](https://www.rfc-editor.org/rfc/rfc1951), with no zlib or gzip wrapper. A publisher MUST sync flush after each payload, which ends the block and byte-aligns the output while retaining the window ([RFC 1951, Section 3.2.4](https://www.rfc-editor.org/rfc/rfc1951#section-3.2.4), `Z_SYNC_FLUSH` in zlib). Each object carries the bytes that flush produced, with no length prefix. A sync flush always ends with the marker `0x00 0x00 0xff 0xff`. A publisher MUST omit it from each object and a consumer MUST append it before decompressing, the same trick as permessage-deflate ([RFC 7692, Section 7.2.1](https://www.rfc-editor.org/rfc/rfc7692#section-7.2.1)). A zero-length payload is neither compressed nor decompressed. The stream is never terminated: no object ends in a final block, so a consumer decompresses incrementally and MUST NOT treat the absent end of stream as truncation. A consumer missing an object MUST abandon the rest of the subgroup, which it can no longer decompress. The compression level is a publisher's choice; any conformant stream decodes. ## Declaring Compression {#declare} The application declares a track compressed, conventionally with a `.z` suffix on the track name, or in its catalog. A consumer MUST NOT infer compression from the payload: raw DEFLATE has no magic number, so a wrong guess yields plausible garbage. There is deliberately no transport signal. A relay drops objects and re-frames what it forwards, so a transport that knew about the window would make the relay responsible for emitting a stream that still decodes, which means carrying a DEFLATE implementation. Instead a relay forwards opaque bytes, and an endpoint that has never heard of this document never asks for a compressed track. ## Security Considerations A shared window is a side channel. The compressed size of one payload reveals how much it has in common with the payloads before it, enough to recover a secret an attacker can partially guess, as CRIME and BREACH demonstrated against TLS and HTTP compression. A publisher MUST NOT compress a secret and attacker-influenced data in the same subgroup. Encryption does not mitigate this, since the sizes are visible to anyone on the path. A few bytes can inflate to gigabytes, so a consumer MUST bound the decompressed size of each object and abandon the subgroup once that bound is exceeded. Each open subgroup also holds a window of up to 32 KiB, so a consumer SHOULD bound how many it decompresses at once. ## IANA Considerations This document requests no registrations. The format lives inside object payloads, which [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) treats as opaque. ## Normative References * [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) * [RFC 1951](https://www.rfc-editor.org/rfc/rfc1951) * [RFC 7692](https://www.rfc-editor.org/rfc/rfc7692) ## Informative References * [draft-lcurley-moq-lite](/draft/moq-lite) ## Acknowledgments This document was drafted with the assistance of Claude, an AI assistant by Anthropic. --- --- url: https://doc.moq.dev/draft/moq-hang.md description: Hang is a real-time conferencing protocol built on top of moq-lite. --- # Media over QUIC - Hang ::: info Rendered from the Internet-Draft source in this repository. Submitted versions are on the [IETF datatracker](https://datatracker.ietf.org/doc/draft-lcurley-moq-hang/). ::: ## Abstract Hang is a real-time conferencing protocol built on top of moq-lite. A room consists of multiple participants who publish media tracks. All updates are live, such as a change in participants or media tracks. ## Note to Readers This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it. Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything. ## Conventions and Definitions The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119](https://www.rfc-editor.org/rfc/rfc2119) [RFC8174](https://www.rfc-editor.org/rfc/rfc8174) when, and only when, they appear in all capitals, as shown here. ## Terminology Hang is built on top of moq-lite [draft-lcurley-moq-lite](/draft/moq-lite) and uses much of the same terminology. A quick recap: * **Broadcast**: A collection of Tracks from a single publisher. * **Track**: A series of Groups, each of which can be delivered and decoded *out-of-order*. * **Group**: A series of Frames, each of which must be delivered and decoded *in-order*. * **Frame**: A sized payload of bytes representing a single moment in time. Hang introduces additional terminology: * **Room**: A collection of participants, publishing under a common prefix. * **Participant**: A moq-lite broadcaster that may produce any number of media tracks. * **Catalog**: A JSON document that describes each available media track, supporting live updates. * **Container**: A tiny header in front of each media payload containing the timestamp. ## Discovery The first requirement for a real-time conferencing application is to discover other participants in the same room. Hang does this using moq-lite's ANNOUNCE capabilities. A room consists of a path. Any participants within the room MUST publish a broadcast with the room path as a prefix which SHOULD end with the `.hang` suffix. For example: ``` /room123/alice.hang /room123/bob.hang /room456/zoe.hang ``` A participant issues an ANNOUNCE\_PLEASE message to discover any other participants in the same room. The server (relay) will then respond with an ANNOUNCE message for any matching broadcasts, including their own. For example: ``` ANNOUNCE_PLEASE prefix=/room/ ANNOUNCE suffix=alice.hang active=true ANNOUNCE suffix=bob.hang active=true ``` If a participant leaves or is disconnected, their broadcast is unannounced. Publishers and subscribers SHOULD terminate any subscriptions once a participant is unannounced. ``` ANNOUNCE suffix=alice.hang active=false ``` ## Catalog {#catalog} The catalog describes the available media tracks for a single participant. It's a JSON document that extends the W3C WebCodecs specification [WebCodecs](https://www.w3.org/TR/webcodecs/). The catalog is published as a `catalog.json` track within the broadcast so it can be updated live as the participant's media tracks change. A participant MAY forgo publishing a catalog if it does not wish to publish any media tracks now and in the future. The catalog track consists of multiple groups, one for each update. Each group contains a single frame with UTF-8 JSON. A publisher MUST NOT write multiple frames to a group until a future specification includes a delta-encoding mechanism (via JSON Patch most likely). A publisher SHOULD also serve the catalog as a `catalog.json.z` track: the identical JSON under the same group and frame rules, differing only by compression ([Compression](#compression)). A consumer reads whichever of the two tracks it prefers. ### Root The root of the catalog is a JSON document with the following schema: ``` type Catalog = { "audio": AudioSchema | undefined, "video": VideoSchema | undefined, "archive": ArchiveSchema | undefined, "clock": ClockSchema | undefined, "text": TextSchema | undefined, "json": JsonTracks | undefined, "binary": BinaryTracks | undefined, // ... any custom fields ... } ``` Additional fields MAY be added based on the application. An application SHOULD name its own root sections with a namespaced key, such as a reverse-DNS name (`com.example.telemetry`), so they cannot collide with a section a later version of this specification defines. The catalog SHOULD be mostly static, delegating any dynamic content to other tracks. For example, a chat entry should name a chat track, not carry individual chat messages. This way catalog updates are rare and a client MAY choose to not subscribe. The `json` and `binary` sections ([Data Tracks](#data)) are how a track like that is listed. This specification defines audio, video, and text media tracks, plus an optional `archive` entry ([Catalog Section](#archive-catalog)) naming the timeline track ([Timeline](#timeline)) that indexes their segments, an optional root `clock` entry ([Clock](#clock)) mapping content time to wall time, and the application data tracks in [Data Tracks](#data). ### Clock {#clock} The catalog's root `clock` field is the broadcast's one continuous clock: ``` type ClockSchema = { "wall": number, "timescale": number | undefined, } ``` The `wall` field is the wall-clock time of PTS zero, in `timescale` units since the moq epoch, 2020-01-01T00:00:00Z. A consumer derives the wall-clock time of any media timestamp as `wall + pts` after converting that timestamp into this timescale, and Unix time by adding the epoch back (for HLS `EXT-X-PROGRAM-DATE-TIME` or DASH `availabilityStartTime`). Every media track and the archive index refer to this one mapping after timescale conversion; there are no competing wall epochs. The `timescale` field is the units per second for `wall`, defaulting to 1000000 (microseconds) when absent. A zero `timescale` is invalid, as is a `wall` outside the JSON-safe integer range; both are refused rather than truncated. The mapping is fixed for the broadcast and independent of `archive`: a live-only publisher exposes its clock without creating a segment index. A discontinuity marker is a delivery event, not a new epoch, and a system-clock adjustment never retimes the mapping. ### Video A video track contains the necessary information to decode a video stream. ``` type VideoSchema = { "renditions": Map, "display": { "width": number, "height": number, } | undefined, "rotation": number | undefined, "flip": boolean | undefined, } ``` The `renditions` field contains a map of track names to video decoder configurations. See the [WebCodecs specification](https://www.w3.org/TR/webcodecs/#video-decoder-config) for specifics and registered codecs. Any field carrying raw bytes, notably `description`, is a hex string ([Binary Fields](#binary)). The `display` field is the size to render the video at, in pixels. It is separate from a rendition's `displayAspectWidth`/`displayAspectHeight` because changing it does not require reinitializing the decoder. In addition to the WebCodecs fields, each rendition MAY carry the common rendition fields ([Common Rendition Fields](#common)) plus: ``` type VideoDecoderConfigExtensions = { "displayAspectWidth": number | undefined, "displayAspectHeight": number | undefined, "stalled": boolean | undefined, } ``` `displayAspectWidth` and `displayAspectHeight` give the display aspect ratio of the media, stretching or shrinking the coded pixels. A consumer that understands neither field MUST assume square pixels, a 1:1 ratio. Both MUST be present together; a consumer that sees only one MUST ignore it. `stalled` indicates that the publisher recommends temporarily avoiding the rendition. The track remains available when `stalled` is true. A consumer SHOULD select an unstalled rendition when it supports one, but MAY select a stalled rendition when no unstalled rendition is suitable. If absent, `stalled` defaults to false. For example: ``` { "renditions": { "720p": { "label": "HD", "codec": "avc1.64001f", "container": { "kind": "legacy" }, "codedWidth": 1280, "codedHeight": 720, "bitrate": 6000000, "stalled": true, "framerate": 30.0, "jitter": 34 }, "480p": { "codec": "avc1.64001e", "container": { "kind": "legacy" }, "codedWidth": 848, "codedHeight": 480, "bitrate": 2000000, "framerate": 30.0, "jitter": 34 } }, "display": { "width": 1280, "height": 720 }, "rotation": 0, "flip": false, } ``` ### Audio An audio track contains the necessary information to decode an audio stream. ``` type AudioSchema = { "renditions": Map, } ``` The `renditions` field contains a map of track names to audio decoder configurations. See the [WebCodecs specification](https://www.w3.org/TR/webcodecs/#audio-decoder-config) for specifics and registered codecs. Any field carrying raw bytes, notably `description`, is a hex string ([Binary Fields](#binary)). In addition to the WebCodecs fields, each rendition MAY carry the common rendition fields ([Common Rendition Fields](#common)). #### PCM {#audio-pcm} Hang defines the `"pcm"` audio codec for uncompressed samples. The `sampleRate` and `numberOfChannels` fields MUST be present and greater than zero. The `description` field MUST NOT be present. If `bitrate` is present, it MUST equal `sampleRate * numberOfChannels * 32`. Each codec payload consists of interleaved IEEE 754 binary32 samples in little-endian byte order. Samples are ordered by sample frame, then by ascending channel index within each frame. The payload length MUST be a non-zero multiple of `4 * numberOfChannels`. The frame timestamp identifies the presentation time of its first sample. The frame duration in seconds is the payload length divided by `4 * numberOfChannels * sampleRate`. For example: ``` { "renditions": { "stereo": { "label": "English stereo", "codec": "opus", "container": { "kind": "legacy" }, "sampleRate": 48000, "numberOfChannels": 2, "bitrate": 128000, "jitter": 20 }, "mono": { "codec": "opus", "container": { "kind": "legacy" }, "sampleRate": 48000, "numberOfChannels": 1, "bitrate": 64000, "jitter": 20 } }, } ``` ### Text A text track carries timed text: captions or subtitles. Unlike audio and video there is no WebCodecs decoder for text, so a consumer parses each cue directly and renders it as an overlay. ``` type TextSchema = { "renditions": Map, } type TextConfig = { "format": "vtt" | "ttml" | "utf8" | string, "role": "subtitle" | "caption" | string | undefined, "lang": string | undefined, // plus the common rendition fields } ``` The `renditions` field maps track names to text configurations, typically one per language. The `format` field selects the cue serialization, and tells a consumer how to parse each frame's payload: * `vtt`: WebVTT ([W3C WebVTT](https://www.w3.org/TR/webvtt1/)). Each payload is a self-contained `WEBVTT` segment whose cues carry absolute timing. A cue's embedded start time MUST match its enclosing frame timestamp. * `ttml`: TTML / IMSC1 ([W3C IMSC](https://www.w3.org/TR/ttml-imsc1.1/)) fragment (XML) with absolute timing. A cue's embedded start time MUST match its enclosing frame timestamp. * `utf8`: raw UTF-8 text with no embedded timing or styling. The cue is shown from its frame timestamp until the next cue; an empty payload clears it. A consumer MUST ignore a rendition whose `format` it does not recognize. The `role` field describes the accessibility intent, defaulting to `subtitle`: * `subtitle`: a transcription of the spoken dialogue, same-language or translated. * `caption`: a textual representation of all audio, including non-speech sounds, for viewers who cannot hear it. The vocabulary is expected to grow, so a consumer MUST NOT reject a rendition whose `role` it does not recognize. It SHOULD keep such a rendition selectable and treat the role as `subtitle`, and MUST preserve the value verbatim if it republishes the catalog. Unlike `format`, an unrecognized `role` never prevents rendering: it describes intent, not the wire. The `lang` field is the BCP-47 [RFC 5646](https://www.rfc-editor.org/rfc/rfc5646) language tag of the track, for example `en` or `es-419`. Two renditions sharing a `lang` are told apart by `label` ([label](#field-label)), one of the common rendition fields ([Common Rendition Fields](#common)). Regardless of `format`, each frame's timestamp ([Container](#container)) is the authoritative cue start time on the media clock, so a relay and a consumer can order and schedule cues without parsing the payload. A text track has no delta frames: every frame is a self-contained cue, so a group MAY consist of multiple frames, following the same rule as a codec that lacks delta frames ([Container](#container)). For example: ``` { "renditions": { "captions.en": { "format": "vtt", "container": { "kind": "legacy" }, "role": "caption", "lang": "en", "label": "English" }, "subtitles.es": { "format": "vtt", "container": { "kind": "legacy" }, "role": "subtitle", "lang": "es" } } } ``` ### Data Tracks {#data} Beyond media, a broadcast MAY publish application data tracks: a chat log, a telemetry feed, a thumbnail, a serialized state blob. The catalog lists them in two sections, split by payload encoding: ``` type JsonTracks = { "tracks": Map, } type BinaryTracks = { "tracks": Map, } ``` A `json` track's frames are UTF-8 JSON values; a `binary` track's frames are opaque to everything but the application. The split is by what a generic consumer can do without knowing the application: parse, inspect, and re-serialize a JSON track, but only copy a binary one. Each map is keyed by track name, so an entry's key is the name to subscribe to. Unlike the media sections these are not rendition sets: entries are distinct tracks, not alternatives to choose between. A publisher MUST omit a section that holds no tracks. A consumer discovers these tracks the same way it discovers media, by reading the catalog; nothing else in the broadcast announces them. #### JSON {#json-tracks} ``` type JsonSchema = { "mode": Mode, "compression": Compression | undefined, "schema": string | undefined, "broadcast": string | undefined, "bitrate": number | undefined, "jitter": number | undefined, "delay": number | undefined, } ``` The `schema` field is an optional identifier for the shape of each value, typically the URL of a JSON Schema. It is descriptive: a consumer that does not recognize it MUST still be able to read the track. #### Binary {#binary-tracks} ``` type BinarySchema = { "mode": Mode, "compression": Compression | undefined, "mime": string | undefined, "broadcast": string | undefined, "bitrate": number | undefined, "jitter": number | undefined, "delay": number | undefined, } ``` The `mime` field is an optional media type ([RFC 6838](https://www.rfc-editor.org/rfc/rfc6838)) for each payload, for example `image/jpeg`. It is descriptive, the same as `schema` above. #### mode {#field-mode} ``` type Mode = "snapshot" | "stream" ``` The `mode` field says how a track's groups compose its frames into what a consumer sees. It is REQUIRED and has no default: reading an append log as a latest-value document silently discards every payload but the last. A consumer MUST ignore a track whose `mode` it does not recognize. A `snapshot` track is lossy. Each group is self-contained and supersedes the previous one, so a consumer reads only the newest group and a publisher MAY drop older ones. A group's first frame is a complete value. A `json` track MAY follow it with JSON Merge Patch ([RFC 7396](https://www.rfc-editor.org/rfc/rfc7396)) deltas applied in order; a `binary` track MUST write exactly one frame per group, since an opaque payload has no delta form. A `stream` track is lossless in the sense that nothing supersedes anything else. It is a single group, never rolled, carrying one self-contained payload per frame, delivered in order. A publisher that cannot write a payload MUST close the track rather than continue the log in a second group. A second group would present a gap as if it were a complete log; ending the track surfaces the failure instead, and a publisher with more to say opens a new track. A consumer MUST NOT skip to the newest group, since a later group does not supersede an earlier one the way a `snapshot` group does. It reads the one group the track carries, and MUST fail the read if the track carries a second, since whatever would have completed the first group is gone and yielding the remainder would present that gap as a continuous log. This is why a consumer takes groups in arrival order rather than by ascending sequence: groups are separate streams, so a second group MAY arrive with a lower sequence than the first, and skipping it would report a truncated log as a whole one. A consumer MUST NOT wait for the first group to end before it looks for a second. A publisher that opens a second group while the first is still open is broken in exactly the way this rule exists to catch, and a consumer that only checks at the group boundary waits forever on a group that publisher may never finish. A consumer MAY yield payloads it has already received from the first group before it fails, since those payloads precede the gap, but it MUST fail rather than block once they run out. Retention is bounded, and this is the limit of "lossless". A group's cache is finite, so a publisher that writes more than it holds evicts the log's earliest frames. A consumer that has not kept up, or that subscribes later, then cannot read the log at all: the read fails once it reaches the evicted prefix, rather than silently resuming at whatever the cache still holds. That is the intended behaviour, since a partial log presented as a whole one is exactly what this mode exists to prevent, and under compression ([Compression](#compression)) the retained frames are undecodable anyway without the evicted prefix as context. A publisher SHOULD therefore keep a `stream` track's log within what its groups retain, and split anything unbounded across successive tracks; a consumer that needs the whole log SHOULD subscribe before the publisher exceeds that. #### compression {#field-compression} ``` type Compression = "deflate" ``` The `compression` field names the compression applied to the track's frames. If absent, the frames are uncompressed. A consumer MUST ignore a track whose `compression` it does not recognize, since it cannot decode the frames. The `deflate` value is the group-scoped DEFLATE of [Compression](#compression). A `snapshot` group covers a single value (plus any deltas), so its window spans that group alone; a `stream` group's frames compress against the earlier ones in the log. #### broadcast {#data-shared} The `broadcast` field carries the same meaning here as it does for a media rendition ([broadcast](#field-broadcast)). #### bitrate, jitter, and delay {#data-estimates} The optional `bitrate` field is the track's maximum bitrate in bits per second. The optional `jitter` and `delay` fields carry the same meaning and rules as they do for a media rendition ([jitter](#field-jitter), [delay](#field-delay)), with a payload in place of a frame. A payload's presentation timestamp is its capture time on the broadcast's clock, the one its media renditions use, when the publisher knows it (a datagram's arrival, a sensor read), and otherwise the time the publisher wrote it. A publisher measures `jitter` and `delay` only from payloads that carry a capture time, and MUST omit both for a track written without one. A publisher MUST NOT use a timestamp on an unrelated clock, such as a device's own uptime, since it would report a meaningless lateness and, through the broadcast-wide minimum, distort every other rendition's `delay`. A `snapshot` update that writes no frame, such as an unchanged JSON value, is not a flush and is not measured. ### Binary Fields {#binary} A decoder config field carrying raw bytes, notably `description` (an `AllowSharedBufferSource` in WebCodecs), is carried in the catalog as a hex string ([RFC 4648, Section 8](https://www.rfc-editor.org/rfc/rfc4648#section-8)). A publisher SHOULD emit lowercase hexadecimal characters and MUST NOT emit a `0x` prefix or any separators. A consumer MUST accept either case. Note that this differs from the `cmaf` container's `init` field ([Container](#container)), which is base64 ([RFC 4648, Section 4](https://www.rfc-editor.org/rfc/rfc4648#section-4)); the two alphabets overlap, so the encoding cannot be detected and must be specified. ### Common Rendition Fields {#common} Audio, video, and text renditions share the following fields, extending the WebCodecs decoder config for audio and video: ``` type CommonExtensions = { "broadcast": string | undefined, "label": string | undefined, "container": Container, "jitter": number | undefined, "delay": number | undefined, } ``` #### broadcast {#field-broadcast} By default a rendition's track lives in the same broadcast that served the catalog. The `broadcast` field overrides that, naming a different broadcast that publishes the track. The value is a relative path, resolved against the path of the broadcast that served the catalog. It uses relative reference resolution ([RFC 3986, Section 5.2](https://www.rfc-editor.org/rfc/rfc3986#section-5.2)): a non-empty reference replaces the catalog broadcast's last path segment before applying `.` and `..` segments. For example, `./source` in a catalog served by `room/transcode` resolves to `room/source`, while `.` resolves to `room`. An empty reference resolves to the catalog broadcast itself. A publisher MUST NOT use an absolute path, nor a reference that escapes above the root. The root is the consumer's authorized subtree, so such a reference names content the consumer cannot reach. A consumer MUST reject a catalog containing one, rather than resolving the reference against a different broadcast or ignoring the rendition. This lets a publisher author a catalog that points at tracks it does not republish. For example, a transcoder produces a catalog listing its own downstream renditions alongside the untouched source rendition, referencing the latter in the source broadcast rather than copying the bytes through. A consumer subscribes to such a rendition in the referenced broadcast, using the rendition's track name unchanged. #### label {#field-label} The `label` field is a human-readable rendition name for a track picker. It is presentation metadata, not the track name used to subscribe. Multiple renditions MAY use the same label. #### container {#field-container} The container used to frame this rendition's media, as described in [Container](#container). If absent, it defaults to `{ "kind": "legacy" }`. #### jitter {#field-jitter} The maximum delay, in milliseconds, that a publisher has measured before handing a frame to the transport. A consumer's jitter buffer SHOULD be at least this large to avoid stalling. If absent, a consumer SHOULD assume no publisher delay has been measured. An encoder compares each frame's flush time with its media timestamp and subtracts the smallest lateness recently observed on the same rendition. The field is the spread above that minimum, so neither a constant encoder delay, a fixed clock offset, nor long-term drift counts as jitter. A container importer instead estimates the delay from the media span of a batch, such as a run of audio PES packets between video packets in TS or an fMP4 fragment, without measuring the time spent waiting for input. A publisher does not count ingest network delay in this field; a consumer measures network delay separately. A publisher MUST round the value up to a whole number of milliseconds, so a consumer sizing a buffer against it is never handed a bound below the real one. A publisher MUST NOT advertise `0`; a track that flushes each frame immediately omits the field instead. A consumer receiving `0` SHOULD treat the field as absent, since it is a publisher rounding down rather than a claim of zero delay. A publisher MUST NOT lower a previously advertised value, since a burst it emitted once it may emit again. For example: * A frame flushed without extra delay contributes no `jitter`, regardless of frame rate. * An encoder that consistently flushes 200 milliseconds late contributes no `jitter`; only variation above its own minimum counts. * A fragment or packet batch contributes the media span between its earliest timestamp and flush point. * Reordered frames contribute the delay they were held before flushing, without treating a decode-order presentation timestamp gap as delay by itself. #### delay {#field-delay} The maximum amount, in milliseconds, by which a rendition's minimum flush lateness ([jitter](#field-jitter)) has trailed the smallest minimum among the broadcast's renditions that measure it. If absent, a consumer SHOULD assume the rendition does not trail the others. A publisher measures each rendition's minimum over the same recent window it uses for `jitter`, from one clock shared by every rendition, and advertises the largest difference observed. A container importer does not measure `delay`. The rounding, `0`, and never-lower rules of `jitter` apply unchanged. A consumer SHOULD hold at least the largest `delay` plus `jitter` among the renditions it plays together. A consumer MUST NOT subtract one rendition's `delay` from another's: each is a maximum over the life of the stream, so two values need not share an origin. For example, a video encoder that flushes 200 milliseconds after the audio encoder for the same media time advertises a video `delay` of 200 and no audio `delay`. ## Container {#container} Audio, video, and text tracks use a container to encapsulate the media payload. A rendition declares its container via the `container` field of its catalog entry ([Common Rendition Fields](#common)): ``` type Container = { "kind": "legacy" } | { "kind": "cmaf", "init": string } | { "kind": "loc" } ``` The `kind` field selects the framing; a consumer MUST ignore a rendition whose `kind` it does not recognize. Every container shares the same group rules: Each moq-lite group MUST start with a keyframe, except a group that contains only a discontinuity marker. If the codec does not support delta frames (e.g. audio), a group MAY consist of multiple keyframes. Otherwise, a group MUST consist of a single keyframe followed by zero or more delta frames. A group with no decodable frames is a walk-now discontinuity: one empty codec payload and no media. A consumer MUST NOT submit the marker to a decoder. Empty groups (zero objects) are permitted and mean nothing. After a discontinuity the timeline continues forward. A publisher that stops producing and may resume on the same track (e.g. an encoder idle for lack of demand) SHOULD publish a discontinuity marker when it stops, so the group before the pause does not reach across the gap and read as live. A group whose timestamps fall below the live edge earlier groups reached is malformed. A delivered sequence hole is a playhead event unless the boundary is contiguous within 1 ms. A consumer re-applies startup delay and skip at a playhead event; it does not reset codec state. ### legacy The default, used when the `container` field is absent. Each frame starts with a timestamp, a QUIC variable-length integer (62-bit max) encoded in microseconds. The remainder of the payload is codec specific; see the WebCodecs specification for specifics. For video, a frame with an empty codec payload is the exclusive end of the frame before it, not media. A video publisher SHOULD end each group with one when the exclusive end is known. A publisher MAY estimate an unknown final duration from the frame cadence, but MUST NOT use batching or reorder delay as that duration. For reordered video, the group presentation endpoint does not necessarily bound the preceding frame in decode order; the publisher omits the marker unless that frame's exclusive end is known. A consumer MUST skip it and MUST NOT submit it to a decoder. It does not mean the track ended. For audio, an empty codec payload retains its terminal-trimming meaning: its timestamp is the exclusive endpoint of the source media. When a codec must receive additional packets to emit buffered source samples, the marker MUST precede those terminal packets in the same group. A consumer MUST NOT submit the marker to the codec decoder, MUST decode the terminal packets, and MUST discard decoded samples at or after the endpoint until a later group begins. Audio publishers do not append per-group duration markers because the codec defines each packet's duration. Data tracks retain empty payloads as data, without endpoint semantics. For example, h.264 with no `description` field would be annex.b encoded, while h.264 with a `description` field would be AVCC encoded. For a text track, the remainder is the cue in the track's declared `format` (for example a `WEBVTT` segment). ### cmaf Each frame is a complete fragmented MP4 fragment (`moof`+`mdat`), carrying its own timestamps. Audio samples MUST be marked as sync samples, including samples inside a group. A sync sample does not declare an audio group boundary; the publisher chooses those boundaries. The `init` field is the initialization segment (`ftyp`+`moov`) for the track, base64-encoded ([RFC 4648, Section 4](https://www.rfc-editor.org/rfc/rfc4648#section-4)). A consumer MUST feed `init` to the decoder before the first frame. ### loc Each frame is a Low Overhead Container frame [draft-ietf-moq-loc](https://datatracker.ietf.org/doc/draft-ietf-moq-loc/): a property block, carrying the timestamp among other properties, followed by the codec payload. For audio and video, consumers accept empty codec payload metadata with the same video-duration and audio-terminal-trimming meanings as the legacy container. Data tracks retain empty payloads as data. A consumer MUST NOT submit it to a decoder. ## Compression {#compression} Some metadata tracks are compressed. Compression is signalled per track, never inferred from the track's name. A data track ([Data Tracks](#data)) declares it with the `compression` field ([compression](#field-compression)). The two tracks this specification defines as always compressed, `catalog.json.z` ([Catalog](#catalog)) and the timeline track ([Timeline](#timeline)), are identified by their role instead. The `.z` suffix on those names is a naming convention, and a consumer MUST NOT treat it as a signal. Such a track is compressed per [DEFLATE Compressed Tracks for MoQ](https://datatracker.ietf.org/doc/draft-lcurley-moq-flate/): each group is one raw DEFLATE stream, sync flushed at each frame boundary. The suffix is the declaration. ## Timeline {#timeline} The timeline track is the broadcast's segment index. MoQ groups carry only an opaque sequence number; the timestamps live inside the media frames. The timeline republishes the broadcast's segmentation as metadata: one record per segment, mapping a span of content time to the group ranges that carry it on each media track. A consumer can answer "which groups cover time T on track X" and "where is the live edge" from a few bytes per segment, without downloading media. This is sufficient to render an HLS or DASH playlist, seek a VOD recording, or index an archive. The timeline is optional. There is one timeline per broadcast, because its purpose is that segments are aligned across the broadcast's tracks: segment N covers the same span of content time on every track, which is what HLS requires of switchable renditions. A broadcast that does not need aligned segments simply omits it. ### Catalog Section {#archive-catalog} The catalog's root `archive` field is the one name for the segment index, and for any durable recording of those ranges: ``` type ArchiveSchema = { "track": string, "timescale": number | undefined, "durationMax": number | undefined, "replay": string | undefined, "store": string | undefined, "version": number | undefined, } ``` A consumer that cannot decode the `archive` entry MUST ignore that entry without rejecting the catalog. The `track` field names the MoQ track carrying the segment records. The name `timeline.z` is RECOMMENDED; a consumer MUST use the advertised name rather than assuming it. A live publisher without a store advertises `archive` with the timeline fields (`track`, `timescale`, `durationMax`) alone. Every range the timeline advertises is FETCHable; with a store they are also durable. There is no sibling `timeline` entry and no generation: a client that must tell recordings apart compares `replay` and `store`. The `timescale` field is the units per second for the records' `pts` and `duration` values, and for `durationMax`. If absent, it defaults to 1000 (milliseconds). A zero `timescale` is invalid and MUST be refused. The `durationMax` field, if present, is the declared upper bound on a segment's `duration`, in `timescale` units. A publisher that controls its encoder knows its keyframe cadence up front, so a consumer can size buffers or write an HLS `EXT-X-TARGETDURATION` from the catalog alone, before observing a single segment. The value MUST NOT change for the life of the broadcast, and a publisher MUST NOT emit a record whose `duration` exceeds it. A publisher that cannot honor that MUST omit the field rather than emit a record contradicting it. The field is absent when the media decides the segmentation instead, which is the common case: a real-time encoder places keyframes on demand and a single GOP may be minutes long, and a publisher importing a source it does not control cannot promise anything about that source. A consumer needing a bound then derives one from the records it has seen, raising it as longer segments arrive. Wall-clock mapping is the catalog root `clock` ([Clock](#clock)), not this section: a consumer derives the wall-clock time of any segment as `clock.wall + pts` after converting `pts` into the clock's timescale. The `replay` field, if present, is a relative MoQ broadcast path ([broadcast](#field-broadcast)) the archive is served back from. Absent, the timeline lives on the catalog's own broadcast. A wildcard replay path names no generation. The `store` field, if present, is the object-store URL the recording objects ([Recording](#recording)) live under. Authorization for `replay` and `store` is external. The `version` field is the recording object format version ([Track Objects](#recording-track)). It is 1 for this specification. A publisher that exposes a store MUST set it; a publisher that does not MUST omit it. A catalog that composes another broadcast's renditions MUST preserve that child's `archive` entry instead of synthesizing one. ### Track Framing {#timeline-framing} The timeline track is a sliding window of records. An unbounded publisher only appends records, while a DVR publisher also removes records from the front as they expire. The first frame of each group is a UTF-8 JSON object containing a checkpoint of the retained window: ``` { "offset": number, "start"?: number, "records": TimelineRecord[], } ``` The `offset` is the absolute index of the oldest retained record. `start` is the absolute index of `records[0]` and defaults to `offset` when omitted. A publisher MAY omit a retained prefix from a checkpoint; a consumer that did not receive that prefix reports `offset` through `start` as skipped. Each subsequent frame is one UTF-8 JSON operation, either `{ "push": TimelineRecord }` to append a record at the next absolute index, or `{ "pop": number }` to remove that many records from the front. Indices MUST NOT exceed 2^53 - 1. A publisher MAY roll groups to bound late-join cost or improve compression. Each new group restates a decodable suffix of the retained window, so group boundaries are an encoding detail and MUST NOT surface as duplicate records to the application. A consumer that missed records reports their absolute index range as skipped before continuing with the retained suffix. The frames are DEFLATE-compressed ([RFC 1951](https://www.rfc-editor.org/rfc/rfc1951)) within each group. The publisher ends each frame's compressed data with an empty sync-flush block (the `0x00 0x00 0xff 0xff` trailer is removed, as in [RFC 7692](https://www.rfc-editor.org/rfc/rfc7692)), so a consumer decompresses frames incrementally from the group's first frame. The `.z` suffix on the RECOMMENDED track name marks this compression, mirroring the catalog's `catalog.json.z` sibling. ### Records {#timeline-records} Each record describes one complete segment: ``` type TimelineRecord = { "segment": number, "pts": number, "duration": number, "tracks": Map | undefined, } type TimelineRange = { "start": number, "end": number, "keyframe": boolean | undefined, } ``` The `segment` field is the segment's number. Numbers are consecutive within a broadcast, anchoring HLS `EXT-X-MEDIA-SEQUENCE`; they are explicit rather than implied by record order so a reader joining mid-stream, or reading a windowed recording, keeps stable numbering. The `pts` field is the segment's start and `duration` its length, both in the timeline's timescale. The next record's `pts` equals `pts + duration` unless content time itself jumped; a consumer SHOULD treat such a jump as a discontinuity. The `tracks` field maps each participating track name to the group ranges it contributes. Each range covers groups `start` through `end` inclusive, as used by moq-lite FETCH and SUBSCRIBE. More than one range means the group sequence is discontinuous inside the segment: the skipped groups never existed. A track absent from the map has no content for the span (a gap; HLS `EXT-X-GAP`). Participating tracks need not be audio or video. A catalog, or an application's own metadata track such as a chat log, is listed exactly like a media track, which is what lets a recording ([Recording](#recording)) address all of them the same way. A consumer that only wants renditions therefore MUST select tracks by consulting the catalog rather than by assuming every name in the map is media. A record MUST tolerate and SHOULD preserve unknown fields, like the catalog. The `keyframe` field states whether the range's first group starts with a keyframe, i.e. whether a player can join or switch renditions there. If absent, it defaults to true; a publisher sets `false` when a source resumes without one, so an exporter knows not to advertise the segment as independently decodable. ### Segmentation {#timeline-segmentation} A segment is a span of content time shared by every media track. A track contributes every group whose start falls inside the span, so a segment boundary SHOULD land on a group start: every group already begins with a keyframe ([Container](#container)), so a boundary at a group start lets each track contribute whole groups and remain independently decodable. A segment MAY span multiple groups of a track (short groups packed into a longer segment). How boundaries are chosen is publisher policy: following a source's existing segmentation (an imported HLS playlist, CMAF segments on disk), or pacing by a minimum duration. A publisher pacing itself SHOULD end a segment at the earliest point that is a group start on every enrolled track and at least the minimum past the segment's start, which makes the track with the coarsest groups pace the broadcast and leaves no track's group split across a boundary. A minimum is always satisfiable, whereas a maximum is not: a single group longer than it cannot be divided. Where no such point exists because two tracks have different coarse cadences, a publisher MUST choose one of them rather than a point interior to any track's group. Whatever the policy, a publisher MUST NOT emit a record until the segment is complete: every *pacing* track's groups for the span are known. Records are therefore self-contained and immediately servable, and the newest record is the live edge. A pacing track that has produced nothing for the span holds the record back; a publisher that knows a track has stopped for good closes it, and the record then simply omits it (a gap). A pacing track is one whose groups arrive continuously, which is what makes them usable as boundaries. A track that publishes on its own schedule cannot pace: a catalog emits a group only when the renditions change, so a timeline waiting for it would stall the moment it went quiet. Such a track is *non-pacing*: its groups are listed in whichever segment is open when they arrive, but it never determines a boundary and never holds a record back. A publisher SHOULD record its catalog this way, so a recording can resolve the renditions in effect at any segment. Placement of a non-pacing track's groups is therefore by arrival rather than by content time: nothing waits for them, so a group that arrives after its segment has already been published is listed in the next one. When a segment closes, every non-pacing group that arrived while it was open belongs to that segment regardless of the timestamp basis carried by the non-pacing track. The frames still carry their own timestamps, so no timing information is lost. A non-pacing track's timestamps do not extend the final segment's duration. A non-pacing track whose group never closes (an append-log such as a `moq-json` stream) is listed once, in the segment its group opened in. A publisher that needs such content addressable per segment SHOULD roll the group at segment boundaries, which costs the shared compression window but makes each segment self-contained. A group that starts before the first boundary belongs to the first segment. The final segment of an ended broadcast has no closing boundary; its `duration` runs to the newest known content. A publisher SHOULD carry the end of the last group's content into that value, since a publisher that knows only where each group *started* would report a duration one group short, and zero for a final segment that is a single group. ## MPEG-TS Service Information {#mpegts-si} A broadcast imported from an MPEG-TS multiplex can carry the multiplex's standalone service-information tables (ISO/IEC 13818-1 program-specific information and ETSI EN 300 468 DVB SI: SDT, NIT, BAT, EIT, and any table the publisher does not recognize) so an exporter can re-emit them byte-for-byte. The sections are opaque: nothing in this mechanism parses a table, so an unrecognized long-form table round-trips exactly like a known one. Short-form tables, recognized or not, are carried with latest-value semantics instead (see below): the short-form tables broadcast systems define are clocks and stuffing, and a hypothetical multi-section static one would collapse to its most recent section. The tables are state, not events: a transport stream retransmits them continuously only because a TS receiver can tune in at any moment, so the repetition rate is a property of the unreliable transport, not of the data. Here each table rides a dedicated snapshot track, delivered once at join and republished on change, per the root catalog's guidance that dynamic content is delegated to other tracks. Consumers that are not TS exporters never pay for it. ### Catalog Section {#mpegts-si-catalog} The catalog's `mpegts` section maps each carried table to its track under an `si` field, keyed by the PID the sections ride on and then by `table_id`: ``` type Mpegts = { // ... other MPEG-TS carriage fields ... "si": { [pid: string]: { [tableId: string]: SiEntry } } | undefined, } type SiEntry = { "track": string, "interval": number | undefined, } ``` Both keys are written in decimal, since JSON object keys are strings. `table_id` is byte 0 of generic section syntax, so the key is no less generic than the PID; which table\_id ranges mean what is a DVB convention that appears nowhere in the schema. * `track`: the name of the snapshot track carrying this table's sections, on the same broadcast as the catalog. * `interval`: how often an exporter MUST re-emit the sections at most, in milliseconds. A hint carrying the table's own repetition requirement (for DVB, the ETSI TS 101 211 maxima). When absent, an exporter SHOULD fall back to a fast cadence of its own choosing, so an unknown table degrades to a safe rate rather than being dropped. Entries are added when a table is first observed, which is acquisition-time traffic; steady-state table revisions touch only the named track, never the catalog. ### Track Format {#mpegts-si-track} An SI track is binary, without the media container framing ([Container](#container)). Each frame is one sub-table: its complete current sections, concatenated verbatim in `section_number` order, each including its header and CRC. Sections are self-delimiting via `section_length`, so the frame needs no framing of its own. A sub-table's identity is its generic section header (`table_id`, `table_id_extension`) extended by the documented disambiguators for two DVB table families: `original_network_id` (bytes 8..10) for SDT other (`table_id` 0x46), and `transport_stream_id` plus `original_network_id` (bytes 8..12) for EIT (0x4E..0x6F), whose generic identities are only unique within one network or transport stream. Each group is a snapshot: reading its frames in order yields the table's complete current state, where a later frame replaces an earlier one with the same sub-table identity. A joiner reads only the newest group and is current; older groups never need to be fetched. A writer MAY append a frame to an open group to revise one sub-table incrementally; a writer that only ever cuts complete groups is trivially conformant. A publisher MUST NOT mix versions within a sub-table's frame: sections of one version are buffered until the generation is judged complete and committed atomically. Completeness is contiguity (all of `0..=last_section_number` present) or one observed full transmission cycle, whichever comes first: some tables number their sections sparsely (DVB EIT schedule skips unused numbers per segment), so a repeated section within the pending generation proves the cycle wrapped and the set is complete as transmitted. A section lost before the cycle wraps is indistinguishable from a legitimately skipped number, so a committed set can transiently omit it until the next cycle re-supplies it; no section-counting receiver can do better. A publisher SHOULD carry only sections whose `current_next_indicator` is set. A section without a long-form header (short-form syntax: TDT/TOT and similar) has no extension, version, or numbering, so its table is a single latest-value slot: each arrival replaces the last, and a byte-identical repetition is not a change. This is what makes a time table proxyable: each tick republishes a small snapshot, a joiner reads the newest, and staleness is bounded by the source's own repetition interval plus path latency, the same bound a receiver of the original multiplex has. Carrying the source's time rather than synthesizing one keeps the clock consistent with the EPG (event times are expressed on the source's clock) and preserves TOT's local-time-offset descriptors, which are operator policy a re-multiplexer cannot invent. ## Recording {#recording} A live broadcast is bounded history: moq-lite [draft-lcurley-moq-lite](/draft/moq-lite) serves the present with SUBSCRIBE and the recent past with FETCH, both ending at the publisher's cache. A *recording* is the persistent tier, writing a broadcast to a filesystem or object store so it can be served back long after the live session ended. A recording is addressed by segment. The timeline ([Timeline](#timeline)) names every segment and the groups that carry it. Each track's object is addressed by its smallest and largest stored group IDs; the timeline supplies those bounds without reading media. A reader that wants segment N of a track issues one whole-object GET, with no range header and no second request, which is what both an HLS or DASH origin and a player seeking a VOD recording need. A recording covers the tracks the timeline describes, plus the catalog and the timeline itself. A broadcast with no timeline has no segments and cannot be recorded this way. ### Layout {#recording-layout} A recording is a set of objects under a common prefix: ``` //.info //groups/. //segments/ ``` The common prefix is application-defined and is not interpreted by this format. `` is the track's UTF-8 name with every byte outside `A-Z a-z 0-9 _ -` percent-encoded as `%` followed by two uppercase hexadecimal digits. For example, `catalog.json` becomes `catalog%2Ejson`. An encoded name never contains `/` or begins with `.`, so a track cannot address anything outside the prefix. `.info`, `groups`, and `segments` are reserved within a track directory. `` and `` are the inclusive first and last group sequence numbers in the object. Recorded group and segment IDs MUST be integers in the range 0 through 9007199254740991 (2^53 - 1), so timeline JSON preserves them exactly. Each filename field is written as exactly 19 decimal digits, padded with leading zeros, so lexical and numeric order agree. Writers and readers MUST reject IDs outside this range, including reconstructed group IDs in the binary table. A range-named object MUST contain at least one group, and its table's first and last sequences MUST match the filename. For each track, object ranges MUST be nonoverlapping and strictly increasing in timeline segment order; gaps are allowed both within and between objects. A track with no stored groups for a segment has no object or range in that record. There are no empty index objects or duplicate copies addressed by segment number. This layout applies to every recorded track, including the catalog, except the recording-owned timeline track identified by the catalog's `archive` field ([Catalog Section](#archive-catalog)). The timeline uses `segments/` with the same binary envelope and at least one complete Window group per object. `` is the committed timeline segment ID, encoded as 19 zero-padded decimal digits within the recording ID range. After its first segment, the recording MUST commit consecutive segment IDs, including all-gap segments. A writer MUST stop before allocating an ID beyond the limit; IDs never wrap. Each track has its own objects so a reader can fetch one rendition without downloading the others. ### Track Objects {#recording-track} `/.info` is a UTF-8 JSON object containing the immutable properties needed to interpret and replay the track: ``` { "version": 1, "priority": 0, "timescale": 1000000 } ``` All three fields are required integers. `version` identifies this recording format and MUST be 1. `priority` and `timescale` have the meanings of moq-lite `TRACK_INFO` [draft-lcurley-moq-lite](/draft/moq-lite): `priority` is in the range 0 through 255, and `timescale` is in the range 1 through 9007199254740991, so JSON consumers can preserve it exactly. A reader MUST preserve integer values exactly. `Publisher Max Age` is not stored; a reader supplies its own serving policy. The track object MUST be durable before its first segment object is stored, including for the timeline track. It is immutable for the lifetime of the recording. A reader MUST refuse an unknown version or invalid track properties. On an existing `.info`, a writer MUST validate and compare the parsed `version`, `priority`, and `timescale` values; JSON whitespace and member order do not affect equality. Different property values MUST fail enrollment, and the existing object MUST NOT be rewritten. ### Segment Objects {#recording-segments} A segment object holds one track's complete groups for one segment: ``` Segment Object { Version (i) = 1 Group Count (i) Group Table { Sequence Delta (i) Frame Count (i) Frame Table { Timestamp (i) Payload Offset (i) Payload Length (i) } ... } ... Payload Bytes (..) } ``` Fields annotated `(i)` are variable-length integers using the QUIC encoding ([RFC 9000](https://www.rfc-editor.org/rfc/rfc9000), Section 16). `Group Count` gives the number of group entries; each `Frame Count` gives the number of frame entries in that group. The first `Sequence Delta` is the absolute group sequence number. Each subsequent value is the group sequence number minus the previous sequence number minus one; zero therefore means the next consecutive group. Groups MUST have strictly ascending sequence numbers; reconstruction MUST reject values exceeding the recording ID limit ([Layout](#recording-layout)). For range-named objects, the sequences MUST match the ranges in the timeline. Frame entries appear in their original order within each group. `Timestamp` is the frame's absolute timestamp in the track's `timescale` units, not a delta. It MUST be in the range 0 through 9007199254740991 (2^53 - 1), so browser replay preserves it exactly; writers and readers MUST reject larger values. `Payload Offset` is relative to the start of `Payload Bytes`, immediately after the last table entry; `Payload Length` is the frame's payload size in bytes. Payloads are concatenated in table order, without gaps, overlap, or trailing bytes. They are the original frame payloads, including any group-scoped compression, without moq-lite FRAME headers. A reader can locate a group or a frame index directly from the table without parsing earlier payloads. A decoder MUST validate the complete table before accessing any payload. Counts and entries MUST fit in the retrieved object, and every offset and length MUST describe a range within its payload bytes, with overflow checked before arithmetic. An unknown version or malformed table is a missing segment, not an invalid recording. A segment object MUST contain whole groups. Segmentation forbids a boundary inside a track's group ([Segmentation](#timeline-segmentation)), so a reader never has to stitch a group across objects. Segment objects are immutable once written and are parseable without the timeline. The timeline uses the same envelope and stores only complete Window groups ([Track Framing](#timeline-framing)) closed while committing that segment. Its own groups are discovered by listing, not included in the record's `tracks`, avoiding a record that must index itself. A reader replays their checkpoints and operations in group order, preserving group boundaries and their independent DEFLATE windows. Neither timeline nor media objects are appended to or rewritten. One writer owns a recording prefix. An existing segment object key MAY be reused only for identical object bytes; a conflicting create MUST fail. ### Writer Behavior {#recording-writer} A writer subscribes to the broadcast and buffers the in-progress segment independently of the publisher or relay cache. It writes a track's segment object once that track's groups for the span are known. Per-track objects are written independently: a track whose content is complete does not wait for a slower one. The recording contract MUST NOT depend on a relay retaining those groups while storage catches up. A writer MUST make each track's `.info` and segment object durable before publishing the timeline record that references them. The recording owns its timeline encoder so it can publish the ranges actually stored. If a track's object cannot be made durable, the writer MUST omit only that track from the record's `tracks`; successfully stored tracks retain their ranges. The segment number and timing are unchanged, including when every track is omitted. The writer pushes the resulting record and every subsequent record through its own Window encoder. It MUST NOT copy source frames whose Window position or group-local DEFLATE dictionary depends on a record it changed. A writer MUST accept new groups for each track in strictly increasing sequence order and MUST refuse a duplicate or decreasing sequence; it does not reorder arrivals. Groups already accepted MAY complete in any order; the writer buffers them and writes their table in sequence order. An incomplete group omitted by an application-forced segment cut MUST NOT be inserted into a later object if that would overlap or precede an earlier object's range. The application MAY force a cut or remove a stalled track; storage does not impose a timeout. A writer MUST NOT insert objects for earlier segments after committing a later segment. For segment N, the writer publishes the stored ranges, applies any retention pops, closes the timeline's current Window group, and stores the complete groups under the timeline track's `segments/N` key, with N encoded as specified in [Layout](#recording-layout). It MUST make that object durable before committing the next segment or deleting expired objects. If the timeline object cannot be made durable, the recording stops at the preceding durable timeline object. On a clean end, the writer MUST flush the final partial segment, finish the timeline track, and make its final complete groups durable. There is no completion marker; object listing alone does not distinguish a clean end from an interruption. ### Retention {#recording-retention} A recording has one of two retention modes: * An *archive* is unbounded and retains every complete segment until explicitly deleted. * A *DVR* retains a configured duration of complete segments and expires the oldest whole segments as newer ones become durable. An application offering DVR without an explicit retention value SHOULD default to at least 30 seconds. The writer removes the oldest segment only when the remaining complete segments still cover the configured duration. Retention is measured from timeline records, not from wall-clock arrival or relay cache state. Trimming occurs only as part of committing a new segment, before that segment's timeline object is finalized ([Writer Behavior](#recording-writer)). A recording MUST NOT trim after its final segment is committed. Expiration first pops expired records from the timeline window and makes the resulting complete timeline groups durable, then deletes the expired segments' objects. The writer MUST retain the latest timeline object, even for an all-gap segment, and enough earlier timeline groups to recover the retained window from a checkpoint. The index therefore never advertises media the retention process has already deleted, although media objects MAY temporarily outlive the index. Relay cache eviction does not change the recording timeline. Before accepting new groups, a restarting DVR writer MUST recover the complete retained timeline and list every recorded track's `groups/` prefix under exclusive ownership of the recording prefix. After waiting the configured deletion grace period from successful recovery, it MUST delete group objects whose keys are absent from the recovered retained records, including expired objects and uncommitted uploads left by a crash. It MUST NOT perform this cleanup if timeline recovery or listing fails or is incomplete, or while another writer can create or commit objects. This completes previously committed expiration; it does not pop additional records after a final segment. Timeline objects needed for checkpoint recovery and `.info` objects are not candidates for this cleanup. ### Bootstrap and Recovery {#recording-recovery} A reader or restarting writer lists the timeline track's `segments/` prefix and replays its objects in numeric segment order, including retention operations, from a retained checkpoint. The catalog supplies the timeline track's name ([Catalog Section](#archive-catalog)). The recovered records determine the committed track object keys; a missing or malformed referenced object MUST NOT be served. Objects not referenced by the recovered timeline do not advertise content on their own. Listing is not an atomic snapshot across tracks; absence from an earlier listing MUST NOT override a successful GET of an object referenced by a later durable timeline record. After replaying segment N below the recording ID limit, a reader follows an active recording by GET of `segments/N+1`, without refreshing media listings. At the limit there is no next key; this does not establish a clean recording end. Not Found does not distinguish a pending commit, an expired DVR segment, or an interrupted recording. A reader that has fallen behind retention MUST bootstrap again from a retained checkpoint. Alternatively, a reader MAY list timeline keys after its last replayed key, consume all pages, sort the results, and replay them in order. A continuation token is used only within that enumeration; the next refresh starts from the last replayed key. A reader MUST NOT advance its replay cursor past a missing segment without recovering from a retained checkpoint. Retention operations evict expired ranges from the reader's cached index; incremental listing alone does not report deletions. ### Reader Behavior {#recording-reader} Reading segment N of track T is: take the minimum and maximum group IDs from T's ranges in record N, GET `groups/.`, and parse the groups. The table MUST contain exactly the group sequences advertised for that track in the record, including its gaps. Nothing on that path reads media the consumer did not ask for, and nothing requires a second request. A reader MAY serve moq-lite FETCH from a recording. Given a group, a reader locates the candidate object from a cached filename range index or the timeline's track ranges, then uses the table for the group and any requested `frame_start` [draft-lcurley-moq-lite](/draft/moq-lite). A filename listing requires no media GETs. On a backend guaranteeing lexical listing order and exclusive offsets, listing after `groups/` (19 padded digits, without the dot) selects the first candidate with an upper bound at least the requested group. A reader MUST check the lower bound and committed timeline membership before serving it; an internal gap is resolved by the candidate's table. An unordered listing MUST be collected and sorted before selecting a candidate; taking its first result is insufficient. The reader reconstructs moq-lite FRAME headers from the stored timestamps and lengths, preserving the original payload bytes. A group absent from the recording is a normal FETCH failure. Recording catalog groups does not establish which catalog update applies to a media group; this format does not define that correlation. A reader deriving a presentation-ordered format renders it from the timeline and transmuxes segment objects on demand. Nothing derived needs to be stored: the playlist or manifest is a function of the timeline, and a media segment is a function of one recorded object. An HLS segment URI can carry the track and both group bounds, allowing the handler to resolve the exact object without listing or a segment-ID index. HLS sequence numbers remain timeline metadata and need not occur in object names. ## Rooms A room is a broadcast path prefix. A participant publishes camera and microphone at `{identity}/camera.hang` and a screen share at `{identity}/screen.hang`, relative to that prefix. Identity MUST contain at least one nonempty path segment. Consumers MAY also recognize the unsuffixed `camera` and `screen` forms. A participant MAY publish a `chat` track containing a JSON window of messages from the last ten seconds. Each edit opens a new group containing one uncompressed UTF-8 JSON header of the form `{"offset": N, "records": ["text", ...]}`. The records are the complete retained window, oldest first. The offset is the absolute index of its first record and advances as records expire. Offsets MUST be nonnegative safe JSON integers (at most 2^53 - 1). Publishers MUST retire records after ten seconds, including while idle. Consumers start at the latest group and report records entering or leaving the window; missing index ranges indicate messages that expired before being received. Consumers MUST report malformed JSON, non-string records, and transport failures as errors, distinct from the clean end of the track. Sender identity comes from the broadcast path, not the payload. This track is distinct from an application's `hang/chat.json` snapshot extension. ## Security Considerations A rendition's `broadcast` reference ([broadcast](#field-broadcast)) resolves against the consumer's root, which is the subtree it is authorized for. Clamping a reference that escapes above that root would silently redirect the subscription to an unrelated broadcast, so a consumer rejects the catalog instead. TODO Security A consumer parsing a recording ([Recording](#recording)) is parsing data at rest that it did not necessarily write. It MUST validate the group/frame table against the bytes actually retrieved before allocating from its counts or accessing payloads ([Segment Objects](#recording-segments)), and treat a malformed object as a missing segment rather than letting it invalidate the recording. It MUST reject a track object with a zero `timescale`. Varint fields are subject to the same limits as moq-lite [draft-lcurley-moq-lite](/draft/moq-lite). A recording inherits the confidentiality and integrity properties of the storage holding it; encryption at rest is transparent to the format and out of scope. ## IANA Considerations This document has no IANA actions. ## Normative References * [draft-lcurley-moq-lite](/draft/moq-lite) * [DEFLATE Compressed Tracks for MoQ](https://datatracker.ietf.org/doc/draft-lcurley-moq-flate/) * [WebCodecs](https://www.w3.org/TR/webcodecs/) ## Appendix A: Changelog ### moq-hang-03 * Defined encoder `jitter` as flush lateness above the rendition's own recent minimum, replacing fixed frame-duration hints; container batches retain media-span estimates. * Clarified that CMAF audio samples are sync samples independently of publisher group boundaries. * Clarified that container importers can estimate jitter from batch media spans without measuring input wait time. * Specified the `jitter` field's computation: the publisher's own structure rather than the network, rounded up to whole milliseconds, never `0` (a consumer treats `0` as absent), and never lowered once advertised. * For video, an empty codec payload is the exclusive end of the frame before it. A video publisher SHOULD end each group with one when the exclusive end is known. Audio retains its terminal-trimming marker before codec drain packets. A publisher MAY estimate an unknown final duration from the frame cadence, but MUST NOT use batching or reorder delay as that duration. A consumer skips it and does not submit it to a decoder. Audio terminal-packet trimming is unchanged. * Specified version 1 recording objects: JSON track properties and binary group/frame tables with ascending, delta-encoded group sequences. * Addressed track objects by inclusive group bounds and timeline objects by consecutive segment IDs, with incremental discovery and per-track omission on storage failure. * Restricted retention updates to segment commits and removed completion markers. * Limited recorded group and segment IDs and frame timestamps to JSON-safe integers, including delta reconstruction. * Compared existing track properties by parsed values rather than JSON serialization. * Required exclusive DVR restart recovery to remove unreferenced group objects left by interrupted expiration. * Replaced the catalog root `timeline` field with `archive`, carrying the timeline track plus optional `replay`, `store`, and recording `version`. * A marker group of one empty frame declares a discontinuity. Empty groups mean nothing. Timestamps only move forward; a group below the live edge is malformed. A delivered sequence hole is a playhead event unless contiguous within 1 ms. * A publisher that stops producing and may resume on the same track SHOULD publish a discontinuity marker when it stops. * An audio endpoint bounds only the terminal packets that follow it in its own group. * Replaced the archive timeline `wall` field with a root `clock` section (`wall` plus `timescale`): one fixed broadcast mapping every track and the archive index convert into, independent of any archive. Zero timescales and walls past the JSON-safe integer range are refused. * Added optional `bitrate` and `jitter` fields to `json` and `binary` track entries. * Added the optional `delay` rendition field: how far a rendition's minimum flush lateness trails the broadcast's earliest rendition, never lowered once advertised and never subtracted across renditions. * Recommended namespaced keys for application root sections. * Added the optional `delay` field to `json` and `binary` track entries, measured only from payloads stamped with their capture time on the broadcast clock. * Required consumers to ignore an undecodable `archive` entry without rejecting the catalog. ## Acknowledgments TODO acknowledge. --- --- url: https://doc.moq.dev/draft/moq-hidden.md description: >- This document defines an extension for MoQ Transport draft-ietf-moq-transport that hides namespaces from discovery. --- # MoQ Hidden Extension ::: info Rendered from the Internet-Draft source in this repository. Submitted versions are on the [IETF datatracker](https://datatracker.ietf.org/doc/draft-lcurley-moq-hidden/). ::: ## Abstract This document defines an extension for MoQ Transport [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) that hides namespaces from discovery. For peers that negotiate this extension, a namespace with a field starting with `.` below the prefix a subscriber asked for is left out of the advertisements it receives, unless its SUBSCRIBE\_NAMESPACE opts in. A platform can then add internal namespaces, such as statistics, without them turning up in applications that list everything and use what they find. ## Note to Readers This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it. Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything. ## Conventions and Definitions The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119](https://www.rfc-editor.org/rfc/rfc2119) [RFC8174](https://www.rfc-editor.org/rfc/rfc8174) when, and only when, they appear in all capitals, as shown here. An endpoint **advertises** a namespace by sending PUBLISH\_NAMESPACE, or NAMESPACE in response to a SUBSCRIBE\_NAMESPACE. A namespace is **hidden** from a subscription when one of its fields beyond the subscription's Track Namespace Prefix starts with the byte 0x2E (`.`). A field inside the prefix never hides anything, so a prefix that names the hidden field itself lists what is under it, and a namespace at or above the prefix has no field beyond it. Only the first byte counts: `catalog.v2` is not hidden. ## Introduction Discovery in [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) is all or nothing: a subscriber that asks for a prefix is told every namespace beneath it. An application that asks for the empty prefix and plays what it finds breaks the moment its platform publishes anything else under the same root, like a relay's own statistics or internal routing state. This extension reserves a leading `.` for such namespaces, as file systems do for hidden files. A hidden namespace is still published, routed, and subscribed to like any other; it is only left out of discovery by default. A subscriber that wants hidden namespaces too says so on the SUBSCRIBE\_NAMESPACE that would list them. For peers that declare this extension, the namespace name and the requested prefix determine whether an opt-in is needed. ## Setup Negotiation An endpoint declares that it understands the HIDDEN parameter ([Opting In](#parameter)) with the following Setup Option ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 10.3): ``` HIDDEN Setup Option { Option Key (vi64) = 0x40B5C Option Value (vi64) = 1 } ``` A receiver MUST ignore the value. An endpoint MUST NOT send the HIDDEN parameter to a peer that did not declare this option, because an unknown parameter fails decoding. A subscriber that wants hidden namespaces therefore waits for the peer's SETUP before sending SUBSCRIBE\_NAMESPACE. The filtering rules below apply only to peers that declared this option. A publisher MUST NOT hide namespaces from discovery solely because of a leading `.` when the peer did not declare it. Such peers receive all authorized namespaces, as in unextended MoQ Transport. ## Opting In {#parameter} A subscriber opts in to hidden namespaces with the following parameter on SUBSCRIBE\_NAMESPACE: ``` HIDDEN Parameter { Type (vi64) = 0x40B5E Value (vi64) = 0 or 1 } ``` A value of 1 opts in; 0 or an absent parameter does not. A receiver MUST close the session with a PROTOCOL\_VIOLATION on any other value. ## Advertising {#advertising} For a peer that declared the HIDDEN Setup Option, a publisher SHOULD NOT advertise a hidden namespace in response to a SUBSCRIBE\_NAMESPACE that did not opt in. Hiding is a convenience for discovery, not access control, so a publisher MAY treat a subscriber it trusts, such as another relay in its own cluster, as opted in. An unsolicited PUBLISH\_NAMESPACE answers no prefix, so it is measured against the empty one: a publisher SHOULD NOT send one for a hidden namespace to a peer that declared the HIDDEN Setup Option. When unsolicited advertisements are live, a SUBSCRIBE\_NAMESPACE is answered with only the namespaces they left out, which is to say those hidden from the empty prefix, that the subscription may see. A peer that did not declare the option already receives all namespaces unsolicited, so its subscription adds no duplicate advertisements. That covers both an opt-in and a prefix that names a hidden field itself, and no namespace is advertised twice. Hiding narrows discovery and nothing else. A SUBSCRIBE, FETCH, or TRACK\_STATUS for a track in a hidden namespace is served exactly as it would be without this extension. ## Security Considerations A hidden namespace is not a secret. Anyone who learns its name can subscribe to it, and a subscriber can opt in at will, so a publisher MUST apply the same authorization to hidden namespaces as to any other. ## IANA Considerations This document requests the following registrations. High, distinctive values are requested to avoid the low ranges reserved by [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) and to minimize collisions with provisional registrations by other extensions. ### MOQT Setup Options This document requests one registration in the "MOQT Setup Options" registry ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 15.4), whose policy is Specification Required. | Value | Name | Reference | |:--------|:-------|:--------------| | 0x40B5C | HIDDEN | This Document | ### MOQT Message Parameters This document requests one registration in the "MOQT Message Parameters" registry ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 15.7). | Value | Name | Carried In | Reference | |:--------|:-------|:--------------------|:--------------| | 0x40B5E | HIDDEN | SUBSCRIBE\_NAMESPACE | This Document | Both values are even, so each is a bare varint. ## Normative References * [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) ## Changelog ### Since draft-lcurley-moq-hidden-00 (in progress) * Apply hidden filtering only to peers that declared the HIDDEN Setup Option. * Measure visibility from the requested namespace prefix, independently of authorization scope. ## Acknowledgments This document was drafted with the assistance of Claude, an AI assistant by Anthropic. --- --- url: https://doc.moq.dev/draft/moq-lite.md description: moq-lite is designed to fanout live content 1->N across the internet. --- # Media over QUIC - Lite ::: info Rendered from the Internet-Draft source in this repository. Submitted versions are on the [IETF datatracker](https://datatracker.ietf.org/doc/draft-lcurley-moq-lite/). ::: ## Abstract moq-lite is designed to fanout live content 1->N across the internet. It leverages QUIC to prioritize important content, avoiding head-of-line blocking while respecting encoding dependencies. While primarily designed for media, the transport is payload agnostic and can be proxied by relays/CDNs without knowledge of codecs, containers, or encryption keys. ## Note to Readers This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it. Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything. ## Conventions and Definitions The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119](https://www.rfc-editor.org/rfc/rfc2119) [RFC8174](https://www.rfc-editor.org/rfc/rfc8174) when, and only when, they appear in all capitals, as shown here. ## Rationale This draft is based on MoqTransport [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/). The concepts, motivations, and terminology are very similar and when in doubt, refer to existing MoqTransport literature. A few things have been renamed (ex. object -> frame) to better align with media terminology. I absolutely believe in the motivation and potential of Media over QUIC. The layering is phenomenal and addresses many of the problems with current live media protocols. I fully support the goals of the working group and the IETF process. But it's been difficult to design such an experimental protocol via committee. MoqTransport has become too complicated. There are too many messages, optional modes, and half-baked features. Too many hypotheses, too many potential use-cases, too many diametrically opposed opinions. This is expected (and even desired) as compromise gives birth to a standard. But I believe the standardization process is hindering practical experimentation. The ideas behind MoQ can be proven now before being cemented as an RFC. We should spend more time building an *actual* application and less time arguing about a hypothetical one. moq-lite is the bare minimum needed for a real-time application aiming to replace WebRTC. Every feature from MoqTransport that is not necessary (or has not been implemented yet) has been removed for simplicity. This includes many great ideas (ex. group order) that may be added as they are needed. This draft is the current state, not the end state. ## Concepts moq-lite consists of: * **Session**: An established QUIC connection between a client and server. * **Broadcast**: A collection of Tracks from a single publisher. * **Track**: A series of Groups, each of which can be delivered and decoded *out-of-order*. * **Group**: A series of Frames, each of which must be delivered and decoded *in-order*. * **Frame**: A sized payload of bytes within a Group. The application determines how to split data into broadcast, tracks, groups, and frames. The moq-lite layer provides fanout, prioritization, and caching even for latency sensitive applications. ### Session A Session consists of a connection between a client and a server. There is currently no P2P support within QUIC so it's out of scope for moq-lite. The moq-lite version identifier is `moq-lite-xx` where `xx` is the two-digit draft version. The identifier for this draft is `moq-lite-07-wip` while it is a work in progress, and becomes `moq-lite-07` once finalized. For bare QUIC, this is negotiated as an ALPN token during the QUIC handshake. For WebTransport over HTTP/3, the QUIC ALPN remains `h3` and the moq-lite version is advertised via the `WT-Available-Protocols` and `WT-Protocol` CONNECT headers. When UDP is unavailable, moq-lite MAY also run over reliable byte-stream transports via Qmux [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/); see [Transports](#transports) for the specific bindings. The session is active immediately after the connection is established. Both endpoints SHOULD begin sending and receiving streams right away to avoid an extra round-trip. Optional capabilities and extensions are negotiated via a SETUP message (see [SETUP](#setup)). Each endpoint MUST open a unidirectional Setup Stream at the start of the session, send a single SETUP message advertising what it supports, and immediately close the stream (FIN); an endpoint with no optional capabilities sends a SETUP with an empty parameter list. Neither endpoint waits for the peer's SETUP before opening other streams. An endpoint MUST buffer a stream whose behavior or encoding depends on a negotiated extension until the peer's SETUP arrives; everything else proceeds immediately. As a fallback, an endpoint that opens an extension stream the peer does not support simply sees that stream reset (see [STREAM\_TYPE](#stream_type)). A negotiated capability applies only to this hop; each session is negotiated independently and relays MUST NOT forward SETUP. While moq-lite is a point-to-point protocol, it's intended to work end-to-end via relays. Each client establishes a session with a CDN edge server, ideally the closest one. Any broadcasts and subscriptions are transparently proxied by the CDN behind the scenes. ### Broadcast A Broadcast is a collection of Tracks from a single publisher. This corresponds to a MoqTransport's "track namespace". A publisher advertises what it can serve via ANNOUNCE\_START messages, each carrying a path prefix: a route covering every broadcast path beneath it. A route is the only shape an advertisement takes: a publisher that serves only some of the paths beneath a prefix, such as a transcoder for any broadcast's derivative, advertises the covering prefix and refuses the requests it will not serve (see [Resolution](#resolution)); no message narrows a route. The subscriber uses the ANNOUNCE\_REQUEST message to discover these routes, then subscribes to specific paths under them; which paths name broadcasts is an application convention. The common convention is that a publisher announces each broadcast's exact path as its own route, so subscribers can enumerate broadcasts, while a service announces one short prefix and serves whatever is requested beneath it. Announcements are live and can change over time, allowing for dynamic origin discovery. A broadcast consists of any number of Tracks. The contents, relationships, and encoding of tracks are determined by the application. ### Track A Track is a series of Groups identified by a unique name within a Broadcast. A track consists of a single active Group at any moment, called the "latest group". When a new Group is started, the previous Group is closed and may be dropped for any reason. The duration before an incomplete group is dropped is determined by the application and the publisher/subscriber's latency target. Every subscription is scoped to a single Track. A subscription starts at a configurable Group (defaulting to the latest) and continues until a configurable end Group or until either the publisher or subscriber cancels the subscription. Both bounds may be refined to a Frame within their Group, so a subscription can start or stop partway through a Group rather than only on a Group boundary (see [Positions](#positions)). The subscriber and publisher both indicate their delivery preference: * `Priority` indicates if Track A should be transmitted instead of Track B. * `Subscriber Max Age` indicates the maximum age before a non-latest Group is dropped from live delivery; `Publisher Max Age` indicates the maximum age before a non-latest Group is dropped from the publisher's cache. The combination of these preferences enables the most important content to arrive during network degradation while still respecting encoding dependencies. ### Group A Group is an ordered stream of Frames within a Track. Each group consists of an append-only list of Frames. A Group is normally served by a dedicated QUIC stream which is closed on completion, reset by the publisher, or cancelled by the subscriber. This ensures that all Frames within a Group arrive reliably and in order. In contrast, Groups may arrive out of order due to network congestion and prioritization. The application SHOULD process or buffer groups out of order to avoid blocking on flow control. A small single-frame Group MAY instead be transmitted as a QUIC datagram when reliability is not required (see [Datagrams](#datagrams)). ### Frame A Frame is a payload of bytes within a Group. A frame is used to represent a chunk of data with an upfront size. The contents are opaque to the moq-lite layer. Frames within a Group are numbered from 0 in the order they were produced. This index is not transmitted per frame; it is implied by position within the Group, anchored by the [GROUP](#group) message's `Frame Start`. A Group Stream normally starts at frame 0, but MAY start later when the publisher only holds (or was only asked for) part of the Group. Each frame carries a presentation timestamp expressed in the parent Track's `Timescale` (see [TRACK\_INFO](#track-info)), used by the moq-lite layer for [expiration](#expiration) decisions. ### Positions {#positions} A Position is a (Group Sequence, Frame Index) pair identifying one frame within a Track. Positions order lexicographically: by group first, then by frame within the group. SUBSCRIBE and FETCH bound their delivery by Position rather than by Group alone. Each carries a `Frame Start` qualifying its start group and a `Frame End` qualifying its end group; both default to the whole group, which is the behavior of a draft that has no such field. The bounds are chosen so that two subscriptions abut exactly. `Frame Start` is the index of the first frame to deliver and `Frame End` is the index of the last (inclusive), so a subscriber that has received frames 0 through `N-1` of group `G` and wants the remainder asks for `Group Start` = `G`, `Frame Start` = `N`. Capping the first subscription at `Group End` = `G`, `Frame End` = `N-1` covers exactly the complement with no gap and no overlap. Because `Group End` and `Frame End` are both encoded as `absolute + 1` (see [SUBSCRIBE](#subscribe)) while `Group Start` and `Frame Start` are not, the two requests carry the *same* numbers on the wire; the end bound is effectively exclusive once encoded. This is what lets a subscriber move a Track to a different publisher partway through a Group instead of waiting for the next Group to start, which matters for Tracks whose current Group may stay open indefinitely. A Group can therefore be assembled from more than one publisher: each contributes a disjoint run of frames, and the subscriber concatenates them in index order. A partial Group is only ever delivered to a subscriber that asked for one. A publisher that cannot serve a Group from the frame the subscription names MUST skip that Group entirely and resolve the subscription to a later one, rather than deliver the part it holds. The resolved start is therefore always either exactly the requested Position or the beginning of a later Group, never some third Position the subscriber did not choose. The asymmetry with Groups is deliberate. A Group is the unit of decodability: dropping leading *Groups* leaves a stream the application can still decode, whereas dropping leading *Frames* often does not, whether because the Group opens with a keyframe the rest depends on or because its compression state lives in the frames that went missing. Only the subscriber knows whether a partial Group is any use to it, so only the subscriber may ask for one. Frame indices are only meaningful relative to a Group the subscriber has already begun receiving, so: * A `Frame Start` qualifies the group `Group Start` names, group 0 included. A subscriber that has seen no such group MUST send `Frame Start` = 0, since it cannot number the frames of a group it has not received. * A `Frame End` is meaningless without a `Group End`; an unbounded subscription (`Group End` = 0) MUST send `Frame End` = 0. A publisher MUST treat a violation of either rule as a protocol violation and reset the stream. ## Flow This section outlines the flow of messages within a moq-lite session. See the Messages section for the specific encoding. ### Connection moq-lite runs on top of any transport that provides ordered, multiplexed, bidirectional streams: bare QUIC, WebTransport over HTTP/3 (required for web support), or Qmux [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/) when UDP is unavailable. See [Transports](#transports) for the bindings. How the underlying connection is authenticated is out-of-scope for this draft. ### Transports {#transports} moq-lite defines four transport bindings. All four carry the same control and data streams defined elsewhere in this document; they differ only in how QUIC streams are multiplexed onto the underlying connection. | | Transport | ALPN / Identifier | Record framing | |---:|:--------------------|:------------------|:--------------------| | 1 | QUIC | `moq-lite-xx` | Native QUIC streams | | 2 | WebTransport / H3 | `moq-lite-xx` (CONNECT header) | Native WebTransport streams | | 3 | Qmux over TCP/TLS | `moq-lite-xx` (ALPN over TLS) | Qmux Record [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/) | | 4 | Qmux over WebSocket | `moq-lite-xx` (Sec-WebSocket-Protocol) | WebSocket message [QMux over WebSocket](https://datatracker.ietf.org/doc/draft-lcurley-qmux-websocket/) | For bindings 1 and 2, moq-lite uses the underlying QUIC/WebTransport stream APIs directly. QUIC datagrams (see [Datagrams](#datagrams)) are supported by bindings 1 and 2 only; a publisher MUST NOT emit datagrams on bindings 3 and 4. For binding 3, a client opens a TCP connection, performs a TLS handshake, and negotiates the moq-lite version as the ALPN token. Each direction of the TLS byte stream then carries Qmux Records as defined in [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/). For binding 4, a client opens a WebSocket connection [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455) offering the moq-lite version as the subprotocol, per [QMux over WebSocket](https://datatracker.ietf.org/doc/draft-lcurley-qmux-websocket/): each WebSocket binary message carries one Qmux Record's `Frames` payload, with the message boundary replacing the Record `Size` field. All other moq-lite semantics (stream types, message encoding, flow control, etc.) are identical across bindings. ### Termination QUIC bidirectional streams have an independent send and receive direction. Rather than deal with half-open states, moq-lite combines both sides. If an endpoint closes the send direction of a stream, the peer MUST also close their send direction. moq-lite contains many long-lived transactions, such as subscriptions and announcements. These are terminated when the underlying QUIC stream is terminated. To terminate a stream, an endpoint may: * close the send direction (STREAM with FIN) to gracefully terminate (all messages are flushed). * reset the send direction (RESET\_STREAM) to immediately terminate. After resetting the send direction, an endpoint MAY close the recv direction (STOP\_SENDING). However, it is ultimately the other peer's responsibility to close their send direction. ### Error Codes {#error-codes} There are two independent error code spaces, one for terminating the session and one for resetting a stream. The same numeric value means different things in each, so an endpoint MUST select the code from the space matching what it is terminating. The shared codes match moq-transport draft-18 and later. Earlier drafts differ: 0x4 denotes UNKNOWN\_OBJECT\_STATUS in draft-16/17 and is unassigned in draft-14/15; TOO\_FAR\_BEHIND was added in draft-17 and MALFORMED\_TRACK in draft-16. An endpoint bridging protocols MUST translate codes according to the negotiated version and error-code space; moq-lite's reserved, own, and application ranges have no corresponding moq-transport ranges. The codes moq-lite uses are listed in full below; an endpoint MUST NOT assign a moq-lite specific meaning to any code below 32. Codes 64 and above are the application's, opaque to moq-lite. An endpoint MUST ignore a code it does not recognize, treating it as an unspecified error. An endpoint MUST NOT infer a meaning for an unregistered code; in particular, it MUST NOT assume a code is an authorization failure unless it is UNAUTHORIZED. Codes 32 through 47 are reserved for a future revision: nothing is assigned there and a value in it MUST NOT be interpreted. Codes 48 through 63 are moq-lite's own, for conditions moq-transport has no code for, and are assigned by the tables below. This is the one range that does not survive a bridge unchanged: an endpoint speaking both protocols MUST map a code here to the moq-transport code with the same meaning rather than forward the value, and MUST map an unmapped one to an unspecified error. #### Session Error Codes Sent when terminating the session, via the transport's session close. | Code | Name | Description | | ------- | ------------- | ----------- | | 0x0 | NO\_ERROR | The session was terminated without error. | | 0x1 | INTERNAL\_ERROR | An implementation-specific error. | | 0x2 | UNAUTHORIZED | The endpoint is not authorized to establish the session, or to perform an operation on it. | | 0x3 | PROTOCOL\_VIOLATION | The peer violated this specification. | | 0x6 | KEY\_VALUE\_FORMATTING\_ERROR | A key-value pair was malformed, or repeated more than allowed. | | 0x10 | GOAWAY\_TIMEOUT | The peer did not close within the GOAWAY drain deadline. | | 0x11 | CONTROL\_MESSAGE\_TIMEOUT | The peer took too long to respond to a control message. | | 0x15 | VERSION\_NEGOTIATION\_FAILED | No version could be negotiated. | #### Stream Error Codes Sent when resetting a stream (RESET\_STREAM), or when refusing to receive one (STOP\_SENDING). | Code | Name | Description | | ------- | ------------- | ----------- | | 0x0 | INTERNAL\_ERROR | An implementation-specific error. | | 0x1 | CANCELLED | The stream was cancelled by either endpoint. A routine unsubscribe. | | 0x2 | DELIVERY\_TIMEOUT | The content missed its delivery deadline. | | 0x3 | SESSION\_CLOSED | The session is closing, taking this stream with it. | | 0x4 | GOING\_AWAY | A GOAWAY was sent or received. | | 0x5 | TOO\_FAR\_BEHIND | The reader fell too far behind and content was dropped to catch up. | | 0x12 | MALFORMED\_TRACK | The track's content could not be parsed. | | 0x30 | NO\_CAPACITY | The publisher could serve this request but has no capacity for it now. Permits one re-resolution (see [Resolution](#resolution)); elsewhere it is terminal like any refusal. Bridges to NO\_CAPACITY in [draft-lcurley-moq-cluster](/draft/moq-cluster). | | 0x31 | CONTROL\_TIMEOUT | The peer took too long to answer a control request. Distinct from DELIVERY\_TIMEOUT, which is content that missed its deadline; it has no moq-transport value and bridges to INTERNAL\_ERROR. | | 0x32 | GROUP\_TOO\_LARGE | The group grew past the publisher's cache budget and was aborted. | | 0x33 | NOT\_FOUND | The requested group, track, or broadcast is not here. | | 0x34 | OLD | The group was superseded by a newer group and dropped. | | 0x35 | EVICTED | The group was dropped under memory pressure. Unlike OLD it was still current, so it can be re-fetched. | | 0x36 | UNROUTABLE | The broadcast is neither announced nor served, so there is no route to it. | | 0x37 | WRONG\_SIZE | A frame's payload length disagreed with its declared size. | | 0x38 | FRAME\_TOO\_LARGE | A frame declared a payload larger than the receiver accepts. | | 0x39 | TIMESTAMP\_MISMATCH | A frame's timestamp does not match its track's timescale. | Note that CANCELLED is 0x1, not 0x0: a stream reset with 0x0 is an INTERNAL\_ERROR, not a routine cancellation. An endpoint terminating a stream because the session is ending SHOULD use SESSION\_CLOSED rather than the session's own code, since the two spaces are disjoint. ### Handshake See the [Session](#session) section for ALPN negotiation and session activation details. ## Streams moq-lite uses a bidirectional stream for each transaction. If the stream is closed, potentially with an error, the transaction is terminated. ### Bidirectional Streams Bidirectional streams are used for control streams. There's a 1-byte STREAM\_TYPE at the beginning of each stream. | ID | Stream | Creator | |--------:|:-------------|:------------| | 0x1 | Announce | Subscriber | | 0x2 | Subscribe | Subscriber | | 0x3 | Fetch | Subscriber | | 0x4 | Probe | Subscriber | | 0x5 | Goaway | Either | | 0x6 | Track | Subscriber | #### Announce A subscriber can open an Announce Stream to discover routes matching a prefix. A route is an advertisement that paths under its prefix can be served; it claims capability, not inventory, so it never asserts that any specific broadcast exists. The subscriber creates the stream with an ANNOUNCE\_REQUEST message. The publisher replies with a single ANNOUNCE\_OK message followed by announcements for any matching routes and any future changes: * ANNOUNCE\_START: a matching route over a path prefix is available. * ANNOUNCE\_END: a previously started route is no longer advertised. * ANNOUNCE\_UPDATE: a previously started advertisement was atomically updated (new hops or cost). ANNOUNCE\_OK carries metadata that applies to every announcement on the stream: the publisher's own `Hop ID` (the implicit trailing entry of every announcement's path) and the number of initial announcements, which lets the subscriber deliver the initial set as a batch (see [ANNOUNCE\_OK](#announce-ok)). Each ANNOUNCE\_START implicitly assigns the next Announce ID on the stream: a counter starting at 0 that increments by 1 per advertisement. The id never appears on the wire; both endpoints derive it from the message order on the (reliable, ordered) stream. ANNOUNCE\_END and ANNOUNCE\_UPDATE reference the Announce ID instead of repeating the route's prefix. Each route has at most one current advertisement per stream. A second ANNOUNCE\_START for an already-advertised route is a protocol violation; an ANNOUNCE\_UPDATE atomically updates the current advertisement's metadata while keeping its id live. The subscriber MUST close the session with a PROTOCOL\_VIOLATION if it receives an ANNOUNCE\_END or ANNOUNCE\_UPDATE referencing an Announce ID that was never assigned or already retired, an ANNOUNCE\_START for a route that is already advertised, or any announcement before ANNOUNCE\_OK. When the stream is closed, the subscriber MUST assume that all routes are now unavailable. A route covers a path when its prefix is a leading run of the path's segments; matching is per path segment, so a prefix never matches half a segment, and equality is byte-by-byte within each segment. A publisher answering a request stream presents each of its routes clamped to the intersection with the requested prefix: a route above the request's prefix appears as the request prefix itself (an empty suffix), which is exactly the covered set the subscriber may see. There MAY be multiple Announce Streams, potentially containing overlapping prefixes, that get their own ANNOUNCE\_OK + announcements. ##### Compression {#announce-compression} An ANNOUNCE\_START or ANNOUNCE\_UPDATE MAY copy the head of a path or the tail of a Hop ID list from a live advertisement on the same stream, its base. A `Base` field names it by distance: the base's Announce ID is the next unassigned Announce ID minus `Base`, so 1 names the latest ANNOUNCE\_START, and 0 names no base. The receiver resolves every base against the advertisements live when the message arrives, and the result is identical to the literal encoding: a base ending later changes nothing. A publisher MAY always send a `Base` of 0. The subscriber MUST close the session with a PROTOCOL\_VIOLATION if a non-zero `Base` names an Announce ID that was never assigned or already retired, a `Keep` is non-zero with a `Base` of 0 or exceeds what the base has, or the resolved path or Hop ID list breaks its own rules. ##### Hidden Paths {#hidden} A route is hidden from a request when a segment of its path below the requested prefix starts with `.` (0x2E). A segment inside the prefix never hides anything, so a request that names the hidden segment itself (`.stats`) lists what is under it, and a route at or above the prefix has no segment below it. Only the first byte counts: `catalog.v2` is not hidden. A publisher SHOULD NOT announce a hidden route unless the ANNOUNCE\_REQUEST set `Hidden`. Hiding is a convenience for discovery, not access control: a publisher MAY treat a subscriber it trusts, such as another relay in its own cluster, as opted in, and MUST authorize hidden paths like any other. SUBSCRIBE, FETCH, and TRACK resolve a hidden path exactly as any other. ##### Routing {#routing} Each advertisement carries the path of Hop IDs it traversed and an accumulated Warm and Cold Route Cost (see [ANNOUNCE\_START](#announce-start)), which relays use to build a loop-free mesh. A receiver MUST discard an announcement whose reconstructed path contains its own Hop ID: it has looped back, so forwarding it would extend the loop and subscribing through it would route the receiver back to itself. This is the only loop defense moq-lite requires, and it catches loops of any length. A conforming sender never sends one (see below), so a receiver MAY instead close the session with a protocol violation; discarding is what keeps a mesh working when one member does not conform. A Hop ID of 0 means unknown and never matches anything; withholding an ID trades loop detection for privacy. A receiver MAY assign an identity of its own to a peer that declared 0, as local selection state for filtering that session; it MUST NOT forward that identity as a Hop ID. A relay that records an announcement whose reconstructed path starts with 0 MUST insert a random non-zero Hop ID in front of it, picked once for the session it arrived on; an empty path becomes that stamp followed by 0. Nothing on the wire says whether an unnamed publisher that reconnects is the same one, so its reconnect reads downstream as a new first hop, while an ANNOUNCE\_UPDATE on the same session keeps the stamp. The 0 behind the stamp keeps the path ranked below fully identified ones. A publisher MUST NOT advertise a path whose entries contain the Hop ID the subscriber declared in its SETUP (see [Hop Parameter](#hop-parameter)). The receiver can only discard it, and acting on it would form a loop, so sending one is never useful. Of the paths that remain a publisher SHOULD advertise the best, and nothing when every known path contains that Hop ID. Selection is per subscriber, so a subscriber that the serving path flows through still receives the best standby path, which is what lets it fail over if its own copy dies. The per-subscriber winner changing travels as an ANNOUNCE\_UPDATE; the last qualifying path appearing or disappearing travels as an ANNOUNCE\_START or ANNOUNCE\_END. When serving a subscription, a publisher MUST select the source by that same exclusion; if only excluded sources remain, the subscription is unroutable. Applying one rule to both advertisement and dispatch keeps advertised paths truthful, which is what prevents subscription cycles of any length. When resolving a path covered by several routes (across any number of streams), the subscriber SHOULD prefer the most specific covering route (see [Resolution](#resolution)), then a path that contains no 0 Hop ID over one that does, then the lowest Warm Route Cost after adding each arriving link's cost (see [Cost Parameter](#cost-parameter)), breaking ties toward the lowest Cold Route Cost, then toward the shortest path, and then toward the most recently received, so a reconnecting publisher is not outranked by the stale session it replaced. A route's identity is its first hop: the endpoint that originated it (see [ANNOUNCE\_START](#announce-start)). Two routes covering one path with the same non-zero first hop are the same origin reached different ways, and a relay MAY move a live subscription between them, resuming at a group boundary, so a route change the identity survives (a reconnect, a cheaper path, a draining session) is invisible to the subscriber. Across differing first hops, or where either is 0, the routes promise nothing about each other's content: a relay MUST NOT splice a live subscription across them, and when the serving session ends, in-flight subscriptions end with it (a reset) and the subscriber re-requests through the best remaining route. Equal first hops promise the same origin, not interchangeable bytes; what a resuming relay serves next is whatever that origin publishes next at the group boundary. ##### Resolution {#resolution} A SUBSCRIBE, FETCH, or TRACK request names a path, and the receiver resolves it against the routes covering that path, after the per-subscriber exclusion above. Only the most specific covering routes are consulted: those with the longest prefix. A route strictly inside another's paths always ranks above it, and a broadcast's exact path is the most specific route there is, so a concrete announcement shadows every broader route covering it at any cost; routes over the same prefix form one tier. The winning tier is the whole answer: a refusal from it never falls through to a less specific route, so a service refusing a path does not leak the request to a catch-all, and one unserved path costs one round trip rather than a walk down the candidates. Within the tier, cost and the tie-breaks of [Routing](#routing) order the routes. A route resolved this way serves the request like any other, and the route is not consumed by it. A relay MUST NOT announce a path merely because it resolved it: the covering route stays the only advertisement until the advertiser announces the concrete path, which it SHOULD do once it is producing, so a later request finds the running broadcast by its exact path instead of resolving a second producer. Standby ordering is a deployment guarantee within one specificity tier, not a bound on every representable Route Cost. A deployment relying on it MUST bound the number of charged links on an admitted path by H and each charged link cost by C, including the receiving link, and MUST enforce those bounds when admitting paths and links. Already-producing origins in that deployment MUST seed their cost at 0; standby origins MUST choose a seed S satisfying `H * C < S < saturation ceiling`. A seed of `2^32` is RECOMMENDED only when `H * C < 2^32`; otherwise the deployment must choose a larger seed or tighter bounds. Unknown, out-of-budget, and saturated routes are outside this guarantee; a receiver MUST NOT infer that they outrank standby capacity merely because they might already carry content. An advertiser that will not serve a resolved request resets the request stream with a typed code (see [Error Codes](#error-codes)). A NO\_CAPACITY reset permits the receiver ONE re-resolution, within the same tier and excluding every route whose advertiser is the refusing one. The exclusion is what makes the retry safe, not the retraction arriving first: an advertiser's capacity and a receiver's view of it are at least half a round trip apart, so a retraction and a request for the slot it gave away necessarily cross. Re-resolution may find no other route, and the request is then unroutable; that is a correct outcome, not a fallback list. A receiver that has spent its re-resolution, or has nothing to spend it on, MUST reset the downstream request with a code other than NO\_CAPACITY, so the single retry cannot compound hop by hop. Every other code, and any unrecognized one, is terminal and propagates without re-resolution, so probing unserved paths costs one round trip per path. A receiver SHOULD NOT cache refusals; rate limiting is the advertiser's concern. #### Subscribe A subscriber opens Subscribe Streams to request a Track. The subscriber MUST start a Subscribe Stream with a SUBSCRIBE message followed by any number of SUBSCRIBE\_UPDATE messages. The publisher replies with a SUBSCRIBE\_OK message once the start group is resolved, followed by a SUBSCRIBE\_END message once the subscription ends. For a live track the publisher MAY withhold SUBSCRIBE\_OK until the first matching group resolves the start; if the track has already ended with no matching groups, it sends SUBSCRIBE\_END with no preceding SUBSCRIBE\_OK. A rejection is a stream reset: a publisher that cannot serve the subscription (no such track, an ended broadcast, or any other refusal) MUST promptly reset the stream rather than leave it pending, so a subscriber distinguishes "pending" from "refused" by the reset, not by a timeout. A route claims capability rather than inventory, so a subscription for a covered path that names nothing is refused this way too. The track's immutable publisher properties are not carried here; they are fetched once via a [Track Stream](#track-stream). The subscriber needs the track's TRACK\_INFO (notably its timescale) to interpret FRAME messages, and MAY open the Track and Subscribe streams concurrently, buffering frames until it arrives. The publisher sends SUBSCRIBE\_OK once the absolute start position is resolved, and SUBSCRIBE\_END once no further groups will be produced and every Group Stream it opens for the subscription has been opened (see [SUBSCRIBE\_OK](#subscribe-ok) and [SUBSCRIBE\_END](#subscribe-end)). The publisher closes the stream (FIN) after SUBSCRIBE\_END, once every counted Group Stream has finished or been reset. Unbounded subscriptions stay open until SUBSCRIBE\_END, and either endpoint MAY reset the stream at any time. #### Fetch A subscriber opens a Fetch Stream (0x3) to request a single Group from a Track. The subscriber sends a FETCH message containing the broadcast path, track name, priority, group sequence, and the frame range within that group. Unlike SUBSCRIBE, FETCH works on both live and ended broadcasts; it is the only way to read an ended one. The publisher responds with FRAME messages directly on the same bidirectional stream — there is no response header. The Subscribe ID, Group Sequence, and index of the first returned frame are implicit, taken from the original FETCH request. Because there is no response header, a publisher that cannot serve the requested frame range in full MUST reset the stream rather than return a shorter run; the subscriber has no way to learn where a truncated response actually started. As with a subscription, the subscriber MUST already have the track's [TRACK\_INFO](#track-info) to parse the returned frames; because the properties are immutable, a single Track Stream lookup is reused across every FETCH of that track (group-by-group fetches do not re-fetch it). The publisher FINs the stream after the last frame, or resets the stream on error. Fetch behaves like HTTP: a single request/response per stream. #### Track {#track-stream} A subscriber opens a Track Stream (0x6) to learn a Track's immutable publisher properties without subscribing or fetching. The subscriber sends a TRACK message containing the broadcast path and track name. The publisher replies with a single TRACK\_INFO message and then FINs the stream, or resets the stream on error (e.g. the track does not exist). The returned properties are fixed for the lifetime of the track, so the subscriber SHOULD cache TRACK\_INFO keyed by broadcast path and track name, and reuse it across every SUBSCRIBE and FETCH of the same track over the session that served it. The properties are fixed for one track, not for the path it arrived on: a path outlives the broadcast on it, and a different broadcast reaching the same path brings its own. Anything cached against a path, on either side, is therefore scoped to the announcement that carried it and is discarded when that announcement is retracted. If FRAME messages cannot be decoded against the cached TRACK\_INFO, the subscriber MUST reset the affected stream with a protocol violation and re-request it. Because a subscriber cannot parse buffered group frames until TRACK\_INFO arrives, the publisher SHOULD prioritize TRACK\_INFO ahead of group data on the connection. #### Probe A subscriber opens a Probe Stream (0x4) to measure, and optionally increase, the available bitrate of the connection. The publisher advertises its Probe level in SETUP (see [Probe Parameter](#probe-parameter)): None, Report (measure only), or Increase (measure and actively probe). The subscriber sends a PROBE message with a target bitrate on the bidirectional stream. The subscriber MAY send additional PROBE messages on the same stream to update the target bitrate; the publisher MUST treat each PROBE as a new target to attempt. If the publisher advertised the Increase capability, it SHOULD pad the connection (or send redundant data) to achieve the most recent target bitrate, without exceeding the congestion window. A publisher that advertised Report but not Increase ignores the target and only reports; it MUST NOT pad above its current sending rate. In either case the publisher periodically replies with PROBE messages on the same bidirectional stream containing the current estimated bitrate and smoothed RTT. If the publisher advertised no Probe capability (e.g., the congestion controller is not exposed), it MUST reset the stream. #### Goaway Either endpoint can open a Goaway Stream (0x5) to initiate a graceful session shutdown. The sender sends a GOAWAY message containing an optional new session URI. If the URI is non-empty, the peer SHOULD establish a new session at the provided URI and migrate any active subscriptions. The peer MUST NOT open new streams on the current session after receiving a GOAWAY. The sender closes the stream (FIN) when it is ready to terminate the session. The peer SHOULD close all streams and the session after migrating or when it no longer needs the session. ## Delivery The most important concept in moq-lite is how to deliver a subscription. QUIC can only improve the user experience if data is delivered out-of-order during congestion. This is the sole reason why data is divided into Broadcasts, Tracks, Groups, and Frames. moq-lite consists of multiple groups being transmitted in parallel across separate streams. How these streams get transmitted over the network is very important, and yet has been distilled down into a few simple properties: ### Prioritization The Publisher and Subscriber both exchange a `Priority` value, which determines which Track should be transmitted next. Group order within a Track is fixed: newest first. A publisher SHOULD attempt to transmit streams based on these rules. This depends on the QUIC implementation and it may not be possible to get fine-grained control. #### Priority The `Subscriber Priority` is scoped to the connection and MAY change over the life of the subscription via SUBSCRIBE\_UPDATE. The `Publisher Priority` is fixed for the lifetime of the Track (see [TRACK\_INFO](#track-info)) and SHOULD be used only to resolve conflicts or ties. A conflict can occur when a relay tries to serve multiple downstream subscriptions from a single upstream subscription. The relay cannot pick any one subscriber's priority, so the upstream subscription SHOULD use the publisher priority instead of some combination of different subscriber priorities. Publisher priority is therefore mostly relevant on the upstream (origin-facing) leg of a relay; closer to the subscriber, the subscriber priority dominates. Rather than try to explain everything, here's an example: **Example:** There are two people in a conference call, Ali and Bob. We subscribe to both of their audio tracks with subscriber priority 2 and video tracks with subscriber priority 1. Each publisher advertises a fixed publisher priority (here audio at 2 and video at 1) used only to break ties. This results in equal priority for `Ali` and `Bob` while prioritizing audio. ```text ali/audio + bob/audio: subscriber_priority=2 publisher_priority=2 ali/video + bob/video: subscriber_priority=1 publisher_priority=1 ``` Because publisher priority cannot change, dynamic adaptation is the subscriber's job. If the subscriber detects that Bob is actively speaking, it raises the subscriber priority of Bob's tracks via SUBSCRIBE\_UPDATE: ```text bob/audio: subscriber_priority=4 publisher_priority=2 bob/video: subscriber_priority=3 publisher_priority=1 ali/audio: subscriber_priority=2 publisher_priority=2 ali/video: subscriber_priority=1 publisher_priority=1 ``` The subscriber priority takes precedence, so the subscriber can likewise full-screen Ali's window by raising the subscriber priority of Ali's tracks above Bob's. #### Group Order Within a Track, a publisher SHOULD transmit the newest Group first. Under congestion this sheds the backlog rather than the live edge, which is what a Group boundary exists to make possible. There is no field to invert it. A subscriber that wants Groups in sequence order gets them by reading in sequence order: reordering is a local decision, it costs the network nothing, and encoding it on the wire only lets one subscriber's preference reach a Track that a relay is fanning out to many. Such a subscriber SHOULD raise its `Subscriber Max Age` so the Groups it intends to read in order are still being delivered when it reaches them. A subscriber MUST support gaps and out-of-order delivery regardless. ### Expiration Expiration governs when an older group is dropped. The publisher SHOULD reset Group Streams for non-latest groups whose age relative to the latest group reaches `Subscriber Max Age` (see [SUBSCRIBE](#subscribe), and the age definition below); the subscriber MAY also locally drop such groups. Expiration only removes the group from live delivery; the publisher MAY still retain it for FETCH or new subscriptions until its age exceeds `Publisher Max Age` (see [TRACK\_INFO](#track-info)). It is not crucial to aggressively expire groups thanks to [prioritization](#prioritization), but a lower priority group still consumes RAM, bandwidth, and potentially flow control. It is RECOMMENDED that an application set conservative limits and only resort to expiration when data is absolutely no longer needed. A group is never expired until a later group (by sequence number) has presented a frame. Once one has, the group's **timestamp age** is the difference between the *newest* frame timestamp of the latest group that has at least one frame, and this group's **reach**, defined below; the group is expired once that age meets the relevant `Max Age`. Timestamps are the only measure: they are consistent across relays and unaffected by buffering or jitter. Wall-clock reclamation of idle content is the retention cache's own policy (`Publisher Max Age`, or any implementation-defined bound), not part of this rule. A group is expired only once it **provably cannot overlap the Max Age window**: every frame it could still present is older than the budget allows. Being behind is not itself a reason to expire anything. [Prioritization](#prioritization) already transmits newer groups first, so an older group consumes only whatever capacity is left over; it therefore closes the gap faster than the live edge advances, and a receiver that is behind can converge without losing content. What cannot be recovered is a group with nothing left worth delivering, and that is what expiration removes. A group's **reach** is the first frame timestamp of the next group by sequence number, or unbounded when no later group has presented a frame. A group cannot present past where its successor begins, so this is the furthest it could still reach. Its own frames do not bound it: frame durations are not carried on the wire, so a group's last frame timestamp is where that frame *starts* presenting, not where the group ends. A group whose successor has not presented a frame is therefore never expired, because nothing yet proves where it stops. The group itself needs no timestamp: a zero-frame group (a keep-alive or gap marker) is bounded by its successor's first frame the same way. Reach is an exclusive bound, so a timestamp age **equal** to Max Age already expires the group: the freshest frame it could still hold sits strictly below its reach, and is therefore strictly older than the budget. A Max Age of zero follows from the same rule without a special case. Measuring in timestamps rather than arrival means a burst of groups delivered together still reads as its true age, so catching up never resets the clock. An expired group SHOULD be reset at the QUIC level to avoid consuming flow control. ### Unidirectional Streams Unidirectional streams are used for data transmission. | ID | Stream | Creator | |-------:|:---------|-------------| | 0x0 | Group | Publisher | | 0x1 | Setup | Either | #### Setup {#setup-stream} Each endpoint MUST open a Setup Stream (0x1) at the start of the session to advertise the optional capabilities and extensions it supports. The opener sends a single SETUP message and immediately closes the stream (FIN). There is exactly one Setup Stream per direction; an endpoint that receives a second Setup Stream MUST close the session with a PROTOCOL\_VIOLATION. An endpoint with no optional capabilities sends a SETUP with an empty parameter list rather than omitting the stream, giving the peer a deterministic signal that no capabilities are forthcoming. See the [Session](#session) section for how an endpoint avoids waiting on the peer's SETUP before exchanging other streams. #### Group A publisher creates Group Streams in response to a Subscribe Stream. A Group Stream MUST start with a GROUP message and MAY be followed by any number of FRAME messages. A Group MAY contain zero FRAME messages, potentially indicating a gap in the track. A frame MAY contain an empty payload, potentially indicating a gap in the group. The GROUP message's `Frame Start` gives the index of the first FRAME on the stream, so a stream carrying only part of a Group is self-describing. It is redundant with the subscription's own `Frame Start` in the steady state, and carried anyway because a SUBSCRIBE\_UPDATE that moves the bound races the Group Streams already in flight: the two travel on different streams with no ordering between them, so the receiver would otherwise have to guess which bound a given Group Stream was opened under. A publisher MUST NOT send two Group Streams for the same Group Sequence on one subscription. A subscriber assembling a Group from more than one publisher does so across separate subscriptions, and is responsible for concatenating the runs in index order and for ignoring any frame it has already received. Both the publisher and subscriber MAY reset the stream at any time. This is not a fatal error and the session remains active. The subscriber MAY cache the error and potentially retry later. ### Datagrams QUIC datagrams provide unreliable, unordered delivery for latency-sensitive content that does not need retransmission. A publisher MAY transmit a Group consisting of exactly one Frame as a single QUIC datagram, in addition to (or instead of) opening a Group Stream, based on application hints, group size, and network conditions; a multi-frame Group is delivered via a Group Stream only. A datagram-delivered group is not cached or retransmitted; a publisher SHOULD only send a datagram if the congestion controller can transmit it immediately. There is no separate subscription for datagram delivery: datagrams are routed to existing subscriptions via the Subscribe ID, and a subscriber receiving the same group via both a stream and a datagram MUST deduplicate by group sequence. Each datagram body has the following encoding (note: there is no message length prefix; the QUIC datagram boundary delimits the payload): ``` DATAGRAM Body { Subscribe ID (i) Group Sequence (i) Timestamp (i) Payload (b) } ``` **Subscribe ID**: The Subscribe ID of an active subscription on the same session. A subscriber receiving a datagram with an unknown Subscribe ID MUST silently drop it. **Group Sequence**: The absolute sequence number of the group carried by this datagram. **Timestamp**: The absolute timestamp of the single frame in the group, expressed in the Track's `Timescale`. Any varint value (including 0) is valid. **Payload**: The frame payload, extending to the end of the datagram. The total datagram body MUST NOT exceed 1200 bytes, ensuring it fits within the minimum QUIC path MTU without IP-layer fragmentation; a publisher MUST NOT send a larger one and a receiver MUST silently drop it. A group whose frame does not fit is simply not eligible for datagram delivery. ## Encoding This section covers the encoding of each message. ### Variable-Length Integers {#varint} A field marked `(i)` is a variable-length integer. moq-lite-07 uses the leading-ones encoding of [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/): the number of leading 1 bits in the first byte gives the length, from 1 byte carrying 7 bits to 9 bytes carrying 64, and every length is valid. Earlier versions use the two-bit length prefix of [RFC 9000](https://www.rfc-editor.org/rfc/rfc9000), Section 16, which carries at most 2^62-1. A relay that cannot encode a value for a peer on an earlier version MUST NOT truncate it. ### Message Length Most messages are prefixed with a variable-length integer indicating the number of bytes in the message payload that follows. This length field does not include the length of the varint length itself. An implementation SHOULD close the connection with a PROTOCOL\_VIOLATION if it receives a message with an unexpected length. The version and extensions should be used to support new fields, not the message length. ### STREAM\_TYPE {#stream\_type} All streams start with a short header indicating the stream type. ``` STREAM_TYPE { Stream Type (i) } ``` The stream ID depends on if it's a bidirectional or unidirectional stream, as indicated in the Streams section. A receiver MUST reset the stream if it receives an unknown stream type. Unknown stream types MUST NOT be treated as fatal; this is the fallback when an extension stream is opened against a peer that did not negotiate it. ### SETUP {#setup} A SETUP message advertises the optional capabilities and extensions the sender supports for this session. It is sent exactly once, as the only message on a [Setup Stream](#setup-stream). ``` SETUP Message { Message Length (i) Parameter Count (i) Setup Parameter (..) ... } Setup Parameter { Parameter ID (i) Parameter Length (i) Parameter Value (..) } ``` The Message Length MUST NOT exceed 65,536 bytes; a receiver MUST treat a longer SETUP as a protocol violation and MAY reject it based on the length prefix alone. **Parameter Count**: The number of Setup Parameters that follow. **Parameter ID**: Identifies the capability or extension. A receiver MUST ignore unknown Parameter IDs, allowing new capabilities to be added without breaking older implementations. A Parameter ID MUST NOT appear more than once; a receiver MUST close the session with a PROTOCOL\_VIOLATION if it does. **Parameter Length**: The length of Parameter Value in bytes. **Parameter Value**: The parameter-specific value, interpreted according to Parameter ID. A capability is available for the session only if the relevant endpoint advertises it; an absent parameter means the sender does not support that capability. The following Setup Parameters are defined: | ID | Name | Value | |-----:|:----------|:------------| | 0x1 | Probe | Level (i) | | 0x2 | Path | Path (s) | | 0x3 | Role | Role (i) | | 0x4 | Cost | Cost (i) | | 0x5 | Hop | Hop ID (i) | #### Probe Parameter {#probe-parameter} The Probe Parameter advertises the sender's capability level when acting as a publisher on a [Probe Stream](#probe). The Parameter Value is a variable-length integer level, where each level includes the one below it: * `0` **None**: The publisher does not support probing. Equivalent to omitting the parameter. * `1` **Report**: The publisher can measure and periodically report its estimated bitrate. * `2` **Increase**: The publisher can additionally pad the connection (or send redundant data) to probe for bandwidth above its current sending rate, up to the subscriber's target. A subscriber MUST consult the publisher's advertised level before relying on a Probe Stream: * At `None`, the subscriber SHOULD NOT open a Probe Stream; if it does, the publisher MUST reset it. * At `Report`, the subscriber MAY open a Probe Stream to monitor the estimated bitrate but MUST NOT expect the publisher to pad above its current sending rate. A subscriber that needs to probe for additional bandwidth MUST use an alternative (e.g. speculatively switching to a higher rendition). * At `Increase`, the subscriber MAY request a target bitrate and expect the publisher to actively probe up to it. #### Path Parameter {#path-parameter} The Path Parameter carries the request target the client wishes to reach, equivalent to the path and query components of a moq-lite URI. A server uses it to route the session before any broadcasts are exchanged; its interpretation is otherwise application-defined and opaque to moq-lite. The Parameter Value is a UTF-8 string holding the `path-abempty` component of the URI [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986); when the URI carries a query, the client MUST append `?` followed by the `query` component, since a deployment commonly puts the session credential there. The value MAY be empty, which is equivalent to omitting the parameter: both request the server's default path. A server that receives an invalid value MUST close the session with a PROTOCOL\_VIOLATION, and one that does not recognize the requested path MUST close the session. This parameter exists for the bindings that negotiate only an ALPN token and have no request URI of their own (bindings 1 and 3 in [Transports](#transports)); a client using one of them SHOULD send it. It MUST NOT be sent on a binding whose handshake carries a request URI (bindings 2 and 4), and only the client sends it; a receiver MUST close the session with a PROTOCOL\_VIOLATION on either violation. A relay MUST NOT forward it; like other per-hop setup metadata it applies only to this hop. #### Role Parameter {#role-parameter} The Role Parameter advertises the direction the client intends to use the session for. A moq-lite session is bidirectional, but a client's authorization (e.g. the credential in the [Path](#path-parameter)) may grant only one direction; the hint lets a server reject a mismatched session during SETUP instead of accepting it and silently carrying no data. The Parameter Value is a variable-length integer: * `0` **Both**: The client may publish and/or subscribe. The default, and equivalent to omitting the parameter. * `1` **Publisher**: The client intends to publish (and not subscribe). * `2` **Subscriber**: The client intends to subscribe (and not publish). A receiver that does not recognize the value MUST treat it as `Both`, so a newer client cannot break an older server. The role is a hint that only ever narrows the session: a server MUST still enforce the client's authorization on every publish and subscribe, and MAY close a session whose advertised role requires a direction the authorization does not grant. Only the client sends it; a client that receives one MUST close the session with a PROTOCOL\_VIOLATION. A relay MUST NOT forward it. #### Cost Parameter {#cost-parameter} The Cost Parameter declares what subscribing from this endpoint costs: a receiver adds the value the sender declared to both Route Costs of every announcement that sender forwards (see [Routing](#routing)). The Parameter Value is a variable-length integer in deployment-chosen units, the same units as the Route Costs. An absent parameter means the default cost of 1, under which the accumulated Route Costs equal the hop count and routing degenerates to shortest-path. A value of 0 is meaningful and distinct from absent: it makes that direction free, e.g. between two relays in the same datacenter. Both endpoints send it and the two values need not match: the parameter prices the sender's own egress. A relay MUST NOT forward it. A declared cost is an assertion, not an instruction: a receiver MAY charge a locally configured value instead, so a peer cannot reprice its neighbours by declaring itself cheap. #### Hop Parameter {#hop-parameter} The Hop Parameter declares the sender's Hop ID: the identity it stamps onto announcements it forwards. The Parameter Value is a variable-length integer; a value of 0 carries no identity and is equivalent to omitting the parameter. Declaring it at setup gives the receiver the peer's identity before any other stream arrives, so route selection applies the same exclusion to the peer's subscriptions as to its announcements (see [Routing](#routing)), even on a session that never opens an Announce Stream. Either endpoint MAY send it; a subscriber-only endpoint with no identity MAY omit it, but a publisher SHOULD have a Hop ID regardless (see [ANNOUNCE\_OK](#announce-ok)). A relay MUST NOT forward it. ### ANNOUNCE\_REQUEST {#announce-request} A subscriber sends an ANNOUNCE\_REQUEST message to indicate it wants to receive announcements for any broadcasts with a path that starts with the requested prefix. ``` ANNOUNCE_REQUEST Message { Message Length (i) Broadcast Path Prefix (s), Hidden (8), } ``` **Broadcast Path Prefix**: Indicate interest for any broadcasts with a path that starts with this prefix. **Hidden**: 1 to also receive hidden routes (see [Hidden Paths](#hidden)), 0 otherwise. Any other value is a PROTOCOL\_VIOLATION. The publisher MUST respond with an ANNOUNCE\_OK message followed by ANNOUNCE\_START messages for any matching routes, followed by ANNOUNCE\_START, ANNOUNCE\_END, and ANNOUNCE\_UPDATE messages for any future updates, subject to [Routing](#routing). Implementations SHOULD consider reasonable limits on the number of matching broadcasts to prevent resource exhaustion. ### ANNOUNCE\_OK {#announce-ok} A publisher sends an ANNOUNCE\_OK message exactly once, as the first message on the response side of an Announce Stream. It carries metadata that is constant for the lifetime of the stream and applies to every announcement that follows. ``` ANNOUNCE_OK Message { Message Length (i) Hop ID (i) Active Count (i) } ``` **Hop ID**: The publisher's own Hop ID. This is treated as the implicit trailing entry of every ANNOUNCE\_START and ANNOUNCE\_UPDATE Hop ID list on this stream; those messages MUST NOT repeat this value as the last entry of their `Hop ID` list. The value 0 is reserved to mean "unknown": either no Hop ID was assigned (e.g. when bridging from an older protocol version) or the endpoint deliberately withholds it to obscure the underlying routing. A publisher that assigns a Hop ID MUST choose a non-zero value, and SHOULD assign itself one (a fresh random value per session suffices), so downstream receivers can detect loops through it. Receivers reconstruct the full path as `Hop IDs ++ [ANNOUNCE_OK.Hop ID]`. **Active Count**: The number of ANNOUNCE\_START messages that the publisher will send immediately as the initial set. The subscriber MAY block reporting any announcement to the application until all `Active Count` initial announcements have arrived, then deliver the initial set as a batch. Any announcements beyond `Active Count` are live updates and SHOULD be reported as they arrive. A value of `0` is valid and means the publisher is offering no initial available broadcasts; all subsequent announcements (if any) are live updates. ### ANNOUNCE\_START {#announce-start} A publisher sends an ANNOUNCE\_START message to advertise a route: a claim that paths under a prefix can be served. Each ANNOUNCE\_START implicitly assigns the next Announce ID on the stream, later referenced by ANNOUNCE\_END and ANNOUNCE\_UPDATE (see [Announce](#announce)). Only the suffix is encoded on the wire, as the full route prefix can be constructed by prepending the requested prefix. ``` ANNOUNCE_START Message { Type (i) = 0x0 Message Length (i) Path Base (i), Path Keep (i), Route Prefix Suffix (s), Hops (..), Warm Route Cost (i), Cold Route Cost (i), } Hops { Hop Base (i), Hop Count (i), Hop ID (i) ..., Hop Keep (i), } ``` **Type**: Set to 0x0 to indicate an ANNOUNCE\_START message. **Path Base** and **Path Keep**: The suffix begins with the first `Path Keep` segments of the suffix advertised by the base that `Path Base` names (see [Compression](#announce-compression)). **Route Prefix Suffix**: The remaining segments of the suffix, which is combined with the requested prefix to form the route's full prefix. An empty suffix advertises the requested prefix itself, which is how a route covering more than the request presents (see [Announce](#announce)). **Hop Base** and **Hop Keep**: The Hop ID list ends with the last `Hop Keep` entries of the list advertised by the base that `Hop Base` names (see [Compression](#announce-compression)), following the literal `Hop ID` entries. The two bases are independent. **Hop Count**: The number of literal Hop ID entries that follow. The resolved list does NOT include the publisher's own `Hop ID` from ANNOUNCE\_OK. An empty list indicates either that the announcement originated locally on the publisher (the publisher itself is the origin) or that the upstream peer does not support hop tracking. A receiver MUST close the stream with a PROTOCOL\_VIOLATION if the Hop Count does not match the number of subsequent Hop ID entries. **Hop ID**: A unique identifier for each relay in the path from the origin publisher, ordered from origin to the upstream of the responding publisher. The responding publisher's own Hop ID is NOT included in the resolved list; it is carried once in ANNOUNCE\_OK, so the total path length is the resolved list's length plus 1 (`Hop Count + Hop Keep + 1`). When forwarding an announcement received from an upstream peer, a relay MUST append the upstream peer's ANNOUNCE\_OK `Hop ID` to the resolved list, since that ID is no longer implicit downstream. The first entry of the reconstructed path identifies the endpoint that originated the route. A Hop ID value of 0 means the hop is unknown: either it was never assigned or a relay deliberately withholds it (see [Routing](#routing)). A received 0 is forwarded unchanged, behind a stamp when it is the first entry (see [Routing](#routing)). When bridging an announcement from an upstream that sent no hop list, a relay writes its stamp followed by 0 for that hop. An identity a receiver assigned that upstream is local selection state and MUST NOT be forwarded as a Hop ID. A receiver MUST close the session with a PROTOCOL\_VIOLATION if a non-zero Hop ID appears twice in the resolved list. Duplicate values of 0 are not a violation, since 0 identifies nothing and any number of hops may be unknown. **Warm Route Cost** and **Cold Route Cost**: What subscribing to content under this route costs, in units chosen by the deployment. This document defines no rule that prices the two apart: the original publisher seeds both with its production cost (0 for content it is already producing, larger for content it would have to start producing on demand, e.g. a standby transcoder that advertises every broadcast it could serve, at a cost reflecting the work of actually serving it), and a forwarding relay adds the cost the upstream peer declared (see [Cost Parameter](#cost-parameter)) to both, saturating rather than wrapping so an absurd upstream value ranks last instead of overflowing to best. The two fields exist for extensions that price a cached copy below the accumulated value: such a discount applies to the Warm cost only, which is what route selection minimizes, while the Cold cost keeps ranking the undiscounted path and breaks a Warm tie (see [Routing](#routing)). Saturation MUST cap each sum at the largest value a variable-length integer can carry, since the sums are re-encoded when forwarded: a peer may legally advertise that largest value, and a wider ceiling would leave the relay unable to encode what it just computed. A relay whose wire cannot express a Cold cost (an endpoint bridging from another protocol, or a peer that predates this field) advertises nothing, and a receiver SHOULD treat the missing value as the saturation ceiling rather than as 0: an unknown path ranks last instead of impersonating the publisher's own. ### ANNOUNCE\_END {#announce-end} A publisher sends an ANNOUNCE\_END message to retract a previously started route, referencing its Announce ID. The id is retired and MUST NOT be referenced again. Retraction claims nothing about the content: a broadcast whose publisher stops advertising may remain readable by exact path, serving its stored groups over FETCH. Retraction does not disturb subscriptions already in flight, which conclude normally with SUBSCRIBE\_END. How a subscriber learns the path of stored content is out of band, e.g. an application catalog. ``` ANNOUNCE_END Message { Type (i) = 0x1 Message Length (i) Announce ID (i) } ``` **Type**: Set to 0x1 to indicate an ANNOUNCE\_END message. **Announce ID**: The ordinal implicitly assigned by a prior ANNOUNCE\_START on this stream. Referencing an id that was never assigned, or one already retired, is a protocol violation. Announce IDs are never reused within a stream; a route that is announced again after an ANNOUNCE\_END gets a fresh id from its next ANNOUNCE\_START. ### ANNOUNCE\_UPDATE {#announce-update} A publisher sends an ANNOUNCE\_UPDATE message to atomically update a previously started advertisement's metadata, referencing its Announce ID. The route's prefix is unchanged and the id stays live; the Hop ID list MAY differ from the original (e.g. after a relay failover or upstream restart). An update carries no content claim: in-flight subscriptions under the route are undisturbed. ``` ANNOUNCE_UPDATE Message { Type (i) = 0x2 Message Length (i) Announce ID (i), Hops (..), Warm Route Cost (i), Cold Route Cost (i), } ``` **Type**: Set to 0x2 to indicate an ANNOUNCE\_UPDATE message. **Announce ID**: The ordinal implicitly assigned by a prior ANNOUNCE\_START on this stream. Referencing an id that was never assigned, or one already retired by an ANNOUNCE\_END, is a protocol violation. **Hops**, **Warm Route Cost**, and **Cold Route Cost**: As defined for [ANNOUNCE\_START](#announce-start). `Hop Base` may name this advertisement itself, which resolves against the list being replaced. An update whose only change is a Route Cost is valid: it is how a relay re-prices a route without disturbing it. ### SUBSCRIBE SUBSCRIBE is sent by a subscriber to start a subscription. ``` SUBSCRIBE Message { Message Length (i) Subscribe ID (i) Broadcast Path (s) Track Name (s) Subscriber Priority (8) Subscriber Max Age (i) Group Start (i) Group End (i) Frame Start (i) Frame End (i) } ``` **Subscribe ID**: A unique identifier chosen by the subscriber. A Subscribe ID MUST NOT be reused within the same session, even if the prior subscription has been closed. **Subscriber Priority**: The priority of the subscription within the session, represented as a u8. The publisher SHOULD transmit *higher* values first during congestion. See the [Prioritization](#prioritization) section for more information. **Subscriber Max Age**: The subscriber's preference, in milliseconds, for how long a non-latest group may remain in flight before being considered stale and dropped from live delivery. The publisher SHOULD reset (at the QUIC level) Group Streams for groups whose age relative to the latest group exceeds this duration. Applies only to non-latest groups; the latest group is never dropped on staleness grounds. A value of `0` means the subscriber wants only the latest group in live delivery (older groups are immediately stale once a newer group arrives). This is a delivery-time preference, not a retention rule: the publisher MAY still hold these groups for FETCH or future subscriptions (see `Publisher Max Age` in [TRACK\_INFO](#track-info)). See the [Expiration](#expiration) section for more information. **Group Start**: The minimum group sequence to deliver: an absolute floor, defaulting to 0 (no floor). A floor is not a request; `Subscriber Max Age` is the only thing that asks for data. The publisher SHOULD start at the oldest group at or above the floor that [Expiration](#expiration) has not expired, and MUST NOT deliver an older one. A `Subscriber Max Age` of 0 therefore starts at the latest group, since every older group is already stale. A subscriber that buffers is then handed the head of what it can still play instead of only the live edge, and is never sent history it would discard on arrival: the same bound decides what to start at and what to expire, so the two cannot disagree. A floor above the latest group simply waits there: that is a resumed subscription naming where it left off. Reaching back is best-effort, not a guarantee that the groups still exist; see `Publisher Max Age` in [TRACK\_INFO](#track-info). **Group End**: The last group to deliver (inclusive). A value of 0 means unbounded (default). A non-zero value is the absolute group sequence + 1. **Frame Start**: The index of the first frame to deliver within the `Group Start` group (see [Positions](#positions)). A value of 0 means from the start of that group (default), so the group is delivered whole. Frames before this index are not delivered, even when delivery begins at that exact group; a start resolved at a later group is always delivered from frame 0. A subscriber that has received no group MUST send 0, since it cannot number the frames of a group it has not seen. **Frame End**: The last frame to deliver (inclusive) within the end group (see [Positions](#positions)). A value of 0 means the whole group (default). A non-zero value is the absolute frame index + 1, matching `Group End`. MUST be 0 when `Group End` is 0, since an unbounded subscription has no end group to qualify. `Group End` and `Frame End` are offset by 1 so 0 can mean "absent". `Group Start` is an absolute sequence, and 0 is group 0. ### SUBSCRIBE\_UPDATE A subscriber can modify a subscription with a SUBSCRIBE\_UPDATE message. A subscriber MAY send multiple SUBSCRIBE\_UPDATE messages to update the subscription. The start and end group can be changed in either direction (growing or shrinking). ``` SUBSCRIBE_UPDATE Message { Message Length (i) Subscriber Priority (8) Subscriber Max Age (i) Group Start (i) Group End (i) Frame Start (i) Frame End (i) } ``` See [SUBSCRIBE](#subscribe) for information about each field. Moving `Frame Start` forward within a group that is already being delivered does not retract frames the publisher has already sent; like `Group Start`, it only bounds what the publisher sends from here on. ### TRACK TRACK is sent by a subscriber to request a Track's immutable publisher properties. It is the first message on a Track Stream (0x6). ``` TRACK Message { Message Length (i) Broadcast Path (s) Track Name (s) } ``` **Broadcast Path**: The broadcast path of the track. **Track Name**: The name of the track. ### TRACK\_INFO {#track-info} TRACK\_INFO is sent by the publisher in response to a TRACK message. It is the sole message on the Track Stream; the publisher FINs immediately afterward, or resets the stream on error (e.g. the track does not exist). ``` TRACK_INFO Message { Message Length (i) Publisher Priority (8) Publisher Max Age (i) Timescale (i) } ``` Every field is **fixed for the lifetime of the Track** and MUST NOT change; a change requires a new Track. This is what lets the properties live on their own stream, fetched once and cached, instead of being echoed on every SUBSCRIBE and FETCH response. Publisher properties fan *out* at a relay (one upstream subscription serving many downstreams), so a change would have to propagate everywhere; subscriber properties fan *in*, which the relay already merges, so they MAY change freely via SUBSCRIBE\_UPDATE. **Publisher Priority**: The publisher's priority for this Track, represented as a u8, used only to resolve ties between subscriptions of equal subscriber priority. See the [Prioritization](#prioritization) section for more information. **Publisher Max Age**: The maximum age, in milliseconds, that the publisher caches a non-latest group past the arrival of a newer group. Applies only to non-latest groups; the latest group is always retained. It is an upper bound on retention, the inverse of an HTTP `Cache-Control: max-age` guarantee: * A subscriber MAY issue a SUBSCRIBE or FETCH with an older `Group Start`, but the publisher MAY have already dropped any group whose age exceeds `Publisher Max Age`. * The publisher MAY drop groups sooner than `Publisher Max Age` under resource pressure; subscribers MUST NOT assume older groups within the bound are still available. A value of `0` means the publisher caches only the latest group (older groups MAY be dropped as soon as a newer group arrives). The unit is milliseconds, matching `Subscriber Max Age`. See the [Expiration](#expiration) section for more information. **Timescale**: The number of timestamp units per second for frame timestamps on this Track. It MUST be non-zero; a subscriber that receives 0 MUST reset the stream with a protocol violation. Common values include `1000` (milliseconds), `1000000` (microseconds), `48000` (audio sample rate), and `90000` (RTP video clock). ### SUBSCRIBE\_OK {#subscribe-ok} A SUBSCRIBE\_OK message confirms a subscription and resolves its absolute start position. It is the first message the publisher sends on the Subscribe Stream, once the start position is known. This is the trimmed-down counterpart of MoqTransport's SUBSCRIBE\_OK: it retains the name and the role of the publisher's positive response, but carries only the resolved start position (all other per-track properties live in [TRACK\_INFO](#track-info)). ``` SUBSCRIBE_OK Message { Type (i) = 0x0 Message Length (i) Group (i) } ``` **Type**: Set to 0x0 to indicate a SUBSCRIBE\_OK message. **Group**: The absolute sequence number where delivery starts when the subscription resolves. Group streams can arrive out of order, so it need not be the first group sent. It MUST be greater than or equal to the requested `Group Start`. This group is not a new floor. A subscriber SHOULD NOT wait for a group between the requested floor and this group. The publisher still delivers such a group if it is created or becomes available later, while it is within `Subscriber Max Age`. A subscriber whose `Subscriber Max Age` resolves the start to the latest group learns that sequence here. There is no matching frame field, because the start frame is never in doubt: a partial group is only delivered when it was asked for, so the subscription starts either exactly where it asked or at the beginning of a later group (see [Positions](#positions)). The subscriber derives the start frame from `Group` and its own request: * `Group` equals the requested start group: delivery begins at the requested `Frame Start`. * `Group` is greater: delivery begins at frame 0. The second case is easy to get wrong, so to be explicit: a subscriber that requested group 5 frame 15 and receives `Group` = 6 starts at **frame 0** of group 6, not frame 15. The frame offset belonged to group 5 and is gone along with the rest of it; it does not carry forward to whichever group the publisher resolved to. ### SUBSCRIBE\_END {#subscribe-end} A SUBSCRIBE\_END message is sent by the publisher to signal that no group at or after a given sequence will be produced. ``` SUBSCRIBE_END Message { Type (i) = 0x1 Message Length (i) Group (i) Stream Count (i) } ``` **Type**: Set to 0x1 to indicate a SUBSCRIBE\_END message. **Group**: The exclusive end of the range: the absolute sequence number of the first group that will never be delivered. A value of 0 means the track ended before producing any groups. The subscriber MUST NOT wait for any group at or after this sequence. **Stream Count**: The number of Group Streams the publisher opened for this subscription, whether they finished or were reset. A group that was skipped, never produced, delivered only as a datagram, or given up before its stream opened is not counted. A relay counts the Group Streams it opened itself, never the count it received upstream. The publisher MUST NOT send SUBSCRIBE\_END until every Group Stream it will open for the subscription has been opened, so the count is final; it does not wait for them to finish. The subscriber has received every Group Stream once it has read the header of `Stream Count` of them, which MAY happen after SUBSCRIBE\_END or the FIN since streams are not ordered. A Group Stream reset before its header arrived is never seen, so a subscriber SHOULD bound how long it waits for the rest, for example by `Subscriber Max Age`. ### FETCH FETCH is sent by a subscriber to request a single group from a track. ``` FETCH Message { Message Length (i) Broadcast Path (s) Track Name (s) Subscriber Priority (8) Group Sequence (i) Frame Start (i) Frame End (i) } ``` **Broadcast Path**: The broadcast path of the track to fetch from. **Track Name**: The name of the track to fetch from. **Subscriber Priority**: The priority of the fetch within the session, represented as a u8. See the [Prioritization](#prioritization) section for more information. **Group Sequence**: The sequence number of the group to fetch. **Frame Start**: The index of the first frame to return within the group (see [Positions](#positions)). A plain absolute index; 0 means the start of the group (default). **Frame End**: The last frame to return (inclusive), encoded as the absolute frame index + 1. A value of 0 means through the end of the group (default). A `Frame End` below `Frame Start` once decoded is a protocol violation; equal bounds are a legal single-frame range. The publisher responds with FRAME messages directly on the same stream — there is no response header. The subscriber parses them using the track's [TRACK\_INFO](#track-info), which it MUST already have (see the [Track Stream](#track-stream)); the group sequence and the index of the first frame are implicit from the FETCH request. The publisher FINs the stream after the last frame, or resets on error. There is no FETCH\_ERROR message — the publisher signals failure by resetting the stream. A publisher holding fewer frames than requested MUST reset rather than truncate, since a short response is indistinguishable from one that started elsewhere. A group that ends before `Frame End` is not a truncation: the publisher FINs after the last frame it has, provided the group is complete and it served everything from `Frame Start` onward. ### PROBE PROBE is used to measure the available bitrate of the connection. ``` PROBE Message { Message Length (i) Bitrate (i) RTT (i) } ``` **Bitrate**: When sent by the subscriber (stream opener): the target bitrate in bits per second that the publisher should pad up to. The publisher only honors a target above its current sending rate if it advertised the Increase capability (see [Probe Parameter](#probe-parameter)); otherwise the target is ignored and the publisher only reports. When sent by the publisher (responder): the current estimated bitrate in bits per second. A value of 0 means unknown. **RTT**: The smoothed round-trip time in milliseconds, as defined in [RFC 9002](https://www.rfc-editor.org/rfc/rfc9002). A value of 0 means unknown. > NOTE: RTT is included in the PROBE message because not all QUIC implementations and browser WebTransport APIs expose RTT statistics directly. This field may be deprecated once RTT is universally available via the underlying transport API. ### GOAWAY {#goaway-message} A GOAWAY message is sent to initiate a graceful session shutdown with an optional redirect. ``` GOAWAY Message { Message Length (i) New Session URI (s) } ``` **New Session URI**: A URI for the peer to reconnect to. An empty string indicates no redirect; the peer should simply close the session. The URI MUST NOT exceed 8,192 bytes; a receiver MUST treat a longer URI as a protocol violation and MAY reject it based on the length prefix alone. A recipient MUST validate the URI against local policy before reconnecting, including verifying the scheme, authority, and port are permitted. If validation fails, the recipient MUST close the session without reconnecting. A client MUST send an empty New Session URI, as it cannot instruct a server to establish connections. A server that receives a non-empty New Session URI MUST close the session with a protocol violation. The new session URI SHOULD use the same scheme as the current session's URI. An endpoint MUST close the session with a protocol violation if it receives more than one GOAWAY. A peer that reconnects to a provided URI SHOULD keep using that URI for subsequent reconnects rather than reverting to the original. A relay that receives a GOAWAY SHOULD treat the announcements that arrived on that session as the most expensive routes available, so a subscription it can serve from another session moves at the next Group boundary rather than when the draining session finally closes. The routes stay usable: a broadcast reachable only over the draining session MUST keep being served until the session ends, which is what makes the sender's deadline a handover window rather than a cutoff. ### GROUP The GROUP message contains information about a Group, as well as a reference to the subscription being served. ``` GROUP Message { Message Length (i) Subscribe ID (i) Group Sequence (i) Frame Start (i) } ``` **Subscribe ID**: The corresponding Subscribe ID. This ID is used to distinguish between multiple subscriptions for the same track. **Group Sequence**: The sequence number of the group. This SHOULD increase by 1 for each new group. A subscriber MUST handle gaps, potentially caused by congestion. **Frame Start**: The index of the first FRAME message on this stream within the group (see [Positions](#positions)). A plain absolute index; 0 means the stream carries the group from its beginning, which is the common case. A non-zero value means the leading frames are not on this stream, either because the subscription started partway into this group or because the publisher only holds part of it. The subscriber MUST NOT assume it will receive the missing frames on another stream; they are a gap unless a separate subscription or FETCH covers them. ### FRAME The FRAME message is a payload within a group. ``` FRAME Message { Timestamp Delta (i) Message Length (i) Payload (b) } ``` **Timestamp Delta**: A signed delta from the previous frame's timestamp, in the Track's negotiated `Timescale`. Encoded as a zigzag-mapped variable-length integer: * Encode: `unsigned = (signed << 1) ^ (signed >> 63)` (arithmetic right shift). * Decode: `signed = (unsigned >> 1) ^ -(unsigned & 1)`. Zigzag interleaves non-negative and negative values so small magnitudes of either sign fit in a 1-byte varint. The first frame of a group is delta-encoded from `0`, so its `Timestamp Delta` is the zigzag encoding of the absolute timestamp. **Payload**: An application-specific payload. The `Message Length` describes the payload size on the wire. ## Appendix A: Changelog ### moq-lite-07 * SUBSCRIBE\_OK `Group` names where delivery starts when the subscription resolves, which need not be the first group sent. It is not a new floor. A subscriber does not wait for a group between the requested floor and it, but a publisher still delivers one that arrives later within Subscriber Max Age. * Corrected the SUBSCRIBE note that offset `Group Start` by 1. Only `Group End` and `Frame End` are offset so 0 can mean absent. `Group Start` is an absolute sequence, and 0 is group 0. * Assigned `moq-lite-07-wip` as this draft's protocol identifier until it is finalized as `moq-lite-07`. * Switched every variable-length integer, including SETUP parameter values, from QUIC's two-bit length prefix to moq-transport's leading-ones encoding, widening the range to 64 bits. * Hid routes with a `.`-prefixed segment below the requested prefix from announce discovery, and added the ANNOUNCE\_REQUEST `Hidden` field to opt in. * Added `Stream Count` to SUBSCRIBE\_END: the number of Group Streams opened for the subscription. SUBSCRIBE\_END is now sent once every counted Group Stream has opened, rather than as soon as the final group is known. * Removed SUBSCRIBE\_DROP and its type 0x2; a group without a Group Stream is not counted. * The Subscribe Stream FIN now follows once every counted Group Stream has finished or been reset. * Added announce compression: ANNOUNCE\_START gains `Path Base` and `Path Keep` to copy the head of a live advertisement's suffix, and ANNOUNCE\_START and ANNOUNCE\_UPDATE gain `Hop Base` and `Hop Keep` to copy the tail of a live advertisement's Hop ID list. * Capped the SETUP Message Length at 65,536 bytes. * A relay puts a random Hop ID, picked per session, in front of an announcement whose reconstructed path starts with 0, and writes that stamp followed by 0 for an empty path. ### moq-lite-06 * Assigned `moq-lite-06` as this draft's protocol identifier. * Require error-code translation when bridging protocols and draft versions. * Made a repeated non-zero Hop ID in one announcement's Hop ID list a PROTOCOL\_VIOLATION, matching draft-lcurley-moq-cluster. Repeated 0 entries stay legal. * Moved the Qmux-over-WebSocket binding details to draft-lcurley-qmux-websocket; the binding itself is unchanged. * Extended the SETUP `Path` parameter to carry the URI query: a client appends `?` and the query component after the path, matching moq-transport's PATH option. The credential a deployment puts in the query was previously unrepresentable on a binding with no request URI. * Allowed an empty SETUP `Path` parameter, equivalent to omitting it; both request the server's default path. Previously an empty value was a protocol violation, which made the two ways of asking for the default disagree. * Corrected SUBSCRIBE\_END `Group` to an exclusive bound: the first sequence that will never be delivered, with 0 meaning no groups were produced. It was previously specified as the inclusive last group, which could not distinguish an empty track from one whose only group was 0. * Split ANNOUNCE\_BROADCAST into three typed messages: ANNOUNCE\_START (0x0), ANNOUNCE\_END (0x1), and ANNOUNCE\_UPDATE (0x2), each prefixed with a Type discriminator like the subscribe stream's responses. * Specified resolution of a request against the routes covering its path: only the most specific tier, the longest prefix, is consulted, so a concrete path shadows every broader route; a refusal never falls through, and cost and the existing tie-breaks order the tier. A route is always a prefix, and no message narrows one: an advertiser that serves only some of the paths beneath its prefix refuses the rest. A relay never announces a path because it resolved it; the advertiser announces the concrete path once producing. Standby ordering requires enforced deployment bounds on charged link count and cost; 2^32 is a recommended seed only when it exceeds their product. * Split the reserved stream error range: 32 through 47 stays reserved, and 48 through 63 is moq-lite's own, assigned by the tables and mapped rather than forwarded across a bridge. Assigned 0x30 NO\_CAPACITY there: it permits one re-resolution within the tier excluding the refusing advertiser, and a receiver that has spent or lacks that retry resets downstream with another code. Assigned 0x32 GROUP\_TOO\_LARGE: a group that grew past the publisher's cache budget is aborted. Every other code is terminal. * Assigned 0x33 NOT\_FOUND, 0x34 OLD, and 0x35 EVICTED in the stream error table: a group the publisher cannot serve because it was never here, has been superseded, or was dropped under memory pressure. * Assigned 0x36 UNROUTABLE, 0x37 WRONG\_SIZE, 0x38 FRAME\_TOO\_LARGE, and 0x39 TIMESTAMP\_MISMATCH in the stream error table, moving them out of the reserved 32 through 47 range, which no longer carries provisional placeholders. * Assigned 0x31 CONTROL\_TIMEOUT in the stream error table: a request stream torn down because the peer never answered, which DELIVERY\_TIMEOUT described as late content. It has no moq-transport value and bridges to INTERNAL\_ERROR. * A disallowed stream type, a role mismatch, or a missing extension is a PROTOCOL\_VIOLATION; the session table gains no code for them, so nothing is sent from the reserved 32 through 47 range in either registry. * Added implicit Announce IDs: each ANNOUNCE\_START assigns the next per-stream ordinal. * ANNOUNCE\_END and ANNOUNCE\_UPDATE reference the Announce ID instead of repeating the broadcast path. * Replaced the duplicate-`active` restart idiom with ANNOUNCE\_UPDATE; a second ANNOUNCE\_START for an already-advertised prefix is now a protocol violation. * Made the Announce Stream violations close the session rather than reset the stream: an unknown or retired Announce ID, an ANNOUNCE\_START for an already-advertised prefix, and any announcement before ANNOUNCE\_OK. * Redefined an announcement as a route: the advertised path is a prefix claiming that paths beneath it can be served, matching moq-transport's namespace semantics, rather than the exact path of one available broadcast. A route claims capability, not inventory; which covered paths name broadcasts is an application convention (announcing each broadcast's exact path keeps enumeration working). ANNOUNCE\_UPDATE updates a route's metadata in place and carries no content claim. Because routes carry no content identity, a relay never splices a live subscription across sources reached through different routes: a serving session ending resets its subscriptions and the subscriber re-requests through the best remaining route. * Stated the ended-broadcast lifecycle without a flag: a broadcast whose route is retracted with ANNOUNCE\_END may remain readable by exact path, its stored groups read over FETCH, discovered out of band; retraction never disturbs in-flight subscriptions. * Specified route matching as per path segment (a prefix never matches half a segment) and specified how a route broader than the requested prefix presents: clamped to the request, as an empty suffix. * Added `Warm Route Cost` and `Cold Route Cost` fields to ANNOUNCE\_START and ANNOUNCE\_UPDATE: the same path priced against two cache states. Warm is the accumulated cost of the transfers a subscription via this advertisement would newly cause, and is what route selection minimizes; Cold prices the path as if nothing along it were carrying, and breaks a Warm tie before path length, with the most recently received advertisement below that. A wire that cannot express Cold is read as the saturation ceiling, not as 0. * Added a SETUP `Cost` parameter (0x4) declaring what subscribing from the sender costs, added by the receiver to every announcement that sender forwards. Both endpoints send their own, so the two directions are priced independently, and a receiver MAY charge a locally configured value instead. Unpriced directions default to 1, degrading to shortest-path routing. * Removed `Exclude Hop` from ANNOUNCE\_REQUEST. The receiver's hop-based loop check already discards a looped announcement, so the field only saved the wasted send. * Stated the receiver's loop check normatively in ANNOUNCE\_START: an announcement whose reconstructed path contains the receiver's own Hop ID is neither forwarded nor selected as a route. * Added a SETUP `Hop` parameter (0x5): each endpoint declares its Hop ID at session setup, carrying session-wide the identity `Exclude Hop` carried per announce stream, and filtering subscriptions as well as announcements (including sessions that never open an Announce Stream). * Made advertisement selection per subscriber: a publisher MUST NOT advertise a path containing the subscriber's declared Hop ID and otherwise advertises the best remaining one (a subscriber the serving path flows through receives the best standby instead of nothing), MUST serve subscriptions by the same exclusion, and the actively-carrying cost discount applies only to the serving path. This is how redundant publishers fail over across a mesh. * Added `Frame Start` and `Frame End` to SUBSCRIBE and SUBSCRIBE\_UPDATE, qualifying the start and end group with a frame index so a subscription can begin or end partway through a group. `Frame Start` is a plain index qualifying the `Group Start` group; `Frame End` is the index + 1, matching `Group End`, and MUST be 0 when `Group End` is absent. * Added `Frame Start` and `Frame End` to FETCH, bounding the returned frames within the group. A publisher that cannot serve the full range resets the stream. * Added `Frame Start` to GROUP, giving the index of the first FRAME on the stream so a partial group is self-describing. * Added the Positions section defining a (group, frame) position, its lexicographic ordering, and the rule that a partial group is only ever delivered to a subscriber that asked for one. A publisher that cannot serve a group from the requested frame skips it and resolves to a later group, so SUBSCRIBE\_OK needs no frame field: the start frame follows from `Group` and the subscriber's own request. * Capped the GOAWAY New Session URI at 8,192 bytes, matching moq-transport. * Restricted the GOAWAY New Session URI to servers, specified a duplicate GOAWAY as a protocol violation, and recommended scheme continuity and sticky redirects. * Exempted a ceiling-cost serving path from the actively-carrying cost discount: a relay whose serving path costs the saturation ceiling (primarily a session that received a GOAWAY) advertises the ceiling instead of 0, so the drain propagates downstream instead of being re-masked by each carrying hop. Keyed on the value, not the reason, which does not travel on the wire. * Added the Error Codes section, defining separate session and stream code spaces and listing the codes moq-lite uses, reused unchanged from moq-transport. Codes 64+ are the application's; 32-63 are reserved and MUST NOT be interpreted, pending a future revision. Previously the codes were unspecified, so an endpoint could neither send one a peer would understand nor safely interpret one it received. Note this renumbers every code an existing implementation sent, and that a stream reset of 0x0 is now INTERNAL\_ERROR rather than a cancellation (CANCELLED is 0x1). * Renamed `Subscriber Max Latency` to `Subscriber Max Age` and `Publisher Max Latency` to `Publisher Max Age`. * Redefined timestamp age so a group is expired only once it provably cannot overlap the Max Age window. Age is now measured from a group's *reach* (the first frame timestamp of its successor, which is the furthest it could still present) to the newest frame of the latest group, and an age equal to Max Age expires it, since reach is an exclusive bound. Previously age ran from the group's own first frame, which expired a group whose later frames were still well inside the budget. A group whose successor has not yet presented a frame is never expired, because frame durations are not on the wire and nothing else proves where it ends. * Removed the wall-clock age measure from expiration: timestamps are the only input, and wall-clock reclamation of idle content is the retention cache's own policy (`Publisher Max Age`) rather than part of the subscription rule. A zero-frame group needs no measure of its own, since its reach is bounded by its successor's first frame like any other group. * Removed `Subscriber Ordered` from SUBSCRIBE and SUBSCRIBE\_UPDATE, and `Publisher Ordered` from TRACK\_INFO and SUBSCRIBE\_OK. Group order within a Track is now normatively newest-first, with no field to invert it: a subscriber that wants sequence order reads in sequence order, which costs the network nothing and does not let one subscriber's preference reach a Track a relay is fanning out to many. Note this removes a byte from the middle of each message, so a lite-06 peer cannot parse a lite-05 one's SUBSCRIBE (the earlier drafts keep the byte, and an implementation serving them SHOULD write 0 and ignore what it reads). * Made `Group Start` an absolute floor (the raw minimum group sequence, default 0) rather than the sequence + 1 with 0 meaning the latest group. The start resolves from `Subscriber Max Age` instead: the oldest group at or above the floor within the budget, so a subscriber that buffers is handed the head of what it can still play. A zero budget still resolves to the latest group, which was the only start the old encoding could ask for by default. * Removed the actively-carrying Warm discount, its ceiling exemption, and the `(Cold cost, hash)` adoption rank with its re-parenting delay: costs are forwarded accumulated only. The `Warm` and `Cold` fields and the selection order are unchanged. * Replaced the no-splice rule with a first-hop identity: two routes covering one path with the same non-zero first hop are the same origin reached another way, and a relay MAY move a live subscription between them at a group boundary. Splicing across differing or unknown (0) first hops remains prohibited. * Ranked a path that contains a 0 Hop ID below every fully identified path before comparing Route Cost. A received 0 is forwarded unchanged, and bridging an upstream that sent no hop list writes 0 for that hop. An assigned identity is local selection state and MUST NOT be forwarded. ### moq-lite-05 * Renamed ANNOUNCE\_INTEREST to ANNOUNCE\_REQUEST and ANNOUNCE to ANNOUNCE\_BROADCAST. * Added a SETUP message and Setup Stream (0x1). * Added a SETUP `Probe` parameter. * Added a SETUP `Path` parameter to convey the request path on bindings that have no request URI (native QUIC and Qmux-over-TCP/TLS). * Added a SETUP `Role` parameter so a client can advertise its intended direction (Publisher/Subscriber/Both) and be rejected during SETUP when its authorization lacks that direction. * Added Track Stream (0x6) and TRACK\_INFO. * Removed FETCH\_OK. * Trimmed SUBSCRIBE\_OK to a single resolved start group. * Split end-of-subscription signaling into SUBSCRIBE\_END. * Renamed `Start Group`/`End Group` to `Group Start`/`Group End` in SUBSCRIBE, SUBSCRIBE\_UPDATE, and SUBSCRIBE\_DROP. * Allowed duplicate `active` ANNOUNCE\_BROADCAST messages to atomically replace the prior advertisement. * Added ANNOUNCE\_OK with `Hop ID` and `Active Count`. * Added mandatory `Timescale` to TRACK\_INFO. * Added `Timestamp Delta` to FRAME. * Added `Timestamp` to the QUIC datagram body. * Moved `Publisher Max Latency` to TRACK\_INFO and redefined it as a maximum retention bound: the longest the publisher caches a non-latest group (the inverse of an HTTP `Cache-Control: max-age` guarantee). `Subscriber Max Latency` keeps its name and remains the subscriber's delivery-time expiration preference. * Expire a group once **either** its timestamp age or its wall-clock arrival age exceeds Max Latency (the shorter lifetime wins), bounding both manipulated timestamps and delivery bursts. * Added QUIC datagram delivery for groups. Datagrams and Group Streams are independent delivery modes with no conversion between them: an oversized (>1200 byte) datagram MUST NOT be sent and is dropped on receipt, and bindings without a datagram channel do not fall back from datagrams to streams. * Added Qmux [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/) transport bindings for TCP/TLS and WebSocket. ### moq-lite-04 * Renamed ANNOUNCE\_PLEASE to ANNOUNCE\_SUBSCRIBE. * ANNOUNCE\_BROADCAST `Hops` count replaced with explicit `Hop ID` list for loop detection. * Added `Exclude Hop` to ANNOUNCE\_REQUEST for relay loop avoidance. * Added GOAWAY stream for graceful session shutdown and migration. * Added RTT to PROBE message. Bitrate and RTT use 0 for unknown. ### moq-lite-03 * Version negotiated via ALPN (`moq-lite-xx`) instead of SETUP messages. * Removed Session, SessionCompat streams and SESSION\_CLIENT/SESSION\_SERVER/SESSION\_UPDATE messages. * Unknown stream types reset instead of fatal; enables extension negotiation via stream probing. * Added FETCH stream for single group download. * Added Start Group and End Group to SUBSCRIBE, SUBSCRIBE\_UPDATE, and SUBSCRIBE\_OK. * Added SUBSCRIBE\_DROP on Subscribe stream. * Subscribe stream closed (FIN) when all groups accounted for. * Added PROBE stream replacing SESSION\_UPDATE bitrate. * Removed ANNOUNCE\_INIT message. * Added `Hops` to ANNOUNCE\_BROADCAST. * Added `Subscriber Max Latency` and `Subscriber Ordered` to SUBSCRIBE and SUBSCRIBE\_UPDATE. * Added `Publisher Priority`, `Publisher Max Latency`, and `Publisher Ordered` to SUBSCRIBE\_OK. * SUBSCRIBE\_OK may be sent multiple times. ### moq-lite-02 * Added SessionCompat stream. * Editorial stuff. ### moq-lite-01 * Added Message Length (i) to all messages. ## Appendix B: Upstream Differences A quick comparison of moq-lite and moq-transport-14: * Streams instead of request IDs. * Pull only: No unsolicited publishing. * FETCH is HTTP-like (single request/response) vs MoqTransport FETCH (multiple groups). * Capabilities negotiated via a SETUP message on a unidirectional stream that does not block other streams, instead of MoqTransport's blocking CLIENT\_SETUP/SERVER\_SETUP handshake on the control stream. * Both moq-lite and MoqTransport use ALPN for version identification. * Names use utf-8 strings instead of byte arrays. * Track Namespace is a string, not an array of any array of bytes. * Subscriptions default to the latest group, not the latest object. * No subgroups * No group/object ID gaps * No object properties * No paused subscriptions (forward=0) ### Deleted Messages * MAX\_SUBSCRIBE\_ID * REQUESTS\_BLOCKED * SUBSCRIBE\_ERROR * UNSUBSCRIBE * PUBLISH\_DONE * PUBLISH * PUBLISH\_OK * PUBLISH\_ERROR * FETCH\_OK * FETCH\_ERROR * FETCH\_CANCEL * FETCH\_HEADER * TRACK\_STATUS * TRACK\_STATUS\_OK * TRACK\_STATUS\_ERROR * PUBLISH\_NAMESPACE * PUBLISH\_NAMESPACE\_OK * PUBLISH\_NAMESPACE\_ERROR * PUBLISH\_NAMESPACE\_CANCEL * SUBSCRIBE\_NAMESPACE\_OK * SUBSCRIBE\_NAMESPACE\_ERROR * UNSUBSCRIBE\_NAMESPACE * OBJECT\_DATAGRAM ### Renamed Messages * SUBSCRIBE\_NAMESPACE -> ANNOUNCE\_REQUEST * SUBGROUP\_HEADER -> GROUP ### Deleted Fields Some of these fields occur in multiple messages. * Request ID * Track Alias * Group Order * Filter Type * StartObject * Expires * ContentExists * Largest Group ID * Largest Object ID * Parameters * Subgroup ID * Object ID * Object Status * Extension Headers ## Security Considerations moq-lite inherits the transport security of the underlying connection: QUIC and WebTransport provide confidentiality and integrity via TLS 1.3, and the Qmux bindings run over TLS (TCP) or a `wss://` WebSocket. How that connection is authenticated is out of scope (see [Connection](#connection)). The considerations below are specific to moq-lite. ### Bandwidth Probing The `Increase` Probe level (see [Probe Parameter](#probe-parameter)) lets a subscriber ask the publisher to pad the connection up to a target bitrate. A publisher MUST NOT treat the target as authorization to send beyond what congestion control allows: padding is bounded by the congestion window, so probing cannot be used to amplify traffic toward the subscriber or a spoofed address. A publisher that only advertised `Report` MUST NOT pad above its current sending rate. Because all data flows on an established, congestion-controlled session to the connecting peer, moq-lite offers no off-path amplification vector. ### Session Redirection GOAWAY carries an optional New Session URI that asks the peer to reconnect elsewhere. A malicious or compromised peer could use this to redirect a client to an attacker-controlled server. A recipient MUST validate the URI against local policy (scheme, authority, and port) before reconnecting, and MUST NOT reconnect if validation fails (see [GOAWAY](#goaway)). Migrated subscriptions carry no implicit trust from the prior session; the new session is authenticated independently. ### Routing Metadata and Privacy Hop IDs (see [ANNOUNCE\_OK](#announce-ok) and [ANNOUNCE\_START](#announce-start)) expose the relay path of a broadcast, which may reveal internal topology. A relay that does not wish to disclose its position MAY use the reserved value 0 ("unknown") instead of a stable identifier, at the cost of losing loop detection through itself (see [Routing](#routing)). The Hop ID announcement filter (see [Hop Parameter](#hop-parameter)) exists for loop avoidance, not access control: a subscriber cannot verify that a publisher honored it, so it MUST NOT be relied upon to hide a broadcast from a peer that declared its Hop ID. ### Resource Exhaustion A peer can open many streams (subscriptions, announcements, fetches), request large announce prefixes, or advertise broad routes. Implementations SHOULD bound the number of concurrent subscriptions, announce matches, and cached groups, and SHOULD rely on QUIC flow control and stream limits to backpressure a misbehaving peer (see [ANNOUNCE\_REQUEST](#announce-request)). Expiration (see [Expiration](#expiration)) bounds how long stale groups consume memory and flow control. A broad route invites a request for any covered path, each of which may start work: an advertiser SHOULD bound the work it starts and refuse beyond that with NO\_CAPACITY, and a receiver re-resolves at most once per request, so a flood of requests costs the mesh one round trip each rather than a search (see [Resolution](#resolution)). ### Datagram Injection Datagrams are routed to a subscription solely by Subscribe ID and carry no per-group authentication beyond that of the QUIC connection. On an unmodified QUIC/WebTransport connection this is sufficient, since datagrams are protected by the transport. A subscriber MUST silently drop any datagram with an unknown Subscribe ID and MUST deduplicate against groups received on streams (see [Datagrams](#datagrams)). ### Opaque Payloads The moq-lite layer treats Frame payloads as opaque and performs no validation of their contents. Confidentiality or integrity of the media itself (e.g. end-to-end encryption transparent to relays) is an application concern and out of scope for this draft. ## IANA Considerations This document has no IANA actions. ## Normative References * [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) * [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/) * [QMux over WebSocket](https://datatracker.ietf.org/doc/draft-lcurley-qmux-websocket/) * [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986) * [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455) * [RFC 9000](https://www.rfc-editor.org/rfc/rfc9000) * [RFC 9002](https://www.rfc-editor.org/rfc/rfc9002) ## Informative References * [draft-lcurley-moq-cluster](/draft/moq-cluster) ## Acknowledgments TODO acknowledge. --- --- url: https://doc.moq.dev/draft/moq-mpegts.md description: >- This document defines the `mpegts` catalog section, which records what demultiplexing an MPEG-2 Transport Stream Information technology - Generic coding of moving pictures and associated audio information: Systems into a MoQ broadcast would otherwise lose: each track's PID and PMT descriptors, the program identity, the service information tables, and a carriage record for every elementary stream the publisher did not decode. --- # MoQ MPEG-TS Catalog Extension ::: info Rendered from the Internet-Draft source in this repository. Submitted versions are on the [IETF datatracker](https://datatracker.ietf.org/doc/draft-lcurley-moq-mpegts/). ::: ## Abstract This document defines the `mpegts` catalog section, which records what demultiplexing an MPEG-2 Transport Stream [Information technology - Generic coding of moving pictures and associated audio information: Systems](https://www.iso.org/standard/83239.html) into a MoQ broadcast would otherwise lose: each track's PID and PMT descriptors, the program identity, the service information tables, and a carriage record for every elementary stream the publisher did not decode. It is a root member of either the hang catalog [draft-lcurley-moq-hang](/draft/moq-hang) or the MSF catalog [draft-ietf-moq-msf](https://datatracker.ietf.org/doc/draft-ietf-moq-msf/), so a subscriber that ignores it still plays the broadcast and one that reads it can rebuild the source multiplex. ## Note to Readers This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it. Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything. ## Conventions and Definitions The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119](https://www.rfc-editor.org/rfc/rfc2119) [RFC8174](https://www.rfc-editor.org/rfc/rfc8174) when, and only when, they appear in all capitals, as shown here. This document uses [Information technology - Generic coding of moving pictures and associated audio information: Systems](https://www.iso.org/standard/83239.html) terminology for PID, PSI, PAT, PMT, PES, descriptor, section, and stream\_type, and [draft-lcurley-moq-hang](/draft/moq-hang) terminology for broadcast, track, group, and frame. A **verbatim** track carries an elementary stream the publisher did not decode, byte-for-byte. ## Introduction {#introduction} A transport stream reaches MoQ in one of two shapes. A publisher can leave the multiplex intact and carry the packet stream as opaque payload, which is what [draft-gregoire-moq-msfts](https://datatracker.ietf.org/doc/draft-gregoire-moq-msfts/) specifies. Or it can demultiplex, which is what this document addresses: each elementary stream becomes its own MoQ track with a codec description, so a relay can drop and prioritize per track and a subscriber can decode without a transport-stream parser ([Comparison with m2ts Packaging](#comparison)). Demultiplexing loses everything that is not media: the PID layout, the PMT descriptors, the program identity, the service information, and any stream the publisher has no decoder for. The `mpegts` section records that. It defines no packaging and no container: decoded media keeps the container its catalog entry declares, and verbatim tracks use the framing in [Verbatim Tracks](#verbatim). ## Comparison with m2ts Packaging {#comparison} [draft-gregoire-moq-msfts](https://datatracker.ietf.org/doc/draft-gregoire-moq-msfts/) carries the transport stream as it arrives: each object is a run of whole 188- or 192-byte packets, and the receiver demultiplexes as it always has. This document takes the stream apart at the publisher and discards the packetization layer, because MoQ already provides what it was there for: * **Packet framing** interleaves elementary streams onto one serial pipe. MoQ delivers each track separately. * **Null packets** pad a multiplex to a constant bitrate. There is no bitrate to pad to. * **The PCR** distributes the multiplex's timebase. Each track carries its own timestamps. * **Continuity counters** detect loss within a stream. That is the transport's job. * **PSI repetition** lets a receiver join mid-stream. The catalog is a track a subscriber fetches on join. What remains is media in a codec-neutral catalog, plus the signaling a demultiplexer cannot reconstruct: that is the `mpegts` section. | | m2ts packaging | mpegts section | |:--------|:--------|:--------| | MoQ track | one per program | one per elementary stream | | Object payload | whole source packets | one media frame, or one verbatim PES payload or section | | Packaging | `m2ts` | none defined; media keeps its own | | Catalog | `m2ts*` members on each track | one `mpegts` root member | | Relay | caches and drops whole objects | drops and prioritizes per elementary stream | The two overlap only on program identity: `m2tsProgramNumber` and `m2tsPmtPid` against [program](#field-program), and `m2tsPsiInterval` against a per-PID `interval` ([si](#field-si)). They do not collide, since those members sit on a track and this section sits at the root. A track is one shape or the other. ## The mpegts Section {#section} A publisher that demultiplexed a transport stream SHOULD add an `mpegts` root member to its catalog ([Carriage](#carriage)): ``` type Mpegts = { "tracks": Map | undefined, "programDescriptors": Descriptor[] | undefined, "program": Program | undefined, "si": Map | undefined, "muxRate": number | undefined, } ``` Every member is optional and a publisher MUST omit an empty one, so a broadcast with nothing to record omits the section entirely. ### tracks `tracks` maps a MoQ track name to that track's MPEG-TS identity: ``` type Track = { "pid": number, "descriptors": Descriptor[] | undefined, "verbatim": Verbatim | undefined, } ``` `pid` is the track's PID in the source, 0..8191. A publisher MUST NOT record 0x0000 (PAT) or 0x1FFF (null packets), which carry no elementary stream. `descriptors` are the track's ES-level descriptors from the PMT ([Descriptor](#descriptor)), in PMT order. `verbatim` marks a track carrying an undecoded elementary stream ([Verbatim Tracks](#verbatim)). A decoded track MAY appear here too, to preserve its `pid` and `descriptors`. A consumer MUST ignore an entry with neither a `verbatim` record nor a track the catalog describes. ### programDescriptors The PMT's program-level descriptors (`program_info`), in PMT order ([Descriptor](#descriptor)). ### program {#field-program} The program identity from the PAT: ``` type Program = { "transportStreamId": number, "programNumber": number, "pmtPid": number, } ``` `transportStreamId`, `programNumber` (the DVB service id), and `pmtPid` come from the PAT. They are the only service-layer fields this document parses; everything else about the program stays inside the opaque `si` tables. A publisher MUST include `program` when the broadcast came from a transport stream, and omit it otherwise. ### si {#field-si} The standalone service information tables, keyed by the PID they ride on and then by `table_id`: ``` type Si = Map type SiEntry = { "track": string, "interval": number | undefined, } ``` JSON object keys are strings, so both are decimal with no leading zeros: `"17"` for PID 0x0011, `"66"` for `table_id` 0x42. A consumer MUST refuse a catalog whose PID key is not an integer in 0..8191, or whose `table_id` key is not an integer in 0..255. A consumer MAY additionally accept the pre-`table_id` form, where a PID maps to `{"interval", "sections"}` with the sections inline: it decodes into one entry per `table_id` (byte 0 of each section) naming no track. A producer MUST NOT write that form. `table_id` is byte 0 of generic section syntax ([Information technology - Generic coding of moving pictures and associated audio information: Systems](https://www.iso.org/standard/83239.html) Section 2.4.4), so the key is no less generic than the PID; which ranges mean what is a delivery-system convention this document does not rely on. `track` names the snapshot track carrying the entry's sections ([SI Track](#si-track)), published in the same broadcast as the catalog. The sections are not in the catalog: a full EPG is megabytes and would be republished on every unrelated catalog change. `interval` bounds how often a rebuilt stream repeats the entry's sections, in milliseconds. A publisher SHOULD use its delivery system's maximum repetition interval for that `table_id`, for example [Digital Video Broadcasting (DVB); Guidelines on implementation and usage of Service Information (SI)](https://www.etsi.org/standards) for DVB: 2000 for SDT actual and EIT present/following actual, 10000 for the NIT, the BAT, SDT other and EIT schedule actual, 30000 for EIT schedule other. It MUST omit `interval` for a `table_id` whose requirement it does not know; a consumer then falls back to its own PSI cadence. The key is `table_id` rather than PID because that is the granularity the requirements are defined at: one PID carries tables wanting different rates. PAT and PMT are never carried here: they are rebuilt from `program`, `programDescriptors`, and the per-track entries. ### muxRate {#field-mux-rate} The rate the source's PCR clock paced the whole multiplex at, in bits per second: every PID, the PSI, and the null packets, measured as the packets between two PCRs over the time they span. It is not a sum of the elementary streams, and a track's own bitrate keeps its codec meaning. A publisher MUST include `muxRate` only while the source holds a constant rate, and MUST omit it for a variable-rate or unpaced source; a value that has become invalid is removed rather than left stale. A consumer rebuilding a transport stream SHOULD pad its output with null packets to `muxRate` ([Rebuilding a Transport Stream](#rebuild)). #### SI Track {#si-track} Each group is a complete picture of the entry's current sections: one frame per sub-table, each frame that sub-table's sections concatenated verbatim in `section_number` order. Sections are self-delimiting through `section_length`, so a frame needs no further framing. A joiner reads only the newest group. Frames apply in order and a later frame replaces an earlier one of the same sub-table, so a publisher MAY append a revision within a group rather than opening a new one. A publisher MUST commit a revised sub-table whole: no group may expose part of one version beside part of another. A sub-table is identified by its `table_id_extension`, plus the bytes that disambiguate it where the extension alone does not: `original_network_id` for SDT other, and `transport_stream_id` with `original_network_id` for the EIT, whose extension is a service id unique only within a transport stream. A short-form section carries no extension, version, or numbering, so its `table_id` is one latest-value slot and each arrival replaces the last; that is what lets a time table be proxied rather than synthesized. ### Descriptor {#descriptor} One PMT descriptor: ``` type Descriptor = { "tag": number, "data": string, } ``` `tag` is the `descriptor_tag` (0x05 registration, 0x0A ISO-639 language, ...), 0..255. `data` is the body after the tag and length, base64 ([RFC 4648, Section 4](https://www.rfc-editor.org/rfc/rfc4648#section-4)). Descriptors are never parsed, so a consumer MUST re-emit a list in order and MUST NOT reorder, merge, or drop entries. ## Verbatim Tracks {#verbatim} An elementary stream the publisher does not decode rides its own MoQ track, byte-for-byte: ``` type Verbatim = { "streamType": number, "framing": "pes" | "section", "streamId": number | undefined, } ``` `streamType` is the PMT `stream_type` to re-announce: 0x86 for SCTE-35 [Digital Program Insertion Cueing Message](https://www.scte.org/standards/), 0x06 for private PES, 0x05 for private sections. `framing` says how the payload is framed, defaulting to `"pes"`. With `"pes"` each frame is one complete PES payload, timestamped with its PTS, or 0 if it carried none. With `"section"` each frame is one complete section, header and CRC included, timestamped with the media time it arrived at. A consumer MUST refuse a catalog that names a `framing` it does not recognize, rather than guess. `streamId` is the original PES `stream_id`, for example 0xBD (`private_stream_1`) for teletext, DVB subtitles, and DVB AC-3. It applies only to `"pes"` framing. A publisher SHOULD record it, since strict demultiplexers reject a relabeled PES; a consumer with none falls back to `private_stream_1`. Each frame MUST be a keyframe in its own group: a verbatim payload is all-or-nothing. A verbatim track has no codec, dimensions, or decoder configuration, so a publisher MUST NOT describe it as a media rendition and MUST describe it only by its `tracks` entry. Nothing there declares a container, so its frames use hang's `legacy` container ([draft-lcurley-moq-hang](/draft/moq-hang)): a varint microsecond timestamp followed by the payload. ## Carriage {#carriage} The section is the same JSON in either catalog format. ### hang {#carriage-hang} A root member named `mpegts`, alongside `video` and `audio` ([draft-lcurley-moq-hang](/draft/moq-hang)). hang carries a decoder config's raw bytes as hex while every binary field here is base64; the alphabets overlap, so the encoding cannot be detected and is stated per field above. ### MSF {#carriage-msf} A root member named `mpegts`, alongside `tracks` ([draft-ietf-moq-msf](https://datatracker.ietf.org/doc/draft-ietf-moq-msf/)). A publisher MUST NOT name a section after a member MSF itself defines. The keys of the section's own `tracks` member are MSF track names; a decoded track also has an MSF track object, a verbatim track does not. This document defines no packaging value; [draft-gregoire-moq-msfts](https://datatracker.ietf.org/doc/draft-gregoire-moq-msfts/) registers `m2ts` for the passthrough shape ([Comparison with m2ts Packaging](#comparison)). ## Rebuilding a Transport Stream {#rebuild} A consumer rebuilding a transport stream: * MUST place each track on its recorded `pid`, and MUST assign an unused PID to a track with none. * MUST put the PMT on `program.pmtPid` and build the PAT and PMT from `program`. * MUST re-emit each track's `descriptors` as its ES-level descriptors, and `programDescriptors` as the PMT's `program_info`. * MUST re-emit each `si` entry's sections byte-for-byte on that entry's PID, reading them from its track ([SI Track](#si-track)), at least as often as its `interval` when declared. * MUST repacketize each verbatim track per its `framing` and `streamType`, using `streamId` when recorded. * SHOULD pad the output with null packets to `muxRate` when present, so the rebuilt stream is constant-rate again; a source that exceeds the rate is passed through rather than delayed or dropped. With no `program` the consumer synthesizes an identity, and SHOULD then omit any carried `si`, which describes a program that no longer exists. A consumer MAY derive implied signaling: a program carrying a section-framed 0x86 stream and no `programDescriptors` implies the SCTE-35 `CUEI` registration descriptor. A consumer MUST NOT derive one that contradicts a recorded descriptor. ## Security Considerations Every binary field here is re-emitted without inspection, by a publisher that never parsed it and a relay that never looked, so a consumer MUST treat all of it as untrusted input. Nothing bounds the number of entries in the section, nor the size of an SI track: a publisher controls both, and an EPG is large by nature. A consumer MUST bound what it accepts of each, and MUST reject a catalog it cannot bound rather than truncate it into a stream that silently differs from what was described. PIDs, program numbers, and stream types are 13-, 16-, and 8-bit values in a JSON document that can hold any number, so a consumer MUST range-check each one. A catalog can also name one PID twice, so a consumer MUST NOT emit a stream in which two elementary streams share a PID, or in which an SI table shares a PID with PAT, PMT, null packets, or an elementary stream. The section makes a broadcast's provenance legible to anything that can read the catalog, relays included: the service name, provider, and network inside the carried SI, and the original PID layout. A publisher that does not want that MUST omit the section. ## IANA Considerations This document requests no registrations. Should [draft-ietf-moq-msf](https://datatracker.ietf.org/doc/draft-ietf-moq-msf/) establish a registry of catalog root members, this document requests registration of `mpegts` with this document as the reference. ## Normative References * [draft-lcurley-moq-hang](/draft/moq-hang) * [draft-ietf-moq-msf](https://datatracker.ietf.org/doc/draft-ietf-moq-msf/) * [Information technology - Generic coding of moving pictures and associated audio information: Systems](https://www.iso.org/standard/83239.html) ## Informative References * [draft-gregoire-moq-msfts](https://datatracker.ietf.org/doc/draft-gregoire-moq-msfts/) * [Digital Video Broadcasting (DVB); Guidelines on implementation and usage of Service Information (SI)](https://www.etsi.org/standards) * [Digital Program Insertion Cueing Message](https://www.scte.org/standards/) ## Appendix A: Example A broadcast demultiplexed from a DVB transport stream: video and audio described as ordinary renditions, a verbatim SCTE-35 stream, and the source's SDT Actual (`table_id` 0x42 on PID 0x0011) carried opaquely on its own track. ``` { "mpegts": { "program": { "transportStreamId": 4660, "programNumber": 1, "pmtPid": 100 }, "programDescriptors": [ { "tag": 5, "data": "Q1VFSQ==" } ], "tracks": { "video0": { "pid": 257 }, "audio0": { "pid": 258, "descriptors": [ { "tag": 10, "data": "ZW5nAA==" } ] }, "0.ts": { "pid": 500, "verbatim": { "streamType": 134, "framing": "section" } } }, "si": { "17": { "66": { "track": "si/17/66", "interval": 2000 } } } } } ``` ## Appendix B: Changelog ### draft-lcurley-moq-mpegts-00 * Initial version. * The `Si` type is keyed by `table_id` only; the PID lives on the enclosing `si` map. * A consumer refuses a catalog with an unrecognized `framing` or an invalid `si` map key. * Added `muxRate`, the source's constant multiplex rate. * A consumer may read the pre-`table_id` inline `sections` form; writing stays track-only. ## Acknowledgments This document was drafted with the assistance of Claude, an AI assistant by Anthropic. --- --- url: https://doc.moq.dev/draft/moq-probe.md description: >- This document defines a PROBE extension for MoQ Transport draft-ietf-moq-transport. --- # MoQ Probe Extension ::: info Rendered from the Internet-Draft source in this repository. Submitted versions are on the [IETF datatracker](https://datatracker.ietf.org/doc/draft-lcurley-moq-probe/). ::: ## Abstract This document defines a PROBE extension for MoQ Transport [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/). A subscriber opens a bidirectional PROBE stream to request that the publisher pad the connection up to a target bitrate. The publisher sends padding as defined in [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 11.5 and periodically responds with the measured bitrate and an elapsed timestamp, enabling the subscriber to estimate the available bandwidth. ## Note to Readers This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it. Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything. ## Conventions and Definitions The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119](https://www.rfc-editor.org/rfc/rfc2119) [RFC8174](https://www.rfc-editor.org/rfc/rfc8174) when, and only when, they appear in all capitals, as shown here. ## Introduction Bandwidth estimation is essential for adaptive bitrate media delivery. A subscriber needs to know the available bandwidth in order to select appropriate media tracks and qualities. ### Application-Limited Many MoQ applications are application-limited: the average bitrate of the media is less than the available bandwidth. Most congestion control algorithms only grow the congestion window or bandwidth estimate when fully utilized. This means the available bandwidth is often underestimated, and the subscriber has no way to know if it can safely switch to a higher quality track. This is particularly problematic for adaptive bitrate (ABR) algorithms. A viewer may get stuck at a low quality rendition indefinitely because the congestion window never grows to reflect the true link capacity. If the viewer does attempt to switch to a higher rendition without first probing, they risk buffering — either because the congestion window has not been warmed up to support the higher bitrate, or because the network genuinely cannot sustain it. Without probing, the subscriber cannot distinguish between these two cases. [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 3.7.2 suggests subscribing to additional tracks at low priority or sending padding to fill the congestion window during probing intervals. However, this is difficult in practice because the subscriber does not know when probing is needed or by how much. The congestion window and bandwidth estimate are internal to the sender's congestion controller and are not exposed to the application, let alone the remote peer. The subscriber cannot distinguish between "the network has more capacity" and "the congestion controller is already fully utilizing the link". ### Hop-by-Hop MoQ is designed to work end-to-end via relays. Each hop may have different network conditions, so bandwidth estimation must be performed per-hop rather than end-to-end. A subscriber needs to know the capacity of its immediate connection, not the capacity of the origin. Using a wire-level extension ensures that PROBE measurements are scoped to a single hop. A relay terminates the PROBE stream and does not forward it upstream, avoiding incorrect measurements that reflect intermediate link capacity. ### This Extension [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) defines padding streams and datagrams (Section 11.5) for probing, but does not define a signaling mechanism for a subscriber to request padding or for a publisher to report measurements. This extension fills that gap: the subscriber opens a PROBE stream to request that the publisher pad the connection to a target bitrate using [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) padding. The publisher responds with periodic measurements, allowing the subscriber to adjust its subscriptions accordingly. ## Setup Negotiation The PROBE extension is negotiated during the SETUP exchange as defined in [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 10.3. Each endpoint advertises the capabilities it supports when acting as a publisher (the responder on a PROBE stream). An endpoint indicates support by including the following Setup Option: ``` PROBE Setup Option { Option Key (vi64) = 0x950BE Option Value Length (vi64) Level (vi64) } ``` **Level**: The publisher capability level the sender supports, where each level includes the one below it: * `1` **Report**: The publisher can measure and periodically report its estimated bitrate via PROBE\_RESPONSE. * `2` **Increase**: The publisher can additionally send padding (or redundant data) to probe for bandwidth above its current sending rate, up to the subscriber's target. The levels are nested rather than independent: probing for more bandwidth is meaningless without measuring it, so Increase always includes Report. Reporting the current bitrate is far simpler to implement, so a publisher may support Report without Increase. A subscriber MUST consult the publisher's advertised Level before relying on a PROBE stream: * If the publisher omitted the option (or sent `0`), the PROBE extension is unavailable. A subscriber MUST NOT open a PROBE stream; if it does, the publisher MUST close the session with a PROTOCOL\_VIOLATION. * At `Report`, the subscriber MAY open a PROBE stream to monitor the measured bitrate but MUST NOT expect padding above the current sending rate. It MUST set Target Bitrate to 0 and use an alternative if it needs to probe for additional bandwidth. * At `Increase`, the subscriber MAY request a non-zero Target Bitrate and expect the publisher to actively probe up to it. ## PROBE Stream The PROBE extension defines two new MOQT messages, PROBE\_REQUEST and PROBE\_RESPONSE, exchanged on a bidirectional request stream. [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) no longer assigns dedicated bidirectional stream types; a request stream is identified by its first message type, and per [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 3.3 a bidirectional stream MUST NOT begin with a message type the peer has not negotiated. The PROBE Setup Option (see [Setup Negotiation](#setup-negotiation)) performs this negotiation. A subscriber opens the stream with a PROBE\_REQUEST message, which MUST be the first message on the stream. The subscriber (stream opener) sends PROBE\_REQUEST messages and the publisher (responder) sends PROBE\_RESPONSE messages on the same stream. An endpoint that receives a PROBE\_REQUEST without having advertised the PROBE Setup Option MUST close the session with a PROTOCOL\_VIOLATION. Either endpoint MAY close or reset the stream at any time. Both messages use the [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 10 control-message framing: a `Type` identifying the message, a 16-bit `Length`, and the payload. ### PROBE\_REQUEST A subscriber sends a PROBE\_REQUEST to indicate the target the publisher should attempt to reach. ``` PROBE_REQUEST Message { Type (vi64) = 0x950BE Length (16) Target Bitrate (vi64) } ``` **Target Bitrate**: The desired bitrate in kilobits per second. The publisher SHOULD send padding as defined in [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 11.5 to attempt to reach this rate, but only if it advertised the Increase capability (see [Setup Negotiation](#setup-negotiation)). A subscriber MUST set this to 0 unless the publisher advertised Increase. A value of 0 indicates no padding is needed; the publisher SHOULD only send media data but MUST continue sending PROBE\_RESPONSE messages. This is useful for passively monitoring the current bitrate without actively probing for more bandwidth. Either endpoint MAY close or reset the stream to stop receiving updates entirely. The subscriber MAY send multiple PROBE\_REQUEST messages on the same stream. Each new PROBE\_REQUEST supersedes the previous one. The publisher MUST use the most recently received target. ### PROBE\_RESPONSE The publisher periodically sends PROBE\_RESPONSE messages containing the measured bitrate and the elapsed time since the last response. ``` PROBE_RESPONSE Message { Type (vi64) = 0x950BF Length (16) Measured Bitrate (vi64) Elapsed (vi64) } ``` **Measured Bitrate**: The estimated bitrate in kilobits per second. How this value is computed is implementation-defined and depends on the congestion controller. Pacing-based algorithms (e.g. BBR) can report the current pacing rate directly, while window-based algorithms (e.g. CUBIC, Reno) may want to smooth the estimate since the sending rate is inherently bursty. This includes media, padding, and any other data sent by the publisher. **Elapsed**: The number of milliseconds since the previous PROBE\_RESPONSE on this stream. For the first PROBE\_RESPONSE, this is the number of milliseconds since the corresponding PROBE\_REQUEST was received. This allows the subscriber to assess the freshness of the measurement and detect stale updates caused by network delays. The publisher SHOULD send PROBE\_RESPONSE messages at regular intervals while probing is active. The interval is implementation-defined but a value between 100ms and 1000ms is RECOMMENDED. ## Padding The publisher SHOULD send padding using the mechanisms defined in [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 11.5 (padding streams and/or padding datagrams). The publisher MUST NOT exceed the target with padding alone. If media traffic already meets or exceeds the target, no additional padding is necessary. The publisher MUST respect the QUIC congestion controller. Padding that would cause the congestion window to be exceeded MUST NOT be sent. The goal is to fill unused capacity, not to cause congestion. ## Security Considerations A malicious subscriber could request an excessively high target to waste publisher resources or cause network congestion. Implementations SHOULD enforce reasonable limits on the target and MAY ignore or cap requests that exceed these limits. A publisher SHOULD rate-limit the amount of padding it sends to avoid being used as an amplification vector. A publisher MAY rate-limit or ignore frequent PROBE\_REQUEST messages to prevent flooding or oscillation. Implementations SHOULD enforce a minimum inter-request interval for PROBE\_REQUESTs from a given subscriber. ## IANA Considerations This document requests the following registrations: ### MOQT Setup Options This document requests a registration in the "MOQT Setup Options" registry ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 15.4), whose policy is Specification Required. moq-transport defines no private-use range for Setup Options; extensions request a (provisional) codepoint. A high, distinctive value is chosen to avoid the low ranges reserved by [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) and to minimize collisions with provisional registrations by other extensions; it also avoids the greasing pattern (`0x7f * N + 0x9D`). This is the same value as the PROBE\_REQUEST message type below; Setup Options and Message Types are independent registries, so the shared value is unambiguous. | Value | Name | Reference | |:--------|:------|:--------------| | 0x950BE | PROBE | This Document | ### MOQT Message Types This document requests registrations in the "MOQT Message Types" registry ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 15). [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) replaced dedicated bidirectional stream types with request message types, so the PROBE stream is identified by its first message rather than a stream type. High, distinctive values are chosen to avoid the low ranges reserved by [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) and to minimize collisions with provisional registrations by other extensions; they also avoid the greasing pattern (`0x7f * N + 0x9D`). | Value | Name | Stream | Reference | |:--------|:---------------|:---------------|:--------------| | 0x950BE | PROBE\_REQUEST | Request, First | This Document | | 0x950BF | PROBE\_RESPONSE | Request | This Document | ## Normative References * [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) ## Acknowledgments This document was drafted with the assistance of Claude, an AI assistant by Anthropic. --- --- url: https://doc.moq.dev/draft/moq-solicit.md description: >- This document defines an extension for MoQ Transport draft-ietf-moq-transport that lets an endpoint declare that advertisements to it must be solicited first. --- # MoQ Solicit Extension ::: info Rendered from the Internet-Draft source in this repository. Submitted versions are on the [IETF datatracker](https://datatracker.ietf.org/doc/draft-lcurley-moq-solicit/). ::: ## Abstract This document defines an extension for MoQ Transport [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) that lets an endpoint declare that advertisements to it must be solicited first. An endpoint that declares nothing receives unsolicited PUBLISH\_NAMESPACE, which is what a peer unaware of this extension implicitly asks for. An endpoint that will instead ask for what it wants says so once during setup, and is spared the advertisements it would otherwise have to ignore. Because sending the option at all identifies an endpoint that implements this extension, the requirement is enforceable between two such endpoints rather than merely advisory. ## Note to Readers This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it. Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything. ## Conventions and Definitions The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119](https://www.rfc-editor.org/rfc/rfc2119) [RFC8174](https://www.rfc-editor.org/rfc/rfc8174) when, and only when, they appear in all capitals, as shown here. An endpoint **advertises** a namespace by sending PUBLISH\_NAMESPACE, or NAMESPACE in response to a SUBSCRIBE\_NAMESPACE. An advertisement is **solicited** when it is carried by a SUBSCRIBE\_NAMESPACE the receiver sent, and **unsolicited** otherwise. On a version of [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) that has NAMESPACE, the mechanism decides this and nothing else does: PUBLISH\_NAMESPACE opens a request of its own and is always unsolicited, however the namespace relates to a prefix the receiver subscribed to. That distinction is what makes the requirement observable from a single message, and so enforceable ([Enforcement](#enforcement)). Earlier versions have no NAMESPACE, so an endpoint answers a SUBSCRIBE\_NAMESPACE with PUBLISH\_NAMESPACE requests and the two are indistinguishable. ## Introduction [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) has two ways to learn that a namespace exists. A publisher advertises one with PUBLISH\_NAMESPACE, and a subscriber asks for the set under a prefix with SUBSCRIBE\_NAMESPACE, which is answered with NAMESPACE. Both are optional, and nothing on the wire says which the peer expects. The result is that an endpoint has to guess, and both guesses are wrong somewhere: * Withhold PUBLISH\_NAMESPACE until asked, and a publisher connected to a relay that never asks stays silent forever. The session looks healthy and no content is ever offered. * Send it unasked, and an endpoint that only publishes is told about every namespace its peer knows, which is a set it has no use for and, on a relay, a set as large as the network. Implementations resolve this out of band today, by configuring each endpoint with what the peer will do. That works only as long as the configuration matches reality, and it makes the same software behave differently depending on a deployment flag rather than on anything in the protocol. This extension replaces the guess with a declaration. An endpoint states in its SETUP that it requires advertisements to be solicited, and the peer honors it. The default, declaring nothing, is to advertise unasked, so an endpoint that has never heard of this extension keeps working exactly as it does today. This is deliberately not a role: it says what an endpoint expects delivered to it, not what it is. An endpoint that sends SUBSCRIBE\_NAMESPACE for everything it cares about declares it on every session, whether it publishes, subscribes, or relays. ## Setup Negotiation An endpoint declares whether it requires solicitation with the following Setup Option ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 10.3): ``` SOLICIT Setup Option { Option Key (vi64) = 0x40B5A Option Value (vi64) = 0 or 1 } ``` A value of 1 means advertisements to the sender MUST be solicited: it sends SUBSCRIBE\_NAMESPACE for what it wants. A value of 0 means it has no requirement, so an advertisement may be sent freely. The option is OPTIONAL, and omitting it asks for the same treatment as a value of 0. The two are not equivalent, because sending either value also identifies the sender as implementing this extension ([Enforcement](#enforcement)), while omitting it says nothing at all. An endpoint that implements this extension and has no requirement SHOULD therefore send 0 rather than omit the option. A receiver MUST treat any non-zero value as 1. The two directions are independent: each endpoint declares its own, and the two need not match. Unlike an extension that changes an encoding, this one needs no negotiation handshake: a declaration only ever asks the peer to send *less*, so a peer that ignores it is merely as talkative as one that never saw it. ## Requiring Solicitation {#announce} An endpoint that declared 1 will solicit the namespaces it wants with SUBSCRIBE\_NAMESPACE, or wants none at all. A peer that receives this declaration and implements this extension MUST NOT send an unsolicited PUBLISH\_NAMESPACE for the remainder of the session; one that does not implement it cannot be bound by it and is covered by [Enforcement](#enforcement). It continues to answer SUBSCRIBE\_NAMESPACE with NAMESPACE as usual; only the unsolicited half is withheld. This is a MUST NOT rather than a SHOULD NOT because the receiver enforces it. A SHOULD NOT that a receiver may close the session over is not a permission an endpoint can actually exercise. A relay is the expected user of this declaration, as is any endpoint that asks for what it wants. So is an endpoint that only publishes, which cannot subscribe to anything and therefore has no use for an advertisement of any kind. An endpoint SHOULD NOT advertise the same namespace both ways on one session. Whichever arrives second replaces the source the first attached, which at best wastes a stream and at worst leaves the receiver holding two independent advertisements it must reconcile. Because this declaration decides which of the two an endpoint uses, honoring it also settles that question for the whole session. ## Enforcement {#enforcement} An endpoint that declared 1 and then receives an unsolicited PUBLISH\_NAMESPACE MUST close the session as a protocol violation if the sender declared either value, and MUST tolerate the message otherwise. An endpoint that sent the option implements this extension, so it read the receiver's declaration and ignored it. It also cannot have advertised before reading that declaration: the receiver's SETUP is what says whether advertising unasked is permitted at all, so an endpoint MUST have processed it before sending its first advertisement. Neither a race nor a partial implementation explains the message, which leaves a bug, and one that both sides would otherwise never see. An endpoint that omitted the option gets the opposite treatment for the same reason: it never saw the declaration, so announcing is exactly what it should do, and closing the session over it would turn this extension into a new way for conforming implementations to fail to interoperate. Versions of [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) without NAMESPACE are exempt, because a PUBLISH\_NAMESPACE there is also how an endpoint answers a SUBSCRIBE\_NAMESPACE and the message alone does not say which it is. Everywhere else the mechanism is the whole test: a receiver MUST NOT treat an advertisement as solicited merely because the namespace falls under a prefix it subscribed to, or an endpoint that subscribes to every prefix it can reach would find the requirement unenforceable against anyone. There is no counterpart for SUBSCRIBE\_NAMESPACE, enforceable or otherwise. An endpoint with nothing to advertise answers one with an empty set, which costs a single stream, while waiting on the peer's SETUP to learn whether the question is worth asking costs a round trip on every session. Asking unconditionally is therefore the cheaper behavior, and it is what an endpoint SHOULD do. ## Security Considerations A declaration only ever reduces what its sender receives, so an attacker who forges one can silence advertisements to the endpoint it impersonates, or add an option to an endpoint that never sent one so its own advertisements are treated as a violation and its session closed. Both are already available to anyone who can interfere with SETUP, which [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) assumes is protected by the underlying transport. A declaration says nothing about authorization. An endpoint that declares no requirements is not thereby entitled to any advertisement, and a peer MUST apply the same authorization to what it advertises as it would without this extension. In particular, a relay MUST NOT treat an absent SOLICIT option as permission to advertise namespaces the peer is not authorized to learn about. The declaration reveals a little about an endpoint's intent, roughly whether it intends to ask for what it wants. An endpoint that considers this sensitive can simply declare nothing, which costs it only the messages it would have avoided. ## IANA Considerations This document requests the following registration. A high, distinctive value is requested to avoid the low ranges reserved by [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) and to minimize collisions with provisional registrations by other extensions. ### MOQT Setup Options This document requests one registration in the "MOQT Setup Options" registry ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 15.4), whose policy is Specification Required. | Value | Name | Reference | |:--------|:--------|:--------------| | 0x40B5A | SOLICIT | This Document | The Key-Value-Pair parity is load-bearing: SOLICIT is even, so its value is a bare varint. This document defines only the values 0 and 1; a later extension that needs to say something else registers its own option rather than overloading this one. ## Normative References * [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) ## Acknowledgments This document was drafted with the assistance of Claude, an AI assistant by Anthropic. --- --- url: https://doc.moq.dev/draft/moq-timestamp.md description: >- This document specifies the transport-level use of the TIMESTAMP and TIMESCALE properties registered by draft-ietf-moq-loc, independent of the LOC container itself. --- # MoQ Object Timestamp Extension ::: info Rendered from the Internet-Draft source in this repository. Submitted versions are on the [IETF datatracker](https://datatracker.ietf.org/doc/draft-lcurley-moq-timestamp/). ::: ## Abstract This document specifies the transport-level use of the TIMESTAMP and TIMESCALE properties registered by [draft-ietf-moq-loc](https://datatracker.ietf.org/doc/draft-ietf-moq-loc/), independent of the LOC container itself. A track-level Timescale property establishes the units, and an object-level Timestamp property carries the presentation time of each object. Exposing media time to the transport lets relays make consistent age-based decisions (e.g. dropping stale objects) without parsing the media container, and it remains consistent across hops regardless of buffering or jitter. No new code points are requested: an endpoint implementing this document is on the wire indistinguishable from a LOC endpoint that carries only these two properties. ## Note to Readers This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it. Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything. ## Conventions and Definitions The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119](https://www.rfc-editor.org/rfc/rfc2119) [RFC8174](https://www.rfc-editor.org/rfc/rfc8174) when, and only when, they appear in all capitals, as shown here. ## Introduction [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) treats object payloads as opaque, leaving timing to the application's container format. This works for endpoints that parse the media, but not for relays. A relay frequently needs a notion of *when* an object is meant to be presented: * **Age-based dropping**: a relay serving a live subscription wants to drop objects too old to be useful. Wall-clock arrival time drifts across hops and is corrupted by buffering and jitter. * **Consistent expiration across hops**: a timestamp embedded in the object is identical at every hop, so every relay makes the same drop decision. * **Synchronization hints**: a subscriber can align objects from multiple tracks (e.g. audio and video) without first decoding each container. This extension exposes media time to the transport with two Key-Value-Pairs ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 2.5): a track-level **Timescale** and an object-level **Timestamp**. The transport does not interpret the *meaning* of the timeline; it only uses the timestamp for relative age comparisons. Both properties are already registered by [draft-ietf-moq-loc](https://datatracker.ietf.org/doc/draft-ietf-moq-loc/), which defines them for use inside the LOC container. This document reuses those registrations verbatim and specifies what a *transport* does with them: a timestamp is only useful to a relay if every publisher writes it the same way, so a second set of code points for the same concept would defeat the purpose. An endpoint that implements both this document and LOC writes one copy of each property, not two. The properties are self-describing and require no SETUP negotiation: a receiver that understands them uses them directly, and one that does not ignores them per [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/). TIMESCALE is what opts a track in: a track that carries it has media times in the stated units, and a track without it has no timeline at all. There is deliberately no default timescale; a wrong guess is off by a factor of 1000 rather than detectably broken, and making presence the signal avoids contradicting [draft-ietf-moq-loc](https://datatracker.ietf.org/doc/draft-ietf-moq-loc/), which reads a bare Timestamp as microseconds. ## TIMESCALE Track Property The TIMESCALE property opts a track into timestamps and establishes the units for every Timestamp on it. It is a track-level Key-Value-Pair, carried with the track's properties (see [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 2.5 and Section 12). Because the value is a single integer, TIMESCALE uses an even Type so the value is a bare varint with no length prefix: ``` TIMESCALE Track Property { Type (vi64) = 0x08 Value (vi64) ; units per second } ``` **Value**: The number of timestamp units per second. Common values include `1000` (milliseconds), `1000000` (microseconds), `48000` (a typical audio sample rate), and `90000` (the RTP video clock). A value of `0` is invalid; a receiver MUST treat a track that declares it as carrying no timeline. Absence is meaningful and is not an error. A track with no TIMESCALE has no media timeline: a receiver MUST NOT infer units for it, and MUST use wall-clock arrival time for age-based decisions on that track. A publisher that emits Timestamps MUST send TIMESCALE, even for units a receiver might otherwise assume. [draft-ietf-moq-loc](https://datatracker.ietf.org/doc/draft-ietf-moq-loc/) permits a bare Timestamp and reads it as microseconds; a receiver that also implements LOC MAY apply that interpretation to a track that omits TIMESCALE, and MUST NOT apply any other. The Timescale is fixed for the lifetime of the track and MUST NOT change. [draft-ietf-moq-loc](https://datatracker.ietf.org/doc/draft-ietf-moq-loc/) also registers TIMESCALE with Object scope; a receiver that implements both applies such an override to that object alone, and a publisher following this document SHOULD NOT send one. The track's properties are delivered in SUBSCRIBE\_OK or TRACK\_STATUS ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 12). Until they arrive, a receiver MUST fall back to wall-clock arrival time for any age-based decision. ## TIMESTAMP Object Property The TIMESTAMP property carries the presentation time of an object, in the track's Timescale. It is an object-level Key-Value-Pair carried in the object's properties ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 2.5, 11.2.1.2). It uses an even Type so the value is a bare varint: ``` TIMESTAMP Object Property { Type (vi64) = 0x10 Value (vi64) ; absolute presentation time, in Timescale units } ``` **Value**: The absolute presentation timestamp of the object, expressed in the track's Timescale. Any value (including 0) is valid. Each Timestamp is absolute, not delta-encoded against a previous object: [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) does not guarantee delivery of every object, and a delta would be corrupted by any missing predecessor. On a track that declares a TIMESCALE, a publisher SHOULD attach TIMESTAMP to every object that has a media time. For an object with no TIMESTAMP, a receiver MUST treat its effective time as the wall-clock arrival time, which avoids stalling expiration on objects that intentionally carry no timestamp (e.g. keep-alives or gap markers). The same fallback covers every object on a track that declares no TIMESCALE, so a receiver needs one rule, not two. ### Age-Based Dropping Given two objects on the same track, both with TIMESTAMP, a relay computes their relative age as the difference of their timestamps divided by the Timescale. A relay serving a live subscription MAY drop an object whose age relative to the most recent object on the track exceeds a locally configured or application-supplied threshold, resetting the corresponding stream per [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/). This decision is identical at every hop because it depends only on values embedded in the objects, not on arrival time. A relay MUST NOT use timestamps to reorder delivery beyond what [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) already permits; this property informs *dropping*, not transmission order. ## Security Considerations Timestamps expose the media timeline to relays, which is the point of the extension, but a relay still treats payloads as opaque and gains no access to media content. A malicious publisher could supply misleading timestamps (e.g. always claiming an object is fresh) to defeat age-based dropping, or wildly out-of-range timestamps to cause a receiver to mis-estimate age. A receiver SHOULD bound the age it computes and SHOULD NOT make security decisions based on timestamps. Because age-based dropping only affects which objects a live subscription receives, the worst case is degraded delivery for that subscription, not a cross-subscription effect. ## IANA Considerations This document requests no registrations. Both properties it uses are already registered by [draft-ietf-moq-loc](https://datatracker.ietf.org/doc/draft-ietf-moq-loc/) in the "MOQ Properties" registry ([draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 15.8), and this document changes neither their code points nor their value encodings: | Value | Name | Scope | Reference | |:------|:----------|:--------------|:----------| | 0x08 | TIMESCALE | Track, Object | [draft-ietf-moq-loc](https://datatracker.ietf.org/doc/draft-ietf-moq-loc/) | | 0x10 | TIMESTAMP | Object | [draft-ietf-moq-loc](https://datatracker.ietf.org/doc/draft-ietf-moq-loc/) | Both Types are even, so each value is a bare varint with no length prefix (see [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) Section 2.5). An earlier version of this document requested its own code points (`0x915C0` and `0x915C2`); they are abandoned in favor of the [draft-ietf-moq-loc](https://datatracker.ietf.org/doc/draft-ietf-moq-loc/) registrations. ## Normative References * [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) * [draft-ietf-moq-loc](https://datatracker.ietf.org/doc/draft-ietf-moq-loc/) ## Acknowledgments This document was drafted with the assistance of Claude, an AI assistant by Anthropic. --- --- url: https://doc.moq.dev/draft/moq-use-cases.md description: >- MoQ is designed to serve live tracks over a CDN to viewers with varying latency and quality targets: the entire spectrum between real-time and VOD. --- # Media over QUIC - Use Cases ::: info Rendered from the Internet-Draft source in this repository. Submitted versions are on the [IETF datatracker](https://datatracker.ietf.org/doc/draft-lcurley-moq-use-cases/). ::: ## Abstract MoQ is designed to serve live tracks over a CDN to viewers with varying latency and quality targets: the entire spectrum between real-time and VOD. However, it's difficult to understand how to use the transport given the layering and complexity of live media delivery. This document outlines how an application could use MoQ to deliver video, audio, and metadata in a variety of scenarios. ## Note to Readers This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it. Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything. ## Conventions and Definitions The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119](https://www.rfc-editor.org/rfc/rfc2119) [RFC8174](https://www.rfc-editor.org/rfc/rfc8174) when, and only when, they appear in all capitals, as shown here. ## Introduction Media over QUIC is still in the concept phase; a loose collection of ideas and drafts on how to utilize QUIC for live media delivery. It's difficult to grasp how to utilize the various layers: * QUIC: A transport layer that provides reliable, ordered, and secure streams. * moq-lite: A pub/sub layer that provides caching and fanout. * moq-karp: A proposed media layer on top of moq-lite that deals with encoding and containers. * Application: Your application that utilizes any of the above layers. This document briefly overviews how live media works and how you could use MoQ to deliver it. ## Video Video encoding involves complex dependencies between frames/slices. The terminology in this section stems from H.264 but is applicable to most modern codecs. Each frame of video is encoded as one or more slices but to simplify the discussion, we'll refer to a slice as a frame. There are three types of frames: * **I-Frame**: A frame that can be decoded independently. * **P-Frame**: A frame that depends on previous frames. * **B-Frame**: A frame that depends on previous or future frames. ### Group of Pictures A simple application can ignore the complexity of P/B frames and focus on I-Frames. This is the optimal approach for many encoding configurations. Each I-Frame begins a Group of Pictures (GoP). A GoP is a set of frames that MAY depend on each other and MUST NOT depend on other GoPs. Each frame has a decode order (DTS) and a frame MUST NOT depend on frames with a higher DTS. This perfectly maps to a QUIC stream, as they too are independent and ordered. The easiest way to use moq-lite is to send each GoP as a GROUP with each frame as a FRAME, hence the names. Each SUBSCRIBE starts at a Group to ensure that it starts with an I-Frame. Each Group is delivered in decode order ensuring that all frames are decodable (no artifacts). A subscriber can choose the Group Order based on the desired user experience: * `SUBSCRIBE order=DESC`: Transmits new Groups first to allow skipping, intended for low-latency live streams. * `SUBSCRIBE order=ASC`: Transmits old Groups first to avoid skipping, intended for VOD and reliable live streams. A publisher or subscriber can skip the remainder of a Group by resetting a Group Stream or by issuing a SUBSCRIBE\_UPDATE. A FETCH can be used to recover any partial groups. ### Layers An advanced application can subdivide a GoP into layers. The most comprehensive way to do this is with Scalable Video Coding (SVC). There is a base layer and one or more enhancement layers that depend on lower layers. For example, a 4K stream could be broken into 4K, 1080p, and 360p (base) layers. However, SVC has limited support and is complex to encode. Another approach is to use temporal scalability via something like B-pyramids. The frames within a GoP are sub-divided based on their dependencies, intentionally creating a hierarchy. For example, even frames could be prevented from referencing odd frames, creating a base 30fps layer and an enhancement 60fps layer. This is effectively a custom SVC scheme however it's limited to time and doesn't require special decoder support. The purpose of these layers is to support degrading the quality of the broadcast. A subscriber could limit bandwidth usage by choose to only receive the base layer or a subset of the enhancements layers. During congestion, the base layer can be prioritized while the enhancement layers can be deprioritized or dropped. However, the cost is a small increase in bitrate (10%) as limiting potential references can only hurt the compression ratio. When using moq-lite, each layer is delivered as a separate Track. This allows the subscriber to choose which layers to receive and how to prioritize them in SUBSCRIBE. It also enables layers to be prioritized within the layer application, for example Alice's base layer is more important than Bob's enhancement layer. The application is responsible for determining the relationship between layers, since they're unrelated tracks as MoqTransport is concerned. The application could use a catalog to advertise the layers and how to synchronize them, for example based on the Group Sequence. ### Non-Reference Frames While not explicitly stated, I believe the complexity in MoqTransport stems almost entirely from a single use-case: the ability to drop individual non-reference frames in the middle of a group. In theory, transmitting enhancement layers as tracks like mentioned above could introduce head-of-line blocking depending on the encoding. This would occur when enhancement layers are not self-referential, a rare configuration which also hurts the compression ratio. And in practice, there's no discernible user impact given the disproportionate size difference between base and enhancement layers. The ability to drop individual non-reference frames in the middle of a group is an explicit non-goal for moq-lite. ## Audio Unlike video, audio is simple and yet has perhaps more potential for optimization. ### Frames Audio samples are very small and for the sake of compression, are grouped into a frame. This depends on the codec and the sample rate but each frame is typically 10-50ms of audio. Audio frames are independent, which means they map nicely to moq-lite Groups. Each audio frame can be transmitted as a GROUP with a single FRAME. ### Groups Audio FRAMEs can also be combined into periodic GROUPs to reduce overhead at the cost of some introducing head-of-line blocking. This won't increase latency except under significant congestion as each FRAME is still streamed. For example, an application could then subscribe to video and audio starting at group X for both tracks, instead of trying to maintain a mapping between the two based on timestamp. This is quite common in HLS/DASH as there's no reason to subdivide audio segments at frame boundaries. ### FEC Real-time audio applications often use Forward Error Correction (FEC) to conceal packet loss. Audio frames are a good candidate for FEC given that they are small and independent. In an ideal world, FEC would be performed by QUIC based on the properties of the hop. However this is not currently not supported and FEC is left to the application. In moq-lite, each FEC packet is transmitted as a separate GROUP with a single FRAME. This means using QUIC streams instead of QUIC datagrams to automatically adjust to the viewer's RTT, automatically retransmitting in scenarios where RTT is small. Lost packets will be retransmitted unless a real-time subscriber updates the subscription to skip them. For example, if group 3 and group 5 are used to reconstruct group 4, then the subscriber can update the subscription to start at group 5, causing group 4 to be skipped. This round trip of feedback to avoid retransmissions is not ideal but keep in mind that Group Order = DESC will be respected, meaning group 4 won't be (re)transmitted if there's more important data to send. This can result in wasted bandwidth versus something like a timeout on the sender, but it does not impact the user experience nor are these unnecessarily retransmittted audio FEC packets large enough to matter. ## Metadata There's a number of non-media use cases that can be served by moq-lite. ### Catalog Originally part of the transport itself, the catalog is a list of all tracks within a broadcast. It's since been delegated to the application and is now just another track with a well-known name. The proposed MoQ catalog format supports live updates. It does this by encoding a base JSON blob and then applying JSON patches over time. If the number of deltas becomes too large, the producer can start over with a new base JSON blob. In moq-lite, the base and all deltas are a single GROUP. The base is the first FRAME and all deltas are subsequent FRAMEs. The producer can create a new GROUP to start over, repeating the process. ### Timeline Another track that is commonly pitched is a timeline track. This records the presentation timestamp of each Group, giving a VOD viewer to seek to a specific time. The `timeline` track is a single Group containing a Frame for each timestamp. The live nature of the timeline track is great for DVR applications while being concise enough for VOD. Timed metadata would use a similar approach or perhaps leverage this track. ### Interaction Another common use-case is to transmit user interactions, such as controller inputs or chat messages. It's up to the application to determine the format and encoding of these messages. Let's take controller input as an example. The application needs to determine its loss vs latency tolerance, as reordering or dropping inputs will lead to a poor user experience. * If you don't want loss, then use a single GROUP with a FRAME per input. * If you don't want latency, then use a GROUP per input with a single FRAME. * If you want a hybrid, then use form of clustering inputs into GROUPs and FRAMEs based on time. A publisher could monitor the session RTT or stream acknowledgements to get a sense of the latency and create Groups accordingly. However, this only applies to the first hop and won't be applicable when relays are involved. ## Latency One explicit goal of moq-lite is to support multiple latency targets. This is accomplished by using the same Tracks and Group for all viewers, but slightly changing the behavior based on the subscription. This is driven by the subscriber, allowing them to choose the trade-off between latency and reliability. This may be done on the fly via SUBSCRIBE\_UPDATE, for example if a high-latency viewer wishes to join the stage and suddenly needs real-time latency. The below examples assume one audio and one video track. See the next section for more complicated broadcasts. ### Real-Time Real-time latency is accomplished by prioritizing the most important media during congestion and skipping the rest. This is slightly different from other media protocols which instead opt to drop packets. The end result is similar, but prioritization means utilizing all available bandwidth as determined by the congestion controller. A subscriber or publisher can reset groups to avoid wasting bandwidth on old data. A real-time viewer could issue: ``` SUBSCRIBE track=audio priority=1 order=DESC SUBSCRIBE track=video priority=0 order=DESC ``` In this example, audio is higher priority than video, and newer groups are higher priority than older groups. Suppose a viewer fell behind after a burst of congestion and has to decide which groups to deliver next. This configuration would result in the transmission order: ``` GROUP track=audio sequence=102 GROUP track=audio sequence=101 GROUP track=audio sequence=100 GROUP track=video sequence=5 GROUP track=video sequence=4 ``` The user experience depends on the amount of congestion: * If there's no congestion, all audio and video is delivered. * If there's moderate congestion, the tail of the old video group is dropped. * If there's severe congestion, all video will be late/dropped and some audio groups/frames will be dropped. ### Unreliable Live Unreliable live is a term I made up. Basically we want low latency, but we don't need it at all costs and we're willing to skip some video to achieve it. This is useful for broadcasts where latency is important but so is picture quality. An unreliable live viewer could issue: ``` SUBSCRIBE track=audio priority=1 order=ASC SUBSCRIBE track=video priority=0 order=DESC ``` This example is different from the real-time one in that audio is reliable and delivered in order. Of course this is optional and up to the application, as it will result in buffering during significant congestion. If the viewer goes through a tunnel and then comes back online, they won't miss any audio. Video will be delivered out of order but the player can maintain a jitter buffer. For example, it could hold onto up to 3s of video frames in memory so it can tolerate an equal amount of congestion. If the buffer fills up, the player can STOP\_SENDING any old groups and/or update the subscription to skip them. ### Reliable Live Reliable live is another term I made up. This is when we have a live stream but primarily care about picture quality. A good example is a sports game where you want to see every frame. A reliable live viewer could issue: ``` SUBSCRIBE track=audio priority=0 order=ASC SUBSCRIBE track=video priority=0 order=ASC ``` This will deliver both audio and video in order, and with the same priority. The viewer won't miss any content unless the publisher resets a group. However, this can result in buffering during congestion and provides a similar user experience to HLS/DASH. ### VOD / DVR Video on Demand (VOD) and Digital Video Recorder (DVR) both involve seeking backwards in a live stream. moq-lite can serve this use-case too, don't worry. A VOD viewer could issue: ``` SUBSCRIBE track=audio priority=0 order=ASC start=345 end=396 SUBSCRIBE track=video priority=0 order=ASC start=123 end=134 ``` The application is responsible for determining the group sequence numbers based on the desired timestamp. This could be done via a `timeline` track or out-of-band. A subscriber will need a specific `end` or else it will download too much data at once, as old media is transmitted at network speed and not encode speed. It will need to issue an updated SUBSCRIBE to expand the range as playback continues and the buffer depletes. A subscriber could use SUBSCRIBE\_UPDATE, however there are race conditions involved. A DVR player does the same thing but can automatically support joining the live stream. It's perfectly valid to specify a `end` in the future and it will behave like reliable live viewer once it reaches the live playhead. Alternatively, a DVR player could prefetch the live playhead by issuing a parallel SUBSCRIBE at a lower priority. This would allow playback to immediately continue after clicking the "Go Live" button, canceling or deprioritizing the VOD subscription. ``` SUBSCRIBE track=video priority=1 order=ASC start=123 end=134 SUBSCRIBE track=video priority=0 order=DESC ``` ### Upstream All of these separate viewers could be watching the same broadcast. How is a relay supposed to fetch the content from upstream? moq-lite addresses this by providing the publisher's Track Priority and Group Order in the INFO message. This is the intended behavior for the first hop and dictates which viewers are preferred. For example, suppose the producer chooses: ``` INFO track=audio priority=1 order=DESC INFO track=video priority=0 order=DESC ``` If Alice is watching a VOD and issues: ``` SUBSCRIBE track=audio priority=0 order=ASC SUBSCRIBE track=video priority=0 order=ASC ``` If Bob is watching real-time and issues: ``` SUBSCRIBE track=audio priority=1 order=DESC SUBSCRIBE track=video priority=0 order=DESC ``` For any congestion on the first mile, then the relay will improve Bob's experience by following the producer's preference. However any congestion on the last mile will always use the viewer's preference. A relay should use the publisher's priority/order only when there's a conflict. If viewers have the same priority/order, then the relay should use the viewer's preference and it can always issue a SUBSCRIBE\_UPDATE when this changes. ## Broadcast A broadcast is a collection of tracks from a single producer. This usually includes an audio track and/or a video track, but there are reasons to have more than that. This is transparent to moq-lite, as the a higher level application is responsible for any grouping between tracks. ### ABR Virtually all mass fan-out use-cases rely on Adaptive Bitrate (ABR) streaming. The idea is to encode the same content at multiple bitrates and resolutions, allowing the viewer to choose based on their unique situation. moq-lite unsurprisingly supports this via multiple Tracks, but relies on the application to determine the relationship between them. This is often done via a `catalog` track that details each track's name, bitrate, resolution, and codec. This includes how a group in one track corresponds to a group in another track. A common approach is to use the same Group Sequence number for all tracks, or perhaps utilize a `timeline` track to map between Group Sequences and presentation timestamps. The viewer may limit the available tracks based on capabilities or preferences. For example, the device may not support the 4K track since it uses AV1, or the screen size may be too small to justify the bandwidth. This is easy enough to support; just ignore these tracks in the catalog. The primary reason to use ABR is to adapt to changing network conditions. The viewer learns about the estimated bandwidth via the SESSION\_UPDATE message, or by measuring network traffic and can then choose the appropriate track based on bitrate. Transitioning between tracks can be done seamlessly by utilizing prioritization. For example, suppose a viewer is watching the 360p track and wants to switch to 1080p at group 69. A real-time or unreliable live viewer could issue: ``` SUBSCRIBE_UPDATE track=360p priority=0 order=DESC end=69 SUBSCRIBE track=1080p priority=1 order=DESC start=69 ``` A reliable live or VOD viewer could issue: ``` SUBSCRIBE_UPDATE track=360p priority=1 order=ASC end=69 SUBSCRIBE track=1080p priority=0 order=ASC start=69 ``` The difference between them is whether to prioritize the old track or the new track. In both scenarios, the subscription will seamlessly switch at group 69 even if it's seconds in the future. The same behavior can be used to switch down. ### SVC We touched on SVC before, but it's worth mentioning as an alternative to ABR. I want to see it used more often but I doubt it will be. Instead of choosing the track based on the bitrate, the viewer subscribes to them all: ``` SUBSCRIBE track=360p priority=2 order=DESC SUBSCRIBE track=1080p priority=1 order=DESC SUBSCRIBE track=4k priority=0 order=DESC ``` During congestion, the 4k enhancement layer will be deprioritized followed by the 1080p enhancement layer. This is a more efficient use of bandwidth than ABR, but it requires more complex encoding. ## Conferences Some applications involve multiple producers, such as a conference calls or a live events. Even though these are separate broadcasts from potentially separate origins, moq-lite can still serve them over the same session. ### Discovery The first step to joining a conference is to discover the available tracks. That's where `ANNOUNCE` comes in. The subscriber indicates interest in any tracks that start with a prefix, such as `["meeting", "1234"]`. As new participants join, new tracks are announced, and the subscriber can choose to subscribe to them. ### Participants Extending the idea that audio is more important than video, we can prioritize tracks regardless of the source. This works because `SUBSCRIBE priority` is scoped to the session and not the broadcast. ``` SUBSCRIBE track=[alice, audio] priority=3 SUBSCRIBE track=[frank, audio] priority=3 SUBSCRIBE track=[alice, video] priority=1 SUBSCRIBE track=[frank, video] priority=1 ``` When Alice starts talking or is focused, we can actually issue a SUBSCRIBE\_UPDATE to increase her priority: ``` SUBSCRIBE_UPDATE track=[alice, audio] priority=2 SUBSCRIBE_UPDATE track=[frank, video] priority=0 ``` Note that audio is still more important than video, but Alice is now more important than Frank. (poor Frank) This concept can further be extended to work with SVC or ABR: ``` SUBSCRIBE track=[alice, 360p] priority=4 SUBSCRIBE track=[frank, 360p] priority=3 SUBSCRIBE track=[alice, 720p] priority=2 SUBSCRIBE track=[frank, 720p] priority=1 ``` ## Security Considerations TODO Security ## IANA Considerations This document has no IANA actions. ## Acknowledgments TODO acknowledge. --- --- url: https://doc.moq.dev/draft/qmux-websocket.md description: >- QMux draft-ietf-quic-qmux is a polyfill that runs QUIC applications over an ordered, reliable byte-stream transport such as TCP with TLS. --- # QMux over WebSocket ::: info Rendered from the Internet-Draft source in this repository. Submitted versions are on the [IETF datatracker](https://datatracker.ietf.org/doc/draft-lcurley-qmux-websocket/). ::: ## Abstract QMux [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/) is a polyfill that runs QUIC applications over an ordered, reliable byte-stream transport such as TCP with TLS. This document defines a binding for QMux over WebSocket [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455). A WebSocket binding lets QUIC applications reach environments where UDP is blocked and where only an HTTP/WebSocket stack is available, including web browsers that lack WebTransport. ## Note to Readers This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it. Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything. ## Conventions and Definitions The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119](https://www.rfc-editor.org/rfc/rfc2119) [RFC8174](https://www.rfc-editor.org/rfc/rfc8174) when, and only when, they appear in all capitals, as shown here. This document uses the terms QMux Record, QMux Frame, and transport parameter as defined in [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/), and the terms WebSocket connection, message, frame, and subprotocol as defined in [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455). ## Introduction QMux [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/) lets an application written against the QUIC stream and datagram API run over an ordered, reliable byte-stream transport. It defines a binding over TCP and over TLS, but a WebSocket binding is out of scope for the QUIC working group charter. A WebSocket binding is nevertheless useful: WebSocket [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455) is available where UDP is blocked, in web browsers that lack WebTransport, and behind HTTP load balancers that cannot route raw TCP or QUIC. This document specifies how to carry QMux over WebSocket: the message framing, the subprotocol negotiation used in place of TLS ALPN, keep-alive behavior, connection close, and datagrams. All other QMux semantics (in-order STREAM frame delivery, stream identifiers, flow control, and transport parameters) apply unchanged from [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/). This binding is application agnostic: any QUIC application that can run over QMux can run over QMux over WebSocket. Media over QUIC Transport [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) is the motivating use case, but nothing in this document is specific to it. ## WebSocket Binding Overview A QMux-over-WebSocket connection is an ordinary WebSocket connection [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455) whose binary messages carry QMux frames, taking the place of the underlying byte-stream transport in [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/). Both the QMux Record layer and the WebSocket message layer provide self-delimiting messages over a reliable, ordered byte stream. The two layers are therefore collapsed: instead of prefixing each Record with its `Size`, the binding relies on the WebSocket message boundary to delimit it. ## Establishing a Connection A client establishes a QMux-over-WebSocket connection by opening a WebSocket connection per [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455) (the opening handshake over HTTP/1.1) or per [RFC 9220](https://www.rfc-editor.org/rfc/rfc9220) (the bootstrapping mechanism over HTTP/2 or HTTP/3). The `ws` URI scheme is used over an unencrypted transport and the `wss` URI scheme is used over a TLS-encrypted transport. Deployments SHOULD use `wss`; an application that expects a TLS transport when running natively over QUIC SHOULD require `wss` here. How the underlying connection is authenticated and authorized is out of scope for this document, as it is for [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/). ## Subprotocol Negotiation QMux over TCP/TLS uses TLS ALPN [RFC 8446](https://www.rfc-editor.org/rfc/rfc8446) to agree on the application protocol. WebSocket has no ALPN exchange, so this binding uses the WebSocket subprotocol negotiation of [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455) Section 1.9 (the `Sec-WebSocket-Protocol` header) in its place, carrying the same application protocol identifier. The subprotocol identifier is exactly the identifier the application would use as its ALPN over native QUIC, for example `moq-transport-18`. It also determines the QMux wire-format version, so there is no separate QMux version negotiation (see [QMux Version](#versions)). A client offers one or more identifiers in the `Sec-WebSocket-Protocol` request header, in decreasing order of preference, one per protocol version it is willing to use. A server selects at most one, in its own order of preference, and echoes it in the response header; if it supports none of the offered identifiers, it MUST fail the handshake. A client MUST treat a response value it did not offer as a failed handshake per [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455), and this binding additionally requires a selection: a client MUST treat the absence of a `Sec-WebSocket-Protocol` response header as a failed handshake too, since without one no QMux version was agreed. ## Record Framing {#framing} Each WebSocket binary message carries exactly one QMux Record's `Frames` field: one or more QMux frames concatenated, as defined in [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/). Because the WebSocket framing layer already delimits each message, the QMux Record `Size` field is redundant: it MUST NOT be transmitted and MUST NOT be expected by the receiver. A QMux-over-WebSocket record is therefore: ``` WebSocket Binary Message { Frames (..), } ``` The frames inside a message are encoded exactly as in [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/), including the in-order STREAM frame requirement: for each QUIC stream, a sender MUST send that stream's payload in order, so a receiver can deliver payload to the application as it arrives without reassembly. An endpoint MAY place multiple frames in a single binary message and MAY split a logical sequence of frames across multiple messages, subject to the constraint that each STREAM frame's payload bytes are delivered in order. An empty binary message (zero frames) is permitted and carries no frames; a receiver MUST accept it and treat it as a no-op. The maximum size of a binary message is bounded by the `max_record_size` transport parameter defined in [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/) and by any WebSocket implementation limits. An endpoint MUST NOT send a binary message whose payload exceeds the peer's advertised `max_record_size`, and MAY treat receipt of an oversized message as a connection error. ## WebSocket Message Types This binding uses WebSocket message and control frames as follows: * *Binary messages* carry QMux frames as defined in [Record Framing](#framing). * *Text messages* MUST NOT be sent. A receiver MUST treat a text message as a connection error and close the WebSocket connection. * *Close frames* terminate the connection as defined in [Connection Close](#close). * *Ping and Pong frames* are used for keep-alive as defined in [Keep-Alive and Idle Timeout](#keepalive) and are otherwise handled by the WebSocket layer; they carry no QMux frames. The first QMux frame sent by each endpoint MUST be the `QX_TRANSPORT_PARAMETERS` frame, exactly as required by [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/); this binding does not change that requirement. ## QMux Version {#versions} The QMux version is not signaled on the wire. As with QMux over TLS, it is implied by the negotiated application protocol: each application protocol that runs over QMux specifies which QMux version each of its identifiers uses. An application protocol used with this binding MUST select a QMux version that provides the Record layer (see [Record Framing](#framing)), i.e. [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/) or later. ## Keep-Alive and Idle Timeout {#keepalive} A WebSocket connection has no built-in idle timeout: if the peer's host crashes without a TCP FIN, the local socket can remain "open" for hours. To detect a dead peer, an endpoint SHOULD send WebSocket Ping frames [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455) periodically and SHOULD close the connection if no WebSocket frame of any kind is received within a timeout, a small multiple of the ping interval. Reasonable defaults are a 5-second ping interval and a 30-second timeout, but the values are a local policy decision. Receipt of any WebSocket frame from the peer (binary, Ping, or Pong) resets the idle timer. This keep-alive operates at the WebSocket layer and is separate from the QMux `max_idle_timeout` transport parameter and `QX_PING` frame, either of which an endpoint MAY also use. ## Datagrams {#datagrams} QMux datagrams are supported. They are negotiated and encoded exactly as in [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/): an endpoint advertises the datagram transport parameter and carries QMux DATAGRAM frames inside binary messages, like any other frame ([Record Framing](#framing)). ## Connection Close {#close} An endpoint terminates a QMux-over-WebSocket connection by sending a WebSocket Close frame [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455) and then closing the underlying transport. A QMux `CONNECTION_CLOSE` frame, if sent, conveys the QMux-level error code and reason and SHOULD be sent in a final binary message before the WebSocket Close frame. Because the WebSocket layer provides its own connection close, there is no draining period: an endpoint MAY close immediately after sending its Close frame. Receipt of a WebSocket Close frame, or loss of the underlying transport, terminates the QMux connection and all of its streams. ## Security Considerations This binding inherits the security considerations of QMux [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/), WebSocket [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455), and, when `wss` is used, TLS [RFC 8446](https://www.rfc-editor.org/rfc/rfc8446). Carrying QMux over WebSocket does not add or remove any QMux-level security property. In particular, this binding provides no transport-layer confidentiality or integrity of its own; deployments that require those properties MUST use `wss` (WebSocket over TLS). The keep-alive mechanism in [Keep-Alive and Idle Timeout](#keepalive) causes an endpoint to send periodic Ping frames. An endpoint SHOULD bound the rate at which it sends and responds to Ping/Pong frames to avoid amplification or resource-exhaustion concerns. Because a server selects the subprotocol from a client-supplied list, a server MUST validate the selected identifier against its own supported set and MUST NOT echo an arbitrary client-supplied value, as required by [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455). ## IANA Considerations This document has no IANA actions. This binding defines no subprotocol identifiers of its own: the `Sec-WebSocket-Protocol` value is the application protocol identifier, which is owned by the application protocol's specification (for example, [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/)). Whether those identifiers are registered in the WebSocket Subprotocol Name Registry [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455) is therefore up to each application protocol, not this document. ## Normative References * [draft-ietf-quic-qmux](https://datatracker.ietf.org/doc/draft-ietf-quic-qmux/) * [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455) ## Informative References * [RFC 8446](https://www.rfc-editor.org/rfc/rfc8446) * [RFC 9220](https://www.rfc-editor.org/rfc/rfc9220) * [draft-ietf-moq-transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) ## Acknowledgments QMux is the work of the QUIC working group; this document only defines a WebSocket binding for it. Thanks to the Media over QUIC working group for motivating a transport that works where UDP does not. --- --- url: https://doc.moq.dev/bin.md description: Ready-to-run tools built on MoQ --- # Applications Everything here is a binary or plugin you can run without writing code. See [Install](/setup/install) for packages. ## Core | Tool | Use it to | | --- | --- | | [moq-relay](/bin/relay/) | Route, cache, and fan out broadcasts. Authenticate with JWTs, cluster across regions, publish stats. | | [moq-cli](/bin/cli) | Move media in and out of MoQ: pipe FFmpeg in, play natively, capture a camera or screen, transcode, mint tokens. | ## Gateways All four ship inside `moq-cli` as `moq import ` and `moq export `. | Gateway | Direction | | --- | --- | | [RTMP](/bin/rtmp) | Accept RTMP/E-RTMP pushes from OBS or FFmpeg; serve RTMP plays; restream to Twitch-style endpoints. | | [SRT](/bin/srt) | Accept SRT contribution; serve SRT to players; pull from or push to a remote SRT endpoint. | | [WebRTC](/bin/rtc) | WHIP ingest and WHEP playback in either HTTP role. | | [HLS](/bin/hls) | Serve any broadcast as HLS from the relay cache; import a remote HLS playlist. | ## Plugins | Plugin | Use it to | | --- | --- | | [OBS Studio](/bin/obs) | Stream from OBS to a relay, or bring a broadcast into a scene as a source. | | [GStreamer](/bin/gstreamer) | `moqsink` and `moqsrc` elements for any pipeline. | ## Demos The [web demo and MoQ Boy](/bin/demo) are runnable examples of browser playback, publishing, and bidirectional input. To embed MoQ in your own program, use a [library](/lib/) instead. --- --- url: https://doc.moq.dev/bin/relay.md description: The MoQ relay server --- # moq-relay `moq-relay` routes broadcasts from publishers to subscribers. It caches groups, merges duplicate subscriptions, and never parses the media, so one relay serves video, audio, and data alike. ## Features * **QUIC, WebTransport, and WebSocket** listeners, so browsers and native clients connect to one process. * **Path-scoped authentication** with JWTs, mTLS for peers, and anonymous patterns, decided by an auth server or a static grant. See [Authentication](/bin/relay/auth). * **Clustering** across hosts and regions with hop-list routing, per-link costs, LAN discovery, and dynamic peer lists. See [Clustering](/bin/relay/cluster). * **A group cache** with byte and age budgets, so late joiners and the HLS gateway can fetch recent history. * **HTTP endpoints** to list broadcasts, fetch groups, probe health, and scrape Prometheus metrics. See [HTTP](/bin/relay/http). * **Live stats** published as MoQ tracks per node and per tenant, split by billing tier. * **Plaintext TCP and Unix-socket listeners** for trusted local workers, and experimental [iroh](/concept/transport#iroh-peer-to-peer-experimental) peer-to-peer. * **Hot reload** of certificates and trust roots. ## Run ```bash cargo install moq-relay # or brew, apt, dnf, winget, docker; see Install moq-relay relay.toml ``` The `.deb` and `.rpm` systemd service reads `/etc/moq-relay/relay.toml` using the same positional config argument. The relay takes one TOML file. A local development config: ```toml [listen] bind = "[::]:4443" tls.generate = ["localhost"] [web.http] listen = "[::]:4443" # serves the certificate fingerprint for local browsers [auth] public = "**" # anonymous access to everything; development only ``` Every option is also a `--flag` or `MOQ_*` environment variable, and `RUST_LOG` controls logging. The [configuration reference](/bin/relay/config) covers every section, and [`demo/relay/`](https://github.com/moq-dev/moq/tree/main/demo/relay) has working configs for development, production, and a cluster. ## Embed `moq-relay` is also a library. An application that wants extra HTTP routes or in-process workers against the cluster origin loads a `Relay` and calls `run`. The owner keeps the listeners, QUIC workers, and shutdown joins, so a new socket added in a library update cannot be dropped by a `..` pattern that still compiles. ```rust use axum::routing::get; use moq_relay::{Config, Relay}; let relay = Relay::load(config).await?; let origin = relay.cluster().origin.clone(); let trigger = relay.shutdown_trigger().clone(); let web = relay.web().routes().route("/hello", get(|| async { "hello" })); relay.with_web(web).run().await?; ``` `Relay::load` binds every socket, so a taken port fails there. Read the actual addresses with `quic_addr()`, `tcp_addr()`, `web_addrs()`, and `internal().addr()`, including ports assigned for `:0`. Clone `ready()` before spawning `run`, then await `ready.wait()` when startup must finish before other workers begin. `config()` returns the resolved settings; `cluster().id()` returns the chosen origin ID. `with_listeners()` registers an extra TCP listener's accept health at the relay's `/metrics`. The `test-support` feature provides `test_relay()` with ephemeral ports, generated TLS, and a certificate fingerprint for client pinning. The accessors borrow and `run` consumes the relay, so clone `cluster`, `auth`, `client`, `stats`, `shutdown`, and `shutdown_trigger` for application tasks before calling it. `trigger.start()` drains every session with a GOAWAY, including any that connect afterwards, and `run` returns once every session has left or the drain window elapses, with the listeners released and the workers joined. `run` also starts the drain on SIGTERM or SIGINT. An application that owns those signals, for example to withdraw the node from DNS and wait out the TTL before draining, calls `with_signals(false)` and fires the trigger itself. Build routes from `web().routes()` (or `internal().routes()`): `with_web` replaces the router, so `Router::new()` drops the built-in routes. Extra listeners (RTMP, SRT, ...) sit beside `run` in the application's `select!`. The drain reaches only MoQ sessions: an extra listener has no GOAWAY, so the application stops it on its own deadline and leaves the encoder to reconnect. `runtime.workers` and `runtime.io_uring` stay inside the owner; do not split the worker group yourself. An application that decides admissions itself leaves `[auth]` empty and answers `relay.admissions()`; see [Authentication](/bin/relay/auth#in-process). See [`rs/moq-relay/examples/embed.rs`](https://github.com/moq-dev/moq/blob/main/rs/moq-relay/examples/embed.rs). ## Operate | Task | Guide | | --- | --- | | Expose it publicly with TLS and host tuning | [Production deployment](/setup/prod) | | Decide who may publish and subscribe where | [Authentication](/bin/relay/auth) | | Add more relays | [Clustering](/bin/relay/cluster) | | Monitor, debug, fetch history | [HTTP endpoints](/bin/relay/http) | ## Troubleshooting * **Address already in use**: something else holds the UDP or TCP port. * **Certificate errors**: the hostname must match the certificate. Local browsers need the fingerprint served over `[web.http]`. * **Connection timeout**: UDP isn't reaching the relay, or the client URL names the wrong port. * **Unauthorized / forbidden**: the token's paths don't cover the connection path, or the broadcast a session asked for. See [path matching](/bin/relay/auth#path-matching). --- --- url: https://doc.moq.dev/bin/relay/config.md description: TOML reference for moq-relay --- # Configuration `moq-relay relay.toml`. Every key is also a CLI flag and environment variable. Most names join the section and key: `listen.tls.cert` is `--listen-tls-cert` / `MOQ_LISTEN_TLS_CERT`. The `listen.bind` key deliberately uses the shorter `--listen` / `MOQ_LISTEN` spelling. Precedence is CLI > env > file > defaults: a flag or environment variable that was actually supplied overrides the file, and a file key that was actually written (an empty list, a `false` boolean) overrides the built-in default. ## \[listen] ```toml [listen] bind = "[::]:443" # QUIC (UDP), as --listen. Omit for a stream-only relay. version = ["moq-lite-05"] # Restrict accepted versions. Omit for all. timeout = "10s" # Handshake deadline. "0" waits forever. [listen.tls] cert = "cert.pem" # Certificate chain and key. Reloaded on change. key = "key.pem" generate = ["localhost"] # Or: a self-signed cert for development. root = ["peer-ca.pem"] # Optional: CAs for client certs (mTLS), reported to the auth server. [listen.tcp] # Plaintext qmux over TCP for trusted local workers. bind = "127.0.0.1:4444" [listen.unix] # Plaintext qmux over a Unix socket, gated by peer credentials. bind = "/run/moq/internal.sock" allow.uid = [1001] ``` `timeout` bounds how long an accepted connection has to finish its handshake: the QUIC, WebTransport, WebSocket, or qmux one, then the MoQ SETUP, through the relay accepting the session. After that it is an ordinary session. A peer that connects and never speaks is closed instead of being held open by keep-alives, with the MoQ timeout code once its transport is up. The `[web]` listeners apply it to reading HTTP request headers and, for the WebSocket fallback, to the SETUP after the upgrade. The `io_uring` workers do not apply it yet. ## \[quic] Transport tuning, applied to accepted and dialed connections alike. ```toml [quic] congestion_control = "delay" # "delay" (BBR, the default) or "loss" (CUBIC). max_streams = 10000 # Concurrent streams per connection, bidi and uni. Default. idle_timeout = "10s" # Drop a connection after this long with nothing on it. Default. keep_alive = "3s" # Ping interval; "0s" disables it. Ignored by iroh. Default. gso = true # UDP segmentation offload. iroh cannot turn it off. mtu_discovery = false # Path MTU discovery. Default. receive_window = 67108864 # Whole-connection window, in bytes. Default (64 MiB). stream_receive_window = 8388608 # Per-stream window. Omit for the backend default. send_window = 33554432 # Unacknowledged outgoing data. Omit for the backend default. qlog = "/var/log/moq/qlog" # Existing directory. Needs the `qlog` build feature. ``` The native QUIC stack uses BBRv3 for delay-based congestion control. Iroh also uses noq and the same congestion controller. `idle_timeout` is how long a peer that vanished without a close keeps its sessions, and so its [cluster routes](/bin/relay/cluster#failure-detection). QUIC uses the smaller of the two endpoints' values ([RFC 9000 section 10.1](https://www.rfc-editor.org/rfc/rfc9000#section-10.1)), so this also bounds the clients and peers on the other end. Keep `keep_alive` under a third of it, so a quiet connection that loses one ping still pings again before the deadline. Raise the receive windows when a fat, long path idles below the link rate: a window under the bandwidth-delay product stalls the sender waiting for credit. `receive_window` bounds how much unread data one peer can make the relay buffer across all of its streams; the transport's own default is unlimited, so it defaults to 64 MiB, enough for a relay-to-relay session to carry several Gbps at a 100 ms RTT. Keep `stream_receive_window` well under `receive_window` so one slow group cannot starve the connection. `send_window` caps unacknowledged outgoing data whatever the peer allows, bounding the transport send buffer. A zero window is refused, and the receive windows must fit a QUIC varint since they ride on the wire as transport parameters. ## \[runtime] By default one work-stealing runtime serves every connection off one UDP socket. On Linux, QUIC can instead run on pinned single-threaded workers, each with its own socket on the listen address (`SO_REUSEPORT`), so a connection is handled start to finish by one thread and its packets never cross cores. ```toml [runtime] workers = 8 # Single-threaded QUIC workers. Omit for the shared runtime. pin = true # Pin each worker to a core. Default. io_uring = false # Drive them with io_uring instead of tokio. ``` Packets are steered by connection ID, so a client that migrates stays with its worker. The group shares one port, including an ephemeral (zero) port, which is resolved once and joined by every worker. Use an explicit port unless something reads the bound address at startup. `workers` needs the `noq` feature and real certificate files rather than `tls.generate`. A build without QUIC rejects `workers` instead of ignoring it. An embedding process leaves this group inside `Relay::run`; taking the sockets out and driving them yourself is how a later library update can drop QUIC while still compiling. `io_uring` additionally needs Linux 6.12+, the `io-uring` cargo feature, and exactly one certificate read at startup; it serves moq-lite only, and refuses to start anywhere it cannot deliver. `[quic]` applies either way, except that `mtu_discovery` (its datagram path sends a fixed payload) and the three flow-control windows (these workers run fixed ones) are refused under `io_uring` rather than quietly ignored. Each worker reports its own counters at [`/metrics`](/bin/relay/http#get-metrics). The kernel charges each worker's ring (~56 KiB, plus a page per socket) to `RLIMIT_MEMLOCK`, a budget shared by every io\_uring the user runs; raise it (`LimitMEMLOCK=` under systemd) if workers fail to start with a message naming that limit. ## \[web] ```toml [web.http] listen = "[::]:4443" # HTTP: fingerprint, announced, fetch, health. [web.https] listen = "[::]:443" # HTTPS plus the WebSocket fallback. cert = "cert.pem" key = "key.pem" [internal] listen = "127.0.0.1:9101" # Unauthenticated /health, /metrics, /nodes, /sessions, POST /sessions/revalidate. Keep private. ``` See [HTTP endpoints](/bin/relay/http). ## \[auth] ```toml [auth] # Exactly one of these: url = "http://127.0.0.1:4440/" # An auth server asked once per session event (`moq auth serve`, # or your own). https:// presents connect.tls; unix:// is a socket. # public = "anon/**" # Or a static anonymous grant rooted at /, publish and subscribe alike. # public_subscribe = ["anon/**", "demo/**"] # Or split them; patterns, `foo/**` for a subtree. # public_publish = ["anon/**"] ``` See [Authentication](/bin/relay/auth). ## \[cluster] ```toml [cluster] connect = ["https://us-east.example.com/?cost=10"] # Peers to dial. ?cost prices the link, or use {url, cost, egress, token} objects. node = "https://us-west.example.com/" # This relay's own URL. connect_api = "https://api.example.com/peers" # Or fetch the peer list (JSON array of URLs and/or objects) live. token = "cluster.jwt" # JWT for dials without an inline ?jwt=. id = 12345 # Stable Hop ID across restarts. [cluster.lan] # Find peers on the LAN over mDNS. enabled = true # secret = "/etc/moq/cluster.key" # Optional: 64 hex chars, or a file holding them. # app = "default" # DNS-SD subtype; moq-cli shares this name. ``` See [Clustering](/bin/relay/cluster). ## \[connect] Settings for outbound dials (cluster peers, auth API). ```toml [connect] timeout = "30s" # Dial plus handshake. "0" waits forever. tls.root = ["ca.pem"] # Trust these CAs (replaces system roots unless system_roots = true). tls.cert = "relay.pem" # Present a client certificate (mTLS to peers and the auth API). tls.key = "relay.key" goaway.redirect = "same-host" # How far to trust a draining peer's redirect URI. goaway.handover = "10s" # Cap on how long the drained upstream keeps serving. ``` A draining upstream may name a replacement URI. `same-host` follows it only onto the host we already dialed, so a peer moves us between ports and schemes; `follow` also lets it choose the host, which means trusting it not to point us into the local network, since a name it controls resolves wherever it likes; `ignore` keeps the current address list. An empty URI also keeps it, including caller-configured fallbacks; an accepted redirect replaces the list with the peer's URI. A malformed or refused redirect ends the connection with an error rather than redialing the old address or a fallback, and so does one leaving the host a `tls.fingerprint` pin verifies. `handover` is a cap: a shorter deadline on the received GOAWAY wins, a longer one does not extend it. ## \[cache] ```toml [cache] capacity = "8GiB" # Target bytes of cached groups. "75%" of memory also works. headroom = "2GiB" # Or: keep this much system memory free and grow into the rest. duration = "30s" # Cap how long a non-latest group is kept, whatever the publisher asked. ``` `duration` defaults to 30s and bounds memory by age, where `capacity` bounds it by bytes. The latest group of every track is always kept. The two reclaim differently: the byte budget is repaid as tracks write, so it caps what *active* publishers build up, while `duration` sweeps on a wall-clock cadence, so a publisher that stalls but stays connected still has its idle groups reclaimed (an open one included, and a subscriber parked inside it is told rather than waiting forever). `headroom` starts a background task that re-samples system memory every few seconds and resizes the pool. Embedders calling `cache::Config::init` directly should know that the task is owned by the `cache::Pool` it resizes, not by the `cache::Cache` struct or the `Relay`: it stops on its next tick once the last `Pool` clone drops. Handing the `cache::Cache` to `cluster::Cluster::new` therefore moves the task's lifetime onto the cluster, and keeping a `Pool` clone of your own keeps the task running for as long as you hold it. ## \[stats] ```toml [stats] enabled = true prefix = ".stats" # Broadcasts appear under /node/. interval = 1 # Seconds between snapshots. node = "sjc/1" # Disambiguates relays sharing a cluster. depth = 1 # Also bucket by the first N path segments (per tenant). linger = "5m" # Keep an empty group's broadcast announced this long. Default. ``` Each node publishes `publisher.json`, `subscriber.json`, and `sessions.json` tracks (plus compressed `.json.z` twins) of cumulative counters per broadcast and auth root, split by a **tier** label chosen by the auth server's grant or `--cluster-tier`, which is what makes billing per customer or per region possible. [Stats](/concept/stats) describes the paths, tracks, and encodings; read them with the [`moq-stats`](https://docs.rs/moq-stats) crate. ## \[iroh] ```toml [iroh] enabled = true secret = "./iroh-secret.key" # Persist the key so the endpoint id survives restarts. # disable_relay = true # Direct addresses only. Right on a LAN, wrong on the internet. ``` See [Transport](/concept/transport#iroh-peer-to-peer-experimental). ## Shutdown ```toml drain_timeout = "10s" # Top-level key, as --drain-timeout / MOQ_DRAIN_TIMEOUT. ``` The first SIGTERM or SIGINT starts a drain: every session is sent a GOAWAY asking it to reconnect, and is force-closed if it is still connected when the window ends. A session that connects during the drain, such as a client with a cached DNS answer, is sent a GOAWAY immediately, with only the time left in the window. The relay exits as soon as every session has left, when the window ends, or immediately on a second signal. `0` skips the GOAWAY and closes every session at once. The exit is logged with how long the drain took, as either `drain complete: every session left` or `drain deadline force-closed sessions` with the number `forced`. A session still in its handshake when the last one leaves is not waited for. Only moq-lite-04+ and moq-transport clients act on a GOAWAY; older ones are closed when the window ends. An embedder can take over the signals and start the drain itself; see [Embed](/bin/relay/#embed). ## \[log] ```toml [log] level = "info" # RUST_LOG overrides this. ``` At `info` the relay logs one `listening` record for the `[listen]` QUIC socket and each public `[web]` listener as it binds. Each record carries the bound address and a `kind` naming the listener: ``` INFO listening addr=[::]:4443 kind=quic INFO listening addr=[::]:4443 kind=http INFO listening addr=[::]:8443 kind=https ``` For these listeners, `addr` is the address the socket bound, not the one configured, so a `listen` port of `0` reports the port the OS picked. That is the only way to learn it from outside the process, and the QUIC and TCP ports are chosen independently. A relay with no `[listen]` UDP socket logs `listening (stream transports only)` instead of the `quic` line. --- --- url: https://doc.moq.dev/bin/relay/auth.md description: >- One auth contract for moq-relay: an auth server per session, or a static anonymous grant --- # Authentication A relay admits a session in exactly one of two ways: | Flag | What admits | | --- | --- | | `--auth-url` | An auth server, asked once per session event with everything the relay knows. `moq auth serve` is the reference server; a Worker or a service of your own answers the same contract. | | `--auth-public` | A static grant for anonymous sessions: patterns rooted at `/`, like a token with an empty root. No server. | Setting both, or neither, fails at startup; an application that embeds the relay may leave both unset and decide [in process](#in-process) instead. Nothing else admits anyone: a verified client certificate is a fact the server weighs, never a grant on its own. A grant names publish and subscribe rights as path patterns under a root, and the session can only see that part of the tree. ## The contract The relay POSTs one JSON body per session event to `--auth-url` and reads a grant back. The schemas are `moq_auth::Request` and `moq_auth::Grant` ([Rust](/lib/rs/moq-auth), [TypeScript](/lib/js/auth)). **Request.** `id` (random 128-bit hex, unique per session), `event` (`connect`, `revalidate`, or `end`), `node` (the relay's `--stats-node`, else `--cluster-node`), `transport` (`quic`, `websocket`, `tcp`, `unix`, `iroh`, or `http` for a one-shot `/fetch` or `/announced` request), `remote` and `local` socket addresses, `server_name` (the SNI or the host the client addressed), `alpn` (the negotiated moq protocol), `path` exactly as dialed, `query` raw, `token` with the credential a moq-transport client put in its SETUP's `AUTHORIZATION TOKEN` option (`kind`, the draft's Token Type, and `value`, the bytes as unpadded base64url), `role` the client declared at SETUP (`publisher`, `subscriber`, absent for both), and `tls` with the verified client certificate when one was presented: `name` (first SAN DNS name, else CN, else the fingerprint; a server cannot tell which), `fingerprint` (SHA-256 of the leaf), `expires`, `issuer`. Nothing is parsed on the server's behalf: the `jwt` query parameter is a convention of `moq auth serve`, not of the relay, and the relay forwards a SETUP token without reading it. An `end` adds `reason`, `duration` in seconds, and `bytes` sent and received. **Grant.** `publish` and `subscribe` as pattern unions (`foo/**` is a subtree, `**` is everything, an empty list is nothing), `root` (optional; replaces the dialed path, which is how a slug aliases to a canonical id), `mounts` (optional object; each key, a path relative to the root, reads from the absolute path it maps to: `{".svc": ".svc/pid"}` resolves `.svc/foo` at `.svc/pid/foo` and presents its announcements under `.svc`, the patterns still authorize `.svc/foo`, and nothing may be published beneath a key; a key that holds a wildcard or overlaps another key or any value, its own included, refuses the grant), `expires` (optional unix seconds; the session closes then), `revalidate` (optional seconds until the relay asks again), `tier` (optional label handed to [stats](/bin/relay/config#stats)), and `peer` (optional; `true` marks another relay, so what it announces counts as entering the cluster elsewhere, not as ingest here). A 2xx with a grant admits. A 401 or 403 refuses. Anything else at connect, a timeout, a 5xx, or an unparseable body, refuses and logs an error; nothing is admitted because the server was down. A grant that names nothing refuses, and one with `revalidate` but no `expires` is refused as invalid, as is one whose `expires` is already at or before now: expiry is exact, with no grace for clock skew, so keep the auth server's clock in sync. The client and relay snapshot each accepted grant's deadline on a monotonic clock, so later polls, outages, and wall-clock adjustments do not restart it. **Revalidate and outage.** On the cadence the relay POSTs `revalidate` with the same request. A grant applies: a changed `root` or `mounts`, or one that no longer covers what the session holds closes it with `Unauthorized` (the live session is not resized in place), and so does a flipped `peer`; a changed `tier` keeps the session and moves its stats: its presence counts under the new tier from then on, as does each group and subscription it starts afterwards, while one already in flight finishes where it began. A 401 or 403 closes the session now, as does a 2xx whose grant names nothing. A 2xx that fails validation otherwise (already expired, `revalidate` without `expires`, or a zero cadence) closes it as `invalid`. Anything else (408, 429, 404, 400, 5xx, a timeout, a transport error) retries with jittered backoff and the session lives until `expires`, so an outage always has the bound the server chose. There is no `Cache-Control` and no cache on the relay. **End.** Every close reports `end` with the reason: `expired`, `refused`, `invalid`, the session's own close classification, or `dropped`. The byte totals are what the transport reports; QUIC reports them, the qmux stream transports do not yet. **Push.** An operator or the auth server can ask this node to re-check now, instead of waiting for the cadence, so a kick, a gate, or a retier lands in one round trip. The internal listener takes the same filter on `GET /sessions` and `POST /sessions/revalidate`: any subset of the fields the server already saw (`id`, `path` as a pattern, `remote` as an IP or CIDR, `transport`, `tls.name`, ...). Every given field must match. An empty filter is every session on this node, so a node under load is nudged through a selector. A push does not decide anything: the relay re-POSTs `revalidate` and the server's reply kicks, narrows, retiers, or keeps the session exactly as a scheduled re-check would. Closing a session with no server in the loop is not a route. ```bash # Kick one session. curl -X POST 'http://127.0.0.1:9101/sessions/revalidate?id=00ff' # Re-check everyone under a path, then see who is still there. curl 'http://127.0.0.1:9101/sessions?path=demo/**' curl -X POST 'http://127.0.0.1:9101/sessions/revalidate?path=demo/**' moq auth revalidate --internal-url http://127.0.0.1:9101 --id 00ff moq auth sessions --internal-url http://127.0.0.1:9101 --path 'demo/**' ``` `GET /sessions` is the dry run: the same matches, each request plus start time, with `query` and `token` omitted so a credential on the plane cannot be replayed. A matching POST returns 202 and the ids; no match is 200 with an empty list. An unknown field, including `query`, is 400. One node, no cluster fan-out: the server already knows each session's `node` from `connect` and calls that relay. **The link.** `https://` presents the relay's `connect.tls` client certificate when one is configured, so a remote server can tell which relay is asking; `unix://` speaks HTTP over a socket; `http://` is accepted for a loopback host only. There is no shared secret. **Patterns.** A grant is any pattern union and means exactly what it says: `foo/**` is a subtree, a bare `foo` is that one broadcast, `live/*` is every broadcast one segment under `live`, and `room/*/chat` is each room's chat and nothing beside it. The relay announces, publishes, and resolves only the paths inside the grant. Announcements travel by literal head on the wire, so a non-prefix grant is filtered locally on each side while the announced route stays a prefix on every protocol version. ```toml [auth] url = "http://127.0.0.1:4440/" ``` ## Tokens With `moq auth serve`, a client presents a JWT in `?jwt=`. Generate a key, sign a token, hand it to the client. Install the [moq CLI](/setup/install) and use its `moq auth` subcommand. ```bash # Asymmetric: the relay only needs public.jwk. moq auth generate --algorithm ES256 --out private.jwk --public public.jwk # Let the bearer publish under rooms/123/alice and subscribe to anything in rooms/123. moq auth sign --key private.jwk --root rooms/123 --publish 'alice/**' --subscribe '**' --expires "$(( $(date +%s) + 3600 ))" > alice.jwt moq auth verify --key public.jwk --in alice.jwt ``` ```bash moq auth serve --key public.jwk # or --key-dir /etc/moq/keys/ for {kid}.jwk rotation ``` The client dials `https://relay.example.com/rooms/123?jwt=`. A moq-transport client can instead put the JWT in its SETUP's `AUTHORIZATION TOKEN` option with Token Type 0; `moq auth serve` verifies it the same way. HMAC (HS256/384/512), RSA (RS/PS), ECDSA (ES256/384), and EdDSA keys all work. A key can itself be **scoped** at generation (`--root`, `--publish`, `--subscribe`), after which it can never sign a broader token. ### Claims | Claim | Meaning | | --- | --- | | `root` | Base path. Optional. | | `publish` | Patterns the bearer may publish under `root`. `**` means everything; omitted means no publishing. | | `subscribe` | Patterns the bearer may subscribe to under `root`. Same rules. | | `exp`, `iat`, `nbf` | Expiry, issue time, and not-before. `exp` is enforced for the whole session, not just at connect, and a token is refused from its `exp` on and before its `nbf`. | | `iss`, `sub`, `jti` | Read and ignored. | Any other claim refuses the token with its name, `aud` included: an unknown claim may narrow the grant, and a misspelled `root` would otherwise widen it to everything. Put application data somewhere other than the token. Tokens and key scopes from the older `moq-token` format still work: each `put` and `get` prefix `p` reads as the subtree `p/**`, and `""` as `**`. When every grant is a subtree, signing writes that older form so relays and auth servers that predate patterns accept it too. A grant only a pattern can express (an exact `foo`, or `*/chat`) is written as `publish`/`subscribe`, which an older verifier refuses. ### Path matching Grants are [patterns](https://docs.rs/moq-pattern) relative to `root`: `foo` is exactly `foo`, `foo/**` is `foo` and everything beneath it, and `**` is everything. Matching is on path boundaries (`foo/**` covers `foo/bar` but not `foobar`). The relay scopes a session by the exact union, including literals, segment wildcards, suffixes, and subtrees. `moq auth serve` authorizes the token at the dialed path: the path may equal the root, extend it (which narrows the grant), or be a parent of it (the grant still applies at the root). An unrelated path is rejected. The relay forwards the raw path and enforces the grant it gets. The session's root is always the path it dialed, and what it sees is named relative to that. For a grant of `demo/**` from the root: | Dialed | Session root | Granted | | --- | --- | --- | | `/demo/bar` | `demo/bar` | `**` | | `/demo` | `demo` | `**` | | `/` | \`\` | `demo/**` | | `/other` | refused | | Anonymous and mTLS rules, on the relay and in `moq auth serve`, are authorized the same way, as a token with an empty root. | root | publish | subscribe | Publish | Subscribe | | --- | --- | --- | --- | --- | | `demo` | `my-stream/**` | `**` | `demo/my-stream/**` | `demo/**` | | `demo` | (none) | `**` | nothing | `demo/**` | | `""` | `**` | `**` | everything | everything | Libraries: [`moq-auth`](/lib/rs/moq-auth) (Rust) and [`@moq/auth`](/lib/js/auth) (TypeScript) sign and verify the same tokens. ## Anonymous access ```toml [auth] public = "anon/**" # anyone may publish and subscribe under anon/ # or asymmetric rules: public_subscribe = ["anon/**", "demo/**"] public_publish = ["anon/**"] ``` A static grant with no expiry and no re-check. Nothing here verifies a token, so a session presenting one (a `jwt` query or any SETUP token) is refused rather than admitted on the public grant. The patterns are rooted at `/`, like a token with an empty root (see [Path matching](#path-matching)): `anon/**` admits a session dialed at `/`, `/anon`, or `/anon/room`, scoped to `anon/`, and refuses one dialed anywhere else. A pattern with no wildcard, such as `anon`, refuses to start: 0.14 read it as a prefix and a pattern reads it as one broadcast, so write `anon/**` for the subtree. `public = "**"` opens everything and is for development only. With `--auth-url` the anonymous rules live on the server instead (`moq auth serve --public-*`). ## mTLS `listen.tls.root` verifies a client certificate chain at the handshake, and a bad chain still fails there. What the certificate admits is the server's decision: the relay reports its facts in the request's `tls` and enforces the grant it gets back. `moq auth serve` grants a certificate only what `--mtls-publish` and `--mtls-subscribe` name, empty by default. Public rules ignore certificates, so a relay or `moq --listen` on `--auth-public` refuses to start with `listen.tls.root` or `web.https.root`. Cluster peers are admitted the same way, so a mesh runs `moq auth serve --mtls-publish '**' --mtls-subscribe '**'` (or a server granting its cluster CA everything); see [Clustering](/bin/relay/cluster). The LAN mesh credential on `/.cluster/` stays relay-internal: it is a secret the relay minted for itself, checked locally, and never a request to the server. ```toml [listen.tls] root = ["/etc/moq/peer-ca.pem"] [connect.tls] cert = "/etc/moq/relay.pem" # presented on outbound dials and to an https:// auth server key = "/etc/moq/relay.key" ``` ## Auth server `moq auth serve` is the reference auth server: the policy the relay used to hold, answered over the contract above. ```bash moq auth serve --listen 127.0.0.1:4440 \ --key-dir /etc/moq/keys \ --public-subscribe 'anon/**' --public-publish 'anon/**' \ --mtls-publish '**' --mtls-subscribe '**' \ --tier edge --expires 1d --revalidate 1m --limit-remote 64 ``` What a session presents decides what is checked. Every credential is either evaluated or refused: nothing is ignored, and no two are combined. | Presented | `--auth-public` | `--auth-url` to `moq auth serve` | | --- | --- | --- | | nothing | the public rules | `--public-*`, or refused | | a JWT | refused | verified, or refused | | a certificate | never requested: a client CA refuses to start | `--mtls-*`, or refused | | a JWT and a certificate | never requested | refused | A JWT, from the `jwt` query or a SETUP `token` of `kind` 0, is verified against `--key FILE` or `--key-dir DIR` (by `kid`, read per request so rotation needs no restart) and authorized at the dialed path, as in [Path matching](#path-matching). A malformed, expired, or unknown-key token is refused; it never falls through to the anonymous rules. So is a SETUP token of any other `kind`. A SETUP token and a `jwt` query with the same value are one JWT, verified once; different values are refused. A JWT presented with a certificate is refused, because neither can safely win: the certificate would override a JWT meant to narrow it, and the JWT would narrow or refuse a peer by accident. So a peer presents `cluster.token` or a certificate, not both. `--mtls-*` and `--public-*` are rooted at `/`, like a token with an empty root, and a session they reach nothing at is refused. Cluster peers are admitted by certificate; a mesh needs `--mtls-publish '**' --mtls-subscribe '**'`. Every grant carries `--tier`. As in 0.14, nothing is re-checked or closed by default: a session lives until its token's `exp` or its certificate's notAfter. `--expires D` bounds a grant with no bound of its own (an anonymous session, a token without `exp`, a certificate without notAfter). `--revalidate D` has the relay re-check each grant on that cadence and needs `--expires`, so an outage still has a bound. Without `--revalidate`, rotating or deleting a key does not close live sessions. `--limit-token N` and `--limit-remote N` need `--revalidate`, and cap live sessions per token and per remote address (port dropped, IPv4-mapped IPv6 folded), counted from `connect` and `end` by session id. The cap is a nuisance limit, not a security boundary: it gates admission and never revokes. A relay that dies without an `end` holds its slots until they miss two cadences, a restart empties the table until the fleet's next cadence refills it, and a session admitted while a live one's slot was missing stays over the cap. A refusal names the rule in the 403 body. `--listen unix:/run/moq-auth.sock` serves a socket. Binding anything but a loopback address needs `--listen-public`: the server has no authentication of its own. ### Migrating from the relay flags Most of the 0.14 relay flags below were deleted; their policy moves to the server and the relay gets `--auth-url`. A relay without a server keeps `--auth-public`, as patterns: `--auth-public 'PREFIX/**'`. With a server, the anonymous rules move too. | 0.14 relay flag | `moq auth serve` | | --- | --- | | `--auth-key FILE` | `--key FILE` | | `--auth-key-dir DIR` | `--key-dir DIR` | | `--auth-public PREFIX` | `--public-publish 'PREFIX/**' --public-subscribe 'PREFIX/**'` | | `--auth-public-publish` / `--auth-public-subscribe` | `--public-publish` / `--public-subscribe`, as patterns | | `--auth-public-api URL` | your own server answering the contract | | `--auth-mtls-tier LABEL` | `--tier LABEL` (one tier per server) | | `listen.tls.root` alone admitting a peer unscoped | `--mtls-publish '**' --mtls-subscribe '**'` | | `--auth-api` (token or proxy mode), `Cache-Control` | `--auth-url` pointed at any server answering the contract; `revalidate` and `expires` in the grant | | `--auth-domain` | your server reads `server_name` and decides | Both on one host: ```bash moq auth serve --listen 127.0.0.1:4440 --key-dir /etc/moq/keys --public-subscribe 'anon/**' moq-relay --auth-url http://127.0.0.1:4440/ ``` ### In process An application that [embeds](/bin/relay/#embed) the relay can be the auth server without the HTTP: leave `[auth]` empty and take `relay.admissions()` before `run`. Each `Admission` carries the same `moq_auth::Request` the server would have read, and is answered with `grant(lease)` or `refuse(err)`. A `lease::Consumer::fixed(grant)` never changes; the consumer of a `lease::Producer` the application keeps is driven by it, which re-checks, updates, revokes, and learns when the session ends. Either way the relay closes the session at the grant's `expires`. `run` refuses to start while nobody has taken the admissions, a dropped `Admissions` fails every later session as unavailable, and an admission left unanswered for ten seconds (the bound an auth server gets) is refused the same way. ```rust let mut relay = Relay::load(config).await?; let mut admissions = relay.admissions().expect("[auth] is empty"); tokio::spawn(async move { while let Some(admission) = admissions.next().await { match policy.decide(&admission.request) { Ok(grant) => { let (producer, consumer) = moq_auth::lease::Producer::new(grant); admission.grant(consumer); tokio::spawn(async move { loop { tokio::select! { reason = producer.closed() => break reason, () = producer.revalidate_requested() => { // Re-run the decision now: update, revoke, or keep. } } } }); } Err(err) => admission.refuse(err), } } }); relay.run().await ``` ## Stream listeners The plaintext TCP and Unix-socket listeners are admitted exactly like QUIC: the path and query ride the `SETUP` (`tcp://127.0.0.1:4444/room?jwt=...`) and reach the auth server in the same request shape, with `transport` set to `tcp` or `unix` and no addresses on a socket. A Unix socket can also require a specific uid, gid, or pid: ```toml [listen.unix] bind = "/run/moq/internal.sock" allow.uid = [1001] ``` Bind TCP to loopback or a private interface; it carries no peer identity. The Unix socket is created mode `0666`, so gate it with a restrictive parent directory or an explicit allowlist. These are native-only paths for gateways and stats publishers on the same host. --- --- url: https://doc.moq.dev/bin/relay/cluster.md description: Connect relays across hosts and regions --- # Clustering Relays connect to each other and forward announcements and subscriptions. A viewer talks to the nearest relay; if the broadcast lives elsewhere, that relay pulls it from a peer and caches it, so the second viewer in a region costs no upstream bandwidth. Each broadcast carries the list of relays it passed through. That hop list catches loops and picks the shortest route, and every relay breaks ties the same way so the cluster converges instead of flapping. Both wire protocols carry it: natively on moq-lite, and via the [cluster extension](/draft/moq-cluster) on moq-transport 17+. When a moq-lite-04 or later peer withdraws its last advertisement for a broadcast, a relay drops every other route to it that passed through that peer, since each was relayed from what the peer just withdrew, rather than falling back to them one by one. During reconnect, another session from that peer can still advertise the broadcast; an old session's withdrawal does not invalidate that route. A relay two hops from the publisher's still holds routes relayed through others. So a change of a broadcast's best route waits 300 ms before it is announced, while a new broadcast and a removed one go out at once. By then the withdrawal has usually removed the other stale routes too, and the relay sends one retraction instead of advertising each stale path in turn. Requests still follow the current best route immediately; only the announcement waits. For 2 s after a relay retracts a broadcast, it holds back a worse path to the same publisher that a peer still hunting offers, instead of announcing the broadcast again. Failover routes must carry copies of the same broadcast. For each track, the relay requires matching timescale, retention window, publisher priority, and group ordering. A source with different properties is refused before its groups are spliced in. If no compatible source remains, the track fails with `Unsupported`. New immutable properties require a new track name or broadcast identity. A route whose original publisher (its first hop) changes is updated in place on both wire protocols, so the broadcast never briefly vanishes downstream. Subscriptions already in flight keep draining the old publisher until it ends and are never spliced onto the new one. New requests resolve through the updated route as a fresh broadcast, without the old publisher's track properties. A publisher whose protocol names no hop (moq-transport without the cluster extension, moq-lite 01 through 03, or a peer that sends 0) gets a random first hop from the relay it connects to, fresh for each connection, followed by a 0. Its reconnect is therefore a new publisher downstream, a reprice on the same connection stays in place, and the 0 keeps it ranked as anonymous. ## Topology List the peers each relay dials. That's the whole topology: a relay dials only peers from `connect`, [`connect_api`](#dynamic-peer-lists), or [LAN discovery](#lan-discovery), never a URL learned from an announcement. ```toml # us-west.toml [cluster] connect = ["https://us-east.example.com/"] ``` A chain (`eu-west <- us-east <- us-west`) dedupes fetches through the middle; a full mesh trades that for one fewer hop. Mix shapes as your traffic demands. For a full mesh, list every other relay, or serve the list from `connect_api`. A session carries both directions, so one dial per pair is enough; listing a pair on both sides opens a redundant second session. ## Link costs Add `?cost=N` to a peer URL to route by price instead of hop count. An unpriced link costs 1, which reproduces plain hop counting. Each relay adds the price of the link an announcement arrived on before forwarding it, so a route's cost is the sum of what it crossed. Prefix advertisements are forwarded and costed the same way as an exact-path route: each hop appends its identity, adds the link price, and passes the claim on. An advertised prefix must overlap the publisher's grant, or it is refused. A prefix wider than the grant is accepted, but it only routes requests for paths the grant covers. Routing prefers the longest covering prefix, then a fully identified hop list over one that holds a 0 (an anonymous hop) at any depth, then the lowest cost, then the shortest hop list, breaking any remaining tie toward the newest announcement so a reconnecting publisher isn't outranked by the session it replaced. An assigned identity for an anonymous peer is local selection state and is never written into the hop list. ```toml [cluster] connect = ["https://sibling.same-dc/?cost=0", "https://us-east.example.com/?cost=10"] ``` The same policy reads as an object, which is the only form that accepts `egress` and `token`. A bare URL stays valid, and an object whose `url` still carries `?cost=` or `?jwt=` alongside those fields is rejected rather than given a precedence a migration could silently get wrong. ```toml [cluster] connect = [ { url = "https://sibling.same-dc/", cost = 0 }, { url = "https://us-east.example.com/", cost = 10, egress = 10, token = "PEER_JWT" }, ] ``` `cost` is what this relay charges to pull from the peer. `egress` is what it declares in SETUP as its own price toward the peer and defaults to `cost`; anything else is refused until asymmetric routing lands, so the two are always equal today. `token` replaces an inline `?jwt=` with identical authorization and redaction. Price is per direction: pulling from a metered origin can cost far more than pushing to it, so each end declares its own and the two need not match. Prices aren't static either. A publisher can re-price a live announcement, which is how a standby transcoder pool seeds a high cost and drops it once it's working, and a relay receiving a GOAWAY re-prices every route learned from that peer to the maximum so new subscriptions go elsewhere while existing ones finish. moq-lite-06 announcements carry two prices, *warm* and *cold*. Both accumulate identically today, so routing runs on link costs alone; the split reserves room for a warm-copy discount, letting a relay advertise its cached copy cheaper on the warm side while the cold price still says who sits closest to the publisher. moq-transport has nowhere to carry the cold price, so a route learned from it ranks with an unknown (worst-case) one. ## LAN discovery On a LAN there may be no one to list. `[cluster.lan]` advertises this relay over mDNS and dials the peers that advertise back, so a rack or a home lab meshes with no seed list. A `moq --cluster-lan` process on the same network joins the same mesh: ```toml [cluster] # Optional. Without it the relay advertises its listen port and fingerprint, # the way `moq --cluster-lan` does. node = "us-west.local:4443" [cluster.lan] enabled = true # secret = "/etc/moq/cluster.key" # Optional. 64 hex chars, or a file holding them. # app = "default" # DNS-SD subtype; moq-cli shares this name. ``` A LAN peer authenticates with its mDNS credential on `/.cluster/` and is never handed `cluster.token`; that token is for `connect` and `connect_api` peers only. The advertisement carries the listener fingerprint when the certificate was generated or supplied in-memory, the `node` URL when one is configured, and at least one of them. `secret` is optional. Without it, anyone who can reach the listener joins, so leave it unset only on networks you trust. With it, only peers that prove they hold the same key are discovered or accepted. mDNS is still an open channel: the secret authenticates the record, it does not hide the credential or the node URL. `app` names the DNS-SD application this relay advertises under; peers using a different name never discover it. It defaults to `default`, which moq-cli shares so the two find each other with no configuration. An application built on the library picks its own name. Startup waits for at least one interface to announce before the relay reports itself ready. ## Dynamic peer lists Point `connect_api` at an HTTP(S) endpoint or local file returning a JSON array of peers: bare URL strings and/or the same objects `connect` accepts. The relay re-checks it (honoring `Cache-Control`, or watching the file) and reconciles: new peers are dialed, missing ones dropped, changed URLs redialed. A bad fetch keeps the last good list. ```json ["https://a.pop.example/?cost=1", {"url": "https://b.pop.example/", "cost": 2}] ``` ```toml [cluster] connect_api = "https://api.example.com/cluster/peers" node = "https://us-west.example.com/" ``` ## Identity Each relay has a Hop ID: the value it adds to a route's hop list for loop detection and shortest-path routing. It is random on every start, which is fine for loop detection but makes a restarted relay look like a new node. Set `cluster.id` to a stable non-zero integer to pin it, below 2^53 if browser clients decode it. ## Failure detection A peer that crashes or drops off the network sends no goodbye, so a relay only learns it is gone when the link goes quiet for [`quic.idle_timeout`](/bin/relay/config#quic) (10s by default). Until then its routes stay in place and subscribes through them go nowhere. Lower it to fail over faster; raise it if a lossy long-haul link drops while the peer is still alive, and keep `quic.keep_alive` well under it. QUIC uses the smaller of the two endpoints' idle timeouts ([RFC 9000 section 10.1](https://www.rfc-editor.org/rfc/rfc9000#section-10.1)), so either relay on a link can shorten it for both. iroh links use the same timeout; WebSocket links keep their own 30s deadline. ## Authentication Peers dial with **mTLS** (recommended: `listen.tls.root` on the listener, `connect.tls.cert`/`key` on the dialer) or a **JWT** (inline `?jwt=` on a peer URL, `token` on a peer object, or a shared `cluster.token` file for every listed peer). The accepting relay admits a peer through the same lease as any client: its certificate is reported to the auth server, which grants it, so a mesh needs `moq auth serve --mtls-publish '**' --mtls-subscribe '**'` (or a server of your own that grants the cluster CA) behind `--auth-url`. A relay on `--auth-public '**'` admits peers through that grant instead, as long as they send no `cluster.token`: public rules refuse a token. LAN peers authenticate with the mDNS credential on `/.cluster/`, a secret the relay minted for itself and checks locally, and never receive `cluster.token`. Dials retry forever with capped backoff, so a rejected peer is loud in the logs rather than fatal. See [Authentication](/bin/relay/auth#mtls). A relay records whether each route entered here or came from a peer, which the hop list alone cannot say: a client and a peer each add one hop. Routes over a dial this relay made, and over an accepted LAN peer, count as a peer's. An accepted peer counts only when its grant sets `peer: true`; otherwise it looks like a client ingesting here. An embedder reads this as `Route::source()` and filters with `origin::Consumer::local()`. The `/nodes` [internal endpoint](/bin/relay/http#get-nodes) lists the peers this relay dialed and holds a session with. --- --- url: https://doc.moq.dev/bin/relay/http.md description: The relay's HTTP endpoints for debugging, history, health, and metrics --- # HTTP Endpoints `[web.http]` and `[web.https]` serve public endpoints; `[internal]` serves operational ones that must stay private. ## Public | Endpoint | Returns | | --- | --- | | `GET /announced/` | Broadcasts announced under the prefix, named relative to it. | | `GET /fetch//?group=N` | One group from the cache, the latest by default, or `404` if the track has no such group. Useful for catch-up and debugging. | | `GET /certificate.sha256` | The fingerprint of the first configured TLS certificate, for pinning a self-signed dev certificate. | | `GET /health` | `200 ok`, unauthenticated, for load balancers. | ```bash curl http://localhost:4443/announced/demo curl http://localhost:4443/fetch/demo/bbb.hang/catalog.json ``` The announcement listing names each announced route by the prefix it covers; by convention a publisher announces each broadcast's exact path, so the list reads as broadcast names. A relay configured with more than one certificate has no single fingerprint to publish, and this endpoint answers for the first. The others are reachable over `https://`, which selects a certificate by SNI at the handshake. GET responses on the public router receive a wildcard CORS origin unless the route sets its own policy. This includes routes merged by an embedding application. GET preflights are supported; routes using other methods configure their own CORS policy. Tokens sent over plain HTTP are visible on the wire, so use HTTPS in production. ## Internal ```toml [internal] listen = "127.0.0.1:9101" ``` ### GET /metrics Prometheus text: bytes, frames, groups, subscriptions, viewers, and sessions split by `tier` and `role`, plus accept-loop counters per TCP listener. Alert on `moq_relay_accept_failures_total{class="exhausted"}`, which means the process ran out of a resource `accept` needs. Content dropped for drifting past a subscriber's budget is counted separately as `moq_relay_stale_bytes_total` and friends. During a [shutdown drain](/bin/relay/config#shutdown), `moq_relay_draining_sessions` counts the sessions sent a GOAWAY that have not left yet. Host CPU and memory belong to a node exporter. Traffic and session counters accumulate for the node's lifetime, including broadcasts and sessions that have ended. The stats publishing prefix (normally `.stats`) is excluded to avoid counting the feed's own traffic. With `--runtime-io-uring`, each QUIC worker thread also reports its own `moq_relay_uring_*` counters under a `worker` label: datagrams and syscalls (the ratios are the GRO/GSO batching and the syscall amortization the runtime exists for), buffer-pool backpressure (`rx_enobufs`, `rx_exhausted`, `tx_stalls`), cross-thread wakes, and timer churn. Every worker reports from the moment the port is bound, so a dead one shows stuck zeros rather than disappearing. These describe the process, not the traffic, so they never appear on the `.stats` broadcast. ### GET /sessions Live sessions on this node. The filter is query parameters: any subset of the fields the auth server already saw (`id`, `path` as a pattern, `remote` as an IP or CIDR, `transport`, `tls.name`, ...). Every given field must match; an empty filter is everyone. Each entry is the request plus start time, with `query` omitted. An unknown field, including `query`, is 400. ```bash curl 'http://127.0.0.1:9101/sessions?path=demo/**' ``` ### POST /sessions/revalidate The same filter, a re-check now. Matching sessions are nudged and the response is 202 with their ids; no match is 200 with an empty list. The relay re-POSTs `revalidate` and the auth server's reply is the verdict. One node, no cluster fan-out. ```bash curl -X POST 'http://127.0.0.1:9101/sessions/revalidate?id=00ff' ``` ### GET /nodes The peers this relay dialed and holds a session with: each node's URL, without its query, and the connections to it, with the same `conn` id the logs use. Peers that dialed this relay are not listed, since they declare no URL. ```json { "nodes": [{ "node": "https://us-east.example.com/", "connections": [{ "id": 3 }] }] } ``` --- --- url: https://doc.moq.dev/bin/cli.md description: The moq media router, for publishing, playing, converting, and gatewaying --- # moq-cli `moq` is a media router. One process connects to a relay (or hosts sessions itself) and moves media into MoQ from a source, out of MoQ to a sink, or plays it locally. Install it with `cargo install moq-cli`, brew, apt, dnf, winget, or Docker; see [Install](/setup/install). ## What it does | Verb | Endpoint | | | --- | --- | --- | | `import` | `ts`, `fmp4`, `flv`, `avc3` | Read a container from stdin (usually FFmpeg). | | `import` | `capture` | Capture a camera, display, window, or app plus a microphone, and encode natively. | | `import` | `hls ` | Pull a remote HLS playlist. | | `import` | `rtmp`, `srt`, `rtc` | Accept pushes (`--listen`) or pull from a remote (`--connect`). | | `export` | `fmp4`, `mkv`, `ts`, `flv`, `h264`, `h265` | Write a container to stdout. | | `export` | `hls --listen` | Serve the broadcast as HLS over HTTP. | | `export` | `rtmp`, `srt`, `rtc` | Serve plays (`--listen`) or push to a remote (`--connect`). | | `play` | | Decode and play in a native window with sound. | | `transcode` | | Publish a just-in-time rendition ladder next to a broadcast. | | `fetch` | `` | Write one group of a track to stdout. | | `auth` | | Generate, sign, and verify relay JWTs. | | `devices` | | List capture sources and their ids. | ## Grammar ```text moq import [options] moq export [options] moq play [options] moq fetch [options] ``` The **MoQ side** goes first and attaches the process to the network: `--connect ` dials a relay (the path is the auth path, `?jwt=` carries a token), and `--broadcast ` names the broadcast. A process can instead host sessions with `--listen`, or both at once. A listener admits clients by `--auth-url` or `--auth-public`, as the relay does (see [Authentication](/bin/relay/auth)); public rules ignore certificates, so `--auth-public` refuses to start with `--listen-tls-root`. `moq import --help` lists the sources and `moq import rtmp --help` a specific one. ```bash # Publish a file (remux to MPEG-TS without re-encoding) ffmpeg -re -i video.mp4 -c copy -f mpegts -pes_payload_size 0 -muxdelay 0 - | \ moq --connect https://relay.example.com/anon --broadcast my-stream.hang import ts # Pull it back out moq --connect https://relay.example.com/anon --broadcast my-stream.hang export ts | ffplay - # With a token moq --connect "https://relay.example.com/rooms/1?jwt=$TOKEN" --broadcast alice.hang import ts ``` The `ts`, `fmp4`, and `flv` imports publish on the broadcast clock the catalog advertises, not the input's own timestamps. The first frame is stamped when it arrives, every track keeps its offset from the others, and an input that restarts its timestamps, such as a restarted encoder, continues forward after the real gap rather than rewinding. So a feed whose PTS starts hours in, or whose first frame arrives late, still names the right wall time. MPEG-TS import carries H.264/H.265 and AAC/MP2/AC-3/E-AC-3, passes SCTE-35 and subtitle PIDs through as tracks, and round-trips the service tables. A `discontinuity_indicator` on the program's PCR PID is a system time-base reset, so it breaks every track's timeline and the exported clock declares the break in turn. The same flag on an elementary PID other than the program PCR PID, a continuity-counter gap, and the 33-bit timestamp rollover move no clock and declare nothing. FLV covers H.264 + AAC. `import ts` samples each elementary stream's access-unit count once a second and logs a stream whose count stopped advancing, with how long it has been quiet on the program clock, once per silence. The mux can keep flowing, PCR and continuity intact, around a PID that delivers nothing, and no transport check downstream sees it. A sparse stream such as SCTE-35 goes quiet between cues, so the line reports rather than alarms; `Import::stats` carries the same counters for a caller that sets its own limit. A corrupt media packet, malformed PES header, or damaged codec access unit is dropped and logged as a warning. Ingest continues on every other PID. Video closes its group at the break and resumes at its next keyframe, as it does after a continuity-counter gap: the pictures in between may reference the lost one, so they are dropped rather than decoded with artefacts. Each break freezes video for up to one GOP. Publishing, catalog, and clock errors still end the import. MPEG-TS import takes one program. A multi-program stream is refused before anything is published, naming its programs, rather than merged onto one clock; a PAT that adds a program mid-stream ends the import the same way. `--program 2` imports program 2 alone. `--program all` publishes each program the first PAT lists as its own broadcast, with its own clock and catalog, keeping the catalog suffix last: `--broadcast event.hang` publishes `event/1.hang`, `event/2.hang`, and so on. `export ts` writes one program per broadcast. `import srt` takes the same `--program`. A selected program's SI describes that service alone: its SDT lists only the selected service, and other services' EIT is dropped. Network-wide tables (NIT, BAT, TDT/TOT, and the SDT and EIT of other transport streams) pass through. SI matches the selection by DVB `service_id`, which is assumed to equal the PAT `program_number`. ```bash moq --connect https://relay.example.com/anon --broadcast event.hang import ts --program all < mux.ts ``` MPEG-TS export restarts its clock and table cadence after a declared marker, discarding the old mux buffer. The first new clock packet signals the break and stdout pacing re-anchors. Every rendition joins the new program generation; no track is fenced across the marker. MPEG-TS export frames AAC as ADTS, which labels only the AAC Main, LC, SSR, and LTP profiles. HE-AAC and HE-AACv2 go out as their AAC-LC core, and decoders find the SBR and PS in band, as ffmpeg's ADTS output does. A track whose profile or channel layout ADTS cannot label is refused rather than mislabeled. A constant-rate MPEG-TS source records its multiplex rate in the catalog (`mpegts.muxRate`, measured off the PCR clock, null stuffing included), and `export ts` pads its output with null packets back to that rate so an IRD or groomer receives a constant-rate stream. `--mux-rate 5000000` pads to an explicit rate instead, including for a broadcast that recorded none. Media is never delayed or dropped to fit: a source that sustains more than the rate overruns it, and a VBR source records nothing, so export without either stays unpadded. fMP4 export writes one fragment per publisher group on each track. Audio follows the publisher's cuts; video normally follows GOPs. Closing a group flushes it even when the live publisher pauses. `--fragment-duration 2s` caps the fragment span as frames arrive, including audio whose publisher never cuts. MKV uses the same flag to cap clusters, which otherwise follow video GOPs. ## Play ```bash moq --connect https://relay.example.com/anon --broadcast my-stream.hang play moq ... play --delay 500ms # trade latency for a jittery link ``` Decodes H.264, H.265, and AV1 video using the platform hardware decoder where available, and Opus, PCM, and AAC-LC (mono or stereo) audio in software. The log names the decoder each track opened. `--video-name` and `--audio-name` pick a rendition. HE-AAC signaled only in band (implicit SBR, as over MPEG-TS) plays as its half-rate AAC-LC core. Playback runs on a clock it owns. `--delay` (default 100 ms) is how far it trails the live edge, which is both the jitter a late frame may absorb and the point past which a stalled group is skipped. The speaker holds the delay, with a 50 ms floor under it, and the picture is scheduled against where the speaker actually is. While video owns the clock, a frame arriving earlier than predicted pulls playback forward, so a late start catches up to live instead of staying behind it. Once the speaker owns the clock, video follows the speaker instead. Video receives encoded frames independently of decoding, so a tune-in burst can update that clock even while the window is waiting for its first picture. Encoded video is retained within the delay budget with byte accounting; a skip resumes at a keyframe. Decoding starts 100 ms before the earliest picture still owed is due, so B-frame reordering and pictures the decoder holds back are covered however deep they go. The window holds at most three decoded pictures; a larger decoder batch waits for room rather than pushing out pictures not yet shown. A stalled window loses its oldest picture once a newer one is due, instead of blocking reception. The configured delay therefore does not turn into seconds of raw video surfaces. Each role follows the catalog for as long as it lasts. Each decoder starts at the newest cached group, including when a rendition is reopened, so playback does not replay the retained backlog. A publisher that retires the rendition being played ends that track and the role picks a replacement. A retired audio rendition plays out what the speaker holds while its replacement fills, so the switch does not cost a delay of silence. Playback is behind the `play` feature, since it pulls in windowing and audio-device dependencies: ```bash cargo install moq-cli --no-default-features --features "iroh,noq,websocket,play" ``` ## Capture ```bash moq --connect https://relay.example.com/anon --broadcast cam.hang import capture moq ... import capture --display --system-audio # share a screen with its sound (macOS) moq ... import capture --window 39193 --no-audio # one window (macOS, Windows, X11) moq ... import capture --camera 0 --width 1280 --height 720 --fps 30 --bitrate 3000000 --codec h265 ``` Video goes through the platform hardware encoder (VideoToolbox, Media Foundation, NVENC, and with the opt-in `vaapi` / `v4l2` features VAAPI and V4L2 M2M) with a built-in H.264 software fallback; audio is Opus. The camera is opened only while someone is watching, and `--bitrate` is the opening ceiling. Backends with live bitrate control lower it to fit the connection's bandwidth estimate. `moq devices` prints every source id. Requires the `capture` feature; on Linux that needs the ALSA headers for the microphone, and `--display` and `pipewire:` cameras also need the `pipewire` feature (links libpipewire). On Windows, display and window capture use Windows.Graphics.Capture and require Windows 10 2004 (build 19041) or newer. Cursor capture follows the capture configuration. The system capture border stays visible unless the OS supports borderless capture and grants access. Frames are converted to NV12 on the GPU; software encoding reads them back. Windows application capture and system audio are separate capabilities, not enabled by this backend. Windows `display:N` selectors are enumeration indices; switching from Desktop Duplication to WGC can change which monitor a saved selector names. Run `moq devices` again and reselect the intended display after upgrading. ## Transcode ```bash moq --connect https://relay.example.com/anon --broadcast cam.hang transcode moq ... transcode --rung 720:2500000 --rung 360:600000 --encoder nvenc --decoder nvdec ``` Publishes `cam.hang/transcode.hang` whose catalog references the source's rendition and adds lower rungs that are decoded and encoded only while someone watches them. On NVIDIA the whole pipeline stays on the GPU; `--frames cpu` forces decoded frames into CPU memory instead of the default `native`. Requires the `transcode` feature. The source is the largest rendition this host can decode with `--decoder`, so a software-only host transcodes from an H.264 rendition rather than a larger H.265 or AV1 one. When no rendition decodes, the command exits naming the decoder's refusal. The ladder is sized against the source picture and follows it, so a source that changes resolution mid-stream (a window capture renegotiated by a resize, a publisher reconnecting at a new size) resolves the rungs again. Rungs that still fit keep serving. A rung the new picture has no room for finishes its track, as does one whose own picture moved, and the latter comes back under a new name (`video/360p.2`), so a viewer on either reselects as it would on any other rendition change. Custom `--rung` values may be supplied in any order. Heights round down to even; heights and bitrates must then increase strictly together. Duplicate heights or bitrates, inverted rankings, and zero-sized or zero-bitrate rungs are rejected before connecting. ## Fetch ```bash moq --connect https://relay.example.com/anon --broadcast my-stream.hang fetch catalog.json | jq moq ... fetch video/hd --group 42 --json ``` Writes one group of a track to stdout over MoQ, with the session's own auth: the counterpart of the relay's HTTP `/fetch//?group=N`. Without `--group` it reads the newest group. By default stdout carries the frame payloads back to back, byte for byte what `curl` gets from `/fetch`. `--json` prints one line per frame instead: `{"group": 42, "frame": 0, "size": 1234, "payload": ""}`, with a zero-based `frame` and padded standard base64. `` is the literal track name. `/fetch` splits its path on the last `/`, so the two agree only for names without one. Fetch only dials `--connect`, and refuses any listener, cluster, auth, or `--hop` flag. It gives up after 30 seconds, as `/fetch` does, and exits non-zero when the broadcast or group is not found (before writing anything), the relay refuses, or the deadline passes. ## Multiple stages Separate stages with `--` to bridge several broadcasts, or both directions, over one connection: ```bash moq --connect https://relay.example.com/anon \ import --broadcast event.hang srt --listen 0.0.0.0:9000 \ -- export --broadcast event.hang hls --listen 0.0.0.0:8080 ``` ## Redundant publishers Two publishers that share a Hop ID (`--hop 42`) are treated as interchangeable sources: relays hold both routes and fail over at a group boundary. They must produce identical tracks with aligned groups. Everywhere else leave `--hop` unset: a fresh id per run is what makes a restarted encoder take over cleanly instead of splicing mid-stream. ## Cluster The CLI reads the same `--cluster-*` flags as `moq-relay`, LAN and WAN alike, and publishes on the cluster origin. A `moq --cluster-lan` process and a `moq-relay` with `[cluster.lan]` on the same network mesh with each other. `--cluster-lan` advertises this process on the LAN over mDNS and meshes with every other participating MoQ process. It reuses `--listen`, filling in an ephemeral port and a generated certificate when those are unset. A LAN peer authenticates with its mDNS credential; `cluster.token` is for `--cluster-connect` and `--cluster-connect-api` peers only. ```bash moq --cluster-lan import capture moq --cluster-lan --cluster-lan-secret /etc/moq/cluster.key import capture ``` `--cluster-lan-secret` restricts the mesh to peers holding the same key. Without it, anyone who can reach the listener joins, so leave it unset only on networks you trust. mDNS is still an open channel: the secret authenticates the record, it does not hide the credential or the node URL. `--cluster-lan-app` names the DNS-SD application this process advertises under. Peers using a different name never discover this one. It defaults to `default`, which moq-relay shares, so the two find each other with no configuration. An application built on the library picks its own name. The WAN flags (`--cluster-connect`, `--cluster-connect-api`, `--cluster-node`, `--cluster-token`, `--cluster-id`, `--cluster-tier`) match the relay. `--cluster-connect` and `--cluster-connect-api` are a MoQ side on their own, so `moq --cluster-connect https://relay.example import ts` needs no `--connect`. See [Clustering](/bin/relay/cluster). ## Auth ```bash moq auth generate --algorithm ES256 --out private.jwk --public public.jwk moq auth sign --key private.jwk --root rooms/123 --publish 'alice/**' --subscribe '**' > alice.jwt moq auth verify --key public.jwk --in alice.jwt ``` `--publish` and `--subscribe` take patterns: `alice` is one broadcast, `alice/**` is a subtree, `**` is everything under `--root`. `moq auth serve` answers a relay's auth requests with the same keys, public rules, an explicit mTLS grant, tiers, and session limits; see [Auth server](/bin/relay/auth#auth-server). ```bash moq auth serve --listen 127.0.0.1:4440 --key-dir keys/ --public-subscribe 'anon/**' ``` `moq auth sessions` and `moq auth revalidate` talk to a relay's internal listener. A push is a re-check: the auth server's reply is what kicks. An empty filter is every session on that node. ```bash # Kick one session by id. moq auth revalidate --internal-url http://127.0.0.1:9101 --id 00ff # Re-check everyone under a path. moq auth revalidate --internal-url http://127.0.0.1:9101 --path 'rooms/123/**' moq auth sessions --internal-url http://127.0.0.1:9101 --path 'rooms/123/**' ``` See [Authentication](/bin/relay/auth). ## Retention and latency `import --max-age` (default 30 s) tells relays how long to keep old groups fetchable, which the [HLS gateway](/bin/hls) depends on. `export --max-age` (default 500 ms) is how long *this* consumer waits for a stalled group before skipping. Raising the first never delays playback. For `export ts`, `--max-age` also bounds how long the muxer holds a leading track for a lagging one. Frames go out in media-time order across all tracks, not arrival order, so two exporters of one broadcast emit them in one order. A track quiet for longer is muxed around until it catches up; a sparse track (SCTE-35) costs that wait once per cue. `--max-age 0` keeps arrival order. A stdout export ends with the broadcast. `export ts --linger 10s` waits that long for the broadcast to come back instead: a publisher that restarts within it is picked up under the same PIDs, with the break flagged (PCR discontinuity, PAT/PMT re-sent). Nothing is written while it is gone. When the linger runs out, the exit code is that of the last end: 0 if the broadcast finished cleanly, 1 if it dropped or failed. The default is `0s`, which exits on the first end the same way. Only `ts` can mark the restart, so the other formats refuse `--linger`. ## Debugging `RUST_LOG=debug` prints the negotiated version and every subscription. `curl http://relay:4443/announced/` confirms the relay is reachable and shows what it holds. Connection refused means UDP isn't getting through; certificate errors on a dev relay want `--connect-tls-insecure` or the `http://` fingerprint flow. --- --- url: https://doc.moq.dev/bin/rtmp.md description: RTMP and enhanced RTMP ingest and playback --- # RTMP `moq import rtmp` accepts pushes from OBS, FFmpeg, and hardware encoders; `moq export rtmp` serves plays to VLC, ffplay, and mpv, or pushes to a remote RTMP server such as Twitch. Both legacy RTMP (H.264 + AAC) and enhanced RTMP (HEVC, AV1, VP9, Opus, AC-3, multitrack) work in each direction. ```bash # Accept an OBS push and publish it to a relay. # In OBS: server rtmp://host:1935/live, any stream key. moq --connect https://relay.example.com/anon --broadcast live.hang import rtmp --listen '[::]:1935' # Serve the broadcast to RTMP players moq --connect https://relay.example.com/anon --broadcast live.hang export rtmp --listen '[::]:1935' ffplay rtmp://localhost:1935/live # Restream to Twitch moq --connect https://relay.example.com/anon --broadcast live.hang export rtmp --connect 'rtmp://live.twitch.tv/app/' ``` A listener bridges exactly one `--broadcast` and ignores the RTMP app and stream key, so multi-tenant routing by key belongs in your own program using the [`moq-rtmp`](https://docs.rs/moq-rtmp) library, which hands you each publish or play request to accept, map to a path, or reject. The CLI listener is unauthenticated; firewall it. A player that advertises enhanced-RTMP multitrack receives every rendition. Any other player receives one video rendition: the largest picture (then highest bitrate) in a codec it advertised. A push carries the largest one. Implemented in pure Rust (no librtmp). The CLI speaks plaintext `rtmp://` only. The library adds RTMPS on the same port when the embedder supplies a TLS config, and by default still accepts plaintext `rtmp://` there, so stream keys can arrive unencrypted. The embedder can set `plaintext` to `false` to serve `rtmps://` only: a plaintext client is refused at its first byte and the refusal is logged with the peer address. Refusing plaintext without a TLS config fails at startup. FLAC and MP3 enhanced-audio payloads are dropped because hang has no catalog codec for them. Incoming chunk streams are reassembled independently, including interleaved control, audio, and video messages. Each connection retains at most 256 chunk stream IDs, including completed streams' header history needed by compressed headers. Reusing an ID does not consume another slot; the connection releases all slots on teardown. A 257th distinct ID is refused, even if its numeric ID is small. The limit counts streams rather than constraining their numeric IDs. Incomplete messages reserve their declared payload lengths against a 64 MiB connection budget before allocation. Completion or an RTMP Abort releases that reservation; malformed input or exhaustion closes the connection and releases its parser state. The 24-bit RTMP message length still allows a payload up to 16 MiB minus one byte. Four maximal payloads fit, with exactly four bytes remaining. These internal limits leave room for the gateway's five chunk-stream roles and [FFmpeg's fixed control/audio/video channels](https://github.com/FFmpeg/FFmpeg/blob/master/libavformat/rtmppkt.h) as well as [OBS's control and media channels](https://github.com/obsproject/obs-studio/blob/master/plugins/obs-outputs/librtmp/rtmp.c). They bound retained state independently of chunk size and retain support for large keyframes. Undecoded input has a separate 64 MiB buffer limit. Connections exceeding these limits are unsupported. --- --- url: https://doc.moq.dev/bin/srt.md description: SRT contribution and playback --- # SRT `moq import srt` accepts SRT pushes (`--listen`) or pulls from a remote SRT source (`--connect`); `moq export srt` serves SRT to players or pushes to a remote. The payload is MPEG-TS, so the same codecs as [`import ts`](/bin/cli) apply: H.264/H.265 video and AAC, MP2, AC-3, or E-AC-3 audio. Ingest logs the same per-stream lines as `import ts`, under an `srt{path=...}` span: an elementary stream that stopped delivering access units, audio frame sync lost, and damaged units refused. Damage drops that unit and keeps the session alive; video closes its group at the break and resumes at its next keyframe, freezing for up to one GOP. ```bash # Accept a contribution feed and publish it moq --connect https://relay.example.com/anon --broadcast event.hang import srt --listen '[::]:9000' # Serve a broadcast to SRT players moq --connect https://relay.example.com/anon --broadcast event.hang export srt --listen '[::]:9000' ffplay srt://localhost:9000 # Pull from a remote encoder moq --connect https://relay.example.com/anon --broadcast event.hang import srt --connect 'srt://encoder.example.com:9000?streamid=live/cam' ``` A multi-program feed is refused, as with `import ts`, unless `--program` picks one: `--program 2` imports program 2 alone, and `--program all` publishes each program as its own broadcast (`event.hang` becomes `event/1.hang`, `event/2.hang`, and so on). ```bash moq --connect https://relay.example.com/anon --broadcast event.hang import srt --listen '[::]:9000' --program all ``` `--latency` sets the SRT receive buffer and doubles as the skip threshold on export. Export paces each SRT payload on the media clock, and re-anchors that pacing on a declared marker, so a restarted timeline plays out from the live edge instead of stalling until it catches up. A `--connect` URL needs a `streamid` query or a path; a listener bridges one `--broadcast` and ignores the stream id it is offered. The library is [`moq-srt`](https://docs.rs/moq-srt). --- --- url: https://doc.moq.dev/bin/rtc.md description: WHIP and WHEP gateway between WebRTC and MoQ --- # WebRTC `moq import rtc` and `moq export rtc` bridge WebRTC using [WHIP](https://datatracker.ietf.org/doc/html/rfc9725) (publish) and WHEP (play) in either HTTP role, so existing WebRTC clients and services plug into MoQ broadcasts. | Command | Role | Media flow | | --- | --- | --- | | `import rtc --listen` | WHIP server | Browsers publish to you | | `import rtc --connect ` | WHEP client | Pull from a remote WebRTC source (a camera, an SFU) | | `export rtc --listen` | WHEP server | Serve a broadcast to WebRTC players | | `export rtc --connect ` | WHIP client | Push a broadcast to a remote WHIP endpoint | ```bash moq --connect https://relay.example.com/anon --broadcast cam.hang import rtc --listen '[::]:8080' moq --connect https://relay.example.com/anon --broadcast cam.hang export rtc --listen '[::]:8080' ``` Peers reach the broadcast at `http://host:8080/`. Opus, H.264, H.265, VP8, VP9, and AV1 are negotiated in both directions. A WHEP peer receives the largest rendition (then highest bitrate) in its negotiated codec. `--cors-origin` opens the endpoint to browsers on other origins, and `--udp-bind` plus `--public-addr` pin one media port for firewalls. The listener is plain HTTP; put a TLS-terminating proxy in front for WHIP clients that require HTTPS. A fresh WHEP peer joins at the current group, so it starts at a keyframe without waiting for the next one. `DELETE` on the resource URL tears a session down per the RFC. Built on [str0m](https://crates.io/crates/str0m); the library is [`moq-rtc`](https://docs.rs/moq-rtc). --- --- url: https://doc.moq.dev/bin/hls.md description: Serve any broadcast as HLS, or import an HLS playlist --- # HLS `moq export hls` serves a MoQ broadcast as HLS over HTTP for players that can't speak MoQ. `moq import hls` pulls a remote HLS master or media playlist into a broadcast. ```bash # Serve. Players open http://localhost:8089/my-stream.hang/master.m3u8 moq --connect https://relay.example.com/anon --broadcast my-stream.hang export hls --listen '[::]:8089' # Import moq --connect https://relay.example.com/anon --broadcast my-stream.hang import hls https://example.com/live/master.m3u8 ``` Export never subscribes to media. It reads the broadcast's [timeline track](/concept/hang#catalog), a log of complete segments aligned across every rendition, to build playlists, then fetches exactly the groups a requested segment covers from the relay's cache and transmuxes them to CMAF on demand. So a segment is servable for as long as the relay's [cache](/bin/relay/config#cache) retains it, and idle renditions cost nothing. Because segments are aligned, the same number names the same span of content in every media playlist; a record with nothing for a rendition renders as `EXT-X-GAP` and a jump in content time as `EXT-X-DISCONTINUITY`. A broadcast whose catalog advertises no timeline is skipped. One server exposes every broadcast by path: ```text /{broadcast}/master.m3u8 /{broadcast}/manifest.mpd /{broadcast}/{video|audio}/{rendition}/media.m3u8 /{broadcast}/{video|audio}/{rendition}/init.{hash}.mp4 /{broadcast}/{video|audio}/{rendition}/seg/{segment}.m4s /{broadcast}/{video|audio}/{rendition}/seg/t{pts}.m4s ``` The init URL carries a hash of its bytes, so a reconfigured rendition gets a new one. An embedder of the library can also label the publisher's run with `Broadcaster::set_generation`. Every segment URL then carries it (`seg/{generation}.{segment}.m4s`), since a restarted publisher reuses segment numbers for different media. `--window` sets the playlist duration (default 16 s), `--listen-tls-cert`/`--listen-tls-key` or `--listen-tls-generate` serve HTTPS, and `--cors-origin` opens it to browsers. H.264/H.265 and AAC/Opus renditions are served. Import handles classic HLS; LL-HLS parts are not implemented yet. The library is [`moq-hls`](https://docs.rs/moq-hls). --- --- url: https://doc.moq.dev/bin/obs.md description: Stream from OBS Studio to MoQ, or bring a broadcast into a scene --- # OBS Plugin The plugin adds a **MoQ** streaming service and a **MoQ Source** to a stock OBS Studio install. * **Publish**: Settings > Stream, choose "MoQ", enter the relay URL (with `?jwt=` if needed) and broadcast path, Start Streaming. * **Subscribe**: add a "MoQ Source", enter the relay URL and broadcast path, and the stream appears in the scene. * **Dock**: **Stream** contains the relay URL, optional publish token and broadcast name, Go Live, and connection state. Leave the broadcast name empty to publish at the relay URL path. Paste a URL with `?jwt=` to fill the token field automatically. Enter a relay URL explicitly; on the shared anonymous relay, use a unique path such as `https://cdn.moq.dev/anon/your-stream`. Reconnect settings live under **Advanced**. **Encoding** chooses between **Use OBS Output settings** and **Custom settings for MoQ**. Both use OBS encoders; custom settings apply only to this MoQ stream. **Encoder latency** defaults to **Low latency** in both modes. It overrides buffering settings only for this MoQ stream; **Keep encoder settings** preserves them. x264 uses its zero-latency tune, VideoToolbox disables B-frames, NVENC uses ultra-low tuning without B-frames/lookahead, and Quick Sync uses ultra-low mode without B-frames. Other encoders retain their settings. **Stats** reports the applied policy. This trades compression efficiency for less buffering, not a guaranteed end-to-end delay: OBS VideoToolbox and NVENC can still queue frames internally. Keyframe join delay and viewer buffering are separate. Custom profiles offer Auto, Quality, or Performance, with hardware/software, video codec, encoder, and audio codec choices. **Stats** shows the active encoding, negotiated draft, dial scheme, and one minute of RTT, estimated send/receive bandwidth, packet loss, and bytes sent. **About** lists plugin and libmoq versions, documentation links, and available video encoders. OBS reports each locally encoded packet's handoff to libmoq against the shared broadcast media clock. Each track's catalog `jitter` is the largest measured delay above that track's own recent minimum, and its `delay` is how far that minimum trails the earliest track, both rounded up to milliseconds. ## Source quality and moq-transcode OBS publishes **one** hang mezzanine. It does not encode a viewer ladder inside the plugin. When a relay or moq.pro enables [`moq-transcode`](/bin/cli#transcode), the ladder catalog appears beside that source as `{broadcast}/transcode.hang` (the same path the `moq … transcode` CLI uses). Prefer a broadcast name ending in `.hang`, a canvas at least as tall as the top rung you want (1080p for the default ladder), and a source bitrate above the top rung ceiling (Quality targets 8 Mbps CBR so the default 5 Mbps 1080p rung can undercut it). The catalog carries coded size and configured CBR bitrate so the transcoder can size rungs before measured rates arrive. Local check before moq.pro (requires a CLI built with the `transcode` feature): ```bash cargo install --locked moq-cli --features transcode # OBS Go Live to e.g. my-obs.hang on a local relay, then: moq --connect https://localhost:4443/anon --broadcast my-obs.hang transcode # Watch my-obs.hang/transcode.hang ``` ## Install Prebuilt archives for macOS (Apple Silicon) and Windows (x64) are attached to each [`obs-moq` release](https://github.com/moq-dev/moq/releases?q=obs-moq). Extract into your OBS plugins directory. The archives are unsigned, so Gatekeeper and SmartScreen warn on first load. Linux builds from source: ```bash nix develop .#obs just obs build ``` macOS and Windows source builds use `just obs setup && just obs build` with Xcode or Visual Studio 2022; see [`cpp/obs/`](https://github.com/moq-dev/moq/tree/main/cpp/obs). ## Advanced settings Off by default; the defaults suit a normal relay. When enabled they cover the things you'd otherwise pass to `moq` on the command line: pinning a protocol draft or QUIC backend, trusting a self-signed relay by fingerprint or a private CA, an SNI override, reconnect pacing, congestion control (delay-based BBR or loss-based CUBIC), stream limits and timeouts, qlog traces for diagnosing stalls, and the WebSocket fallback race. A rejected value stops the stream with the reason in the log rather than silently using a default. The plugin is C++ over [libmoq](/lib/c/)'s C ABI and ships with every libmoq release. --- --- url: https://doc.moq.dev/bin/gstreamer.md description: moqsink and moqsrc elements --- # GStreamer Plugin Two elements: **moqsink** publishes a pipeline to a relay, **moqsrc** subscribes to a broadcast and exposes one source pad per rendition. ```bash # Inspect (Nix bundles the plugin with gst-launch) nix shell github:moq-dev/moq/release#moq-gst --command gst-inspect-1.0 moq # Play the public test broadcast nix shell github:moq-dev/moq/release#moq-gst --command gst-launch-1.0 -e \ moqsrc name=s url=https://cdn.moq.dev/demo broadcast=bbb.hang \ s.video_0 ! queue ! decodebin3 ! videoconvert ! autovideosink \ s.audio_0 ! queue ! decodebin3 ! audioconvert ! autoaudiosink # Publish a test pattern gst-launch-1.0 -e videotestsrc is-live=true ! x264enc tune=zerolatency ! h264parse \ ! video/x-h264,stream-format=byte-stream,alignment=au ! mux.sink_0 \ moqsink name=mux url=https://cdn.moq.dev/anon broadcast=.hang sink_0::encoder=true ``` Install via `apt install gstreamer1.0-moq` or `dnf install gstreamer1-moq` ([Install](/setup/install)), or build with `cargo build -p moq-gst` and point `GST_PLUGIN_PATH_1_0` at the output. `http://` URLs pin the relay's certificate fingerprint automatically, so local development needs no TLS setup. That scheme is for localhost only: the fingerprint is fetched unauthenticated and the WebSocket fallback runs as cleartext `ws://`, so use `https://` for anything else. ## moqsink | Codec | Caps | | --- | --- | | H.264, H.265, AV1, VP8, VP9 | `video/x-h264`, `video/x-h265`, `video/x-av1`, `video/x-vp8`, `video/x-vp9` | | AAC, MP3, Opus | `audio/mpeg`, `audio/x-opus` | | Captions | `text/x-raw` (one WebVTT cue per buffer, PTS is the cue start and the buffer duration its end) | | Opaque data | `application/octet-stream` (raw bytes on a named track, one group per buffer) | A `text/x-raw` pad is how captions get in: ffmpeg cannot mux a subtitle track into fragmented MP4, so `moq import fmp4` can't carry one, while a demuxer that resolves timed text (`qtdemux` on a 3GPP timed-text track) can feed the pad directly. A cue with no duration is dropped rather than left on screen. Each `sink_%u` request pad is one track. Pad properties: `track` names it (default: after the codec), `container=loc` publishes it as [LOC](/concept/standard#loc) instead of the legacy hang container, `encoder=true` marks it as fed by a local encoder, and `track-status`/`track-error` report its lifecycle. Element properties: `url`, `broadcast`, `tls-disable-verify`, `quic-idle-timeout`, `quic-keep-alive`, and read-only `status`, `connected`, `moq-version`, and `estimated-send-rate`, `estimated-recv-rate`, `connection-stats`, and `sessions`. The sink reconnects for as long as the pipeline runs and only reports `failed` on an answer redialing can't change, such as a rejected token. `connection-stats` is null while disconnected. While connected it is a `GstStructure` named `moq-connection-stats` containing the transport metrics available from the active backend. Missing metrics are omitted rather than reported as zero. Its possible `guint64` fields are `rtt-us`, `estimated-send-rate-bps`, `estimated-recv-rate-bps`, `bytes-sent`, `bytes-received`, `bytes-lost`, `packets-sent`, `packets-received`, and `packets-lost`. Poll the property for current counters; property notification marks connection and disconnection edges. `sessions` is a `GstStructure` named `moq-sessions` with `guint64` fields `started` and `ended`, the cumulative connect and disconnect counts for the current element session in the same shape as the relay's sessions track. One read returns both counters from the same instant, so `started - ended` is 1 while connected and 0 otherwise, reconnects are `started - 1` once `started` is at least 1, and a rate is the delta over any window you sample. Unlike `status`, a connection that drops before you poll still moves both counters. Set `encoder=true` on audio and video pads a local encoder feeds (`x264enc`, `opusenc`, ...). The pad then measures how late each frame reaches the sink behind its running time and raises the catalog `jitter` by the spread, and `delay` by how far it trails the earliest such pad, so players buffer for an encoder that delivers irregularly or behind the others. Leave it off, the default, for file, demuxed, and network media: their arrival reflects the disk or the network, not the original encoder, and a GStreamer segment cannot tell the two apart. Text and opaque pads refuse it. After a pause, flush, or changed TIME segment, the next media buffer starts a new timeline epoch and resets the handoff baseline. The pause does not inflate advertised jitter, and previously measured maxima remain. Resumed timestamps must continue forward on the broadcast media clock. A video pad joining mid-GOP drops delta frames until its first keyframe. If a source rewinds below the producer's live edge without signalling a break, the pad drops that frame and waits for a keyframe at or beyond the live edge. It keeps the rendition alive and preserves the media timeline; it does not shift rewound timestamps forward. ## moqsrc Pads are named by kind and appear as the catalog announces renditions: `video_0`, `video_1`, `audio_0`. Link the pad you want by name; the terse `moqsrc ! decodebin3` form links only the first pad offered, which may be audio. Each pad sends EOS when its rendition ends. A rendition that leaves the catalog keeps its pad until its track ends, while one whose format changes is replaced by a new pad. Properties: `url`, `broadcast`, `tls-disable-verify`. Debug with `GST_DEBUG=*:4` for GStreamer and `RUST_LOG=debug` for the plugin. --- --- url: https://doc.moq.dev/bin/demo.md description: The web demo and MoQ Boy --- # Demos Both run locally from a checkout and live at [moq.dev](https://moq.dev). ## Web demo ```bash just # relay + Big Buck Bunny + the web UI at http://localhost:5173 ``` A Vite app in [`demo/web/`](https://github.com/moq-dev/moq/tree/main/demo/web) that uses the [``](/lib/js/watch) and [``](/lib/js/publish) web components. It shows: * **Watching** a live broadcast with an adjustable latency budget and a stats overlay. * **Publishing** your camera, microphone, screen, or a file from the browser with WebCodecs. * **Meeting**: a multi-participant room (`meet.html`) using `@moq/room`. Open the same room in two tabs. * **Discovery**: broadcasts appear as they're announced under the prefix. `just web serve https://cdn.moq.dev/anon` points it at the public relay instead of a local one. ## MoQ Boy A crowd-controlled Game Boy Color. The emulator runs server-side, streams video and audio over MoQ, and any viewer can press buttons. Live at [moq.dev/boy](https://moq.dev/boy). ```bash just boy # relay + emulator + viewer just boy start path/to/game.gb # your own ROM; run several for a grid ``` What it demonstrates: * **On-demand encoding.** Emulation and encoding pause when the last viewer leaves and resume with a keyframe when one arrives. No timers, just subscription state. * **Discovery by prefix.** Game sessions appear under `boy/game/`; viewers each publish a tiny broadcast under `boy/viewer/`. No control plane. * **Bidirectional data.** Button presses go up on a raw JSON `command` track; a `status` track comes down with everyone's held buttons and latency. Same relay, same protocol as the media. * **Split trust.** In production the game prefix is authenticated (only the server publishes games) while the viewer prefix is anonymous. Tracks: `catalog.json`, `video0.avc3` (160x144 H.264 at 60 fps), `audio0.opus`, and the raw `status` and `command` tracks. Code: [`rs/moq-boy`](https://github.com/moq-dev/moq/tree/main/rs/moq-boy) (emulator and publisher), [`js/moq-boy`](https://github.com/moq-dev/moq/tree/main/js/moq-boy) (the `` element, published as `@moq/boy`), and [`demo/boy`](https://github.com/moq-dev/moq/tree/main/demo/boy). The same shape covers teleoperation: swap the emulator for a robot. --- --- url: https://doc.moq.dev/lib.md description: MoQ libraries for Rust, TypeScript, C, Python, Kotlin, Swift, Go, and Dart --- # Libraries Two primary implementations and six bindings, all speaking the same wire protocol. A publisher in Python is consumable by a subscriber in Swift. | Language | Package | Best for | | --- | --- | --- | | [Rust](/lib/rs/) | `moq-net`, `hang`, and friends on crates.io | Servers, CLIs, native apps, anything that needs the full stack including hardware codecs. | | [TypeScript](/lib/js/) | `@moq/*` on npm | Browsers (WebTransport + WebCodecs) and Node/Bun/Deno. | | [Swift](/lib/swift/) | `Moq` via SwiftPM | iOS, iPadOS, macOS. | | [Kotlin](/lib/kt/) | `dev.moq:moq` on Maven Central | Android and the JVM. | | [Python](/lib/py/) | `moq-rs` on PyPI | Scripts, ML pipelines, voice agents. | | [Go](/lib/go/) | `moq.dev/moq` | Go services and tooling. | | [Dart](/lib/dart/) | `moq` on pub.dev | Flutter apps. | | [C](/lib/c/) | `libmoq` | C/C++ and any language with a C FFI. | ## How they relate Rust is the reference implementation; every server-side tool is built on it. TypeScript is a from-scratch browser implementation. The other six wrap the Rust core: Python, Kotlin, Swift, Go, and Dart are generated from one [UniFFI](https://mozilla.github.io/uniffi-rs/) crate (`moq-ffi`) and then wrapped in an idiomatic layer, while C gets a hand-written stable ABI (`libmoq`). A feature added to the core lands in all of them together. ## What every binding can do The wrapped bindings share one feature set, so the language pages only show how it looks in that language: * **Connect** to a relay with TLS options (system roots, custom CA, fingerprint pinning, mTLS) and a JWT in the URL, or **serve** sessions yourself and accept or reject each request by path. * **Reconnect** automatically with backoff when the transport drops, with `status`/`epoch` reporting each (re)connect and backoff tunable down to retrying forever. The peer's inbound QUIC stream limit is configurable for subscribe-heavy clients. * **Discover** broadcasts by prefix, wait for a specific one, or request one by path, including a path a prefix claim serves on demand. Advertise an exact path with `create_broadcast` then `announce` / `unannounce` (a broadcast is invisible to local consumers and peers alike until announced), or claim a path prefix with `dynamic(prefix, route)`. * **Publish and subscribe to media** with the hang catalog filled in from the bitstream, plus raw pixels or PCM in and out with the codec running inside the binding (VideoToolbox, Media Foundation, NVENC, openh264, Opus). A publisher follows the connection's send estimate through `session.bandwidth()`: reserve a share for an app-owned encoder, or pass the handle when encoding so the built-in video encoder follows the grant. * **Connection health.** `stats()` snapshots RTT, send/receive estimates, and byte/packet counters. `bandwidth()` divides that send estimate among tracks sharing the connection. * **Raw tracks** of arbitrary bytes with timestamps, sparse or replayed groups, per-subscriber priority and max age, and best-effort datagrams. * **JSON tracks** in snapshot mode (latest value, merge-patch deltas, optional compression) or stream mode (append log). * **Fetch** a single group by sequence from the cache, decoded through the container or raw. * **Serve on demand**: accept track and broadcast requests as they arrive instead of publishing up front. * **Catalog extensions**: write your own section next to `video` and `audio`, and read others' back. * **Routes**: see which relays a broadcast came through, and advertise a cost as a standby publisher: warm `cost` for what pulling costs today, undiscounted `cold` for what it costs with nothing cached (omit `cold` to price both alike). * **Errors** distinguish auth rejection (don't retry) from shutdown (expected) from transport failure. A protocol error carries the peer's session or stream code, a known kind when recognized, and keeps an application or unknown code without loss. Dart is the exception on codecs: its published binaries carry no encoder or decoder, so it moves already-encoded frames. --- --- url: https://doc.moq.dev/lib/rs.md description: The reference implementation, as a set of crates on crates.io --- # Rust The reference implementation. Every crate is on [crates.io](https://crates.io/search?q=moq) with API docs on docs.rs. ## Crates | Crate | Does | | --- | --- | | [moq-net](/lib/rs/moq-net) | The pub/sub layer: sessions, origins, broadcasts, tracks, groups, frames. Transport-agnostic. | | [moq-pattern](https://docs.rs/moq-pattern) | Exact path patterns: grammar, matching, and set algebra. Re-exported by moq-net and moq-auth. | | [moq-tokio](https://docs.rs/moq-tokio) | Stands up QUIC with noq, TLS, WebSocket fallback, and iroh, from config or CLI flags. | | [moq-sock](https://docs.rs/moq-sock) | Dual-stack socket binding, `SO_REUSEPORT` groups steered by QUIC connection ID, and CPU pinning for thread-per-core listeners. | | [moq-uring](https://docs.rs/moq-uring) | Experimental Linux io\_uring worker: one pinned thread per ring serving moq-lite over its own QUIC stack. | | [hang](/lib/rs/hang) | The media layer: catalog, containers, ordered frame delivery. | | [moq-mux](/lib/rs/moq-mux) | Import and export fMP4/CMAF, MPEG-TS, Matroska, FLV, and Annex-B. | | [moq-archive](https://docs.rs/moq-archive) | Versioned hang recordings on any `object_store` backend: track layout, `.info` JSON, and segment objects. | | [moq-video](/lib/rs/moq-video) | Native capture, hardware encode/decode (Apple, Windows, NVIDIA, VAAPI, V4L2, Android), and GPU rendering. | | [moq-v4l](https://docs.rs/moq-v4l) | Safe Video4Linux 2 bindings with the kernel headers checked in, so a build needs no libclang. | | [moq-audio](/lib/rs/moq-audio) | Microphone and speaker, Opus/PCM/AAC codecs, echo cancellation. | | [moq-transcode](https://docs.rs/moq-transcode) | Just-in-time rendition ladders, GPU-resident on NVIDIA. | | [moq-auth](/lib/rs/moq-auth) | The authorization contract: requests, grants, leases, the HTTP client, the reference server, JWT keys, signing, and verification, plus listing live sessions and pushing a re-check. | | [moq-room](/lib/rs/moq-room) | Headless rooms: announce-derived roster, token claims, and a chat track. | | [moq-json](/lib/rs/moq-json) | JSON over tracks: snapshots with merge-patch deltas, or append logs. | | [moq-binary](/lib/rs/moq-binary) | Opaque payloads over tracks: snapshots or append logs. | | [moq-e2ee](https://docs.rs/moq-e2ee) | End-to-end encryption of groups, datagrams, and track names, scoped to a publisher epoch. | | [moq-flate](https://docs.rs/moq-flate) | Group-scoped DEFLATE for any track. | | [moq-loc](https://docs.rs/moq-loc), [moq-msf](https://docs.rs/moq-msf) | The IETF LOC container and MSF catalog. | | [moq-stats](https://docs.rs/moq-stats) | Publish and consume relay traffic counters as tracks. | | [moq-hls](https://docs.rs/moq-hls), [moq-rtmp](https://docs.rs/moq-rtmp), [moq-srt](https://docs.rs/moq-srt), [moq-rtc](https://docs.rs/moq-rtc) | The [gateways](/bin/), as libraries you can embed with your own auth. | | [moq-ffi](https://docs.rs/moq-ffi), [libmoq](/lib/c/) | The UniFFI core behind the language bindings, and the C ABI. | | [moq-relay](/bin/relay/), [moq-cli](/bin/cli) | The binaries, also usable as crates. | | [web-transport](https://github.com/moq-dev/web-transport) | The QUIC/WebTransport/qmux transports, in a sibling repository. | ## Quick start `moq-tokio` configures the endpoint; `moq-net` does the protocol. ```rust // The Origin is the local hub: the session fills it with remote broadcasts // and serves your local broadcasts out of it. let origin = moq_tokio::origin::spawn(); let client = moq_tokio::connect::Config::default().init(Default::default())?; let url = url::Url::parse("https://cdn.moq.dev/anon")?; // Reconnects on its own; `closed()` resolves when it gives up. let session = client.with_origin(origin.clone()).connect(url); // Subscribe: wait for a route, resolve the broadcast at its path, read the catalog. let consumer = origin.consume(); let mut announced = consumer.announced(); while let Some(update) = announced.next().await { if !update.kind.is_active() { continue } let broadcast = consumer.request_broadcast(&update.prefix).await?; let catalog = broadcast .track(hang::Catalog::DEFAULT_NAME)? .subscribe(hang::Catalog::default_subscription()) .await?; // moq-mux decodes the catalog; moq-video / moq-audio decode the media. } ``` ```rust // Publish: create and announce a broadcast on the origin, then fill it. let mut broadcast = origin.publish("my-stream.hang", Default::default())?; // moq-mux (from a container) or moq-video / moq-audio (from a device) fill it. // The route retracts on `unannounce()` or when the broadcast ends. To serve a whole // subtree on demand instead, `origin.dynamic("room", Default::default())?` yields // each requested path for the application to accept or reject. ``` Before exiting, `session.close().await` delivers what was already queued, such as the tracks you just finished, within one second. Then `Client::close` (on a clone of the client) sends the QUIC close before the runtime stops. The examples run the session and the origin work concurrently (`tokio::select!` or `spawn`), since the announcement loop is live. Runnable examples: [`rs/hang/examples/video.rs`](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/video.rs) (publish) and [`subscribe.rs`](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/subscribe.rs). URLs may be `https://` (WebTransport, with raw QUIC preferred for native), `moql://`/`moqt://` (raw QUIC), or `iroh://`. A `?jwt=` query carries the token. `http://` is for a relay on localhost only: it fetches the certificate fingerprint unauthenticated before upgrading, so never send a token over it. Connections race QUIC against WebSocket and remember which won. The `Default::default()` above is the QUIC transport section, and the same value serves a dial and a listener: ```rust let mut quic = moq_tokio::quic::Config::default(); quic.congestion_control = Some(moq_tokio::quic::CongestionControl::Delay); quic.receive_window = Some(64 << 20); // whole connection, in bytes quic.stream_receive_window = Some(8 << 20); // per stream quic.send_window = Some(32 << 20); // unacknowledged data we may hold let client = moq_tokio::connect::Config::default().init(quic)?; ``` Unset flow-control windows keep the transport defaults, except `receive_window`, which defaults to 64 MiB because the transport's is unlimited. `init` errors on a knob the transport cannot honor rather than dropping it: iroh cannot disable GSO. `quic::Resolved::default()` is what an untouched config resolves to, so read the defaults from there. The [relay reference](/bin/relay/config#quic) documents each field. ## Connection monitoring `Connection::monitor()` returns a cloneable `moq_tokio::connection::Monitor` that observes the live session across reconnects without keeping the connection loop alive. Its `stats()` and `snapshot()` return `None` between connections; `connection::Snapshot` pairs transport statistics with the negotiated protocol. Use `presence()` for cumulative connects and disconnects, or `presence_changed().await` to wait for those counters to change. ## WebAssembly `moq-net` compiles to `wasm32-unknown-unknown` and rides the browser's own `WebTransport` through the `web-transport` crate, so Rust logic can be shared between native and web. Skip `moq-tokio` there, build the transport with `web_transport::ClientBuilder`, and drive the returned session future with `wasm_bindgen_futures::spawn_local` (nothing is `Send` on wasm). If you just want MoQ in a page, the [TypeScript libraries](/lib/js/) are the easier path. ## Conventions The API is producer/consumer pairs at every level (origin, broadcast, track, group). Producers write and consumers read; cloning a consumer shares the subscription, and the last clone dropping closes it. Everything is `async`, executor-agnostic, and errors are typed enums with `thiserror`. --- --- url: https://doc.moq.dev/lib/rs/moq-net.md description: The pub/sub layer --- # moq-net [![crates.io](https://img.shields.io/crates/v/moq-net)](https://crates.io/crates/moq-net) [![docs.rs](https://docs.rs/moq-net/badge.svg)](https://docs.rs/moq-net) The networking layer: real-time pub/sub with caching, fan-out, and prioritization on top of QUIC. It negotiates [moq-lite](/concept/moq-lite) or IETF moq-transport at setup and presents one API either way. Media is a layer above ([hang](/lib/rs/hang)); relays and CDNs implement only this. ## What it gives you * **Origins** scope what a session can see, and merge duplicate subscriptions so a broadcast is pulled upstream once no matter how many local readers. * **Broadcasts** are created unannounced and invisible to everyone, then announced as an exact route, or served below a prefix with `dynamic`. A consumer of the same origin sees exactly what a peer sees. Discovery accepts pattern unions; events carry the advertised prefix and captures for a complete match. * **Patterns** (`Pattern`, `Patterns`) are re-exported from [`moq-pattern`](https://docs.rs/moq-pattern). Literal `Path` stays a coordinate. * **Tracks** carry groups with a priority, a retention window, and a timescale. Subscribers set their own priority and max age and can change them live. * **Groups** are written frame by frame and delivered on independent streams. Old groups are cached for fetch-by-sequence; stale groups are skipped per the subscriber's budget. * **Track ends**: `finish()` ends a track at its live edge, while `finish_at(n)` declares the exclusive end ahead of it and still accepts the groups below. A subscriber awaits it with `finished()`. A remote track ends only once every group below its end has arrived or was dropped; one reset before its header arrived is skipped after the subscription's max age on moq-lite (one second without one), or after one second on IETF. * **Datagrams** send a single small frame unreliably on moq-lite 05+ and moq-transport. * **Routes** record the relay hops and a cost, which is what the relay [cluster](/bin/relay/cluster) routes on. A hop of 0 marks the chain anonymous: `Route::is_anonymous()` is true, and that route ranks below every fully identified one. `Route::source()` says where a delivered route entered: `Source::Local`, or `Source::Peer(hop)` when a handle marked `origin::Producer::peer()` announced it. `origin::Consumer::local()` sees only the local ones. * **Stats** counters per broadcast and session, drained by [`moq-stats`](https://docs.rs/moq-stats). It runs over anything implementing `web_transport_trait::poll::Session`: noq, the browser, iroh, or qmux over TCP, Unix sockets, and WebSockets. [`moq-tokio`](https://docs.rs/moq-tokio) wires those up. ```bash cargo add moq-net moq-tokio ``` See the [Rust quick start](/lib/rs/#quick-start) and [docs.rs/moq-net](https://docs.rs/moq-net). The TypeScript twin is [`@moq/net`](/lib/js/net). The [path pattern](/concept/moq-lite#path-patterns) grammar lives on the concept page. ## Driving sessions `Client::connect(now, transport)`, `Server::accept(now, transport)`, and `server::Handshake::ok()` return `(Session, Driver)`. `moq-net` never spawns tasks or reads the clock: the caller polls the driver and supplies the time. `moq_net::time::run` does that on tokio or in the browser. ```rust let now = tokio::time::Instant::now().into_std(); let (session, driver) = client.connect(now, transport).await?; tokio::spawn(moq_net::time::run(driver)); ``` A custom event loop calls `driver.poll(now, waiter)` with a nondecreasing `moq_net::time::Instant`. `Ok(Some(at))` asks to be polled again by `at` or on external activity, `Ok(None)` only on external activity, and `Err` is the terminal error (`Error::Closed` for a clean finish): stop polling. Tests drive the same interface with explicitly advanced instants. Dropping the last session handle requests closure on the next poll, and `session.abort(err)` closes with `err`'s code. Either discards stream data the peer has not acknowledged yet. `session.close().await` first waits, up to one second, for finished tracks to deliver their last groups and FIN, returning `Error::Timeout` if it gave up. Finish or abort live tracks before calling it. moq-transport (IETF) sessions close without waiting. Dropping the driver cancels the session. `moq-tokio` and `moq-wasm` drive sessions for their callers. A deliberate local session close ends received tracks cleanly after their delivered groups. A peer close ends received tracks and open group readers with the session error, preserving its close code. `origin::Producer::new` returns a driver with the same `time::Driver` interface. It calls `cache::Pool::gc(now)` after each poll and folds the next cleanup time into its returned deadline. A standalone pool needs `gc(now)` called by its owner, at least by the returned deadline; `None` means expiry is disabled. The origin driver finishes once every owner is gone: each `origin::Producer` clone, published broadcast, and `origin::Dynamic`. Producer-side children pin their parent where no cycle exists, so a handler can drop its producer and keep serving. Read handles (`origin::Consumer`, announce cursors) never pin it: they see `Closed` once the owners are gone. Cache activity is dated lazily: reads and writes mark a group active without reading a clock, and the next `gc` pass stamps it with the supplied instant. Expiry is therefore approximate; a late `gc` extends retention. ## Patterns `Pattern` describes a set of paths; `Patterns` is a union reduced by containment. `contains` is the authorization check (every path the other matches, this one matches too). `overlaps` asks whether they share any path. `rooted` places a pattern under a literal root; `rebase` is the inverse, and can return several residuals (`**/a` at `a` is both `""` and `**/a`). `intersect` returns the exact overlap as a union, and `captures` reports what one pattern's wildcards stand for in a contained pattern. ```rust use moq_net::Pattern; let scope: Pattern = "room/**".parse()?; assert!(scope.matches("room/alice")); assert!(scope.contains(&"room/camera-*".parse()?)); assert!(scope.overlaps(&"*/alice".parse()?)); assert_eq!( scope.intersect(&"*/alice".parse()?)? .iter().map(Pattern::as_str).collect::>(), ["room/alice"] ); assert_eq!( scope.rebase("room").iter().map(Pattern::as_str).collect::>(), ["**"] ); assert_eq!( "camera-*".parse::()?.rooted("room")?.as_str(), "room/camera-*" ); ``` ## Advertising Three operations, on an origin: * `origin.publish(path, route)` creates and advertises a broadcast in one call. * `origin.create_broadcast(path)` returns a producer. The broadcast is invisible and unroutable, for local consumers and peers alike, until `broadcast.announce(route)`. * `broadcast.announce(route)` / `broadcast.unannounce()` own that advertisement. Announcing again re-prices the standing route, which competes on cost with remote routes at the same path (a tie goes to the local broadcast). The route retracts on `unannounce()`, `close()`, or the last producer dropping; tracks already in flight carry on to their own end. * `broadcast.close()` ends the broadcast for good: it retracts, leaves local discovery, and answers every later track lookup with `Unroutable`. Tracks already subscribed carry on to their own end. It can never be announced again. Dropping the last producer does the same. * `origin.dynamic(prefix, route)` claims `prefix` and every path beneath it (`""` claims everything). Hold the returned `origin::Dynamic` while the claim should stay advertised; drop it to retract. A request beneath it with no winning announced local broadcast is a `Request` to `accept` or `reject`; reject what you will not serve rather than narrowing the claim, since a route is always a prefix on every wire. A route is a capability, not an inventory. Producer and consumer handles are scoped by any `Patterns` union. A prefix route is allowed when its subtree overlaps that scope; exact creates and requests must match it, so a broad route can advertise the wire-compatible prefix while excluded requests are refused locally. A disjoint route is `Unauthorized`. `origin.consume().announced()` yields `announce::Update` values: `prefix` is the covered prefix relative to the consumer's root, `kind` is `Announced`, `Updated` (a reprice in place), or `Retracted`, `captures` reports what the most specific matching scope member's wildcards stood for when the prefix pins them, and `route` carries hops and cost (on a retraction, its last values). The consumer is also a `futures::Stream`. A prefix is not a broadcast name; sessions request each scope member's literal head and filter locally. Routes with a `.`-prefixed segment below that head are [hidden](/concept/moq-lite#hidden-broadcasts) unless `with_hidden(true)` opts the consumer in. Sessions always ask the peer for hidden routes, so each local consumer decides. ## Limiting reads Use `Subscription::default().with_groups(2..=5)` to request only groups 2 through 5. `2..5` excludes group 5, and `..` leaves both ends unbounded. The range limits the data eligible under the subscription's max-age budget; it does not fetch historical data by itself. A reader's `set_groups(2..=5)` applies a local limit. It preserves read progress: a lower start does not rewind the reader, and an omitted start keeps its current floor. An omitted end removes the cap, making unread buffered groups available again. Local limits do not update the subscription's upstream request. Inside a group, `set_frames(...)` applies the same range syntax to frame indices. --- --- url: https://doc.moq.dev/lib/rs/hang.md description: The media layer --- # hang [![crates.io](https://img.shields.io/crates/v/hang)](https://crates.io/crates/hang) [![docs.rs](https://docs.rs/hang/badge.svg)](https://docs.rs/hang) The [hang media format](/concept/hang) on top of `moq-net`: a live catalog describing renditions with WebCodecs-style decoder configs, and containers that carry a timestamp with every frame. * **Catalog producer and consumer.** Typed `VideoConfig`, `AudioConfig` and text renditions, the `json` and `binary` data track sections, shared video properties (display size, rotation, flip), stalled hints, cross-broadcast rendition references, and the `archive` entry (timeline track, optional replay path, store URL, and format version). Extend it with your own sections through a lock or `#[serde(flatten)]`, or read unknown sections as raw JSON. * **Containers.** `legacy`, `cmaf`, and `loc`, decoded for you; unknown kinds pass through untouched. * **`Ordered`** reads a media track as timestamped frames, reorders groups, and skips ones that fall past your max age. * **Codecs described**: H.264, H.265, VP8, VP9, AV1, AAC, Opus, PCM. ```bash cargo add hang ``` `Catalog::::subscribe(&broadcast).await?` opens the uncompressed catalog track and returns a typed consumer; `consumer.next().await?` yields root updates. Updates exceeding `catalog::MAX_RENDITIONS` are refused with `Error::TooManyRenditions`. Media retention uses the public `container::MAX_AGE` constant. Producing media is usually done through [`moq-mux`](/lib/rs/moq-mux) (from a container) or [`moq-video`](/lib/rs/moq-video) and [`moq-audio`](/lib/rs/moq-audio) (from a device), which build the catalog for you. Examples: [`video.rs`](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/video.rs) and [`subscribe.rs`](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/subscribe.rs). API: [docs.rs/hang](https://docs.rs/hang). The TypeScript twin is [`@moq/hang`](/lib/js/hang). --- --- url: https://doc.moq.dev/lib/rs/moq-mux.md description: Container import and export --- # moq-mux [![crates.io](https://img.shields.io/crates/v/moq-mux)](https://crates.io/crates/moq-mux) [![docs.rs](https://docs.rs/moq-mux/badge.svg)](https://docs.rs/moq-mux) Turns existing container formats into hang broadcasts and back. This is what `moq import`/`export` and the gateways are built on. | Format | Import | Export | Notes | | --- | --- | --- | --- | | fMP4 / CMAF | yes | yes | Passthrough as `cmaf` or repackaged as `legacy`. | | MPEG-TS | yes | yes | H.264/H.265; AAC, MP2, AC-3, E-AC-3, Opus up to 7.1; SCTE-35 and subtitle PIDs carried as tracks; service tables round-trip; signalled timebase discontinuities preserved; paced export. | | FLV / RTMP | yes | yes | Legacy H.264 + AAC + MP3, plus enhanced-RTMP HEVC, AV1, VP9, Opus, AC-3, E-AC-3, and multitrack. | | Matroska / WebM | yes | yes | | | Annex-B (H.264, H.265) | yes | yes | Parameter sets extracted to the catalog or re-injected per keyframe. | Importers parse the bitstream to fill the catalog (resolution, codec string, `description`), split groups at keyframes, and stamp timestamps. Exporters do the inverse and skip stalled groups past a max age. Per-codec producers (`import::Opus`, H.264, and so on) are available for feeding frames you already have. fMP4 export emits one fragment per publisher group by default, including audio. A closed group flushes even if the live publisher pauses before its next frame. `fmp4::Export::with_fragment_duration` adds an explicit duration cap. A zero cap emits one fragment per frame; video with unknown duration waits for the next timestamp or endpoint marker. Audio and samples with explicit durations remain immediate. CMAF audio samples are always encoded as sync samples; the decoded `Frame::keyframe` marks only the first audio sample of a MoQ group. Each catalog track constructor returns one `container::Producer` that owns the media stream and its catalog entry. `set` publishes or replaces its config, `modify` edits the published config through a guard, and dropping the producer retires the entry. Calling `modify` before the first `set` returns `Error::NotPublished`. Container writes measure bitrate; importers can also measure batch span or reorder delay for jitter. Locally encoded frames call `container::Producer::flush(timestamp, Instant::now())`; jitter is the spread above that track's own recent minimum lateness, and delay is how far that minimum trails the earliest track on the same catalog. Both are published as soon as they rise. `import::Track::discontinuity()` marks a source seek or pause, clears partial input, and restarts the flush baseline without lowering advertised values. It forwards the container timeline marker, so resumed timestamps must continue forward on the broadcast clock. Generic imports remain clock-free. Invalid or decreasing jitter or delay is rejected before the edit is retained, including while the initial catalog is reserved. Codec importers propagate catalog and media errors through their configuration and frame-writing methods. Data tracks go through the catalog too. `catalog.json_stream(track, config)` (or `json_snapshot`, `binary_snapshot`, `binary_stream`) writes the track's `json` or `binary` entry, measures an absent `bitrate` from the writes, and retires the entry when the producer drops. To list the track in your own section beside application fields, pass that section's entry instead of a `json::Config` or `binary::Config`: any `RenditionConfig` that embeds the data config through `AsMut`. ```rust #[derive(Serialize, Deserialize, Clone)] struct Mavlink { #[serde(flatten)] binary: hang::catalog::BinaryConfig, // mode, compression, bitrate, ... sysid: u8, } impl AsMut for Mavlink { fn as_mut(&mut self) -> &mut hang::catalog::BinaryConfig { &mut self.binary } } // Plus `RenditionConfig` writing to `catalog.ext.mavlink`, a map // serialized under the `com.example.mavlink` root key. let binary = hang::catalog::BinaryConfig::new(hang::catalog::Mode::Stream); let mut telemetry = catalog.binary_stream(track, Mavlink { binary, sysid: 1 })?; telemetry.append(packet)?; ``` The producer sets the entry's `mode` and encodes the track with its `compression`. Read it back from `Catalog` and subscribe with `catalog::Entry::new(name, &entry.binary)`. A payload that knows when it was captured (a datagram's arrival, a sensor read) carries that `Instant`. The producer maps it onto the broadcast clock and writes it as the frame timestamp, and the entry advertises `jitter` and `delay` the way a media rendition does, so telemetry lagging its video shows up as `delay`. An instant ahead of now is refused. A device's own clock is an unrelated epoch; keep it in the payload. ```rust telemetry.append(moq_net::Timed::from(packet).at(received_at))?; ``` The fMP4, MPEG-TS, and FLV importers publish the source's own timestamps unless built with `live()`, which translates them onto the catalog's broadcast clock: the first frame is live on arrival, every track of the input shares that one mapping, and a source that restarts its timestamps continues forward after the real idle gap. fMP4 passthrough rewrites each fragment's `tfdt` to match. Use it for a live feed with its own zero; publish verbatim only when the catalog's clock (`Config::with_clock`) already names the source's zero. An application running its own demuxer gets the same mapping from `clock::Anchor`: one per source, plus one `clock::Lane` per track. A single `SourceMap` per track would let tracks drift apart by their first-PTS difference, and one `SourceMap` shared across tracks reads their interleaving as a reset. Each lane detects its own restarts, and the anchor moves once for all of them. Call `Lane::restart()` before a frame when the demuxer sees a discontinuity out of band. ```rust use moq_mux::clock; let mut anchor = clock::Anchor::new(catalog.clock()); let mut video = clock::Lane::default(); let mut klv = clock::Lane::default(); // Both tracks keep their source spacing on the broadcast clock. let video_ts = anchor.translate(&mut video, video_pts)?; let klv_ts = anchor.translate(&mut klv, klv_pts)?; ``` ```bash cargo add moq-mux ``` API: [docs.rs/moq-mux](https://docs.rs/moq-mux). Real-world usage: [`rs/moq-cli`](https://github.com/moq-dev/moq/tree/main/rs/moq-cli). Container producers and consumers take a format configured from the track's audio or video catalog entry (`catalog::hang::Container::try_from(&config)`). For a raw track, supply `container::Kind` explicitly. `cut(Some(end))` flushes and closes the group immediately. Legacy and LOC video write an empty timestamped frame at that end; audio and CMAF do not. With no explicit end, the producer uses a known sample duration or observed cadence, independently of batching and reorder jitter. Streaming consumers deliver frames immediately. The live fMP4 exporter receives duration endpoints as metadata and uses them to time samples still buffered when the group closes. Fetched groups retain their trailing frame until the marker or group completion arrives. --- --- url: https://doc.moq.dev/lib/rs/moq-video.md description: Native capture, hardware codecs, and GPU rendering --- # moq-video [![crates.io](https://img.shields.io/crates/v/moq-video)](https://crates.io/crates/moq-video) [![docs.rs](https://docs.rs/moq-video/badge.svg)](https://docs.rs/moq-video) What a browser gets from `getUserMedia` and WebCodecs, for native Rust: no ffmpeg, no GStreamer, no system codec to install. | Module | Does | Backends | | --- | --- | --- | | `capture` | Camera, display, window, or application frames | AVFoundation + ScreenCaptureKit (macOS), V4L2 + X11/portal + PipeWire (Linux), Media Foundation + Windows.Graphics.Capture (Windows) | | `encode` | Frames to H.264/H.265, published as a hang track | VideoToolbox, Media Foundation, NVENC, VAAPI, V4L2 M2M, MediaCodec (Android), openh264 | | `decode` | A subscribed track back to frames | VideoToolbox, Media Foundation/DXVA, NVDEC, VAAPI, V4L2 M2M, MediaCodec (Android), openh264 | | `render` | A frame as a `wgpu` texture | wgpu, with zero-copy Metal and Vulkan imports | Highlights: * **Automatic backend selection**, hardware first. Linux GPU libraries are `dlopen`ed at runtime, so one binary starts anywhere and warns when it falls back to software. openh264 (the default-on `openh264` feature) is statically linked as the H.264 fallback; H.265 is hardware-only; AV1 decodes via NVDEC. The VAAPI encoder, decoder, and GPU resize share one render node: the first whose driver does all three, or the one the `MOQ_VAAPI_DEVICE` environment variable names (for example `/dev/dri/renderD129`). * **Publish on demand.** `encode::publish_capture` advertises the track up front and opens the camera only while someone subscribes. * **GPU ownership where the platform allows.** Matching codec backends consume their native GPU surfaces directly. The renderer imports `CVPixelBuffer` and supported DMA-BUF formats. Linux/NVIDIA producers can import dedicated Vulkan RGBA8 slots into CUDA with timeline-semaphore ordering and completion-driven slot return. Vulkan/CUDA surfaces deliberately have no CPU pixel fallback; other surfaces use the typed `Surface::into_i420()` and configured `Surface::to_rgba(config)` when needed. * **Live bitrate control** where the selected backend supports it, without forcing a keyframe. An unsupported backend keeps its opening rate. * **Typed group structure.** `encode::Config::gop` is a `Gop` enum (`Keyframe { interval }` today), so a later mode adds a variant instead of replacing the field. `cut()` opens a group at the next frame on both `Encoder` and `Sink`, and refuses with `Error::CutUnsupported` on a backend that cannot force one rather than letting the boundary silently slip to the interval. * **Device enumeration** for cameras, displays, windows, and apps, matching `moq devices`. With `capture` enabled, `capture::camera_modes` lists a Linux camera's convertible sizes and exact rates before configuring it. Rates are `moq_video::Rate`, an exact rational that preserves 30000/1001: `frames(duration)` counts frames, `rounded()` gives the nearest whole frame rate for integer-only platform APIs, and `as_f64()` yields the catalog value. Sizes and rates shared by YUYV and MJPEG are combined; invalid I420 dimensions are excluded. A size range contributes its smallest and largest valid sizes aligned to the driver's step, and an empty rate list means no discrete intervals were reported. Device errors are returned rather than treated as an empty list. Other platforms return `Error::Unsupported`. Windows display and window capture require Windows 10 2004 (build 19041) or newer and use Windows.Graphics.Capture without a Desktop Duplication or GDI fallback. `Config::cursor` controls cursor capture. Frames leave the capture pool as owned GPU NV12 textures; odd native edges are cropped to even sizes. The conversion declares BT.601 limited range through 576 lines and BT.709 limited range above it, preserving that color through resize and CPU download. Media Foundation encodes on the capture device; openh264 uses the download path. Display enumeration covers all adapters, and window enumeration excludes DWM-cloaked windows and reports visible frame bounds. Windows `display:N` selectors are enumeration indices, not persistent monitor identities. Switching from Desktop Duplication to WGC can change their mapping; enumerate displays again and reselect the intended monitor after upgrading. Enumeration does not start capture. A settled source resize ends the stream so callers can reopen; closing the captured item returns `Error::SourceUnavailable`. Dropping the stream releases its session even when no new frames arrive. If no usable first frame arrives within five seconds after capture starts, opening fails with `Error::SourceUnavailable` and releases the session. Windows shows a capture border by default. On build 20348 or newer the backend requests borderless access; denial keeps the border and does not fail capture. Older supported builds keep the border. Application capture and system audio are not provided by this backend. With `pipewire` enabled, `capture::cameras` also lists PipeWire camera nodes as `pipewire:` after the V4L2 devices. V4L2 lists only devices offering YUYV or MJPEG. A PipeWire V4L2 node is hidden only when its device path was already listed by V4L2, so identical webcams stay distinct, and PipeWire-only cameras and NV12- or RGB-only V4L2 devices remain visible through PipeWire. Explicit `pipewire:` selectors still open hidden nodes, and `pipewire` alone opens the camera with the highest session priority, the session manager's default. That reaches cameras V4L2 cannot: a Raspberry Pi CSI camera behind libcamera, and any camera from inside a Flatpak or Snap sandbox, where the default camera comes through the xdg-desktop-portal Camera interface. The mode is chosen from the node's own format list by the same rules as V4L2 (below) and offered exactly, across YUY2, NV12, RGB, and MJPEG, and `camera_modes` lists the same modes. `capture::Config::framerate` is an `Option` request in the same exact type; the stream reports the rate the device accepted, or `None` when the driver reported none. V4L2 and PipeWire cameras choose the closest geometry, then the format whose accepted rate is nearest the request, then the cheaper conversion when both match equally well. ```rust let mut video = moq_video::decode::Consumer::new(&broadcast, &rendition, "video", Default::default()).await?; let mut renderer = moq_video::render::Renderer::new(&device, &queue, Default::default())?; while let Some(frame) = video.read().await? { let texture = renderer.render(&frame)?; } ``` ```bash cargo add moq-video # nvidia, mediacodec, openh264 on by default cargo add moq-video --features capture # camera + screen capture, no system build deps cargo add moq-video --features render # wgpu rendering cargo add moq-video --features v4l2 # Linux V4L2 M2M codecs, no system build deps cargo add moq-video --features vaapi # Linux VAAPI codecs (bindgen needs libclang) cargo add moq-video --features pipewire # Wayland screen + PipeWire cameras (links libpipewire) cargo add moq-video --no-default-features --features openh264 # software H.264 only cargo add moq-video --no-default-features --features nvidia # Linux NVIDIA only, no C++ or wgpu ``` The language bindings use the codec-only shape, which is what keeps their Android floor at API 24 instead of MediaCodec's API 26 entry points. API: [docs.rs/moq-video](https://docs.rs/moq-video). Pair with [`moq-audio`](/lib/rs/moq-audio). `decode::Config::output` chooses where decoded pictures live. `Output::Native`, the default, hands back whatever the backend decoded into: a `CVPixelBuffer`, a Direct3D11 texture, a CUDA buffer, a VAAPI DMA-BUF for zero-copy rendering, or CPU pixels from a software decoder. `Output::Cpu` delivers every picture as `Surface::I420`, decoded straight to system memory where the backend can. Native surfaces still answer `Surface::into_i420()`, which returns an `I420` with geometry and color metadata intact; call `I420::into_data()` only when packed bytes are required. `decode::Config::scale_hint` is best effort and only a decoder with a hardware scaler honors it; `Frame::resize` is the exact-size operation. `decode::Consumer` takes `decode::Options`, which carries the subscription's `start` and `max_age` beside the decoder config. Linux/NVIDIA applications with a native Vulkan producer use `frame::vulkan::Importer`. Each reusable image is a dedicated, optimal-tiling `VK_FORMAT_R8G8B8A8_UNORM` allocation exported with an opaque memory FD, plus an opaque-FD timeline semaphore and the physical-device UUID. Publishing consumes the producer-owned slot; awaiting its completion returns that slot only after CUDA readers finish. Import capacity bounds retained images. Unsupported devices, formats, layouts, and synchronization are errors, with no CPU mapping or staging fallback. A non-exportable application image needs one Vulkan GPU copy into an exportable slot. The image is `VK_FORMAT_B8G8R8A8_UNORM` when imported through `Image::bgra8` instead. `frame::cuda::Converter` turns a published Vulkan frame into the NV12 `Surface::Cuda` NVENC encodes in place, on the GPU, in one declared color space (matrix and range) with 4:2:0 chroma averaged per 2x2 block and no transfer function applied. Its buffers come from a pool sized at construction, and `cuda::Frame::resize` scales a converted frame for a smaller rendition from the same pool, so one captured frame feeding HD and SD holds a fixed number of buffers and a producer that outruns its encoder gets an error instead of unbounded device memory. Open the encoder with `encode::Kind::Named("nvenc")` and the same `encode::Config::color`: `Kind::Auto` could fall back to a software encoder that reads the frame back, and the portable `Surface::resize` downloads when the GPU scaler fails. Everything under `frame::cuda` and `frame::vulkan` runs on the device or returns an error. `just rs vulkan-cuda` runs the opt-in native Vulkan/CUDA/NVENC hardware exercise, including a three-view 1280x720 workload that reports per-stage latency and CPU time. --- --- url: https://doc.moq.dev/lib/rs/moq-audio.md description: Native audio capture, codecs, playback, and echo cancellation --- # moq-audio [![crates.io](https://img.shields.io/crates/v/moq-audio)](https://crates.io/crates/moq-audio) [![docs.rs](https://docs.rs/moq-audio/badge.svg)](https://docs.rs/moq-audio) The audio half of a native call: microphone in, hang track out, speaker at the far end. Everything is Rust, so there is no C toolchain, CMake step, or codec to install. `Layout` names speaker meaning separately from a channel count. `Mono` is center, `Stereo` is left then right, and the surround layouts up to `SevenPointOne` interleave in the SMPTE/WAVE order (front left, front right, center, LFE, back, side). A catalog carries only a count, which reads as that count's WAVE default (`Layout::from_channels`: 6 is 5.1, 8 is 7.1). `Discrete(n)` preserves unnamed channels without inventing speaker positions, so it passes through but never remixes. Decoding and playback downmix with the ITU-R BS.775 coefficients and upmix by leaving the extra speakers silent; the playback mix runs in whatever layout the output device opened. Encoding keeps source PCM in `encode::Input` and codec requirements in `encode::Settings`; `encode::Options` adds publication policy. Decoding likewise separates low-level `decode::Config`, PCM `decode::Output`, and subscription `decode::Options`. | Module | Does | | --- | --- | | `capture` | Microphones via CoreAudio, WASAPI, ALSA (and PipeWire/PulseAudio hosts), plus macOS system audio | | `encode` | PCM to Opus (with DTX and voice-activity signaling), raw PCM for the lowest latency, or AAC-LC through a platform encoder | | `decode` | Opus, PCM, and AAC-LC back to PCM, resampled to the rate you want | | `playback` | One output device mixing every track in a call, with click-free volume ramps | | `aec` | Acoustic echo cancellation (a port of WebRTC's), so a laptop with no headset doesn't feed itself back | `decode` picks a backend per track the way `moq-video` does: a platform decoder first, then software. `decode::Config::kind` narrows it: `Kind::Software` skips the platform tier, and `Kind::Named` requires the track to be a codec, by its name (`"opus"`, `"pcm"`, or `"aac"`), and refuses any other. Backends are never named; `Decoder::name()` reports which one opened. | Backend | Decodes | Hosts | | --- | --- | --- | | `libopus` | Opus, mono or stereo, and surround up to 7.1 (channel mapping family 1) | all | | `pcm` | PCM | all | | `symphonia` | AAC-LC, mono or stereo (the default-on `aac` feature) | all | No platform decoder is wired in yet, so multichannel AAC and HE-AAC declared in its config are refused at construction on every host. HE-AAC signaled only in band plays as its half-rate LC core. Linux has no OS audio decoder, so it will stay that way there. Surround Opus is pure Rust, so it is the one multichannel path every host has. Opus mapping families other than 0 and 1 (ambisonics, and 255's unpositioned channels) are refused, since they declare no speakers. Opus always decodes at 48 kHz, its own clock, whatever input rate the OpusHead records (44.1 kHz and unknown included), and applies the head's pre-skip and output gain. A track without a description decodes mono or stereo from the catalog with neither. A description that is present but malformed (truncated, wrong signature, a new major version, or a channel count its mapping family forbids) is refused rather than falling back to the catalog's fields. `encode` selects the same way, through `encode::Settings::kind`, where `Kind::Named` requires `Settings::codec` to match the codec name, and `Encoder::name()` reports what opened. | Backend | Encodes | Hosts | | --- | --- | --- | | `libopus` | Opus, mono or stereo | all | | `pcm` | PCM | all | `encode::Codec::Aac` is AAC-LC (`mp4a.40.2`) at the input's rate and layout: mono, stereo, 3.0, 4.0, 5.0, 5.1, or 7.1, the layouts with an AAC channelConfiguration. Frames are 1024 samples, so `Settings::from_input` sets `frame_duration` to match. The catalog's AudioSpecificConfig is built from the settings when the track is registered, and since it has no field for the encoder's delay, packets are stamped that much earlier so the first input sample still lands at the first timestamp. There is no software AAC encoder, and no platform encoder is wired in yet, so `Codec::Aac` is refused at construction on every host for now. Linux has no OS encoder, so it will stay that way there. Highlights: * **`encode::Publication`** advertises the track and opens the microphone only while someone listens. Stop, swap devices, and restart without changing the track subscribers know; read a level meter for the UI. * **A/V sync signal.** `Sink::buffered()` reports how far ahead the speaker is, which is what a video clock steers by. * **Activity per packet**, read off the Opus stream, so a call UI shows who is talking without a second voice detector. * **One Linux build dependency**: ALSA headers, and only when `capture` or `playback` is enabled. ```rust let mut audio = moq_audio::decode::Consumer::new(&broadcast, &rendition, "audio", Default::default()).await?; let engine = moq_audio::playback::Engine::open(Default::default()).await?; let mut input = moq_audio::playback::Input::default(); input.sample_rate = audio.sample_rate(); input.layout = audio.layout(); let mut sink = engine.sink(input)?; while let Some(frame) = audio.read().await? { let write = sink.write(&frame.data)?; if write.dropped_sample_frames > 0 { eprintln!("dropped {} live audio frames", write.dropped_sample_frames); } } ``` Playback writes never block. Inspect the returned input sample-frame counts for telemetry, but do not retry dropped live audio because that would add latency. For a speakerphone, build one echo-cancellation control set from the playback engine and give a clone to the microphone configuration. Other clones are safe for UI toggles, but only one live microphone can attach the adaptive state: ```rust let aec = engine.canceller(moq_audio::aec::Config::default())?; let controls = aec.clone(); let mut microphone = moq_audio::capture::Config::default(); microphone.aec = Some(aec); controls.set_enabled(false); // passthrough without reopening the device ``` A second `Engine::canceller` call, or a second microphone using the same controls while the first is live, returns `Error::Busy`. Dropping the live capture frees the microphone slot. The engine slot frees only once every `Control` clone and the live capture have dropped, since a capture holds the controls alive. ```bash cargo add moq-audio --features playback # decode and play the example above cargo add moq-audio --features capture,playback,aec # microphone, speaker, echo cancellation (Linux: cpal links libasound) cargo add moq-audio --features capture,pipewire # capture through the PipeWire cpal host ``` `pipewire` and `pulseaudio` only configure cpal when `capture` or `playback` also enables device I/O. A host flag by itself does not compile or link cpal. API: [docs.rs/moq-audio](https://docs.rs/moq-audio). Pair with [`moq-video`](/lib/rs/moq-video). --- --- url: https://doc.moq.dev/lib/rs/moq-auth.md description: >- The authorization contract, the lease a session holds, and the JWT that rides it --- # moq-auth [![crates.io](https://img.shields.io/crates/v/moq-auth)](https://crates.io/crates/moq-auth) [![docs.rs](https://docs.rs/moq-auth/badge.svg)](https://docs.rs/moq-auth) Everything a party needs to ask for or answer an authorization on [moq-relay](/bin/relay/auth), as a library. Use it in an auth server that answers the relay, in a service that mints tokens for clients, or in your own accept loop that decides in process. * **Request and grant**: `Request` is the JSON a relay POSTs per session event (`connect`, `revalidate`, `end`) with everything it knows: id, node, transport, addresses, SNI and ALPN, the raw path and query, the moq-transport SETUP `token` (its Token Type and bytes, base64url on the wire), the declared role, and the verified certificate facts. `Grant` is the answer: `publish` and `subscribe` pattern unions, an optional `root` alias, `mounts` that read a subtree from elsewhere on the origin, `expires`, `revalidate`, `tier`, and `peer`, which marks the session as a cluster peer so the routes it announces report `Source::Peer`. `Grant::validate` refuses a grant that names nothing, asks to be revalidated without a bound or at no interval, or has already expired; expiry is exact, with no grace for clock skew. * **Lease**: `lease::Producer` and `lease::Consumer` are the handle a session holds for its grant. The consumer reads the current grant, waits for a change, and learns why the lease ended; the producer updates and revokes. Either side's terminal call returns the reason the lease actually ended with, so whichever got there first is what both report. `Consumer::fixed` is a grant nobody drives. `Consumer::revalidate` nudges a re-check now and the producer observes it via `poll_revalidate` or `revalidate_requested`, which is how the relay's session push lands. Whoever runs the accept loop builds the producer, so an embedder decides in process with no trait and no HTTP. Enforcing `expires` is the holder's job; the `Client` driver also revokes at expiry so its `end` event goes out. `Grant::deadline()` (feature `tokio`, also enabled by `client` and `serve`) snapshots expiry on Tokio's clock. Call it once per accepted grant and retain the deadline; an expiry already past is now. * **Client**: `Client::new(url, tls)` and `Client::connect(request)` drive a lease against an auth server over `https://`, `unix://`, or loopback `http://`: revalidate on cadence with jittered backoff through an outage until `expires`, revoke on a 401/403 or an invalid grant, and POST `end` with the reason, duration, and byte totals the session reported through `lease::Consumer::close` when it ended. Dropping the consumer reports zero bytes. `end.reason` is `dropped`, `expired`, `refused`, `invalid`, or the session's own classification. * **Server**: `serve::Policy` and `serve::Server` (feature `serve`) are the reference auth server behind `moq auth serve`: a JWT from the `jwt` query or a type-0 SETUP token, verified against a key file or a `{kid}.jwk` directory (a token of another type, or both at once, is refused), an explicit grant for verified certificates, the anonymous permissions, a tier, the revalidation cadence, a default `expires`, and live session caps per token and per remote address. A token is authorized at the dialed path with `Claims::authorize`; residuals become the grant. `Server::router` is an axum `POST /` you can mount in your own service. * **Keys**: generate HS256/384/512, RS256/384/512, PS256/384/512, ES256/384, or EdDSA keys as JWKs, with a `kid` for rotation and an optional immutable scope that caps every token the key signs. * **Claims**: `root`, `publish`, `subscribe`, `exp`, `iat`. Grants are [`Pattern`](https://docs.rs/moq-pattern) unions: `foo` is one broadcast, `foo/**` is a subtree, `**` is everything. `Key::sign` and `Key::verify` handle the signature and expiry. Legacy `put`/`get` prefix claims and scopes read as subtrees (`p` is `p/**`), and grants that are all subtrees are written that way so older verifiers accept them. * **Authorization**: `Claims::authorize(path)` scopes verified claims to the path a client dialed and returns the publish and subscribe patterns relative to it, exactly as `moq auth serve` does. The relay forwards the raw path and enforces the grant it gets. ```bash cargo add moq-auth ``` For command-line use, install the [moq CLI](/setup/install) and run `moq auth generate|sign|verify`, `moq auth serve` for the [auth server](/bin/relay/auth#auth-server), or `moq auth sessions|revalidate` to list live sessions and push a re-check through the relay's internal listener. Examples: [`basic.rs`](https://github.com/moq-dev/moq/blob/main/rs/moq-auth/examples/basic.rs) and [`asymmetric.rs`](https://github.com/moq-dev/moq/blob/main/rs/moq-auth/examples/asymmetric.rs). The TypeScript twin, [`@moq/auth`](/lib/js/auth), reads the same request, builds the same grant, and mints identical tokens. API: [docs.rs/moq-auth](https://docs.rs/moq-auth). --- --- url: https://doc.moq.dev/lib/rs/moq-room.md description: Headless multi-participant rooms over MoQ --- # moq-room [![crates.io](https://img.shields.io/crates/v/moq-room)](https://crates.io/crates/moq-room) [![docs.rs](https://docs.rs/moq-room/badge.svg)](https://docs.rs/moq-room) The native twin of [`@moq/room`](/lib/js/room). A room is a path prefix. There is no service and no storage: joining is minting a moq-auth token rooted at that prefix and dialing the relay. Participants are discovered from the announce stream. Identity is the path before `camera.hang` / `screen.hang`. Each participant publishes `{identity}/camera.hang` (camera + mic) and `{identity}/screen.hang` (screenshare; its announce/unannounce is the share lifecycle). Capture and encode stay in [`moq-video`](/lib/rs/moq-video) and [`moq-audio`](/lib/rs/moq-audio). ```bash cargo add moq-room ``` ```rust use moq_net::Path; use moq_room::{Kind, Room, claims}; let token = key.sign(&claims("meet/demo", "alice")?, None)?; let origin = moq_tokio::origin::spawn(); let mut room = Room::new(&origin.consume(), Some(Path::new("alice").to_owned())); while let Some(event) = room.next().await { if event.kind == Kind::Camera { // subscribe to event.broadcast } } ``` The JSON window `chat` track (using `moq-json::window`) is `moq_room::chat`. That is not hang.live's `hang/chat.json` catalog extension. Gossip, tickets, and 1:1 Call stay in iroh-live. API: [docs.rs/moq-room](https://docs.rs/moq-room). --- --- url: https://doc.moq.dev/lib/rs/moq-json.md description: JSON over MoQ tracks, as snapshots, streams, or a sliding window --- # moq-json [![crates.io](https://img.shields.io/crates/v/moq-json)](https://crates.io/crates/moq-json) [![docs.rs](https://docs.rs/moq-json/badge.svg)](https://docs.rs/moq-json) JSON over [`moq-net`](/lib/rs/moq-net) tracks, in three modes: * **snapshot**: lossy latest-value, with RFC 7396 merge-patch deltas. * **stream**: lossless append-log in a single group. * **window**: a bounded run of records a reader can join at any point. On snapshot and stream, `Config` is the codec options. The track-owning pair is `producer::Config` / `consumer::Config`. Compression is a shared `Compression` enum (`None` or `Deflate`), not a bool: both sides set the same field. Window still uses `ProducerConfig` / `ConsumerConfig` and a boolean `compression` flag. ```rust let mut config = moq_json::snapshot::Config::default(); config.compression = moq_json::Compression::Deflate; let mut producer = moq_json::snapshot::Producer::new(track, config); producer.update(&value)?; ``` A value is stamped when written, unless it carries its capture time: `moq_net::Timed::from(&value).at(captured)`. Writes return the encoded frame size, and an unchanged snapshot `update` returns `None`. A snapshot producer also edits in place, so independent owners each touch only their own keys instead of clobbering one another. `mutate(|value| ...)` runs a closure and publishes the result, matching `Producer.mutate` in TypeScript; `modify()` returns a guard that holds the lock across several edits and publishes on drop. The TypeScript twin is [`@moq/json`](/lib/js/json). API: [docs.rs/moq-json](https://docs.rs/moq-json). --- --- url: https://doc.moq.dev/lib/rs/moq-binary.md description: Opaque binary payloads over MoQ tracks --- # moq-binary [![crates.io](https://img.shields.io/crates/v/moq-binary)](https://crates.io/crates/moq-binary) [![docs.rs](https://docs.rs/moq-binary/badge.svg)](https://docs.rs/moq-binary) Opaque payloads over [`moq-net`](/lib/rs/moq-net) tracks, in two modes: * **snapshot**: lossy latest-value, one payload per group. * **stream**: lossless append-log in a single group. The bytes are never inspected. Compression is [`moq-flate`](https://docs.rs/moq-flate), the same group-scoped DEFLATE `moq-json` uses. `Config` is the codec options. The track-owning pair is `producer::Config` / `consumer::Config`. Compression is a shared `Compression` enum (`None` or `Deflate`), not a bool: both sides set the same field. ```rust let mut config = moq_binary::snapshot::Config::default(); config.compression = moq_binary::Compression::Deflate; let mut producer = moq_binary::snapshot::Producer::new(track, config); producer.update(payload)?; ``` A payload is stamped when written, unless it carries its capture time: `moq_net::Timed::from(bytes).at(captured)`. Writes return the encoded frame size. The TypeScript twin is [`@moq/binary`](/lib/js/binary). API: [docs.rs/moq-binary](https://docs.rs/moq-binary). --- --- url: https://doc.moq.dev/lib/js.md description: The browser implementation, published as @moq/* on npm --- # TypeScript A from-scratch implementation for browsers, built on WebTransport, WebCodecs, and WebAudio. `@moq/net` also runs in Node, Bun, and Deno. ## Packages | Package | Does | | --- | --- | | [@moq/net](/lib/js/net) | The pub/sub layer: connections, broadcasts, tracks, groups, frames, discovery. | | [@moq/pattern](https://www.npmjs.com/package/@moq/pattern) | Exact path patterns: grammar, matching, and set algebra. Re-exported by `@moq/net` and `@moq/auth`. | | [@moq/hang](/lib/js/hang) | The media layer: catalog types and containers. | | [@moq/watch](/lib/js/watch) | Subscribe, decode, and render. `` plus an optional UI overlay. | | [@moq/publish](/lib/js/publish) | Capture, encode, and publish. `` plus an optional UI overlay. | | [@moq/room](/lib/js/room) | Headless rooms: announce-derived roster, local publish, remote watch, and a chat track. | | [@moq/auth](/lib/js/auth) | Validate a relay's request, build its grant, and mint and verify relay JWTs. | | [@moq/signals](/lib/js/signals) | The reactive primitives every package exposes its state through. | | [@moq/json](/lib/js/json) | JSON over tracks: snapshots with merge-patch deltas, or append logs. | | [@moq/binary](/lib/js/binary) | Opaque payloads over tracks: snapshots or append logs. | | [@moq/flate](https://www.npmjs.com/package/@moq/flate) | Group-scoped DEFLATE for any track. | | [@moq/loc](https://www.npmjs.com/package/@moq/loc), [@moq/msf](https://www.npmjs.com/package/@moq/msf) | The IETF LOC container and MSF catalog. | | [@moq/boy](https://www.npmjs.com/package/@moq/boy) | The [MoQ Boy](/bin/demo) player element. | ## Web components The fastest way in. No framework, no build step required: ```html ``` With a bundler, `bun add @moq/watch @moq/publish` and import the same `/element` entrypoints (the suffix keeps tree-shaking from dropping the registration). Add `/ui` for the ready-made control overlays. Every attribute is also a typed, reactive JS property, and the elements expose their internal pipeline (`broadcast`, `video`, `audio`, `signals`) for apps that want more. They work in React, Vue, Solid, and plain HTML alike; `@moq/signals` ships React and Solid adapters for the reactive state. ## JavaScript API Below the elements, `Watch.Player` owns a headless playback pipeline, `Publish.Broadcast` owns a headless publisher, and `@moq/net` is the protocol itself. Examples: [`js/net/examples/`](https://github.com/moq-dev/moq/tree/main/js/net/examples) covers connecting, publishing, subscribing, and discovery. ## Browser support | Browser | Transport | | --- | --- | | Chrome, Edge 97+ | WebTransport | | Firefox 153+ | WebTransport. Earlier Firefox ships it but allows too few incoming streams, so the client falls back to WebSocket there. | | WebKit (Safari, Chrome, Firefox, and Edge on iOS) | WebSocket fallback. Safari 26.4 ships WebTransport, but WebKit bugs stall long sessions, so the client doesn't use it yet. | | Anything else | Automatic WebSocket fallback, with TCP's head-of-line blocking | WebCodecs support varies per codec and browser; `` and `` render what the current browser can do. Outside localhost the relay needs a real certificate, or the page must pin its fingerprint. ## Server-side `@moq/net` runs in Bun, Node 21+, and Deno over the WebSocket fallback with no changes (older Node needs the `ws` polyfill on `globalThis`). For real QUIC on the server, install the native [`@moq/web-transport`](https://www.npmjs.com/package/@moq/web-transport) polyfill. `@moq/hang`, `@moq/watch`, and `@moq/publish` are browser-only, so server-side media work means raw tracks plus your own encoder. --- --- url: https://doc.moq.dev/lib/js/net.md description: The pub/sub layer in TypeScript --- # @moq/net [![npm](https://img.shields.io/npm/v/@moq/net)](https://www.npmjs.com/package/@moq/net) The TypeScript twin of [`moq-net`](/lib/rs/moq-net): connections, origins, broadcasts, tracks, groups, and frames, negotiating moq-lite or moq-transport at setup. ```ts import * as Moq from "@moq/net"; const url = new URL("https://cdn.moq.dev/anon?jwt=..."); // Publish. The origin is the routing table the connection announces and serves, // so a broadcast survives a reconnect. const origin = new Moq.Origin.Producer(); const connection = await Moq.Connection.connect({ url, publish: origin.consume(), consume: origin, }); const broadcast = origin.createBroadcast(Moq.Path.from("chat.room")); const track = broadcast.createTrack("messages"); const group = track.appendGroup(); group.writeString("hello"); // or writeFrame({ payload, timestamp }) group.close(); broadcast.announce(); // Subscribe const request = origin.request(Moq.Path.from("chat.room")); let active = request.active.peek(); while (!active) { await request.active.changed(); active = request.active.peek(); } const consumer = active.track("messages").subscribe({ priority: 0 }); for (;;) { const group = await consumer.recvGroup(); if (!group) break; console.log(await group.readString()); } ``` * **Origins** hold the broadcasts, not the connection: closing a session unannounces them but leaves them created for the next one. `origin.request(path)` resolves an announced local broadcast with no round trip, so a page that watches what it publishes reads its own copy, unless a cheaper route announces the same path. A refusal from the most specific route ends the request: `request.closed` settles with the handler's error and no broader route is asked. Create, populate, then `announce()` for an exact path; use `dynamic(prefix, route)` when the set of paths is not known: an exact-path subscribe before the tracks exist is refused, and nobody, local or remote, can see or reach a broadcast until it announces. * **Connections** race WebTransport against WebSocket. `new Connection({ url })` pools one connection per relay URL and reconnects with backoff, which the elements use. Supplying WebTransport/WebSocket options, discovery, delay, `goaway`, or a caller-owned origin selects a private loop; explicit `share: true` refuses those options. On a relay's GOAWAY the connection dials the replacement at once while the old session keeps serving its groups until it closes or the `goaway.handover` cap (default 10s, lowered to the relay's deadline) passes; the handle and its origin stay the same. An empty GOAWAY redials the same URL through a fresh DNS resolve. A redirect is followed onto the same host by default (`goaway.redirect: "follow"` lets the relay pick the host, `"ignore"` never moves), and moves the pooled entry to the new URL; a malformed or refused one, including a host change under a pinned certificate, ends the connection with `Error.RefusedRedirect` instead of reconnecting. `closed` settles when the handle is released (`null` on a clean close); the failure that stopped retrying the current URL is `error`, and a new URL recovers the same handle. A connection owns one send-rate sampler and one `Bandwidth.Allocator`; publishers reserve against it so their encoder targets sum to the estimate instead of each matching it. * **Bandwidth** (`Bandwidth.Allocator`) divides the connection's send-rate estimate by track priority, max-min fair within a tier. An idle track claims nothing. The receive side is untouched. * **Discovery** by any pattern scope (`origin.announced(scope)`, such as `room/*/chat`; default everything). Each event's `prefix` is the covered prefix relative to the origin, `captures` reports what the scope's wildcards matched when the prefix pins them, and `kind` says whether it was announced, updated, or retracted. The consumer is an async iterable. `origin.broadcasts(scope)` is a live `Getter>` of the same covered prefixes for UIs that need the current set. A borrowed `Connection.origin` also exposes `dynamic(prefix, route)` for serving paths on demand. * **Subscriptions** carry a priority, a `Time.Milli` max age, and optional `groups` bounds. Groups arrive out of order and are read frame by frame, with `Error.TooFarBehind` when a reader asks for a frame the group never held and `Error.GroupTooLarge` when a write exceeds the cache budget and aborts the group. * **Track ends**: `close()` ends a track at its live edge, while `finishAt(n)` declares the exclusive end ahead of it and still accepts the groups below. A subscriber reads the end with `final()` or awaits `finished()`. A remote track ends only once every group below its end has arrived or was dropped; one reset before its header arrived is skipped after the subscription's max age on moq-lite (one second without one), or after one second on IETF. * **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. `track.fetchGroup(sequence)` on moq-lite resolves when the publisher sends the first response byte or finishes an empty group. A missing group rejects the fetch with `StreamCode.NotFound`, including every concurrent caller sharing that fetch. `fetchGroup(sequence, { signal })` abandons a pending fetch with the signal's reason; the shared stream is cancelled only once every caller has left. Once resolved, close the group instead. * **Errors**: deliberately closing a connection ends its received tracks cleanly. A peer close ends received tracks and open group readers with the session error and its close code. Errors live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. * **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. A `Broadcast.Consumer` names its broadcast by `path`: the path it was requested at relative to the origin handle's root, or empty for a standalone broadcast. Those references resolve against it. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate. The [path pattern](/concept/moq-lite#path-patterns) grammar lives on the concept page. ## Patterns `Path.Pattern` describes a set of paths; `Path.Patterns` is a union reduced by containment. `contains` is the authorization check. `overlaps` asks whether they share any path. `rooted` places a pattern under a literal root; `rebase` is the inverse, and can return several residuals. `intersect` returns the exact overlap as a union, and `captures` reports what one pattern's wildcards stand for in a contained pattern. ```ts import * as Moq from "@moq/net"; const scope = Moq.Path.Pattern.parse("room/**"); scope.matches("room/alice"); // true scope.contains(Moq.Path.Pattern.parse("room/camera-*")); // true scope.overlaps(Moq.Path.Pattern.parse("*/alice")); // true scope.intersect(Moq.Path.Pattern.parse("*/alice")).toJSON(); // ["room/alice"] scope.captures(Moq.Path.Pattern.parse("room/alice"))?.map((capture) => capture.text); // ["alice"] scope.rebase("room").toJSON(); // ["**"] Moq.Path.Pattern.parse("camera-*").rooted("room").text; // "room/camera-*" ``` ## Advertising Three operations, on an origin: * `origin.createBroadcast(path)` returns a producer. The broadcast is invisible and unreachable, for local consumers and peers alike, until `broadcast.announce()`. * `broadcast.announce(route)` / `broadcast.unannounce()` own that advertisement. Announcing again re-prices the standing route. * `origin.dynamic(prefix, route)` claims `prefix` and every path beneath it (`""` claims everything). Hold the returned `Origin.Dynamic` while the claim should stay advertised; `close()` retracts it. A request beneath it that no announced local broadcast wins is an `Origin.Request` to `accept` or `reject`; reject what you will not serve rather than narrowing the claim, since a route is always a prefix on every wire. A route is a capability, not an inventory. `origin.announced(scope)` yields `Announce.Update` values: `prefix` is the covered prefix relative to the origin, `captures` is one pattern per scope wildcard when the prefix pins a complete match (otherwise `undefined`), `kind` is `"announced"`, `"updated"` (a reprice in place), or `"retracted"`, and `route` carries hops and cost (on a retraction, its last values). The consumer is an async iterable. A prefix is not a broadcast name; the scope filters locally while sessions request its literal head on the wire. Paths with a `.`-prefixed segment below that head are [hidden](/concept/moq-lite#hidden-broadcasts) unless `announced(scope, { hidden: true })` opts in; `broadcasts(scope, { hidden: true })` takes the same option. Examples in [`js/net/examples/`](https://github.com/moq-dev/moq/tree/main/js/net/examples). Runs in the browser and, over WebSocket, in Node, Bun, and Deno; see [server-side](/lib/js/#server-side). --- --- url: https://doc.moq.dev/lib/js/hang.md description: The media layer in TypeScript --- # @moq/hang [![npm](https://img.shields.io/npm/v/@moq/hang)](https://www.npmjs.com/package/@moq/hang) The [hang media format](/concept/hang) as TypeScript types and codecs, shared by [`@moq/watch`](/lib/js/watch) and [`@moq/publish`](/lib/js/publish). * **Catalog**: zod schemas for the root, video, audio and text renditions, the JSON and binary data track sections, containers, and the `archive` entry (timeline track plus optional replay, store, and version). The root is a loose object, so `z.extend(Catalog.RootSchema, { yourSection })` adds your own. * **Containers**: `Container.Legacy` producer/consumer and `Container.Cmaf` init and data segment helpers. * **Utilities**: hex, priority and latency math, an Opus polyfill for browsers without a native decoder, and the browser quirks the media packages work around. ```ts import * as Catalog from "@moq/hang/catalog"; import * as Container from "@moq/hang/container"; ``` `Catalog.watch(broadcast)` iterates validated catalog roots. It throws `Catalog.TooManyRenditions` for an update above the 64 rendition limit, and `Catalog.EscapingBroadcast` for a `broadcast` reference that walks above the handle's `path`. Run the same checks on a catalog from another source with `Catalog.checkRenditions(root)` and `Catalog.checkResolvable(root, base)`. `Hang.Timeline.Consumer.subscribe(broadcast, root.archive)` reads segment `push`, `pop`, and `skip` events when a root advertises an archive. Most apps never import it directly; the elements and `Broadcast` classes in the watch and publish packages do. Reach for it when hand-rolling a catalog or building a custom player. ## Wall clock A catalog root may carry a `clock`: `{ wall, timescale }`, the wall-clock time of PTS zero in `timescale` units (microseconds by default) since 2020-01-01. `Catalog.wallClockTime(clock, pts, ptsTimescale)` maps a frame's PTS to a `Date`. A `Container.Legacy` frame timestamp is microseconds, so pass `1_000_000`. ```ts for await (const root of Catalog.watch(broadcast)) { if (!root.clock) continue; // the publisher exposes no clock // `frame` comes from your own `Container.Legacy` consumer. const captured = Catalog.wallClockTime(root.clock, frame.timestamp, 1_000_000); } ``` * The mapping is fixed for the life of the broadcast. A discontinuity or a system-clock adjustment on the publisher does not change it. * It is independent of `archive`. A live-only broadcast carries a `clock` with no segment index, so a DVR view can label its timeline from it either way. * A `Date` holds whole milliseconds, so anything finer in the clock is truncated. * A result past the `Date` range comes back as an invalid `Date`, not an exception, so check it before use. Two machines' wall times are only comparable when your application already knows their clocks are synchronized. The catalog says where PTS zero was on the publisher's clock, never whether the viewer's clock agrees with it. If you do know, set `delay` to `target - (arrived - captured)`, where `arrived` is the viewer's `Date.now()` for the frame that arrived earliest relative to its timestamp, since `` anchors playback on that frame. That holds only with the default zero `buffer`; a lookahead moves the anchor. `` adds the largest active rendition's buffer on top, so a frame renders at about `captured + target` plus that buffer: the rendition's catalog `delay` plus its `jitter` (one video frame or one codec frame when unset), and for audio the worklet quantum too. Viewers only line up when that buffer matches, and a negative result means the target is already missed. The library never does this for you. Playback stays arrival-based: it does not read the `clock`, estimate the viewer's clock from the session RTT, or exchange time over a track, and a timestamp stays relative to the broadcast rather than a reading of any wall clock. --- --- url: https://doc.moq.dev/lib/js/watch.md description: Subscribe, decode, and render broadcasts in the browser --- # @moq/watch [![npm](https://img.shields.io/npm/v/@moq/watch)](https://www.npmjs.com/package/@moq/watch) A player: subscribes to a broadcast, picks renditions, decodes with WebCodecs, renders video to a canvas and audio through WebAudio, and keeps them in sync at the latency you ask for. ```html ``` ## Attributes | Attribute | | | --- | --- | | `url`, `name` | Relay URL (with `?jwt=` if needed) and broadcast name. | | `paused`, `muted`, `volume` | The usual player controls, mirrored as reactive properties. | | `delay` | How far playback trails the live edge: `"auto"` (derived from RTT, the default), a duration like `"300ms"`, or `"instant"` to paint frames as they decode with no pacing at all. | | `buffer` | Future-dated media held beyond the live edge before playback skips ahead, e.g. `"30s"`. Defaults to none. | | `captions` | The caption track to show, or absent for off. `el.text.out.available` lists the renditions for a picker. | | `visible` | Only subscribe to video while the element is on screen: a margin (`"20%"` default, `"200px"`), `"always"`, or `"never"`. | | `announced` | Wait for the broadcast to be announced before subscribing (default on), so a player can be mounted before the stream exists. | | `catalog-format` | `hang` (default, from the `.hang` suffix), `hangz` (compressed), `msf`, or `manual` to supply the catalog yourself. | Video holds its last picture while paused, out of view, or waiting for a resumed rendition's first frame. Its reported timestamp stays with that picture. Going offline or closing the player clears it. The overlay adds play/pause, volume, fullscreen, a quality selector, a buffering indicator, an unsupported-codec warning, and a stats panel. `` shows what the browser can play. ## Binding from a framework `import "@moq/watch/element"` registers `` while the module evaluates, so a browser-only entrypoint that imports it before mounting gets an upgraded element. `el.broadcast`, `el.video`, `el.audio`, `el.sync`, and `el.signals` are assigned by the constructor and readable right away. Defer the import and that guarantee goes with it. A dynamic `import()` inside `onMount`, one behind a `browser` guard, or a ` ``` ## Attributes | Attribute | | | --- | --- | | `url`, `name` | Relay URL (with `?jwt=` if needed) and broadcast name. | | `source` | `camera`, `screen`, or `file`. | | `muted`, `invisible` | Disable audio or video capture. | | `preview` | What the nested element shows: the raw `source` (default), a decoded copy of the `encoded` stream to see what viewers get, or `none`. | | `announce` | When to advertise: once a `source` is live (default), `always`, or `never`. A camera source waits for every enabled track, and `source` waits until each captured track's config resolves or fails, so the first catalog lists every rendition. The broadcast is created while connected either way, but nobody can see or subscribe to it until it is announced. | A nested `