open-standards/joule-code/joule-code-rs/crates/joule-moq-web/web/moq/session.js
Transaction Science 8e075cba2d joule-moq-web: the room page in an end-to-end encrypted room, and the e2ee scenario (WIP)
The page checks the room's record, loads the e2ee build, leaves a KeyPackage, follows the
room service's log (web/room/e2ee.js), seals each frame before it is signed and published,
and opens each object through §6 before it is decoded; objects of an epoch not yet reached
wait. It shows the room is end-to-end encrypted, its epoch and its roster; no watching and
no seeking back in such a room. The session hands each object's property list to a sink.
wai-live-web's roomDescriptor gives the recording mode the record is checked against.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 08:43:46 -04:00

379 lines
14 KiB
JavaScript

// A MOQT draft-22 client session over a transport of ./transport.js, the codec being
// joule-moq-web's wasm build.
//
// - SETUP on this end's control stream (its first unidirectional stream), with joule-moq's
// receipt extension, and either a room grant with its proof of possession or a token by
// value. A room grant is public, so it is presented only with a proof (WAI live-rooms
// §10.5.2): the session waits for the relay's SETUP, which carries the session's
// challenge, and sends its own after it, the grant as its authorization token and a proof
// by the principal's key (signed in the wasm build) over the challenge and the
// `//authority/path` of the URL the transport opened. The relay's SETUP is read from its
// control stream, data streams that come first held until it.
// - Requests on bidirectional streams, Request IDs even from 0 (§6.4.2): PUBLISH,
// PUBLISH_NAMESPACE, SUBSCRIBE (with priorities, filters and FILL_PARAMETERS), FETCH,
// SUBSCRIBE_NAMESPACE, TRACK_STATUS.
// - Every data stream read concurrently, each by a task of its own as it arrives: a
// subscriber that reads its streams one at a time, in the order they arrive, can stall
// itself, since the stream it waits on may have its bytes held back for flow-control
// credit that the unread streams behind it hold (joule-moq-relay's README, "A subscriber
// that does not read").
// - GOAWAY (§9.2): no new request is opened once one arrives; the caller is told the new
// session URI and the timeout, and moves.
import { StreamReset } from "./transport.js";
const STEP_MS = 10000;
const ALIAS_WAIT_MS = 3000;
// A Track Alias a subgroup stream names before this end has read the SUBSCRIBE_OK that
// gives it waits up to ALIAS_WAIT_MS.
export function withTimeout(p, ms, what) {
let t;
return Promise.race([p, new Promise((_, no) => { t = setTimeout(() => no(new Error(`${what}: no answer in ${ms} ms`)), ms); })]).finally(() => clearTimeout(t));
}
const hexToBytes = (h) => new Uint8Array((h.match(/../g) || []).map((x) => parseInt(x, 16)));
const utf8 = (h) => new TextDecoder().decode(hexToBytes(h));
export class RequestRefused extends Error {
constructor(message, first) {
super(message);
this.first = first;
}
}
class Queue {
constructor() { this.items = []; this.waiters = []; this.done = false; }
push(x) { const w = this.waiters.shift(); if (w) w(x); else this.items.push(x); }
end() { this.done = true; for (const w of this.waiters.splice(0)) w(null); }
next() {
if (this.items.length) return Promise.resolve(this.items.shift());
if (this.done) return Promise.resolve(null);
return new Promise((r) => this.waiters.push(r));
}
}
export class MoqSession {
constructor(transport, moq, { token, grant, implementation = "joule-moq-web", log = () => {}, maxPayload = 4 << 20, properties = false } = {}) {
this.t = transport;
// Each object's property list, encoded, as its fourth argument to a sink: what a
// member of an end-to-end encrypted room opens an object with.
this.properties = properties;
this.moq = moq;
this.token = token;
// { record, seed }: a room grant's record and its principal's 32-byte seed.
this.grant = grant;
this.implementation = implementation;
this.log = log;
this.maxPayload = maxPayload;
this.nextRequestId = 0;
this.tracks = new Map(); // downstream Track Alias -> handler { object, end }
this.aliasWaiters = new Map();
this.fetches = new Map(); // request id -> fetch handler
this.goaway = null;
this.onGoaway = null;
this.closedInfo = null;
this.counters = { uni_streams: 0, subgroups: 0, fetch_streams: 0, objects: 0, bytes: 0, resets: 0, unrouted: 0, decode_errors: 0, streams_open_max: 0 };
this.openStreams = 0;
this.peerSetup = null;
this.closed = transport.closed.then((i) => { this.closedInfo = i; return i; });
}
static async connect(transport, moq, opts) {
const s = new MoqSession(transport, moq, opts);
await s.start();
return s;
}
async start() {
this.setupReceived = new Promise((ok, no) => { this._setupOk = ok; this._setupNo = no; });
this.acceptUniLoop();
this.acceptBidiLoop();
this.control = await this.t.openUni();
if (!this.grant) {
await this.control.write(this.moq.clientSetup(this.token || undefined, this.implementation, true, 0n));
this.peerSetup = await withTimeout(this.setupReceived, STEP_MS, "the relay's SETUP");
this.log(`session: SETUP exchanged over ${this.t.kind}`);
return;
}
this.peerSetup = await withTimeout(this.setupReceived, STEP_MS, "the relay's SETUP");
const type = Number(this.moq.grantChallengeOption());
const opt = (this.peerSetup.options || []).find((o) => o.type === type && o.bytes);
if (!opt) throw new Error("the relay's SETUP carries no challenge for a grant's proof");
const u = new URL(this.t.url);
this.target = `//${u.host}${u.pathname}`;
const challenge = hexToBytes(opt.bytes);
// `proofBy`: a test's proof by another key than the principal's, which the relay refuses.
const setup = this.grant.proofBy
? this.moq.clientSetupWithProof(this.grant.record, this.moq.grantProofBy(this.grant.record, this.grant.proofBy, challenge, this.target), this.implementation, true, 0n)
: this.moq.clientSetupWithGrant(this.grant.record, this.grant.seed, challenge, this.target, this.implementation, true, 0n);
await this.control.write(setup);
this.log(`session: SETUP exchanged over ${this.t.kind}, the grant presented with its proof for ${this.target}`);
}
// ------------------------------------------------------------------- incoming streams
async acceptUniLoop() {
for (;;) {
const r = await this.t.acceptUni();
if (!r) return;
this.counters.uni_streams++;
// Each stream is read by a task of its own, at once.
this.readUni(r);
}
}
async acceptBidiLoop() {
for (;;) {
const s = await this.t.acceptBidi();
if (!s) return;
// This client publishes by PUBLISH, so the relay opens no request to it; one it
// opens anyway is refused (REQUEST_ERROR NOT_SUPPORTED).
this.refuseIncoming(s);
}
}
async refuseIncoming({ writer, reader }) {
const ms = new this.moq.MessageStream();
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
ms.push(value);
const m = ms.next();
if (m) {
await writer.write(this.moq.encodeControl(JSON.stringify({ message: "REQUEST_ERROR", code: 3, reason: "this client takes no requests" })));
await writer.close();
break;
}
}
} catch { /* the stream ended */ }
ms.free();
}
async readUni(reader) {
const dec = new this.moq.UniDecoder(BigInt(this.maxPayload));
let sink = null;
let kind = null;
this.openStreams++;
this.counters.streams_open_max = Math.max(this.counters.streams_open_max, this.openStreams);
try {
for (;;) {
let chunk;
try {
chunk = await reader.read();
} catch (e) {
this.counters.resets++;
if (sink && sink.reset) sink.reset(e instanceof StreamReset ? e.code : -1);
return;
}
if (chunk.done) {
try { dec.finish(); } catch (e) { this.counters.decode_errors++; this.log(`a stream ended inside an item: ${e.message || e}`); }
if (sink && sink.end) sink.end();
return;
}
this.counters.bytes += chunk.value.length;
dec.push(chunk.value);
for (;;) {
let item;
try { item = dec.next(); } catch (e) {
this.counters.decode_errors++;
this.log(`a data stream does not decode: ${e.message || e}`);
reader.cancel(0);
if (sink && sink.reset) sink.reset(-2);
return;
}
if (!item) break;
const k = item.kind;
const j = JSON.parse(item.json);
if (k === "header") {
kind = j.header;
sink = await this.route(j, dec);
if (!sink) { this.counters.unrouted++; reader.cancel(0); item.free(); return; }
} else if (k === "control") {
this.onControl(j.control);
} else if (k === "object" || k === "fetch_object") {
this.counters.objects++;
const payload = item.payload();
const props = this.properties ? item.properties() : null;
sink.object && sink.object(j, payload, k, props);
} else if (k === "end_of_range") {
sink.endOfRange && sink.endOfRange(j.end_of_range);
}
item.free();
}
}
} finally {
this.openStreams--;
dec.free();
void kind;
}
}
async route(h, dec) {
if (h.header === "control") {
return { object() {}, end: () => { if (!this.closedInfo) this.log("the relay's control stream ended"); } };
}
if (h.header === "subgroup") {
this.counters.subgroups++;
let t = this.tracks.get(h.track_alias);
if (!t) t = await this.waitAlias(h.track_alias);
if (!t) return null;
return t.stream(h);
}
if (h.header === "fetch") {
this.counters.fetch_streams++;
const f = this.fetches.get(h.request_id);
if (!f) return null;
dec.setGroupOrder(f.order || 1);
return f.stream(h);
}
return null;
}
waitAlias(alias) {
return new Promise((r) => {
const t = setTimeout(() => { done(null); }, ALIAS_WAIT_MS);
const done = (x) => {
clearTimeout(t);
const ws = (this.aliasWaiters.get(alias) || []).filter((w) => w !== done);
if (ws.length) this.aliasWaiters.set(alias, ws); else this.aliasWaiters.delete(alias);
r(x);
};
this.aliasWaiters.set(alias, [...(this.aliasWaiters.get(alias) || []), done]);
});
}
setTrack(alias, handler) {
this.tracks.set(alias, handler);
for (const w of this.aliasWaiters.get(alias) || []) w(handler);
}
onControl(m) {
if (m.message === "SETUP") {
this._setupOk(m);
} else if (m.message === "GOAWAY") {
this.goaway = { uri: utf8(m.new_session_uri), timeout_ms: m.timeout_ms };
this.log(`GOAWAY: new session URI "${this.goaway.uri}", timeout ${this.goaway.timeout_ms} ms`);
if (this.onGoaway) this.onGoaway(this.goaway);
}
}
// ---------------------------------------------------------------------- requests
async request(desc) {
if (this.goaway) throw new Error(`GOAWAY received: no new ${desc.message}`);
if (this.closedInfo) throw new Error("the session is closed");
const id = this.nextRequestId;
this.nextRequestId += 2;
// The authorization token rides in SETUP, where the relay's grant rules read it for
// every request of the session.
const d = { ...desc, request_id: id };
const { writer, reader } = await this.t.openBidi();
const bytes = this.moq.encodeControl(JSON.stringify(d));
await writer.write(bytes);
const q = new Queue();
const ms = new this.moq.MessageStream();
(async () => {
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
ms.push(value);
for (let m; (m = ms.next()); ) q.push(JSON.parse(m));
}
} catch (e) {
q.push({ message: "STREAM_RESET", code: e.code });
} finally {
ms.free();
q.end();
}
})();
const first = await withTimeout(q.next(), STEP_MS, desc.message);
if (!first) throw new RequestRefused(`${desc.message}: the stream ended before its answer`, null);
return { id, first, messages: q, writer, reader };
}
static refusal(what, first) {
if (first.message === "REQUEST_ERROR") return new RequestRefused(`${what}: REQUEST_ERROR ${first.code} ${utf8(first.reason || "")}`, first);
return new RequestRefused(`${what}: an unexpected ${first.message}`, first);
}
async publish(namespace, name, alias, trackProperties) {
const r = await this.request({ message: "PUBLISH", namespace, name, track_alias: alias, track_properties: trackProperties });
if (r.first.message !== "REQUEST_OK") throw MoqSession.refusal(`PUBLISH ${namespace.join("/")}--${name}`, r.first);
return r;
}
// SUBSCRIBE: `handler` gets each subgroup stream of the track (`stream(header)` gives
// its object sink) and, with `params.fill`, the fill fetch stream named by the request.
async subscribe(namespace, name, params, handler) {
const id = this.nextRequestId;
if (params && params.fill) this.fetches.set(id, { order: params.group_order || 1, stream: (h) => handler.fill(h) });
let r;
try {
r = await this.request({ message: "SUBSCRIBE", namespace, name, params });
} catch (e) {
this.fetches.delete(id);
throw e;
}
if (r.first.message !== "SUBSCRIBE_OK") {
this.fetches.delete(id);
throw MoqSession.refusal(`SUBSCRIBE ${namespace.join("/")}--${name}`, r.first);
}
const alias = r.first.track_alias;
this.setTrack(alias, handler);
(async () => {
for (let m; (m = await r.messages.next()); ) {
if (m.message === "PUBLISH_DONE") { handler.done && handler.done(m); }
}
})();
return { ...r, alias };
}
// FETCH: every entry of the fetch stream, until its FIN.
async fetch(namespace, name, params, onObject) {
const id = this.nextRequestId;
let finished;
const ended = new Promise((r) => { finished = r; });
const entries = { objects: 0, ranges: [] };
this.fetches.set(id, {
order: (params && params.group_order) || 1,
stream: () => ({
object: (j, payload, k, props) => { entries.objects++; onObject(j, payload, props); },
endOfRange: (e) => entries.ranges.push(e),
end: () => finished("fin"),
reset: (code) => finished(`reset ${code}`),
}),
});
let r;
try {
r = await this.request({ message: "FETCH", namespace, name, params });
} catch (e) {
this.fetches.delete(id);
throw e;
}
if (r.first.message !== "FETCH_OK") {
this.fetches.delete(id);
throw MoqSession.refusal(`FETCH ${namespace.join("/")}--${name}`, r.first);
}
const how = await withTimeout(ended, STEP_MS * 2, "the fetch stream");
this.fetches.delete(id);
return { ok: r.first, how, ...entries };
}
// One subgroup stream of `bytes`, at `sendOrder` where the transport has it.
async sendStream(bytes, sendOrder) {
const w = await this.t.openUni({ sendOrder });
await w.write(bytes);
await w.close();
}
async openStream(sendOrder) {
return this.t.openUni({ sendOrder });
}
close(code = 0, reason = "") {
this.t.close(code, reason);
}
}