+ A multi-participant room over MoQ. Open this page in two tabs (or two browsers) with the same
+ room name. There is no chat, no 3D, no memes: camera, microphone, screenshare, and a roster
+ from the announce stream.
+
+
+
+
+
+
+ Join to publish, then open this URL in another tab.
+
+
+
+
+
+
+
+
+
+
diff --git a/demo/web/src/meet.ts b/demo/web/src/meet.ts
new file mode 100644
index 0000000000..089588acff
--- /dev/null
+++ b/demo/web/src/meet.ts
@@ -0,0 +1,231 @@
+/**
+ * Conferencing demo on @moq/room: a room is a path prefix, participants are
+ * discovered from the announce stream, each publishes camera and optional screen.
+ */
+
+import "@moq/publish/support/element";
+import "@moq/watch/support/element";
+import { Local, type Member, Net, Publish, Room, Signals } from "@moq/room";
+
+const RELAY_URL = import.meta.env.VITE_RELAY_URL ?? "http://localhost:4443";
+
+const $ = (id: string): T => {
+ const el = document.getElementById(id);
+ if (!el) throw new Error(`missing #${id}`);
+ return el as T;
+};
+
+function segment(raw: string, fallback: string): string {
+ const cleaned = raw
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9-]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 32);
+ return cleaned || fallback;
+}
+
+function randomName(): string {
+ return `p-${Math.random().toString(36).slice(2, 8)}`;
+}
+
+const params = new URLSearchParams(location.search);
+const roomInput = $("room");
+const nameInput = $("name");
+const relayEl = $("relay-url");
+roomInput.value = params.get("room") ?? "demo";
+nameInput.value = params.get("name") ?? randomName();
+relayEl.value = RELAY_URL;
+
+const ui = new Signals.Effect();
+const joined = new Signals.Signal(false);
+const tiles = new Map();
+const tilesEl = $("tiles");
+const emptyEl = $("tiles-empty");
+
+let session: Signals.Effect | undefined;
+let connection: Net.Connection.Reload | undefined;
+let local: Local | undefined;
+let room: Room | undefined;
+let localPreview: Publish.Preview.Renderer | undefined;
+
+function setPill(label: string, state: "ok" | "wait" | "bad"): void {
+ $("conn-text").textContent = label;
+ const dot = $("conn-status").querySelector(".dot") as HTMLElement;
+ const color = state === "ok" ? "bg-emerald-500" : state === "wait" ? "bg-amber-400" : "bg-red-500";
+ dot.className = `dot w-2 h-2 rounded-full ${color}`;
+}
+
+function tile(id: string, title: string, canvas: HTMLCanvasElement, you = false): HTMLElement {
+ const el = document.createElement("div");
+ el.className = "rounded-lg overflow-hidden border border-neutral-800 bg-neutral-900";
+ const label = document.createElement("div");
+ label.className = "px-3 py-1.5 text-xs font-mono text-neutral-300 border-b border-neutral-800 truncate";
+ label.textContent = you ? `${title} (you)` : title;
+ canvas.className = "w-full h-auto bg-black";
+ canvas.style.aspectRatio = "16 / 9";
+ el.append(label, canvas);
+ tiles.set(id, { element: el, label });
+ tilesEl.append(el);
+ emptyEl.hidden = true;
+ return el;
+}
+
+function dropTile(id: string): void {
+ tiles.get(id)?.element.remove();
+ tiles.delete(id);
+ emptyEl.hidden = tiles.size > 0;
+}
+
+function roomUrl(relay: string, roomName: string): URL {
+ const base = relay.replace(/\/+$/, "");
+ return new URL(`${base}/anon/meet/${roomName}`);
+}
+
+function leave(): void {
+ session?.close();
+ session = undefined;
+ localPreview?.close();
+ localPreview = undefined;
+ room?.close();
+ room = undefined;
+ local?.close();
+ local = undefined;
+ connection?.close();
+ connection = undefined;
+ for (const id of [...tiles.keys()]) dropTile(id);
+ $("controls").hidden = true;
+ $("join").textContent = "Join";
+ joined.set(false);
+ setPill("Disconnected", "bad");
+}
+
+function join(): void {
+ leave();
+
+ const roomName = segment(roomInput.value, "demo");
+ const name = segment(nameInput.value, randomName());
+ roomInput.value = roomName;
+ nameInput.value = name;
+
+ const next = new URL(location.href);
+ next.searchParams.set("room", roomName);
+ next.searchParams.set("name", name);
+ history.replaceState(undefined, "", next);
+
+ let relay: URL;
+ try {
+ relay = new URL(relayEl.value.trim());
+ } catch {
+ relayEl.value = RELAY_URL;
+ relay = new URL(RELAY_URL);
+ }
+
+ const identity = Net.Path.from(name);
+ connection = new Net.Connection.Reload({
+ url: roomUrl(relay.toString(), roomName),
+ enabled: true,
+ });
+ local = new Local({
+ connection: connection.established,
+ identity,
+ enabled: true,
+ user: { id: name, name },
+ });
+ local.cameraEnabled.set(true);
+ local.microphoneEnabled.set(true);
+ room = new Room({ connection, identity });
+
+ const localCanvas = document.createElement("canvas");
+ tile("local", name, localCanvas, true);
+ localPreview = new Publish.Preview.Renderer({
+ canvas: localCanvas,
+ frame: local.cameraCapture.out.frame,
+ display: local.cameraCapture.out.display,
+ flip: true,
+ });
+
+ $("controls").hidden = false;
+ $("join").textContent = "Leave";
+ joined.set(true);
+
+ session = new Signals.Effect();
+ session.run((effect) => {
+ if (!connection) return;
+ const status = effect.get(connection.status);
+ const label = status.charAt(0).toUpperCase() + status.slice(1);
+ setPill(label, status === "connected" ? "ok" : status === "connecting" ? "wait" : "bad");
+ });
+
+ const members = new Map();
+ session.run((effect) => {
+ if (!room) return;
+ const remotes = effect.get(room.remotes);
+ const live = new Set();
+
+ for (const [id, remote] of remotes) {
+ for (const member of [effect.get(remote.camera), effect.get(remote.screen)]) {
+ if (!member) continue;
+ const key = `${id}/${member.kind}`;
+ live.add(key);
+ const title =
+ member.kind === "screen"
+ ? `${effect.get(remote.user.name) ?? id} screen`
+ : (effect.get(remote.user.name) ?? id);
+ const previous = members.get(key);
+ if (previous === member) {
+ const existing = tiles.get(key);
+ if (existing) existing.label.textContent = title;
+ continue;
+ }
+ previous?.canvas.set(undefined);
+ dropTile(key);
+ members.set(key, member);
+ const canvas = document.createElement("canvas");
+ tile(key, title, canvas);
+ member.canvas.set(canvas);
+ member.muted.set(false);
+ }
+ }
+
+ for (const [key, member] of members) {
+ if (live.has(key)) continue;
+ member.canvas.set(undefined);
+ members.delete(key);
+ dropTile(key);
+ }
+ });
+}
+
+$("join").addEventListener("click", () => {
+ if (joined.peek()) leave();
+ else join();
+});
+
+function arm(id: string, pick: () => Signals.Signal | undefined): void {
+ const button = $(id);
+ button.addEventListener("click", () => {
+ const s = pick();
+ if (!s) return;
+ s.set(!s.peek());
+ });
+ ui.run((effect) => {
+ effect.get(joined);
+ const s = pick();
+ const on = s ? effect.get(s) : false;
+ button.classList.toggle("bg-emerald-700", on);
+ button.classList.toggle("hover:bg-emerald-600", on);
+ button.classList.toggle("bg-neutral-800", !on);
+ });
+}
+
+arm("toggle-camera", () => local?.cameraEnabled);
+arm("toggle-mic", () => local?.microphoneEnabled);
+arm("toggle-screen", () => local?.screenEnabled);
+
+if (import.meta.hot) {
+ import.meta.hot.dispose(() => {
+ leave();
+ ui.close();
+ });
+}
diff --git a/demo/web/vite.config.ts b/demo/web/vite.config.ts
index 3210e42f3c..7c21a5a507 100644
--- a/demo/web/vite.config.ts
+++ b/demo/web/vite.config.ts
@@ -28,6 +28,7 @@ export default defineConfig({
watch: resolve(__dirname, "src/watch.html"),
publish: resolve(__dirname, "src/publish.html"),
stats: resolve(__dirname, "src/stats.html"),
+ meet: resolve(__dirname, "src/meet.html"),
},
},
},
diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts
index 10c5038fce..775556145b 100644
--- a/doc/.vitepress/config.ts
+++ b/doc/.vitepress/config.ts
@@ -165,6 +165,7 @@ export default defineConfig({
{ text: "moq-video", link: "/lib/rs/moq-video" },
{ text: "moq-audio", link: "/lib/rs/moq-audio" },
{ text: "moq-token", link: "/lib/rs/moq-token" },
+ { text: "moq-room", link: "/lib/rs/moq-room" },
],
},
{
@@ -175,6 +176,7 @@ export default defineConfig({
{ text: "@moq/hang", link: "/lib/js/hang" },
{ text: "@moq/watch", link: "/lib/js/watch" },
{ text: "@moq/publish", link: "/lib/js/publish" },
+ { text: "@moq/room", link: "/lib/js/room" },
{ text: "@moq/token", link: "/lib/js/token" },
{ text: "@moq/signals", link: "/lib/js/signals" },
],
diff --git a/doc/bin/demo.md b/doc/bin/demo.md
index 0a63fe1c6d..f2bfbe7c67 100644
--- a/doc/bin/demo.md
+++ b/doc/bin/demo.md
@@ -19,6 +19,7 @@ that uses the [``](/lib/js/watch) and
- **Watching** a live broadcast with an adjustable latency budget and a stats overlay.
- **Publishing** your camera, microphone, screen, or a file from the browser with WebCodecs.
+- **Meeting**: a multi-participant room (`meet.html`) using `@moq/room`. Open the same room in two tabs.
- **Discovery**: broadcasts appear as they're announced under the prefix.
`just web serve https://cdn.moq.dev/anon` points it at the public relay
diff --git a/doc/index.md b/doc/index.md
index bcac65f56f..e217716fcf 100644
--- a/doc/index.md
+++ b/doc/index.md
@@ -83,7 +83,7 @@ See the [concepts](/concept/) page for a breakdown of the layering, rationale, a
| Use case | Reach for |
| --- | --- |
| Live streaming | Ingest with [OBS](/bin/obs), [RTMP](/bin/rtmp), or [SRT](/bin/srt); distribute with [moq-relay](/bin/relay/); watch with [``](/lib/js/watch); keep legacy players via [HLS](/bin/hls). |
-| Conferencing | [``](/lib/js/publish) and [``](/lib/js/watch) in the browser, one broadcast per participant, plus [WebRTC](/bin/rtc) for WHIP/WHEP clients. |
+| Conferencing | [`@moq/room`](/lib/js/room) / [`moq-room`](/lib/rs/moq-room) for the roster, [``](/lib/js/publish) and [``](/lib/js/watch) in the browser, plus [WebRTC](/bin/rtc) for WHIP/WHEP clients. |
| Voice and video AI | Server-side media in [Rust](/lib/rs/) or [Python](/lib/py/), faster-than-real-time playback in the browser. See [MoQ for AI](/concept/use-case/ai). |
| Real-time data | Chat, game state, telemetry, and control channels over the same relays with [`moq-net`](/lib/rs/moq-net) or [`@moq/net`](/lib/js/net). |
| Interactive streams | Media down, input up. [MoQ Boy](/bin/demo) is a crowd-controlled Game Boy built this way. |
diff --git a/doc/lib/js/index.md b/doc/lib/js/index.md
index b95245f4d8..294b8a7d9d 100644
--- a/doc/lib/js/index.md
+++ b/doc/lib/js/index.md
@@ -16,6 +16,7 @@ and WebAudio. `@moq/net` also runs in Node, Bun, and Deno.
| [@moq/hang](/lib/js/hang) | The media layer: catalog types and containers. |
| [@moq/watch](/lib/js/watch) | Subscribe, decode, and render. `` plus an optional UI overlay. |
| [@moq/publish](/lib/js/publish) | Capture, encode, and publish. `` plus an optional UI overlay. |
+| [@moq/room](/lib/js/room) | Headless rooms: announce-derived roster, local publish, remote watch, and a chat track. |
| [@moq/token](/lib/js/token) | Mint and verify relay JWTs. |
| [@moq/signals](/lib/js/signals) | The reactive primitives every package exposes its state through. |
| [@moq/json](https://www.npmjs.com/package/@moq/json) | JSON over tracks: snapshots with merge-patch deltas, or append logs. |
diff --git a/doc/lib/js/room.md b/doc/lib/js/room.md
new file mode 100644
index 0000000000..6537078013
--- /dev/null
+++ b/doc/lib/js/room.md
@@ -0,0 +1,52 @@
+---
+title: "@moq/room"
+description: Headless multi-participant rooms over MoQ
+---
+
+# @moq/room
+
+[](https://www.npmjs.com/package/@moq/room)
+
+A room is a path prefix. There is no service and no storage: joining is minting
+a moq-token rooted at that prefix and dialing the relay. Participants are
+discovered from the announce stream. Identity is the path before `camera.hang` /
+`screen.hang`. Each participant publishes `{identity}/camera.hang` (camera + mic, hd/sd)
+and `{identity}/screen.hang` (screenshare).
+
+```ts
+import { Local, Room } from "@moq/room";
+import { Connection, Path } from "@moq/net";
+import { claims } from "@moq/room";
+import { sign } from "@moq/token";
+
+const token = await sign(key, claims("meet/demo", "alice"));
+const connection = new Connection.Reload({
+ url: new URL(`https://relay.example.com/meet/demo?jwt=${token}`),
+ enabled: true,
+});
+
+const local = new Local({
+ connection: connection.established,
+ identity: Path.from("alice"),
+ user: { name: "Alice" },
+});
+local.enabled.set(true);
+local.cameraEnabled.set(true);
+
+const room = new Room({ connection, identity: Path.from("alice") });
+```
+
+On a public prefix, skip the token and dial that path directly. The conferencing
+demo at [`demo/web`](https://github.com/moq-dev/moq/tree/main/demo/web) (`meet.html`)
+does that under `anon/meet/{room}`.
+
+hang.live should depend on this package for the roster, local publish, remote
+watch, and `hang/user.json` + `hang/preview.json`. Location stays an app-defined
+catalog extension. The JSON window chat track (`Chat`) uses `@moq/json` Window;
+hang.live's JSON chat (`hang/chat.json`) stays an extension of the same catalog
+`hang` section.
+
+The native twin is [`moq-room`](/lib/rs/moq-room).
+
+See the package [README](https://github.com/moq-dev/moq/blob/main/js/room/README.md)
+for the full API.
diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md
index e60c801b21..e865dd1595 100644
--- a/doc/lib/rs/index.md
+++ b/doc/lib/rs/index.md
@@ -20,6 +20,7 @@ The reference implementation. Every crate is on
| [moq-audio](/lib/rs/moq-audio) | Microphone and speaker, Opus/PCM/AAC codecs, echo cancellation. |
| [moq-transcode](https://docs.rs/moq-transcode) | Just-in-time rendition ladders, GPU-resident on NVIDIA. |
| [moq-token](/lib/rs/moq-token) | JWT keys, signing, verification, path authorization. |
+| [moq-room](/lib/rs/moq-room) | Headless rooms: announce-derived roster, token claims, and a chat track. |
| [moq-json](https://docs.rs/moq-json) | JSON over tracks: snapshots with merge-patch deltas, or append logs. |
| [moq-flate](https://docs.rs/moq-flate) | Group-scoped DEFLATE for any track. |
| [moq-loc](https://docs.rs/moq-loc), [moq-msf](https://docs.rs/moq-msf) | The IETF LOC container and MSF catalog. |
diff --git a/doc/lib/rs/moq-room.md b/doc/lib/rs/moq-room.md
new file mode 100644
index 0000000000..f5b9779b8b
--- /dev/null
+++ b/doc/lib/rs/moq-room.md
@@ -0,0 +1,43 @@
+---
+title: moq-room
+description: Headless multi-participant rooms over MoQ
+---
+
+# moq-room
+
+[](https://crates.io/crates/moq-room)
+[](https://docs.rs/moq-room)
+
+The native twin of [`@moq/room`](/lib/js/room). A room is a path prefix. There
+is no service and no storage: joining is minting a moq-token rooted at that
+prefix and dialing the relay. Participants are discovered from the announce
+stream. Identity is the path before `camera.hang` / `screen.hang`.
+
+Each participant publishes `{identity}/camera.hang` (camera + mic) and
+`{identity}/screen.hang` (screenshare; its announce/unannounce is the share
+lifecycle). Capture and encode stay in [`moq-video`](/lib/rs/moq-video) and
+[`moq-audio`](/lib/rs/moq-audio).
+
+```bash
+cargo add moq-room
+```
+
+```rust
+use moq_net::{Origin, Path};
+use moq_room::{Kind, Room, claims};
+
+let token = key.sign(&claims("meet/demo", "alice")?, None)?;
+let origin = Origin::random().produce();
+let mut room = Room::new(&origin.consume(), Some(Path::new("alice").to_owned()));
+while let Some(event) = room.next().await {
+ if event.kind == Kind::Camera {
+ // subscribe to event.broadcast
+ }
+}
+```
+
+The JSON window `chat` track (using `moq-json::window`) is
+`moq_room::chat`. That is not hang.live's `hang/chat.json` catalog extension.
+
+Gossip, tickets, and 1:1 Call stay in iroh-live. API:
+[docs.rs/moq-room](https://docs.rs/moq-room).
diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md
index 84504046dd..c2ddf04a4c 100644
--- a/drafts/draft-lcurley-moq-hang.md
+++ b/drafts/draft-lcurley-moq-hang.md
@@ -420,6 +420,28 @@ A consumer looks up a time by finding the record whose span covers it, which nam
To locate an individual unrecorded group within a span, a consumer MAY extrapolate from the surrounding records when the media track's group sequence numbers are contiguous, or inspect the fetched media itself.
+# Rooms
+
+A room is a broadcast path prefix. A participant publishes camera and microphone
+at `{identity}/camera.hang` and a screen share at `{identity}/screen.hang`, relative
+to that prefix. Identity MUST contain at least one nonempty path segment.
+Consumers MAY also recognize the unsuffixed `camera` and `screen` forms.
+
+A participant MAY publish a `chat` track containing a JSON window of messages
+from the last ten seconds. Each edit opens a new group containing one
+uncompressed UTF-8 JSON header of the form `{"offset": N, "records": ["text", ...]}`.
+The records are the complete retained window, oldest first. The offset is the
+absolute index of its first record and advances as records expire. Offsets MUST
+be nonnegative safe JSON integers (at most 2^53 - 1).
+
+Publishers MUST retire records after ten seconds, including while idle. Consumers
+start at the latest group and report records entering or leaving the window;
+missing index ranges indicate messages that expired before being received.
+Consumers MUST report malformed JSON, non-string records, and transport failures
+as errors, distinct from the clean end of the track. Sender identity comes from
+the broadcast path, not the payload. This track is distinct from an application's
+`hang/chat.json` snapshot extension.
+
# Security Considerations
TODO Security
diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts
index 18d7ae7d18..bf73b79dea 100644
--- a/js/net/src/connection/reload.test.ts
+++ b/js/net/src/connection/reload.test.ts
@@ -174,3 +174,32 @@ test("announcedBroadcast follows the reconnect loop", async () => {
globalThis.WebTransport = original;
}
});
+
+test("closing an announce consumer during upstream teardown does not append retractions", async () => {
+ const { Producer } = await import("../announced.ts");
+ const { Signal } = await import("@moq/signals");
+ const { spyOn } = await import("bun:test");
+ const upstream = new Producer();
+ const reload = new Reload({ enabled: false });
+ reload.established.set({
+ probe: new Signal(undefined),
+ discovery: true,
+ announced: () => upstream.consume(),
+ } as unknown as import("./established.ts").Established);
+ const consumer = reload.announced();
+ const errors = spyOn(console, "error").mockImplementation(() => {});
+ try {
+ upstream.append({ path: Path.from("alice/camera.hang"), active: true });
+ await consumer.next();
+ upstream.close();
+ // Let the upstream read settle, but close before the pump's finally callback runs.
+ await Promise.resolve();
+ consumer.close();
+ await settle();
+ expect(errors.mock.calls).toEqual([]);
+ } finally {
+ consumer.close();
+ reload.close();
+ errors.mockRestore();
+ }
+});
diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts
index e8b0bd9969..d4be26d6d8 100644
--- a/js/net/src/connection/reload.ts
+++ b/js/net/src/connection/reload.ts
@@ -264,12 +264,6 @@ export class Reload {
const producer = new Announce.Producer(prefix);
const consumer = producer.consume();
- // Closing the consumer closes the shared state, so stop appending after that.
- let closed = false;
- void consumer.closed.then(() => {
- closed = true;
- });
-
const pump = new Effect();
pump.run((effect) => {
const conn = effect.get(this.established);
@@ -299,7 +293,7 @@ export class Reload {
} finally {
// Retract everything from the connection that just went away, so a per-broadcast
// watcher tears down instead of clinging to the dead route.
- if (!closed) {
+ if (consumer.closed.peek() === undefined) {
for (const path of active) {
producer.append({ path, active: false });
}
diff --git a/js/room/README.md b/js/room/README.md
new file mode 100644
index 0000000000..6540ba0d12
--- /dev/null
+++ b/js/room/README.md
@@ -0,0 +1,81 @@
+
+
+
+
+# @moq/room
+
+[](https://www.npmjs.com/package/@moq/room)
+[](https://www.typescriptlang.org/)
+
+Headless multi-participant rooms over [Media over QUIC](https://moq.dev/). A room is a path prefix. There is no service and no storage: joining is minting a moq-token rooted at that prefix (the LiveKit AccessToken analogue) and dialing the relay.
+
+Participants are discovered from the announce stream. Identity is the path before `camera.hang` / `screen.hang`. Each participant publishes:
+
+- `{identity}/camera.hang`: camera + microphone, hd/sd renditions
+- `{identity}/screen.hang`: screenshare; its announce/unannounce is the share lifecycle
+
+This is the generic room layer extracted from [hang.live](https://hang.live) (roster, local/remote, `hang/*.json` metadata) and [iroh-live](https://github.com/n0-computer/iroh-live) (the `chat` track `iroh-rooms` is moving onto the announce bus). Memes, 3D layout, chat UI, and accounts stay in the app. The native twin is [`moq-room`](../../rs/moq-room).
+
+## Install
+
+```bash
+bun add @moq/room
+```
+
+## Token
+
+Sign with [`@moq/token`](../token). `root` is the room, `get: ""` subscribes to everyone, `put: "/"` so a participant cannot publish at someone else's paths.
+
+```ts
+import { claims } from "@moq/room";
+import { sign } from "@moq/token";
+
+const token = await sign(key, claims("meet/demo", "alice"));
+// Dial https://relay.example.com/meet/demo?jwt=
+```
+
+On a public prefix (`anon/`), skip the token and dial that path directly.
+
+## Usage
+
+```ts
+import { Local, Room } from "@moq/room";
+import { Connection, Path } from "@moq/net";
+
+const connection = new Connection.Reload({
+ url: new URL("https://relay.example.com/anon/meet/demo"),
+ enabled: true,
+});
+
+const identity = Path.from("alice");
+const local = new Local({
+ connection: connection.established,
+ identity,
+ user: { name: "Alice" },
+});
+local.enabled.set(true);
+local.cameraEnabled.set(true);
+local.microphoneEnabled.set(true);
+
+const room = new Room({ connection, identity });
+
+// room.remotes is a Map. Each Remote has camera/screen
+// Members; assign member.canvas and set member.muted to false to play audio.
+```
+
+hang.live should depend on this package for `Room`, `Local`, `Remote`, and the `hang/*.json` metadata tracks. Location stays an app-defined catalog extension (`TRACK.location`). hang.live's JSON chat (`TRACK.chat` = `hang/chat.json`) is also an extension; the JSON window track is `Chat.TRACK` (`"chat"`).
+
+```ts
+import { Chat } from "@moq/room";
+
+const publisher = Chat.Publisher.create(broadcast);
+publisher.send("hello");
+
+const subscriber = Chat.Subscriber.subscribe(broadcast.consume());
+const event = await subscriber.recv(); // push, pop, or skip
+// Call publisher.finish() and subscriber.close() when done.
+```
+
+A conferencing demo (no memes, no 3D, no chat UI) lives at [`demo/web/src/meet.html`](../../demo/web/src/meet.html).
+
+Room members start muted; set `member.muted` to `false` to play audio. Chat uses uncompressed JSON strings, a retained ten-second window with push/pop/skip events; it is not compatible with the raw UTF-8 iroh-live track. Empty normalized identities are rejected by `claims`.
diff --git a/js/room/package.json b/js/room/package.json
new file mode 100644
index 0000000000..31ab361b56
--- /dev/null
+++ b/js/room/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@moq/room",
+ "type": "module",
+ "version": "0.1.0",
+ "description": "Headless multi-participant rooms over MoQ: announce-derived roster, local publish, remote watch, and a chat track",
+ "license": "(MIT OR Apache-2.0)",
+ "repository": "github:moq-dev/moq",
+ "sideEffects": false,
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "build": "rimraf dist && tsc -b tsconfig.build.json && bun ../common/package.ts",
+ "check": "tsc --noEmit",
+ "test": "bun test --only-failures",
+ "release": "bun ../common/release.ts"
+ },
+ "dependencies": {
+ "@moq/hang": "workspace:^",
+ "@moq/json": "workspace:^",
+ "@moq/net": "workspace:^",
+ "@moq/publish": "workspace:^",
+ "@moq/signals": "workspace:^",
+ "@moq/watch": "workspace:^"
+ },
+ "devDependencies": {
+ "@types/audioworklet": "^0.0.100",
+ "@types/bun": "^1.4.0",
+ "@typescript/lib-dom": "npm:@types/web@^0.0.350",
+ "rimraf": "^6.1.3",
+ "typescript": "7.0.2",
+ "vite": "^8.2.2"
+ }
+}
diff --git a/js/room/src/chat.test.ts b/js/room/src/chat.test.ts
new file mode 100644
index 0000000000..9b025be278
--- /dev/null
+++ b/js/room/src/chat.test.ts
@@ -0,0 +1,45 @@
+import { expect, spyOn, test } from "bun:test";
+import { Broadcast } from "@moq/net";
+import { HISTORY, Publisher, Subscriber, TRACK } from "./chat.ts";
+
+test("chat expires ten-second history and late readers only see retained messages", async () => {
+ const clock = spyOn(performance, "now").mockReturnValue(0);
+ const broadcast = new Broadcast.Producer();
+ const publisher = Publisher.create(broadcast);
+ const subscriber = Subscriber.subscribe(broadcast.consume());
+ try {
+ publisher.send("first");
+ expect(await subscriber.recv()).toEqual({ push: { index: 0, value: "first" } });
+ clock.mockReturnValue(HISTORY - 1);
+ publisher.expire();
+ clock.mockReturnValue(HISTORY);
+ publisher.expire();
+ expect(await subscriber.recv()).toEqual({ pop: { start: 0, end: 1 } });
+ publisher.send("second");
+ const late = Subscriber.subscribe(broadcast.consume());
+ expect(await late.recv()).toEqual({ push: { index: 1, value: "second" } });
+ publisher.finish();
+ expect(await late.recv()).toBeUndefined();
+ late.close();
+ } finally {
+ publisher.finish();
+ subscriber.close();
+ broadcast.close();
+ clock.mockRestore();
+ }
+});
+
+test("chat rejects non-string window records and propagates track failures", async () => {
+ const broadcast = new Broadcast.Producer();
+ const track = broadcast.createTrack(TRACK);
+ const subscriber = Subscriber.subscribe(broadcast.consume());
+ try {
+ track.writeString('{"offset":0,"records":[42]}');
+ await expect(subscriber.recv()).rejects.toThrow("chat record must be a string");
+ track.close(new Error("chat aborted"));
+ await expect(subscriber.recv()).rejects.toThrow("chat aborted");
+ } finally {
+ subscriber.close();
+ broadcast.close();
+ }
+});
diff --git a/js/room/src/chat.ts b/js/room/src/chat.ts
new file mode 100644
index 0000000000..eef3351c09
--- /dev/null
+++ b/js/room/src/chat.ts
@@ -0,0 +1,106 @@
+/**
+ * Chat over an uncompressed JSON window on `chat`, retaining ten seconds of messages.
+ * Sender identity comes from the broadcast, not the payload.
+ * @module
+ */
+
+import * as Json from "@moq/json";
+import type { Broadcast, Track } from "@moq/net";
+import { Effect, Signal } from "@moq/signals";
+
+/** Name of the track carrying the chat window. */
+export const TRACK = "chat";
+/** Delivery priority, below audio and video. */
+export const PRIORITY = 10;
+/** Milliseconds a published message stays in the window. */
+export const HISTORY = 10_000;
+/** A message entering, leaving, or missed from the window. */
+export type Event = Json.Window.Event;
+
+/** Track settings for the latest chat window. */
+export function info(): Pick {
+ return { priority: PRIORITY, ordered: false };
+}
+
+/** Publishes chat messages and retires them after ten seconds, including while idle. */
+export class Publisher {
+ #producer: Json.Window.Producer;
+ #expires = new Signal([]);
+ #signals = new Effect();
+
+ /** Create the chat track on a broadcast. */
+ static create(broadcast: Broadcast.Producer): Publisher {
+ return new Publisher(broadcast.createTrack(TRACK, info()));
+ }
+
+ /** Publish a chat window over an existing track. */
+ constructor(track: Track.Producer) {
+ // Every edit restates the retained window, so a late reader never replays expired records.
+ this.#producer = new Json.Window.Producer(track, { opRatio: 0 });
+ this.#signals.run((effect) => {
+ const next = effect.get(this.#expires)[0];
+ if (next === undefined) return;
+ effect.timer(() => this.expire(), Math.max(0, next - performance.now()));
+ });
+ }
+
+ /** Append nonempty text, first retiring messages whose history has elapsed. */
+ send(text: string): void {
+ if (!text) return;
+ this.expire();
+ this.#producer.push(text);
+ this.#expires.update((expires) => [...expires, performance.now() + HISTORY]);
+ }
+
+ /** Retire elapsed messages now; the publisher also schedules this automatically. */
+ expire(): void {
+ const now = performance.now();
+ const expires = this.#expires.peek();
+ let count = 0;
+ while (count < expires.length && expires[count] <= now) count++;
+ if (!count) return;
+ this.#producer.pop(count);
+ this.#expires.set(expires.slice(count));
+ }
+
+ /** Finish the track and cancel expiry timers. */
+ finish(): void {
+ this.#signals.close();
+ this.#producer.finish();
+ }
+}
+
+/** Reads changes to a participant's retained chat window. */
+export class Subscriber {
+ #track: Track.Subscriber;
+ #consumer: Json.Window.Consumer;
+
+ /** Subscribe to the newest retained window on a broadcast. */
+ static subscribe(broadcast: Broadcast.Consumer): Subscriber {
+ return new Subscriber(broadcast.track(TRACK).subscribe({ ordered: false }));
+ }
+
+ /** Read window changes from an existing subscription. */
+ constructor(track: Track.Subscriber) {
+ const latest = track.latest();
+ if (latest !== undefined) track.startAt(latest);
+ this.#track = track;
+ this.#consumer = new Json.Window.Consumer(track);
+ }
+
+ /** Return the next window change, or undefined on clean completion; failures throw. */
+ async recv(): Promise {
+ const event = await this.#consumer.next();
+ if (!event) return undefined;
+ if ("push" in event) {
+ if (typeof event.push.value !== "string") throw new Error("chat record must be a string");
+ return { push: { index: event.push.index, value: event.push.value } };
+ }
+ return event;
+ }
+
+ /** Cancel the subscription and release buffered records. */
+ close(): void {
+ this.#track.close();
+ }
+}
diff --git a/js/room/src/index.ts b/js/room/src/index.ts
new file mode 100644
index 0000000000..1c8f9a3b10
--- /dev/null
+++ b/js/room/src/index.ts
@@ -0,0 +1,38 @@
+/**
+ * Headless multi-participant rooms over MoQ.
+ *
+ * A room is a path prefix. The connection URL and token root already carry it;
+ * this package has no service and no storage. Participants are discovered from
+ * the announce stream, identity is the path before `camera`/`screen`, and each
+ * participant publishes `{identity}/camera.hang` (camera + mic, hd/sd) and
+ * `{identity}/screen.hang` (screenshare, whose announce/unannounce is the share
+ * lifecycle).
+ *
+ * @module
+ */
+
+export * as Hang from "@moq/hang";
+export * as Net from "@moq/net";
+export * as Publish from "@moq/publish";
+export * as Signals from "@moq/signals";
+export * as Watch from "@moq/watch";
+
+export * as Chat from "./chat.ts";
+export { Local, type LocalProps } from "./local.ts";
+export {
+ consume,
+ type ExtendedCatalog,
+ type HangCatalog,
+ PRIORITY,
+ type Preview,
+ serve,
+ TRACK,
+ type User,
+ type UserFields,
+ type UserProps,
+ userFields,
+} from "./metadata.ts";
+export { broadcastPath, isKind, KIND, type Kind, kindFromSegment, type Parsed, parse } from "./path.ts";
+export { Member, Remote, type RemoteProps } from "./remote.ts";
+export { Room, type RoomProps } from "./room.ts";
+export { type Claims, claims } from "./token.ts";
diff --git a/js/room/src/local.test.ts b/js/room/src/local.test.ts
new file mode 100644
index 0000000000..2d9a5ddf04
--- /dev/null
+++ b/js/room/src/local.test.ts
@@ -0,0 +1,52 @@
+import { expect, mock, test } from "bun:test";
+import { Path } from "@moq/net";
+import { Signal } from "@moq/signals";
+
+const sources: FakeSource[] = [];
+class FakeSource {
+ out = { source: new Signal(undefined) };
+ constructor() {
+ sources.push(this);
+ }
+ close() {}
+}
+class FakePipeline {
+ out = { display: new Signal(undefined), frame: new Signal(undefined) };
+ close() {}
+}
+class FakeBroadcast {
+ net = new Signal(undefined);
+ catalog = { mutate: (fn: (value: object) => void) => fn({}) };
+ close() {}
+}
+// Exercise room lifecycle wiring independently of camera drivers and browser workers.
+mock.module("../../publish/src/index.ts", () => ({
+ Source: { Camera: FakeSource, Microphone: FakeSource, Screen: FakeSource },
+ Video: { Capture: FakePipeline, Encoder: FakePipeline },
+ Audio: { Encoder: FakePipeline },
+ Broadcast: FakeBroadcast,
+}));
+const { Local } = await import("./local.ts");
+async function flush() {
+ for (let i = 0; i < 30; i++) await Promise.resolve();
+}
+
+test("screen capture stays enabled while pending and resets after a live share ends", async () => {
+ const local = new Local({ connection: undefined, identity: Path.from("alice") });
+ try {
+ await flush();
+ local.screenEnabled.set(true);
+ await flush();
+ expect(local.screenEnabled.peek()).toBe(true);
+ const screen = sources.at(-1);
+ screen?.out.source.set({ video: {} });
+ await flush();
+ expect(local.preview.peek().screen).toBe(true);
+ screen?.out.source.set(undefined);
+ await flush();
+ expect(local.screenEnabled.peek()).toBe(false);
+ expect(local.preview.peek().screen).toBe(false);
+ } finally {
+ local.close();
+ }
+});
diff --git a/js/room/src/local.ts b/js/room/src/local.ts
new file mode 100644
index 0000000000..61520b37c8
--- /dev/null
+++ b/js/room/src/local.ts
@@ -0,0 +1,263 @@
+/**
+ * The local participant: camera+mic and screenshare publishers.
+ *
+ * @module
+ */
+
+import type * as Moq from "@moq/net";
+import * as Publish from "@moq/publish";
+import { Effect, type Getter, type GetterInit, getter, Signal } from "@moq/signals";
+import { type Preview, serve, type UserProps, userFields } from "./metadata.ts";
+import { broadcastPath, KIND } from "./path.ts";
+
+type Established = Moq.Connection.Established;
+
+/** Constructor options for {@link Local}. */
+export interface LocalProps {
+ /** Live session, usually a `Connection.Reload`'s `established`. */
+ connection: GetterInit;
+ /** Participant identity; broadcast names are `{identity}/camera.hang` and `{identity}/screen.hang`. */
+ identity: GetterInit;
+ /** When true, announce the camera broadcast (joining the room). Defaults to false. */
+ enabled?: boolean | Signal;
+ /** Capture the camera. Pass a Signal to share it with the app (hang.live Settings). */
+ cameraEnabled?: boolean | Signal;
+ /** Capture the microphone. Pass a Signal to share it with the app. */
+ microphoneEnabled?: boolean | Signal;
+ /** Prompt for and capture a screen. Pass a Signal to share it with the app. */
+ screenEnabled?: boolean | Signal;
+ /** Seed the published user.json fields. */
+ user?: UserProps;
+}
+
+/**
+ * Local camera and screen broadcasts for one participant.
+ *
+ * Enable {@link enabled} to join (announce `{identity}/camera.hang`). Camera and
+ * microphone capture are separate knobs; the screenshare broadcast is
+ * announced only while a share is live.
+ */
+export class Local {
+ /** Participant identity. */
+ readonly identity: Getter;
+
+ /** Announce the camera broadcast (join the room). */
+ readonly enabled: Signal;
+
+ /** Capture the camera. */
+ readonly cameraEnabled: Signal;
+ /** Capture the microphone. */
+ readonly microphoneEnabled: Signal;
+ /** Prompt for and capture a screen. Unannounce when the share ends. */
+ readonly screenEnabled: Signal;
+
+ /** True while the local participant is composing a chat message. */
+ readonly typing: Signal;
+ /** True while a chat message is live. */
+ readonly chatting: Signal;
+
+ /** Published user.json fields. */
+ readonly user: ReturnType;
+
+ /** Camera capture source. */
+ readonly webcam: Publish.Source.Camera;
+ /** Microphone capture source. */
+ readonly microphone: Publish.Source.Microphone;
+ /** Screen capture source. */
+ readonly share: Publish.Source.Screen;
+
+ /** Camera+mic broadcast at `{identity}/camera.hang`. */
+ readonly camera: Publish.Broadcast;
+ /** Screenshare broadcast at `{identity}/screen.hang`. */
+ readonly screen: Publish.Broadcast;
+
+ /** Shared capture feeding the camera renditions. */
+ readonly cameraCapture: Publish.Video.Capture;
+ /** Shared capture feeding the screen renditions. */
+ readonly screenCapture: Publish.Video.Capture;
+
+ /** Camera HD encoder. */
+ readonly cameraHd: Publish.Video.Encoder;
+ /** Camera SD encoder. */
+ readonly cameraSd: Publish.Video.Encoder;
+ /** Camera microphone encoder. */
+ readonly cameraAudio: Publish.Audio.Encoder;
+
+ /** Screen HD encoder. */
+ readonly screenHd: Publish.Video.Encoder;
+ /** Screen SD encoder. */
+ readonly screenSd: Publish.Video.Encoder;
+ /** Screen audio encoder (tab/system audio when the share includes it). */
+ readonly screenAudio: Publish.Audio.Encoder;
+
+ #preview = new Signal({});
+ #screenVideo = new Signal(undefined);
+ #screenAudioSource = new Signal(undefined);
+ #screenLive = new Signal(false);
+
+ #signals = new Effect();
+
+ constructor(props: LocalProps) {
+ this.identity = getter(props.identity);
+ this.enabled = Signal.from(props.enabled ?? false);
+ this.cameraEnabled = Signal.from(props.cameraEnabled ?? false);
+ this.microphoneEnabled = Signal.from(props.microphoneEnabled ?? false);
+ this.screenEnabled = Signal.from(props.screenEnabled ?? false);
+ this.typing = new Signal(false);
+ this.chatting = new Signal(false);
+ this.user = userFields(props.user);
+
+ const connection = getter(props.connection);
+
+ this.webcam = new Publish.Source.Camera({
+ enabled: this.cameraEnabled,
+ constraints: {
+ width: { ideal: 1280 },
+ height: { ideal: 720 },
+ frameRate: { ideal: 30 },
+ facingMode: { ideal: "user" },
+ },
+ });
+ this.#signals.cleanup(() => this.webcam.close());
+
+ this.microphone = new Publish.Source.Microphone({
+ enabled: this.microphoneEnabled,
+ constraints: {
+ channelCount: { ideal: 1, max: 2 },
+ autoGainControl: { ideal: true },
+ noiseSuppression: { ideal: true },
+ echoCancellation: { ideal: true },
+ },
+ });
+ this.#signals.cleanup(() => this.microphone.close());
+
+ this.share = new Publish.Source.Screen({
+ enabled: this.screenEnabled,
+ video: {
+ frameRate: { ideal: 30 },
+ width: { max: 1920 },
+ height: { max: 1080 },
+ },
+ audio: {
+ channelCount: { ideal: 2, max: 2 },
+ autoGainControl: { ideal: false },
+ echoCancellation: { ideal: false },
+ noiseSuppression: { ideal: false },
+ },
+ });
+ this.#signals.cleanup(() => this.share.close());
+
+ this.cameraCapture = new Publish.Video.Capture({ source: this.webcam.out.source });
+ this.#signals.cleanup(() => this.cameraCapture.close());
+
+ this.screenCapture = new Publish.Video.Capture({ source: this.#screenVideo });
+ this.#signals.cleanup(() => this.screenCapture.close());
+
+ const cameraName = new Signal(broadcastPath(this.identity.peek(), KIND.camera));
+ const screenName = new Signal(broadcastPath(this.identity.peek(), KIND.screen));
+ this.#signals.run((effect) => {
+ const identity = effect.get(this.identity);
+ cameraName.set(broadcastPath(identity, KIND.camera));
+ screenName.set(broadcastPath(identity, KIND.screen));
+ });
+
+ this.camera = new Publish.Broadcast({
+ connection,
+ enabled: this.enabled,
+ name: cameraName,
+ display: this.cameraCapture.out.display,
+ flip: true,
+ });
+ this.#signals.cleanup(() => this.camera.close());
+
+ this.screen = new Publish.Broadcast({
+ connection,
+ enabled: this.#screenLive,
+ name: screenName,
+ display: this.screenCapture.out.display,
+ });
+ this.#signals.cleanup(() => this.screen.close());
+
+ this.cameraHd = new Publish.Video.Encoder("video/hd", {
+ broadcast: this.camera,
+ capture: this.cameraCapture,
+ enabled: this.cameraEnabled,
+ config: { maxPixels: 1280 * 720 },
+ });
+ this.#signals.cleanup(() => this.cameraHd.close());
+
+ this.cameraSd = new Publish.Video.Encoder("video/sd", {
+ broadcast: this.camera,
+ capture: this.cameraCapture,
+ enabled: this.cameraEnabled,
+ config: { maxPixels: 640 * 360 },
+ });
+ this.#signals.cleanup(() => this.cameraSd.close());
+
+ this.cameraAudio = new Publish.Audio.Encoder("audio", {
+ broadcast: this.camera,
+ source: this.microphone.out.source,
+ enabled: this.microphoneEnabled,
+ });
+ this.#signals.cleanup(() => this.cameraAudio.close());
+
+ this.screenHd = new Publish.Video.Encoder("video/hd", {
+ broadcast: this.screen,
+ capture: this.screenCapture,
+ enabled: this.#screenLive,
+ config: { maxPixels: 1920 * 1080 },
+ });
+ this.#signals.cleanup(() => this.screenHd.close());
+
+ this.screenSd = new Publish.Video.Encoder("video/sd", {
+ broadcast: this.screen,
+ capture: this.screenCapture,
+ enabled: this.#screenLive,
+ config: { maxPixels: 960 * 540 },
+ });
+ this.#signals.cleanup(() => this.screenSd.close());
+
+ this.screenAudio = new Publish.Audio.Encoder("audio", {
+ broadcast: this.screen,
+ source: this.#screenAudioSource,
+ enabled: this.#screenLive,
+ });
+ this.#signals.cleanup(() => this.screenAudio.close());
+
+ this.#signals.run((effect) => {
+ const source = effect.get(this.share.out.source);
+ this.#screenVideo.set(source?.video);
+ this.#screenAudioSource.set(source?.audio);
+ const live = !!source?.video || !!source?.audio;
+ const wasLive = this.#screenLive.peek();
+ this.#screenLive.set(live);
+ if (!live && wasLive) {
+ this.screenEnabled.set(false);
+ }
+ });
+
+ this.#signals.run((effect) => {
+ this.#preview.set({
+ video: !!effect.get(this.webcam.out.source),
+ audio: !!effect.get(this.microphone.out.source),
+ screen: effect.get(this.#screenLive),
+ name: effect.get(this.user.name),
+ avatar: effect.get(this.user.avatar),
+ chat: effect.get(this.chatting),
+ typing: effect.get(this.typing),
+ });
+ });
+
+ serve(this.camera, this.user, this.#preview, this.#signals);
+ }
+
+ /** Latest preview.json value this participant is publishing. */
+ get preview(): Getter {
+ return this.#preview;
+ }
+
+ /** Release this participant's subscriptions and media resources. */
+ close() {
+ this.#signals.close();
+ }
+}
diff --git a/js/room/src/metadata.test.ts b/js/room/src/metadata.test.ts
new file mode 100644
index 0000000000..473c286587
--- /dev/null
+++ b/js/room/src/metadata.test.ts
@@ -0,0 +1,81 @@
+import { expect, test } from "bun:test";
+import { TRACK, userFields } from "./metadata.ts";
+
+test("userFields seeds from values", () => {
+ const user = userFields({ id: "a", name: "Ada", avatar: "ada.png" });
+ expect(user.id.peek()).toBe("a");
+ expect(user.name.peek()).toBe("Ada");
+ expect(user.avatar.peek()).toBe("ada.png");
+ expect(user.color.peek()).toBeUndefined();
+});
+
+test("core tracks are hang/*.json and extras share the section", () => {
+ expect(TRACK.user).toBe("hang/user.json");
+ expect(TRACK.preview).toBe("hang/preview.json");
+ expect(TRACK.chat).toBe("hang/chat.json");
+ expect(TRACK.location).toBe("hang/location.json");
+});
+
+import * as Json from "@moq/json";
+import * as Net from "@moq/net";
+import type * as Publish from "@moq/publish";
+import { Effect, Signal } from "@moq/signals";
+import type * as Watch from "@moq/watch";
+import { consume, type ExtendedCatalog, type Preview, serve } from "./metadata.ts";
+
+// Drain queued effects and their asynchronous subscription continuations.
+async function flush() {
+ for (let i = 0; i < 30; i++) await Promise.resolve();
+}
+
+test("metadata updates keep the same track and reach an existing subscriber", async () => {
+ const net = new Net.Broadcast.Producer();
+ const catalog = new Signal({});
+ const broadcast = { net: new Signal(net), catalog } as unknown as Publish.Broadcast;
+ const user = userFields({ name: "Alice" });
+ const effect = new Effect();
+ try {
+ serve(broadcast, user, new Signal({}), effect);
+ await flush();
+ const track = net.consume().track(TRACK.user).subscribe();
+ const consumer = new Json.Snapshot.Consumer<{ name: string }>(track);
+ expect((await consumer.next())?.name).toBe("Alice");
+ user.name.set("Bob");
+ await flush();
+ expect((await consumer.next())?.name).toBe("Bob");
+ track.close();
+ } finally {
+ effect.close();
+ net.close();
+ }
+});
+
+test("metadata clears when entries disappear or broadcast becomes inactive", async () => {
+ const net = new Net.Broadcast.Producer();
+ const catalog = new Signal({
+ hang: { user: { track: TRACK.user }, preview: { track: TRACK.preview } },
+ });
+ const active = new Signal(net.consume());
+ const user = new Json.Snapshot.Producer({ track: net.createTrack(TRACK.user) });
+ const preview = new Json.Snapshot.Producer({ track: net.createTrack(TRACK.preview) });
+ user.update({ name: "Alice" });
+ preview.update({ info: { video: true } });
+ const consumed = consume({ out: { catalog, active } } as unknown as Watch.Broadcast);
+ try {
+ await flush();
+ expect(consumed.user.name.peek()).toBe("Alice");
+ expect(consumed.preview.peek()).toEqual({ video: true });
+ catalog.set({ hang: { preview: { track: TRACK.preview } } });
+ await flush();
+ expect(consumed.user.name.peek()).toBeUndefined();
+ expect(consumed.preview.peek()).toEqual({ video: true });
+ active.set(undefined);
+ await flush();
+ expect(consumed.preview.peek()).toEqual({});
+ } finally {
+ consumed.close();
+ user.finish();
+ preview.finish();
+ net.close();
+ }
+});
diff --git a/js/room/src/metadata.ts b/js/room/src/metadata.ts
new file mode 100644
index 0000000000..fa507fc643
--- /dev/null
+++ b/js/room/src/metadata.ts
@@ -0,0 +1,239 @@
+/**
+ * The `hang/*.json` metadata convention: a catalog section pointing at JSON
+ * snapshot tracks on the same broadcast.
+ *
+ * Core carries `user.json` (id, name, avatar) and `preview.json` (presence
+ * booleans). Location (and hang.live's JSON chat) ride the same catalog
+ * section as app-defined extensions; this module does not serve or consume
+ * them. The JSON window `chat` track is `Chat.TRACK` (`"chat"`), not
+ * `hang/chat.json`.
+ *
+ * @module
+ */
+
+import type { Root as CatalogRoot } from "@moq/hang/catalog";
+import * as Json from "@moq/json";
+import type * as Moq from "@moq/net";
+import type * as Publish from "@moq/publish";
+import { Effect, type Getter, type Readonlys, readonlys, Signal } from "@moq/signals";
+import type * as Watch from "@moq/watch";
+
+/** Delivery priority for hang metadata tracks: below the catalog, above audio. */
+export const PRIORITY = 90;
+
+/**
+ * Well-known hang catalog tracks.
+ *
+ * `user` and `preview` are served by this package. `chat` and `location` are
+ * app-defined extensions of the same catalog section (hang.live uses both).
+ */
+export const TRACK = {
+ user: "hang/user.json",
+ preview: "hang/preview.json",
+ chat: "hang/chat.json",
+ location: "hang/location.json",
+} as const;
+
+/** Display name, id, and avatar published on `hang/user.json`. */
+export type User = {
+ id?: string;
+ name?: string;
+ avatar?: string;
+ color?: string;
+};
+
+/** Presence published on `hang/preview.json`. */
+export type Preview = {
+ audio?: boolean;
+ video?: boolean;
+ screen?: boolean;
+ name?: string;
+ avatar?: string;
+ chat?: boolean;
+ typing?: boolean;
+};
+
+type TrackRef = {
+ track: string;
+};
+
+/** The `hang` catalog section. Extra keys (chat, location) pass through. */
+export type HangCatalog = {
+ user?: TrackRef;
+ preview?: TrackRef;
+ chat?: TrackRef;
+ location?: TrackRef;
+};
+
+/** A hang catalog with the optional `hang` section. */
+export type ExtendedCatalog = CatalogRoot & {
+ hang?: HangCatalog;
+};
+
+/** Signals a publisher reads when serving user metadata. */
+export type UserInput = {
+ id: Getter;
+ name: Getter;
+ avatar: Getter;
+ color: Getter;
+};
+
+/** Writable user fields a publisher owns. */
+export type UserFields = {
+ id: Signal;
+ name: Signal;
+ avatar: Signal;
+ color: Signal;
+};
+
+/** Seed values for {@link UserFields}. */
+export type UserProps = {
+ id?: string | Signal;
+ name?: string | Signal;
+ avatar?: string | Signal;
+ color?: string | Signal;
+};
+
+/** Create the writable user signals a publisher owns. */
+export function userFields(props?: UserProps): UserFields {
+ return {
+ id: Signal.from(props?.id),
+ name: Signal.from(props?.name),
+ avatar: Signal.from(props?.avatar),
+ color: Signal.from(props?.color),
+ };
+}
+
+/**
+ * Publish `user.json` and `preview.json` on `broadcast`, and advertise them in
+ * the catalog's `hang` section. Extra hang keys already on the catalog are left
+ * alone so an app can add chat/location without fighting this.
+ */
+export function serve(broadcast: Publish.Broadcast, user: UserInput, preview: Getter, effect: Effect): void {
+ broadcast.catalog.mutate((catalog) => {
+ const extended = catalog as ExtendedCatalog;
+ if (!extended.hang) extended.hang = {};
+ extended.hang.user = { track: TRACK.user };
+ extended.hang.preview = { track: TRACK.preview };
+ });
+
+ effect.cleanup(() => {
+ broadcast.catalog.mutate((catalog) => {
+ const hang = (catalog as ExtendedCatalog).hang;
+ if (!hang) return;
+ delete hang.user;
+ delete hang.preview;
+ if (!hang.chat && !hang.location) {
+ delete (catalog as ExtendedCatalog).hang;
+ }
+ });
+ });
+
+ serveSnapshot(broadcast, TRACK.user, effect, (effect) => ({
+ id: effect.get(user.id),
+ name: effect.get(user.name),
+ avatar: effect.get(user.avatar),
+ color: effect.get(user.color),
+ }));
+
+ serveSnapshot(broadcast, TRACK.preview, effect, (effect) => ({
+ info: effect.get(preview),
+ }));
+}
+
+function serveSnapshot(
+ broadcast: Publish.Broadcast,
+ name: string,
+ effect: Effect,
+ value: (effect: Effect) => T,
+): void {
+ effect.run((effect) => {
+ const net = effect.get(broadcast.net);
+ if (!net) return;
+
+ // A day-long cache so a late joiner still replays the latest value.
+ const track = net.createTrack(name, { latencyMax: 86_400_000, priority: PRIORITY });
+ effect.cleanup(() => track.close());
+
+ const producer = new Json.Snapshot.Producer({ track });
+ effect.cleanup(() => producer.finish());
+
+ effect.run((effect) => {
+ producer.update(value(effect));
+ });
+ });
+}
+
+type Consumed = {
+ user: Readonlys;
+ preview: Getter;
+ close: () => void;
+};
+
+/**
+ * Subscribe to `user.json` and `preview.json` on a watched broadcast.
+ *
+ * Track names come from the catalog's `hang` section so a publisher that
+ * renamed them still works. Missing sections leave the signals empty.
+ */
+export function consume(broadcast: Watch.Broadcast): Consumed {
+ const user = {
+ id: new Signal(undefined),
+ name: new Signal(undefined),
+ avatar: new Signal(undefined),
+ color: new Signal(undefined),
+ };
+ const preview = new Signal({});
+ const signals = new Effect();
+
+ signals.run((effect) => {
+ const catalog = effect.get(broadcast.out.catalog) as ExtendedCatalog | undefined;
+ const hang = catalog?.hang;
+ const active = effect.get(broadcast.out.active);
+ effect.cleanup(() => {
+ for (const field of Object.values(user)) field.set(undefined);
+ preview.set({});
+ });
+ if (!active || !hang) return;
+
+ if (hang.user) {
+ subscribeJson(active, hang.user.track, effect, (value) => {
+ user.id.set(value.id);
+ user.name.set(value.name);
+ user.avatar.set(value.avatar);
+ user.color.set(value.color);
+ });
+ }
+
+ if (hang.preview) {
+ subscribeJson<{ info?: Preview }>(active, hang.preview.track, effect, (value) => {
+ preview.set(value.info ?? {});
+ });
+ }
+ });
+
+ return {
+ user: readonlys(user),
+ preview,
+ close: () => signals.close(),
+ };
+}
+
+function subscribeJson(
+ broadcast: Moq.Broadcast.Consumer,
+ name: string,
+ effect: Effect,
+ update: (value: T) => void,
+): void {
+ const track = broadcast.track(name).subscribe({ priority: PRIORITY });
+ effect.cleanup(() => track.close());
+
+ const consumer = new Json.Snapshot.Consumer(track);
+ effect.spawn(async () => {
+ for (;;) {
+ const value = await Promise.race([effect.cancel, consumer.next()]);
+ if (value === undefined) break;
+ update(value);
+ }
+ });
+}
diff --git a/js/room/src/path.test.ts b/js/room/src/path.test.ts
new file mode 100644
index 0000000000..dd0e705c2e
--- /dev/null
+++ b/js/room/src/path.test.ts
@@ -0,0 +1,40 @@
+import { expect, test } from "bun:test";
+import { Path } from "@moq/net";
+import { broadcastPath, isKind, KIND, kindFromSegment, parse } from "./path.ts";
+
+const p = (s: string) => Path.from(s);
+
+test("parse splits identity and kind", () => {
+ expect(parse(p("alice/camera"))).toEqual({ identity: p("alice"), kind: "camera" });
+ expect(parse(p("alice/screen"))).toEqual({ identity: p("alice"), kind: "screen" });
+});
+
+test("parse accepts a .hang suffix on the kind", () => {
+ expect(parse(p("alice/camera.hang"))).toEqual({ identity: p("alice"), kind: "camera" });
+ expect(parse(p("alice/screen.hang"))).toEqual({ identity: p("alice"), kind: "screen" });
+});
+
+test("parse keeps a multi-segment identity", () => {
+ expect(parse(p("guest/uuid/camera"))).toEqual({ identity: p("guest/uuid"), kind: "camera" });
+});
+
+test("parse rejects a path with no kind or no identity", () => {
+ expect(parse(p("alice"))).toBeUndefined();
+ expect(parse(p("alice/chat"))).toBeUndefined();
+ expect(parse(p("camera"))).toBeUndefined();
+ expect(parse(p(""))).toBeUndefined();
+});
+
+test("kindFromSegment and isKind", () => {
+ expect(kindFromSegment("camera")).toBe("camera");
+ expect(kindFromSegment("camera.hang")).toBe("camera");
+ expect(kindFromSegment("chat")).toBeUndefined();
+ expect(isKind("camera")).toBe(true);
+ expect(isKind("screen")).toBe(true);
+ expect(isKind("chat")).toBe(false);
+});
+
+test("broadcastPath joins identity and kind with a .hang suffix", () => {
+ expect(broadcastPath(p("alice"), KIND.camera)).toBe(p("alice/camera.hang"));
+ expect(broadcastPath(p("guest/uuid"), KIND.screen)).toBe(p("guest/uuid/screen.hang"));
+});
diff --git a/js/room/src/path.ts b/js/room/src/path.ts
new file mode 100644
index 0000000000..0edbf4a9af
--- /dev/null
+++ b/js/room/src/path.ts
@@ -0,0 +1,65 @@
+/**
+ * Room path convention: a participant identity is everything before the last
+ * segment, and that last segment is the broadcast kind (`camera` or `screen`).
+ *
+ * `alice/camera`, `alice/camera.hang`, and `guest/uuid/screen` are all valid.
+ * A `.hang` suffix on the kind is optional and equivalent.
+ *
+ * @module
+ */
+
+import { Path } from "@moq/net";
+
+/** The two broadcasts each participant may publish. */
+export const KIND = {
+ camera: "camera",
+ screen: "screen",
+} as const;
+
+/** A participant broadcast kind. */
+export type Kind = (typeof KIND)[keyof typeof KIND];
+
+/** An announced path split into identity and kind, or `undefined` if it is not a room broadcast. */
+export type Parsed = {
+ /** Path prefix identifying the participant; may be more than one segment. */
+ identity: Path.Valid;
+ /** `camera` (camera + mic) or `screen` (screenshare). */
+ kind: Kind;
+};
+
+/** True when `value` is a {@link Kind}. */
+export function isKind(value: string): value is Kind {
+ return value === KIND.camera || value === KIND.screen;
+}
+
+/**
+ * Strip an optional `.hang` catalog-format suffix from a path segment.
+ *
+ * `camera` and `camera.hang` are the same kind; publishers may omit the suffix
+ * (hang.live does) because hang is the default catalog format.
+ */
+export function kindFromSegment(segment: string): Kind | undefined {
+ const kind = segment.endsWith(".hang") ? segment.slice(0, -".hang".length) : segment;
+ return isKind(kind) ? kind : undefined;
+}
+
+/**
+ * Split a room-relative broadcast path into identity and kind.
+ *
+ * The last segment is the kind; everything before it is the identity. Returns
+ * `undefined` when there is no identity, or the last segment is not a kind.
+ */
+export function parse(path: Path.Valid): Parsed | undefined {
+ const parts = Path.parts(path);
+ if (parts.length < 2) return undefined;
+
+ const kind = kindFromSegment(parts[parts.length - 1]);
+ if (!kind) return undefined;
+
+ return { identity: Path.from(...parts.slice(0, -1)), kind };
+}
+
+/** The broadcast path a participant publishes for `kind`. */
+export function broadcastPath(identity: Path.Valid, kind: Kind): Path.Valid {
+ return Path.join(identity, Path.from(`${kind}.hang`));
+}
diff --git a/js/room/src/remote.ts b/js/room/src/remote.ts
new file mode 100644
index 0000000000..bc87cab75e
--- /dev/null
+++ b/js/room/src/remote.ts
@@ -0,0 +1,214 @@
+/**
+ * A remote participant: camera and screen watch pipelines plus metadata.
+ *
+ * @module
+ */
+
+import type * as Moq from "@moq/net";
+import { Effect, type Getter, type GetterInit, getter, type Readonlys, readonlys, Signal } from "@moq/signals";
+import * as Watch from "@moq/watch";
+import { consume, type Preview, type UserInput } from "./metadata.ts";
+import { KIND, type Kind } from "./path.ts";
+
+type Established = Moq.Connection.Established;
+
+/** One watched broadcast (camera or screen) for a remote participant. */
+export class Member {
+ /** `camera` or `screen`. */
+ readonly kind: Kind;
+ /** Broadcast path relative to the connection root. */
+ readonly path: Moq.Path.Valid;
+
+ /** Canvas to paint into; assign from the app. */
+ readonly canvas = new Signal(undefined);
+ /** Mute this member's audio; defaults to true. */
+ readonly muted = new Signal(true);
+ /** Playback volume, 0..1. */
+ readonly volume = new Signal(0.5);
+
+ /** Watched broadcast and catalog. */
+ readonly broadcast: Watch.Broadcast;
+ /** Video decoding pipeline. */
+ readonly video: Watch.Video.Decoder;
+ /** Audio decoding pipeline. */
+ readonly audio: Watch.Audio.Decoder;
+ /** Canvas video renderer. */
+ readonly renderer: Watch.Video.Renderer;
+ /** Speaker audio output. */
+ readonly emitter: Watch.Audio.Emitter;
+
+ /** Published participant identity and display fields. */
+ readonly user: Readonlys;
+ /** Published presence fields. */
+ readonly preview: Getter;
+
+ #videoEnabled = new Signal(false);
+ #audioEnabled = new Signal(false);
+ #metadata: ReturnType;
+ #signals = new Effect();
+
+ constructor(kind: Kind, path: Moq.Path.Valid, connection: Getter) {
+ this.kind = kind;
+ this.path = path;
+
+ this.broadcast = new Watch.Broadcast({
+ connection,
+ enabled: true,
+ name: path,
+ reload: true,
+ });
+ this.#signals.cleanup(() => this.broadcast.close());
+
+ const videoSource = new Watch.Video.Source({
+ broadcast: this.broadcast,
+ supported: Watch.Video.Decoder.supported,
+ });
+ const audioSource = new Watch.Audio.Source({
+ broadcast: this.broadcast,
+ supported: Watch.Audio.Decoder.supported,
+ });
+ this.#signals.cleanup(() => {
+ videoSource.close();
+ audioSource.close();
+ });
+
+ const sync = new Watch.Sync({
+ latency: "real-time",
+ connection,
+ video: videoSource.out.jitter,
+ audio: audioSource.out.jitter,
+ });
+ this.#signals.cleanup(() => sync.close());
+
+ this.video = new Watch.Video.Decoder(videoSource, sync, { enabled: this.#videoEnabled });
+ this.audio = new Watch.Audio.Decoder(audioSource, sync, { enabled: this.#audioEnabled });
+ this.#signals.cleanup(() => {
+ this.video.close();
+ this.audio.close();
+ });
+
+ this.renderer = new Watch.Video.Renderer(this.video, {
+ canvas: this.canvas,
+ });
+ this.emitter = new Watch.Audio.Emitter(this.audio, {
+ volume: this.volume,
+ muted: this.muted,
+ });
+ this.#signals.cleanup(() => {
+ this.renderer.close();
+ this.emitter.close();
+ });
+
+ this.#signals.run((effect) => {
+ this.#videoEnabled.set(effect.get(this.renderer.out.visible));
+ });
+ this.#signals.run((effect) => {
+ this.#audioEnabled.set(effect.get(this.emitter.out.enabled));
+ });
+
+ this.#metadata = consume(this.broadcast);
+ this.user = this.#metadata.user;
+ this.preview = this.#metadata.preview;
+ this.#signals.cleanup(() => this.#metadata.close());
+ }
+
+ /** Release this participant's subscriptions and media resources. */
+ close() {
+ this.#signals.close();
+ }
+}
+
+/** Constructor options for {@link Remote}. */
+export interface RemoteProps {
+ /** Participant identity. */
+ identity: Moq.Path.Valid;
+ /** Live session, usually a `Connection.Reload`'s `established`. */
+ connection: GetterInit;
+}
+
+/**
+ * Groups one identity's `camera` and `screen` broadcasts.
+ *
+ * User and preview metadata come from the camera broadcast when it is live,
+ * otherwise from the screen.
+ */
+export class Remote {
+ /** Participant identity. */
+ readonly identity: Moq.Path.Valid;
+
+ readonly #camera = new Signal(undefined);
+ readonly #screen = new Signal(undefined);
+ readonly #user = {
+ id: new Signal(undefined),
+ name: new Signal(undefined),
+ avatar: new Signal(undefined),
+ color: new Signal(undefined),
+ };
+ readonly #preview = new Signal({});
+
+ /** The live camera member, if announced. */
+ readonly camera: Getter;
+ /** The live screen member, if announced. */
+ readonly screen: Getter;
+ /** Published participant identity and display fields. */
+ readonly user: Readonlys;
+ /** Published presence fields. */
+ readonly preview: Getter;
+
+ #connection: Getter;
+ #signals = new Effect();
+
+ constructor(props: RemoteProps) {
+ this.identity = props.identity;
+ this.#connection = getter(props.connection);
+ this.camera = this.#camera;
+ this.screen = this.#screen;
+ this.user = readonlys(this.#user);
+ this.preview = this.#preview;
+
+ this.#signals.run((effect) => {
+ const member = effect.get(this.#camera) ?? effect.get(this.#screen);
+ if (!member) {
+ this.#user.id.set(undefined);
+ this.#user.name.set(undefined);
+ this.#user.avatar.set(undefined);
+ this.#user.color.set(undefined);
+ this.#preview.set({});
+ return;
+ }
+ this.#user.id.set(effect.get(member.user.id));
+ this.#user.name.set(effect.get(member.user.name));
+ this.#user.avatar.set(effect.get(member.user.avatar));
+ this.#user.color.set(effect.get(member.user.color));
+ this.#preview.set(effect.get(member.preview));
+ });
+ }
+
+ /** Attach a live camera or screen broadcast. */
+ attach(kind: Kind, path: Moq.Path.Valid): void {
+ const slot = kind === KIND.camera ? this.#camera : this.#screen;
+ slot.peek()?.close();
+ slot.set(new Member(kind, path, this.#connection));
+ }
+
+ /** Detach a camera or screen broadcast that went offline. */
+ detach(kind: Kind): void {
+ const slot = kind === KIND.camera ? this.#camera : this.#screen;
+ slot.peek()?.close();
+ slot.set(undefined);
+ }
+
+ /** True when neither camera nor screen is live. */
+ empty(): boolean {
+ return !this.#camera.peek() && !this.#screen.peek();
+ }
+
+ /** Release this participant's subscriptions and media resources. */
+ close() {
+ this.#camera.peek()?.close();
+ this.#screen.peek()?.close();
+ this.#camera.set(undefined);
+ this.#screen.set(undefined);
+ this.#signals.close();
+ }
+}
diff --git a/js/room/src/room.test.ts b/js/room/src/room.test.ts
new file mode 100644
index 0000000000..ba9fdefbd9
--- /dev/null
+++ b/js/room/src/room.test.ts
@@ -0,0 +1,40 @@
+import { expect, mock, spyOn, test } from "bun:test";
+import * as Net from "@moq/net";
+import { Signal } from "@moq/signals";
+
+// Vite's worklet loader is not available in Bun; discovery does not start audio.
+mock.module("../../watch/src/audio/render-worklet.ts?worklet", () => ({ default: "blob:fake-render" }));
+const { Remote } = await import("./remote.ts");
+const { Room } = await import("./room.ts");
+
+async function flush() {
+ for (let i = 0; i < 30; i++) await Promise.resolve();
+}
+
+test("room restores the announce prefix and reconciles local identity changes", async () => {
+ const streams: Net.Announce.Producer[] = [];
+ const connection = {
+ established: new Signal(undefined),
+ announced(prefix: Net.Path.Valid) {
+ const stream = new Net.Announce.Producer(prefix);
+ streams.push(stream);
+ stream.append({ path: Net.Path.from("bob/camera.hang"), active: true });
+ return stream.consume();
+ },
+ } as unknown as Net.Connection.Reload;
+ const attach = spyOn(Remote.prototype, "attach").mockImplementation(() => {});
+ const identity = new Signal(Net.Path.from("alice"));
+ const room = new Room({ connection, identity, prefix: Net.Path.from("room-a") });
+ try {
+ await flush();
+ expect(attach).toHaveBeenCalledWith("camera", Net.Path.from("room-a/bob/camera.hang"));
+ expect(room.remotes.peek().has(Net.Path.from("bob"))).toBe(true);
+ identity.set(Net.Path.from("bob"));
+ await flush();
+ expect(streams).toHaveLength(2);
+ expect(room.remotes.peek().size).toBe(0);
+ } finally {
+ room.close();
+ attach.mockRestore();
+ }
+});
diff --git a/js/room/src/room.ts b/js/room/src/room.ts
new file mode 100644
index 0000000000..203bc0d80c
--- /dev/null
+++ b/js/room/src/room.ts
@@ -0,0 +1,122 @@
+/**
+ * A room is a path prefix. Participants are discovered from the announce
+ * stream; identity is the path before `camera`/`screen`.
+ *
+ * @module
+ */
+
+import * as Moq from "@moq/net";
+import { Effect, type Getter, type GetterInit, getter, Signal } from "@moq/signals";
+import { type Kind, parse } from "./path.ts";
+import { Remote } from "./remote.ts";
+
+/** Constructor options for {@link Room}. */
+export interface RoomProps {
+ /**
+ * Reconnecting connection whose URL (and token root) already carry the room
+ * prefix. Announcements are relative to that prefix.
+ */
+ connection: Moq.Connection.Reload;
+ /**
+ * Local participant identity. Announcements under this identity are skipped
+ * so the local camera/screen do not appear as remotes.
+ */
+ identity?: GetterInit;
+ /** When false, the announce loop is idle. Defaults to true. */
+ enabled?: GetterInit;
+ /**
+ * Announce prefix relative to the connection URL. Defaults to empty (the
+ * whole root). A connection whose URL is broader than one room (a preview
+ * of several rooms) passes the room name here.
+ */
+ prefix?: GetterInit;
+}
+
+/**
+ * Runs the announce loop and exposes remote participants as a signal map keyed
+ * by identity.
+ */
+export class Room {
+ /** Connection supplying room announcements. */
+ readonly connection: Moq.Connection.Reload;
+ /** Local identity excluded from the roster. */
+ readonly identity: Getter;
+ /** Whether room discovery is active. */
+ readonly enabled: Getter;
+ /** Room prefix relative to the connection root. */
+ readonly prefix: Getter;
+
+ #remotes = new Signal(new Map());
+ #signals = new Effect();
+
+ constructor(props: RoomProps) {
+ this.connection = props.connection;
+ this.identity = getter(props.identity);
+ this.enabled = getter(props.enabled ?? true);
+ this.prefix = getter(props.prefix);
+
+ this.#signals.run((effect) => {
+ if (!effect.get(this.enabled)) return;
+
+ effect.get(this.identity);
+ const prefix = effect.get(this.prefix) ?? Moq.Path.empty();
+ const announced = this.connection.announced(prefix);
+ effect.cleanup(() => announced.close());
+
+ effect.spawn(this.#run.bind(this, announced, effect));
+ effect.cleanup(() => {
+ for (const remote of this.#remotes.peek().values()) remote.close();
+ this.#remotes.set(new Map());
+ });
+ });
+ }
+
+ /** Remote participants, keyed by identity. */
+ get remotes(): Getter