@moq/net
The TypeScript twin of moq-net: connections, origins, broadcasts, tracks, groups, and frames, negotiating moq-lite or moq-transport at setup.
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.closedsettles with the handler's error and no broader route is asked. Create, populate, thenannounce()for an exact path; usedynamic(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; explicitshare: truerefuses 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 thegoaway.handovercap (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 withError.RefusedRedirectinstead of reconnecting.closedsettles when the handle is released (nullon a clean close); the failure that stopped retrying the current URL iserror, and a new URL recovers the same handle. A connection owns one send-rate sampler and oneBandwidth.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 asroom/*/chat; default everything). Each event'sprefixis the covered prefix relative to the origin,capturesreports what the scope's wildcards matched when the prefix pins them, andkindsays whether it was announced, updated, or retracted. The consumer is an async iterable.origin.broadcasts(scope)is a liveGetter<ReadonlyMap<Path.Valid, Route>>of the same covered prefixes for UIs that need the current set. A borrowedConnection.originalso exposesdynamic(prefix, route)for serving paths on demand. - Subscriptions carry a priority, a
Time.Millimax age, and optionalgroupsbounds. Groups arrive out of order and are read frame by frame, withError.TooFarBehindwhen a reader asks for a frame the group never held andError.GroupTooLargewhen a write exceeds the cache budget and aborts the group. - Track ends:
close()ends a track at its live edge, whilefinishAt(n)declares the exclusive end ahead of it and still accepts the groups below. A subscriber reads the end withfinal()or awaitsfinished(). 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 withStreamCode.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.Streamwith aStreamCode, while a session close givesError.Sessionwith aSessionCode. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such asError.TooFarBehind,Error.FrameTooLarge, andError.GroupTooLargesubclassError.Stream, so onecodecheck 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.relativefor the cross-broadcast catalog references hang uses. ABroadcast.Consumernames its broadcast bypath: 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. LiteralPathstays a coordinate.
The path pattern 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.
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, untilbroadcast.announce().broadcast.announce(route)/broadcast.unannounce()own that advertisement. Announcing again re-prices the standing route.origin.dynamic(prefix, route)claimsprefixand every path beneath it (""claims everything). Hold the returnedOrigin.Dynamicwhile the claim should stay advertised;close()retracts it. A request beneath it that no announced local broadcast wins is anOrigin.Requesttoacceptorreject; 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 unless announced(scope, { hidden: true }) opts in; broadcasts(scope, { hidden: true }) takes the same option.
Examples in js/net/examples/. Runs in the browser and, over WebSocket, in Node, Bun, and Deno; see server-side.