open-standards/joule-code/joule-code-rs/crates/joule-moq-web
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-21 16:04:32 -04:00
..
e2e room: a tile whose chain this page anchored says from which group it holds it (browser-sink §10) 2026-10-21 16:04:32 -04:00
src browser sink for the page: wai-live-web's routing, constraint, binding and frame-state functions, a bound wasm decoder, and the verifier's hashes 2026-10-21 15:14:57 -04:00
web room: a tile whose chain this page anchored says from which group it holds it (browser-sink §10) 2026-10-21 16:04:32 -04:00
.gitignore joule-moq-web: the browser client, its test pages and its end-to-end harness 2026-10-18 13:44:37 -04:00
Cargo.toml joule-moq-web: feature e2ee, the member of an end-to-end encrypted room for a page 2026-10-20 08:28:26 -04:00
README.md docs: the browser sink in joule-moq-web's README and the live-conformance index 2026-10-21 16:02:15 -04:00

joule-moq-web

A browser client for MOQT, draft-ietf-moq-transport-22, and the wasm build of joule-moq it runs on. A page publishes its camera and microphone to joule-moq-relay with WebCodecs, over WebTransport or the relay's WebSocket binding, subscribes to the other participants of a room, verifies every object against its receipt, shows the room's events and captions, and composes the participants with WAI's integer layout and mixer (wai/wai-live-web, the other wasm build the page loads).

The room page runs two ways: against a test room, every key from a fixture, on loopback; or against a room service (joule-moq-room), as the public room at wai.transaction.science/room/ does, where the page makes its own key and the service's clock authors the room's lifecycle.

The two wasm builds

Build From What the page uses it for
joule_moq_web this crate, over joule-moq with default-features = false SETUP, every request a client sends, framed control messages and data streams read as their bytes arrive, subgroup streams written; each group signed into its receipt (TrackSigner) and each object verified against it (TrackVerifier)
wai_live_web wai/wai-live-web, over wai-rs with live_compose and timed_text the room descriptor and grants, the lifecycle replay and the composition at an instant, the layout and the mixer, signed events and captions, the MSF event-timeline binding, the WAI1 envelope
wai_live_web_decode wai/wai-live-web with its feature int_video everything wai_live_web does, and the decoder on the browser sink's wasm route (WAI extensions/browser-sink.md §7.2): a wai.video.int_motion object verified against its group and object receipts, decoded by the integer decoder, and every frame bound. The binding page loads it; the room's own tracks route to the user agent's decoders and the room page does not
joule_moq_web_e2ee this crate with its feature e2ee, over joule-moq-mls (MLS, RFC 9420) and joule-moq's secure-objects everything joule_moq_web does, and the member of an end-to-end encrypted room: the room's confidentiality record checked, its MLS group joined and followed, each frame sealed and each object opened (WAI extensions/confidentiality.md). A page loads it in place of joule_moq_web for such a room alone

joule-moq without its default feature runtime is its codec, its carriage of objects and receipts, its receipt builder and verifiers and its session's setup rules, with no tokio and no native dependency. The relay and every other dependent take the default features and build what they built before.

Sizes. Built as e2e/run.sh builds them, joule_moq_web is about 780 KB (260 KB compressed); joule_moq_web_e2ee, under the workspace's wasm profile (link-time optimisation, one codegen unit, optimised for size) and with no name section, about 2.0 MB (650 KB compressed). The difference is MLS and its crypto provider, which a page in any other room never loads.

Signatures. Receipts are verified in wasm by joule-moq's ReceiptVerifier (its ReceiptChains for the chain), the same code the relay's door runs. WebCrypto's Ed25519 is not used: a receipt's verification also needs BLAKE3 and the Merkle walk, which WebCrypto does not have, and one implementation keeps the page's verdicts the relay's.

The page

web/room/ is the room client; web/moq/ its transport, session and media framing; web/compose/ runs live-conformance/compose in the page; web/binding/ runs the browser sink (WAI extensions/browser-sink.md) in the page.

