MoQ is under active development. APIs and protocols may change between releases.

Skip to content

moq-lite ​

moq-lite is the pub/sub protocol this project speaks. It is a deliberately small subset of the IETF moq-transport draft, so it works against any moq-transport relay (including Cloudflare) while staying simple enough to implement in an afternoon. The wire spec is draft-lcurley-moq-lite.

Terminology ​

moq-liteMeaningmoq-transport name
SessionOne connection, publishing and subscribing at once.Session
OriginThe set of broadcasts visible to a session, scoped by the URL path.(none)
BroadcastA named, discoverable collection of tracks from one publisher.Namespace
TrackA live sequence of groups, delivered out of order until closed.Track
GroupA sequence of frames delivered reliably and in order, on its own QUIC stream.Group
FrameA sized chunk of bytes.Object
DatagramOne 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.

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 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). 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 /:

SegmentMatches
roomThat literal segment.
*Exactly one nonempty segment.
camera-*One segment starting with camera-.
pre*sufOne 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<Vec<Pattern>>, 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 and the relay's HTTP fetch serve history.

Each subscription carries the knobs that decide behavior under congestion:

KnobEffect
Priority (0..255)Higher-priority tracks get bandwidth first. Audio above video, base layer above enhancement.
OrderWhich group to send first when several are pending. Newest first for live, oldest first for catch-up.
Max ageHow 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:

TrackPriorityOrderMax age
audio100ascending500 ms
video50descending2 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.

ClientRelayWorks
moq-litemoq-liteyes
moq-litemoq-transportyes
moq-transportmoq-litewithout moq-transport-only features
moq-transportmoq-transportdepends 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.

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.

Licensed under MIT or Apache-2.0