@vutbay/moq-web
v0.2.0
Published
Browser-native MoQ-transport (IETF draft-14) client — publish + subscribe — addressing tracks the upx way. Vendors the video-dev/moq-js draft-14 codec.
Readme
@vutbay/moq-web
A reusable, browser-native MoQ-transport (IETF draft-14) client with both publish and
subscribe, on the browser's native WebTransport API. It addresses tracks the upx way
(MoqNamespace::from_scope + "{object_type}/{channel_id}"), so a browser viewer/presenter
and the upx server-side MoqEgress speak to the same track on the same relay.
This is the M1 keystone of Orbit media (orbit/docs/MOQ-MEDIA-DESIGN.md §2): browser-native
MoQ publish + subscribe, no SFU — the relay does the fan-out. It is proven against a real
moq-relay-ietf (a headed Playwright publish → relay → subscribe round-trip; see below).
It wraps (vendors) the video-dev/moq-js draft-14 protocol codec (src/moq/, MIT/Apache —
see src/moq/VENDOR.md) behind a small high-level API.
API
import { connect, MoqNamespace, moqTrack } from "@vutbay/moq-web"
// Address the SAME track upx's MoqEgress publishes:
// namespace = the tenant ScopeChain as a tuple of "kind:id" fields (root -> leaf)
// track = "{object_type}/{channel_id}"
const ns = MoqNamespace.fromScope([
{ kind: "organization", id: "org1" },
{ kind: "store", id: "store9" },
]) // tuple ["organization:org1", "store:store9"]
const track = moqTrack("broadcast", "room.42") // "broadcast/room.42"
// --- connect (SETUP handshake, draft-14 / 0xff00000e) ---
const session = await connect("https://relay.example:4443/path", {
// for a self-signed dev relay: the cert's DER SHA-256 (cert must be ECDSA P-256, <=14 days)
serverCertificateHashes: [der_sha256_bytes],
})
// --- PUBLISH (presenter) ---
const pub = await session.announce(ns, track) // PUBLISH_NAMESPACE + await OK; serves the relay's SUBSCRIBE
await pub.writeObject(frameBytes) // one object as its own group (keyframe boundary)
// ...or explicit groups:
const g = await pub.group(/* priority */ 0)
await g.writeObject(chunk1)
await g.writeObject(chunk2)
await g.finish()
// --- SUBSCRIBE (viewer) ---
const sub = session.subscribe(ns, track) // SUBSCRIBE
for await (const { group, bytes } of sub.objects()) {
render(bytes) // each delivered MoQT object, in arrival order
}
// ...or just the first object:
const first = await sub.first()connect retries a transient WebTransport/QUIC connect failure (a fresh session per attempt)
and waits a short connectSettleMs after ready before opening the control stream — both
make the handshake reliable on a low-latency (localhost) link.
Protocol surface
- SETUP: CLIENT_SETUP → SERVER_SETUP, version negotiated to draft-14 (
0xff00000e). - publish: PUBLISH_NAMESPACE (+ OK), serve the relay's upstream SUBSCRIBE (SUBSCRIBE_OK), subgroup-stream writes — group → object(bytes) → FIN.
- subscribe: SUBSCRIBE (+ OK), read incoming subgroup streams →
(group, bytes)per object. - namespace/track: a namespace is the MoQT tuple of
"kind:id"fields (varint count + length-prefixed UTF-8 per field) — byte-for-byte what upxMoqNamespaceencodes and the relay'sTrackNamespacedecodes; the track is the"{object_type}/{channel_id}"name.
Media pipeline (M2)
On top of the transport, @vutbay/moq-web ships a reusable WebCodecs media pipeline
(src/media/): capture → encode → MoQ objects (publish), and subscribe → MoQ objects →
decode → render. Video first (audio is a follow-up).
import { connect, MoqNamespace, moqTrack, VideoTrackPublisher, VideoTrackSubscriber } from "@vutbay/moq-web"
// --- PRESENTER: a video MediaStreamTrack → VP8 → MoQ objects ---
const pub = await session.announce(ns, track)
const vp = new VideoTrackPublisher(pub, { codec: "vp8", keyFrameIntervalMs: 1000 })
await vp.start(stream.getVideoTracks()[0]) // getDisplayMedia()/getUserMedia()/canvas.captureStream()
// ...later: await vp.stop()
// --- VIEWER: MoQ objects → VP8 decode → <canvas> ---
const sub = session.subscribe(ns, track)
const vs = new VideoTrackSubscriber(sub, { render: document.querySelector("canvas") })
vs.run() // consume + decode + render until the track ends (or vs.stop())- Codec: VP8 (
vp8) by default — libvpx is always present in Chromium (no GPU / proprietary-codec dependency), so it decodes reliably; the codec is configurable. - Capture uses
MediaStreamTrackProcessor(the same path Orbit feeds withgetDisplayMedia()). - Framing (
src/media/framing.ts): one MoQ object = one encoded frame, behind a compact self-describing header (encodeFrame/decodeFrame) — magic/version, frame type (key/delta), timestamp (µs), and on every keyframe theVideoDecoderConfig(codec + coded dimensions + the codecdescription/extradata) so a late joiner can init its decoder from the keyframe object alone. - Group model: a keyframe starts a NEW MoQ group (the GOP boundary); a late subscriber joins at the next keyframe/group, then plays the deltas. Deltas are dropped until the first keyframe arrives.
Prove it — the media round-trip (headed)
npm run media-keystone # headed; HEADED=0 for headless, KEEP_OPEN_MS=ms to watch longerscripts/run-media-keystone.mjs reuses the keystone's relay+cert+teardown: page A draws a known
synthetic pattern (solid-green background + a moving white bar) onto a canvas, captureStream()s
it, encodes (VP8) and publishes; page B subscribes, decodes, and renders onto its own canvas. The
drive then asserts (a) decoded-frame count > 0 and (b) a sampled pixel of the rendered picture
matches the source's known green — i.e. the picture round-tripped (browser → relay → browser),
not just bytes.
Build
npm install
npm run typecheck # tsc --noEmit
npm run bundle:browser # esbuild IIFE -> test/harness/moq-web.iife.js (window.MoqWeb)
npm run build # tsc declarations + an ESM bundle in dist/Prove it — the keystone round-trip (headed)
Publishes a known payload from one browser and reads the identical bytes in another, through a
real moq-relay-ietf:
npm run keystone # headed (two visible Chromium windows); HEADED=0 for headlessThe drive (scripts/run-keystone.mjs) generates a fresh ECDSA P-256 short-lived cert,
starts the relay on a free UDP port, serves the harness over http://127.0.0.1 (a secure
context for WebTransport), launches headed Chromium, and asserts the bytes round-trip
browser → relay → browser. It tears everything down on exit (detached relay process-group +
forced exit — no leftover relay or browser).
Browser cert trust
Chrome's WebTransport rejects a self-signed cert unless you pass serverCertificateHashes
(the cert's DER SHA-256), and it requires the cert to be ECDSA P-256 and ≤14 days
valid. The keystone drive regenerates such a cert each run, so it never goes stale. (The
relay's --dev fingerprint endpoint is the alternative, but fetching it hits the same
untrusted-cert wall — passing the hash directly avoids that.)