Step What it does
Enter (room service) ?service=<url> or data-service on #room-app: the page fetches the room (GET /room), turns the camera and microphone on for a publisher, makes an Ed25519 key with the browser's random generator, and asks for a grant for it (POST /grant, a publisher's or a viewer's; a full room offers watching). It connects over the WebSocket binding at /moq on its own origin (ws names another), WebTransport not tried, and presents the grant with a proof by its key. The lifecycle is the service's clock's: a join under a grant the page does not hold has it read the room's grants again (Room.setGrants). A viewer publishes nothing. Leave closes the session; the relay ends the tracks and the clock records it.
Connect WebTransport to the relay's URL with protocols: ["moqt-22"] (WT-Available-Protocols) and, for a development certificate, serverCertificateHashes; when the browser has no WebTransport or the session does not open, the WebSocket binding (subprotocol moqt-22). transport=websocket forces the binding.
Session SETUP with the receipt extension and the participant's room grant: the page waits for the relay's SETUP, which carries the session's challenge, then sends its own with the grant as its authorization token and a proof of the principal's key over that challenge and the URL's //authority/path, signed in wasm (WAI live-rooms §10.5.2); requests on bidirectional streams with even Request IDs; every data stream read by a task of its own as it arrives (the relay's README, "A subscriber that does not read"); GOAWAY: no new request, a new session to the URI it names, the tracks published and the subscriptions made again there, the old session closed by the page before the timeout.
Publish getUserMedia (the profile's processing as its constraints) into VideoEncoder and AudioEncoder, the codec the first of the room profile's codecs the encoder supports. Each encoded frame is one group of one object (moq-streaming-format §4.1): a WAI1 envelope whose payload is in SPEC §5's framing for its capability (VP9 in IVF, AV1 in its five-field framing, Opus as an Ogg Opus stream). Its receipt is signed into a group receipt chained to the group before; the receipts-track object, in WAI's binary form (jwp-receipts §2.7.3), goes first, then the media object with its WAI_RECEIPT property. A catalog (MSF) names each track's codec, wai_receipt_alias and wai_receipts_track.
Subscribe Per participant: its events track, its catalog, then each media track's receipts track (no delivery timeout) and the media track, audio at a higher subscriber priority than video. Each receipts-track object is admitted to the track's chain under the participant's key; each object is verified against the receipt of its position before it is decoded; an object whose receipts arrived and which did not is accounted for by inclusion. A late joiner FETCHes every earlier group of each track and its receipts track from group 0.
Route and bind Before a track is decoded, the page routes it (browser-sink §4): it builds a capability report from the catalog's codec string, dimensions and wai_determinism, puts to the user agent only the codec strings the WebCodecs Codec Registry lists (isConfigSupported, then Media Capabilities for its powerEfficient), and decodes the track only if its route is the user agent's decoder. Each object verified against its receipt gives the page its content hash and its group receipt's link hash; every frame a video decoder outputs is bound to them in a side table keyed by decoder and timestamp (web/room/binding.js, §9.2), and clone() carries the binding. With ?debug=binding the page shows, for each tile, the state of the frame it last drew: its binding, the receipt and chain it verified under, its route and its energy entry (§10).
Compose The room's lifecycle (the clock's join, publish, leave events) replayed by wai-live-web gives the participant set; the WCD1 descriptor and the set give the tiles, drawn on a canvas, re-laid out on each lifecycle event; the voices are mixed by the integer mixer, in blocks of 20 ms, into an AudioWorklet that plays them.
Events Every record verified (signature, chain, the grant's policy, through the room verifier); caption events shown with their origin (human, machine, machine-reviewed), the other kinds as a timeline.
End-to-end encryption When the service's room has a confidentiality record (GET /room's e2ee), the page says so before anyone enters, offers no watching and no seeking back, checks the record against the descriptor (its room, authority and recording mode), and loads joule_moq_web_e2ee. It leaves a KeyPackage with the service before it publishes, then reads the room's log (web/room/e2ee.js): each lifecycle record checked as the clock's, each MLS message checked and applied by the wasm member; it creates the group or commits when the room calls on it, and applies what it made only when the log returns it. Each encoded frame is sealed under the page's epoch before its receipt is signed over the sealed bytes; nothing is sent before the page holds an epoch. Each object received is verified against its receipt, then opened through the receiving checks, and only then decoded; one sealed in an epoch the page has not reached waits, and one that does not open is counted with its reason. The page shows its epoch and the members of the group. Every key is in the wasm member's memory and nowhere else.

What a browser needs

Path Needs
WebTransport WebTransport with serverCertificateHashes (for a development certificate: ECDSA P-256, valid at most fourteen days), protocols (where it is missing the relay takes the session as -22), and a secure context (https: or loopback).
WebSocket binding WebSocket with a subprotocol. Nothing else.
Publishing getUserMedia, MediaStreamTrackProcessor, VideoEncoder for one of the profile's codecs (VP9 or AV1 here), AudioEncoder for Opus at 48 kHz, OffscreenCanvas where the camera's size is not the profile's.
Subscribing VideoDecoder, AudioDecoder, a 2D canvas, AudioWorklet.
Both WebAssembly with BigInt at the boundary (64-bit integers cross as BigInt).

What the WebSocket binding costs. Every stream rides one TCP connection, so a lost packet holds up every stream behind it, which QUIC's independent streams do not; there are no datagrams in a useful sense (a DATAGRAM capsule is reliable and ordered); stream data is framed again in capsules (a few bytes for each 16 KiB chunk and for each stream's open and FIN); stream-count credit is granted by capsule; and there is no data flow control below TCP's, so a receiver that stops reading is bounded by the relay's buffer and then closed. Delivery timeouts still apply at the relay, before a write.

