moq-net
The networking layer: real-time pub/sub with caching, fan-out, and prioritization on top of QUIC. It negotiates moq-lite or IETF moq-transport at setup and presents one API either way. Media is a layer above (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 frommoq-pattern. LiteralPathstays 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, whilefinish_at(n)declares the exclusive end ahead of it and still accepts the groups below. A subscriber awaits it withfinished(). 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 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, orSource::Peer(hop)when a handle markedorigin::Producer::peer()announced it.origin::Consumer::local()sees only the local ones. - Stats counters per broadcast and session, drained by
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 wires those up.
cargo add moq-net moq-tokioSee the Rust quick start and docs.rs/moq-net. The TypeScript twin is @moq/net. The path pattern 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.
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.
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::<Vec<_>>(),
["room/alice"]
);
assert_eq!(
scope.rebase("room").iter().map(Pattern::as_str).collect::<Vec<_>>(),
["**"]
);
assert_eq!(
"camera-*".parse::<Pattern>()?.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, untilbroadcast.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 onunannounce(),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 withUnroutable. 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)claimsprefixand every path beneath it (""claims everything). Hold the returnedorigin::Dynamicwhile the claim should stay advertised; drop it to retract. A request beneath it with no winning announced local broadcast is aRequesttoacceptorreject; 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 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.