Tests

  • cargo test -p joule-moq-web: the surface's pure-Rust half on the host; with --features e2ee, the member's surface too: two members and a clock key through one ordered log as pages do, seal, sign, read the stream back, verify and open, and every refusal an object can meet.

  • node --test web/moq/capsule.test.mjs web/moq/media.test.mjs web/room/binding.test.mjs: the binding's capsules against the bytes the relay's README states, the payload framings, and the page's side table against the sequences of wai/live-conformance/browser.

  • e2e/run.sh: everything built (the relay, both wasm builds, the host's half of wai-live-web), the browser-automation package from e2e/package-lock.json with install scripts off, then e2e/room.e2e.mjs in the host's installed browser (not a bundled one) with a fake camera and microphone, the relay on loopback under a test configuration (grants required, the door on every media namespace):

    Scenario Shows
    compose every layout, mix, descriptor and composition case of live-conformance/compose through the wasm build gives the corpus's outcome, and wai-rs's corpus check holds in the browser
    webtransport two participants publish and receive each other: frames decode, every received object verifies against its receipt and the relay's door refuses none, the canvas's tiles equal the host's layout for the same descriptor and set and show video, the mix reaches the AudioWorklet; a machine caption and a human caption show with their labels; a late joiner gets the earlier groups by FETCH, each verified, its chains admitting them, group 0 among them, as history, and every page re-lays out for three; the receipts objects ride in the binary form, their sizes reported beside the JSON form's; a grant presented without its proof is refused the session
    websocket the same over the binding, forced
    goaway the relay is stopped with GOAWAY naming a second relay; every page moves, media is verified again there, and the first relay closes no session at its timeout
    service a room service keeps the room, as in production: the relay (WebSocket only, its door holding each publisher to its own key, trusting the service's authority) and the service behind a same-origin proxy; pages enter from their buttons with keys of their own. Two publish and see each other, every object verifies, the layout equals the host's, a machine caption and a typed human caption show their labels, the clock (not a page) authors the lifecycle, a late joiner gets earlier groups by FETCH, a participant seeks back eight seconds into the other's tracks from the relay's DVR window (the relay keeps one and a cache far smaller than the span; the dvr scenario's checks: the control enables, the window's video plays in its own view from its first key frame at the pace it was captured, every object verifies, nothing is unknown, the page returns to live and live video goes on decoding, the relay served it from its window), a viewer watches, a grant with no proof or with a proof by another key is refused the session, a publisher past the room's places is told it is full, and a leave is recorded
    binding the browser sink: web/binding/ runs live-conformance/browser's routes, constraints, bindings, frame states and side-table sequences through the page's wasm and JavaScript; decodes a wai.video.int_motion object on the wasm route after verifying it against its receipts, every frame bound and its SHA-256 the published one, a changed object refused and one with no receipt left unbound; carries the binding through clone(), close(), new VideoFrame(frame), transfer to a worker and back, copyTo() and re-encode; binds the user agent's own VP9 decoder's frames through the side table and refuses a timestamp given twice; reports what the user agent keeps of an unregistered VideoFrame metadata member; builds a capability report from the user agent's answers, the interfaces' own record showing no codec string outside the registry put to them; and, where the host has a per-process energy counter (e2e/energy.py), reports a figure per decode measured around bursts of decodes beside powerEfficient. Then a two-party room with ?debug=binding: each page's tile of the other shows a frame bound to an object that page verified, under its group receipt, as verified
    e2ee an end-to-end encrypted room kept by a room service (e2ee = true), the relay keeping a DVR window: the page says so before anyone enters; two publishers key through the service's log (a GroupInfo, a commit, a Welcome), media flows both ways, every object verified against its receipt over the sealed bytes, opened, then decoded; the page shows its epoch and roster; a viewer, outside the group, receives the bytes exactly as sealed (no envelope, the AEAD's length) and opens nothing; every change a relay could make to an object is refused with the extension's reason (bad_tag, key_id_missing, properties_malformed, object_id_too_large, key_id_unknown, sender_not_owner, replayed) and the object opens once; an object changed on the wire is refused bad_tag; a late joiner's FETCH of earlier groups opens nothing sealed before its join (epoch_not_joined); a member that stops publishing and stays is removed by the next commit and opens nothing of the later epoch it still receives (removed_before_epoch), and an object it sealed before is refused sender_removed; the relay's DVR store keeps nothing of the room

    With --base <origin> the service scenario runs against a deployment instead: nothing is started, and the page at <origin>/room/ is driven the same way (its production test). Its seek back is skipped there, and reported as skipped rather than passed, since the deployment's relay may keep no window; --seek on runs it, and --seek off skips it on loopback too.

    Every wait is bounded; each browser runs under the host's memguard where it exists.

Not yet implemented

  • WebTransport for the public room: its relay is reached over the WebSocket binding through an HTTP front, and offers no UDP endpoint.
  • Simulcast and staged layers; publishing a screen; datagrams.
  • In an end-to-end encrypted room: watching without being a member, ingest by WHIP or WebRTC, and seeking back (WAI confidentiality §15.3).
  • In the fixture's room, a page holding the clock's key authors the lifecycle, driven by the test; with a room service, the service's clock does.

Licence

Apache-2.0.