From 87653bf90efd000bc66b245dc98e36c572f86538 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 12 Sep 2026 06:45:53 -0700 Subject: [PATCH 1/5] chore: claim quest/m2/room-sdk From 1b349c6b38b73b0c8c65b6258f41be9407e440fd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 12 Sep 2026 07:22:51 -0700 Subject: [PATCH 2/5] feat(room): extract hang.live conferencing into @moq/room Headless Room/Local/Remote over the announce bus, plus a meet.html demo with no memes or 3D. hang.live and iroh-live can depend on the package for roster, camera/screen paths, hang/*.json metadata, and token claims. Closes #697 Co-Authored-By: grok-4.6 --- .gitignore | 1 + .taplo.toml | 2 +- CONTRIBUTING.md | 1 + biome.jsonc | 2 +- bun.lock | 23 +++ demo/web/README.md | 1 + demo/web/package.json | 1 + demo/web/src/meet.html | 82 +++++++++++ demo/web/src/meet.ts | 223 ++++++++++++++++++++++++++++ demo/web/vite.config.ts | 1 + doc/bin/demo.md | 1 + doc/index.md | 2 +- doc/lib/js/index.md | 1 + doc/lib/js/room.md | 48 ++++++ js/room/README.md | 68 +++++++++ js/room/package.json | 34 +++++ js/room/src/index.ts | 37 +++++ js/room/src/local.ts | 246 +++++++++++++++++++++++++++++++ js/room/src/metadata.test.ts | 17 +++ js/room/src/metadata.ts | 233 +++++++++++++++++++++++++++++ js/room/src/path.test.ts | 40 +++++ js/room/src/path.ts | 65 ++++++++ js/room/src/remote.ts | 202 +++++++++++++++++++++++++ js/room/src/room.ts | 107 ++++++++++++++ js/room/src/token.test.ts | 22 +++ js/room/src/token.ts | 34 +++++ js/room/src/vite-env.d.ts | 1 + js/room/tsconfig.build.json | 4 + js/room/tsconfig.json | 9 ++ package.json | 1 + quest/m2/README.md | 1 - quest/m2/livekit-shim.md | 7 +- quest/m2/room-sdk.md | 51 ------- quest/m3/carrier-voice/README.md | 4 +- 34 files changed, 1509 insertions(+), 63 deletions(-) create mode 100644 demo/web/src/meet.html create mode 100644 demo/web/src/meet.ts create mode 100644 doc/lib/js/room.md create mode 100644 js/room/README.md create mode 100644 js/room/package.json create mode 100644 js/room/src/index.ts create mode 100644 js/room/src/local.ts create mode 100644 js/room/src/metadata.test.ts create mode 100644 js/room/src/metadata.ts create mode 100644 js/room/src/path.test.ts create mode 100644 js/room/src/path.ts create mode 100644 js/room/src/remote.ts create mode 100644 js/room/src/room.ts create mode 100644 js/room/src/token.test.ts create mode 100644 js/room/src/token.ts create mode 100644 js/room/src/vite-env.d.ts create mode 100644 js/room/tsconfig.build.json create mode 100644 js/room/tsconfig.json delete mode 100644 quest/m2/room-sdk.md diff --git a/.gitignore b/.gitignore index 8d90ed6bc9..4dd3d6aa6b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ /.claude/cache/ /.claude/tmp/ /.claude/worktrees/ +/.worktrees/ # IDE .idea diff --git a/.taplo.toml b/.taplo.toml index f248b5f2cb..f29768ddf0 100644 --- a/.taplo.toml +++ b/.taplo.toml @@ -1,5 +1,5 @@ # Skip vendored / build trees so `taplo format` doesn't recurse into them. -exclude = ["**/node_modules/**", "**/target/**", "**/dist/**", "**/.venv/**", "**/.direnv/**"] +exclude = ["**/node_modules/**", "**/target/**", "**/dist/**", "**/.venv/**", "**/.direnv/**", "**/.worktrees/**", "**/.claude/worktrees/**"] # Match the existing repo style (4-space indent on Cargo.toml, lines up # to ~120 chars) so the initial format pass stays minimal. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6b2a24cc3..0256ef258f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,7 @@ For each finding: - If you disagree with the finding, reply and move on. # Follow-ups + If you encounter issues, or findings that are out of scope, create follow-up quests. They should be unplanned and just a summary of the problem, maybe with a potential solution. The user will need to run `/plan-quests` to fully scope them. diff --git a/biome.jsonc b/biome.jsonc index 4d6b347e59..6866c5bb20 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -19,7 +19,7 @@ // indexes. Exclude two kinds it can't handle: // - MPEG transport-stream fixtures whose `.ts` extension collides with TypeScript. // - SVG assets, which Biome routes to its HTML parser and rejects. - "includes": ["**", "!rs/moq-mux/src/container/ts/test_data", "!**/*.svg"] + "includes": ["**", "!rs/moq-mux/src/container/ts/test_data", "!**/*.svg", "!.worktrees", "!.claude/worktrees"] }, "linter": { "rules": { diff --git a/bun.lock b/bun.lock index e9479591a0..d1723d725c 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,7 @@ "@moq/hang": "workspace:^", "@moq/json": "workspace:^", "@moq/publish": "workspace:^", + "@moq/room": "workspace:^", "@moq/watch": "workspace:^", }, "devDependencies": { @@ -228,6 +229,26 @@ "vite": "^8.2.2", }, }, + "js/room": { + "name": "@moq/room", + "version": "0.1.0", + "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", + }, + }, "js/signals": { "name": "@moq/signals", "version": "0.2.3", @@ -613,6 +634,8 @@ "@moq/qmux": ["@moq/qmux@0.3.3", "", { "dependencies": { "@moq/web-socket-stream": "^0.1.1" } }, "sha512-Ez1GCVwpyNGB0eWvhKIXOjXrfRlMdmO0lpJ7gqInZFzsOsYTyML0WRgh24Itczp9Kev0CXsUA/K+XrGs1dRKUA=="], + "@moq/room": ["@moq/room@workspace:js/room"], + "@moq/signals": ["@moq/signals@workspace:js/signals"], "@moq/smoke-browser": ["@moq/smoke-browser@workspace:test/smoke/clients/js"], diff --git a/demo/web/README.md b/demo/web/README.md index f15cd5a900..b70d388ada 100644 --- a/demo/web/README.md +++ b/demo/web/README.md @@ -19,6 +19,7 @@ Run `just web` (or `bun --bun vite` from this directory) and open the pages: - `watch.html` - Watch inspector: one tile per live broadcast discovered under a prefix, click to make a tile active (audio + a live stats panel for video/audio/network and a custom `meta.json` metadata track). - `publish.html` - Publish from a camera/screen/file, plus an editor for the custom `meta.json` metadata track. +- `meet.html` - Conferencing: a room is a path prefix, participants publish `{identity}/camera` and `{identity}/screen`, discovered from the announce stream. Open the same room in two tabs. - `stats.html` - Relay stats dashboard: auto-discovers every node publishing `.stats` and aggregates external vs. cluster traffic. Needs `[stats] enabled = true` on the relay (the demo configs already set it). # License diff --git a/demo/web/package.json b/demo/web/package.json index 525d774558..5d73429820 100644 --- a/demo/web/package.json +++ b/demo/web/package.json @@ -15,6 +15,7 @@ "@moq/hang": "workspace:^", "@moq/json": "workspace:^", "@moq/publish": "workspace:^", + "@moq/room": "workspace:^", "@moq/watch": "workspace:^" }, "//devDependencies": "These are only needed for local development with workspace packages. They are NOT needed when using the published npm packages.", diff --git a/demo/web/src/meet.html b/demo/web/src/meet.html new file mode 100644 index 0000000000..102800db83 --- /dev/null +++ b/demo/web/src/meet.html @@ -0,0 +1,82 @@ + + + + + + + MoQ Demo (Meet) + + + + + + + + + +

MoQ Meet

+

+ 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..5b2b318c88 --- /dev/null +++ b/demo/web/src/meet.ts @@ -0,0 +1,223 @@ +/** + * 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, el); + tilesEl.append(el); + emptyEl.hidden = true; + return el; +} + +function dropTile(id: string): void { + tiles.get(id)?.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); + if (members.has(key)) continue; + members.set(key, member); + const canvas = document.createElement("canvas"); + const title = + member.kind === "screen" + ? `${effect.get(remote.user.name) ?? id} screen` + : (effect.get(remote.user.name) ?? id); + tile(key, title, canvas); + member.canvas.set(canvas); + } + } + + 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/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..f4f9f45277 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) 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..49f65b96a4 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. | | [@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..e203c51d8b --- /dev/null +++ b/doc/lib/js/room.md @@ -0,0 +1,48 @@ +--- +title: "@moq/room" +description: Headless multi-participant rooms over MoQ +--- + +# @moq/room + +[![npm](https://img.shields.io/npm/v/@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` / +`screen`. Each participant publishes `{identity}/camera` (camera + mic, hd/sd) +and `{identity}/screen` (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`. Chat and location stay +app-defined extensions of the same catalog `hang` section. + +See the package [README](https://github.com/moq-dev/moq/blob/main/js/room/README.md) +for the full API. diff --git a/js/room/README.md b/js/room/README.md new file mode 100644 index 0000000000..e6f9cbc247 --- /dev/null +++ b/js/room/README.md @@ -0,0 +1,68 @@ +

+ Media over QUIC +

+ +# @moq/room + +[![npm version](https://img.shields.io/npm/v/@moq/room)](https://www.npmjs.com/package/@moq/room) +[![TypeScript](https://img.shields.io/badge/TypeScript-ready-blue.svg)](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` / `screen`. Each participant publishes: + +- `{identity}/camera` — camera + microphone, hd/sd renditions +- `{identity}/screen` — screenshare; its announce/unannounce is the share lifecycle + +This is the generic room layer extracted from [hang.live](https://hang.live) (and the announce-bus shape [iroh-live](https://github.com/n0-computer/iroh-live) is moving `iroh-rooms` onto). Memes, 3D layout, chat UI, and accounts stay in the app. + +## 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 member.muted from the UI. +``` + +hang.live should depend on this package for `Room`, `Local`, `Remote`, and the `hang/*.json` metadata tracks. Chat and location stay app-defined extensions of the same catalog `hang` section (`TRACK.chat`, `TRACK.location`); call `broadcast.catalog.mutate` to advertise extra tracks next to `user` and `preview`. + +A conferencing demo (no memes, no 3D) lives at [`demo/web/src/meet.html`](../../demo/web/src/meet.html). diff --git a/js/room/package.json b/js/room/package.json new file mode 100644 index 0000000000..7fbdde7285 --- /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", + "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/index.ts b/js/room/src/index.ts new file mode 100644 index 0000000000..e84b02babb --- /dev/null +++ b/js/room/src/index.ts @@ -0,0 +1,37 @@ +/** + * 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` (camera + mic, hd/sd) and + * `{identity}/screen` (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 { 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.ts b/js/room/src/local.ts new file mode 100644 index 0000000000..20efceea27 --- /dev/null +++ b/js/room/src/local.ts @@ -0,0 +1,246 @@ +/** + * 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` and `{identity}/screen`. */ + identity: GetterInit; + /** When true, announce the camera broadcast (joining the room). Defaults to false. */ + enabled?: 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`). 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; + + /** 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`. */ + readonly camera: Publish.Broadcast; + /** Screenshare broadcast at `{identity}/screen`. */ + 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 = new Signal(false); + this.microphoneEnabled = new Signal(false); + this.screenEnabled = 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; + this.#screenLive.set(live); + if (!live && effect.get(this.screenEnabled)) { + 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), + }); + }); + + serve(this.camera, this.user, this.#preview, this.#signals); + } + + /** Latest preview.json value this participant is publishing. */ + get preview(): Getter { + return this.#preview; + } + + 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..ae0b0eeebb --- /dev/null +++ b/js/room/src/metadata.test.ts @@ -0,0 +1,17 @@ +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"); +}); diff --git a/js/room/src/metadata.ts b/js/room/src/metadata.ts new file mode 100644 index 0000000000..773b469f1d --- /dev/null +++ b/js/room/src/metadata.ts @@ -0,0 +1,233 @@ +/** + * 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). Chat and location ride the same catalog section as app-defined + * extensions; this module does not serve or consume them. + * + * @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, initial: value(effect) }); + 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); + 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..767834d9ff --- /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 without a .hang suffix", () => { + expect(broadcastPath(p("alice"), KIND.camera)).toBe(p("alice/camera")); + expect(broadcastPath(p("guest/uuid"), KIND.screen)).toBe(p("guest/uuid/screen")); +}); diff --git a/js/room/src/path.ts b/js/room/src/path.ts new file mode 100644 index 0000000000..8a71288ae5 --- /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)); +} diff --git a/js/room/src/remote.ts b/js/room/src/remote.ts new file mode 100644 index 0000000000..e5df465bdf --- /dev/null +++ b/js/room/src/remote.ts @@ -0,0 +1,202 @@ +/** + * 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 room prefix. */ + readonly path: Moq.Path.Valid; + + /** Canvas to paint into; assign from the app. */ + readonly canvas = new Signal(undefined); + /** Mute this member's audio. */ + readonly muted = new Signal(false); + /** Playback volume, 0..1. */ + readonly volume = new Signal(0.5); + + readonly broadcast: Watch.Broadcast; + readonly video: Watch.Video.Decoder; + readonly audio: Watch.Audio.Decoder; + readonly renderer: Watch.Video.Renderer; + readonly emitter: Watch.Audio.Emitter; + + readonly user: Readonlys; + 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, + visible: "always", + }); + 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()); + } + + 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({}); + + readonly camera: Getter; + readonly screen: Getter; + readonly user: Readonlys; + 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(); + } + + 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.ts b/js/room/src/room.ts new file mode 100644 index 0000000000..401de97c8a --- /dev/null +++ b/js/room/src/room.ts @@ -0,0 +1,107 @@ +/** + * A room is a path prefix. Participants are discovered from the announce + * stream; identity is the path before `camera`/`screen`. + * + * @module + */ + +import type * 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; +} + +/** + * Runs the announce loop and exposes remote participants as a signal map keyed + * by identity. + */ +export class Room { + readonly connection: Moq.Connection.Reload; + readonly identity: Getter; + readonly enabled: 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.#signals.run((effect) => { + if (!effect.get(this.enabled)) return; + + const announced = this.connection.announced(); + 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> { + return this.#remotes; + } + + async #run(announced: Moq.Announce.Consumer, effect: Effect): Promise { + for (;;) { + const update = await Promise.race([effect.cancel, announced.next()]); + if (!update) break; + + const parsed = parse(update.path); + if (!parsed) continue; + + const local = this.identity.peek(); + if (local && parsed.identity === local) continue; + + if (update.active) { + this.#add(parsed.identity, parsed.kind, update.path); + } else { + this.#remove(parsed.identity, parsed.kind); + } + } + } + + #add(identity: Moq.Path.Valid, kind: Kind, path: Moq.Path.Valid): void { + let remote = this.#remotes.peek().get(identity); + if (!remote) { + const created = new Remote({ identity, connection: this.connection.established }); + this.#remotes.mutate((remotes) => remotes.set(identity, created)); + remote = created; + } + remote.attach(kind, path); + } + + #remove(identity: Moq.Path.Valid, kind: Kind): void { + const remote = this.#remotes.peek().get(identity); + if (!remote) return; + remote.detach(kind); + if (remote.empty()) { + remote.close(); + this.#remotes.mutate((remotes) => remotes.delete(identity)); + } + } + + close() { + this.#signals.close(); + } +} diff --git a/js/room/src/token.test.ts b/js/room/src/token.test.ts new file mode 100644 index 0000000000..aa367a2397 --- /dev/null +++ b/js/room/src/token.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import { claims } from "./token.ts"; + +test("claims root the token at the room and scope put to the identity", () => { + expect(claims("meet/demo", "alice")).toEqual({ + root: "meet/demo", + get: "", + put: "alice/", + }); +}); + +test("claims does not double a trailing slash on put", () => { + expect(claims("meet/demo", "alice/").put).toBe("alice/"); +}); + +test("claims accepts a multi-segment identity", () => { + expect(claims("hang/room", "guest/uuid")).toEqual({ + root: "hang/room", + get: "", + put: "guest/uuid/", + }); +}); diff --git a/js/room/src/token.ts b/js/room/src/token.ts new file mode 100644 index 0000000000..1d63129f13 --- /dev/null +++ b/js/room/src/token.ts @@ -0,0 +1,34 @@ +/** + * The LiveKit AccessToken analogue: minting a moq-token rooted at the room prefix. + * + * This package has no service and no storage. Joining a room is signing a token + * with these claims and dialing the relay at that root. Sign it with `@moq/token`. + * + * @module + */ + +/** Claims a room participant should present. Compatible with `@moq/token` `Claims`. */ +export type Claims = { + /** Room prefix. Broadcast paths are relative to this. */ + root: string; + /** Subscribe to every broadcast in the room. */ + get: string; + /** Publish only under this participant's identity. */ + put: string; +}; + +/** + * Token claims for a participant in `room`. + * + * `root` is the room prefix, `get` is `""` (everything under the room), and + * `put` is `"/"` so a participant cannot publish at anyone else's + * paths. hang.live grants `put` on the whole room subtree today; this is the + * narrower grant. + */ +export function claims(room: string, identity: string): Claims { + return { + root: room, + get: "", + put: identity.endsWith("/") ? identity : `${identity}/`, + }; +} diff --git a/js/room/src/vite-env.d.ts b/js/room/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/js/room/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/js/room/tsconfig.build.json b/js/room/tsconfig.build.json new file mode 100644 index 0000000000..49ae17a868 --- /dev/null +++ b/js/room/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/js/room/tsconfig.json b/js/room/tsconfig.json new file mode 100644 index 0000000000..fee24f08e5 --- /dev/null +++ b/js/room/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "./src", + "types": ["audioworklet", "bun"] + }, + "include": ["src", "../common/worklet.d.ts"] +} diff --git a/package.json b/package.json index a62ad231a4..1155c94911 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "js/msf", "js/watch", "js/publish", + "js/room", "js/signals", "js/wasm", "demo/boy", diff --git a/quest/m2/README.md b/quest/m2/README.md index f178726c44..1e63c0b864 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -90,7 +90,6 @@ can act on. Each still carries its own plan and regression test. - [#3137](/quest/m2/3137-moqsrc-bound-the-pending-rendition-subscriptions-a.md) - moqsrc: bound the pending rendition subscriptions a catalog can open - [#3115](/quest/m2/3115-moqsink-the-publication-has-no-generation-so-a-flush.md) - moqsink: a flushing restart after EOS opens a new publication generation - [#709](/quest/m2/709-automatic-letsencrypt-support.md) - the relay provisions and renews its own ACME certificate over HTTP-01, persisted on disk -- [Room SDK](/quest/m2/room-sdk.md) - a headless room package: a room is a path prefix, no service, no storage - [Runtime QA hosts](/quest/m2/runtime-qa-hosts.md) - run exact source snapshots on accessible Linux and device hosts with retrievable debug evidence - [Media QA on other engines](/quest/m2/browser-media-qa-engines.md) - the media harness measures a Firefox or WebKit player over the fallback and names what each engine lacks - [LiveKit shim](/quest/m2/livekit-shim.md) - a drop-in livekit-client-compatible package running rooms over MoQ diff --git a/quest/m2/livekit-shim.md b/quest/m2/livekit-shim.md index a9e89de469..e91428349a 100644 --- a/quest/m2/livekit-shim.md +++ b/quest/m2/livekit-shim.md @@ -13,8 +13,7 @@ and the connect URL/token changed. ## Plan -- The shim is a LiveKit-API facade over the - [room SDK](/quest/m2/room-sdk.md), which carries hang.live's convention: the room is a path prefix in the connection URL and token root, +- The shim is a LiveKit-API facade over `@moq/room`, which carries hang.live's convention: the room is a path prefix in the connection URL and token root, participants are discovered from the bare announce stream, identity is the next path segment, and each participant publishes `/camera` (camera + mic, hd/sd renditions) and `/screen` (screenshare, @@ -32,10 +31,6 @@ and the connect URL/token changed. publish at each other's paths (hang.live grants `put` on the whole room subtree today). -## Required - -- [Room SDK](/quest/m2/room-sdk.md) - ## Related - [WebRTC bridge evaluation](/quest/m3/livekit-webrtc-bridge.md) diff --git a/quest/m2/room-sdk.md b/quest/m2/room-sdk.md deleted file mode 100644 index 6e0032b4b8..0000000000 --- a/quest/m2/room-sdk.md +++ /dev/null @@ -1,51 +0,0 @@ -# [L] Room SDK - -## Goal - -A headless `@moq/room` package, extracted from hang.live's room layer: -announce-derived roster, local participant publishing, remote participant -composition, and the `hang/*.json` metadata convention, in the @moq signals -idiom. No service and no storage: joining a room is minting a moq-token rooted -at the room prefix, and the package docs show that seam (the LiveKit -AccessToken analogue). hang.live migrates onto the package as proof. - -## Plan - -- API sketch, ported from hang.live `app/src/room/{index,local,watch}.ts` - with the minimal `preview/{room,member}.ts` pair as the starting point: - - `Room`: input a connection (the URL and token root already carry the - room prefix); runs the announce loop and exposes remote participants as - a signal map keyed by identity, skipping the local paths. - - `Local`: enable signals for camera/microphone/screen; publishes - `/camera` (camera + mic, hd/sd renditions) and - `/screen`, whose announce/unannounce is the screenshare - lifecycle. - - `Remote`: groups an identity's `camera` and `screen` broadcasts and - composes watch + metadata into video/audio/user/presence signals. - - Metadata: port `metadata.ts` (~330 lines, render-free). Core carries - `user.json` (id, name, avatar) and the `preview.json` presence booleans; - chat and location stay app-defined extensions of the same catalog - section rather than SDK surface. -- Tokens: `root` = room prefix, `get: ""`, `put: "/"`, so - participants cannot publish at each other's paths (hang.live grants `put` - on the whole room subtree today). Document in the package README; no - hosted endpoint. -- hang.live pins old @moq lines (net 0.3 as `@moq/lite`, publish 0.2, watch - 0.2); the extraction lands on the current packages, so migrating hang.live - (in that repo) doubles as its overdue upgrade. -- This is the deliberate replacement for `` (removed in moq#883 as - a crude demo): a headless library, no element. Custom elements and - framework bindings are follow-ups if wanted. - -The conferencing demo [#697](https://github.com/moq-dev/moq/issues/697) -asked for is this package's proof: hang.live's room, migrated onto it, is the -demo, and `` is not coming back as an element. - -## Closes - -- [#697](https://github.com/moq-dev/moq/issues/697) - close this issue when the quest finishes - -## Related - -- [LiveKit client shim](/quest/m2/livekit-shim.md) - builds its - Room/Participant facade on this diff --git a/quest/m3/carrier-voice/README.md b/quest/m3/carrier-voice/README.md index 10978e4b47..2338c53917 100644 --- a/quest/m3/carrier-voice/README.md +++ b/quest/m3/carrier-voice/README.md @@ -52,5 +52,5 @@ bespoke media fork for each service. - [SIP media stack](/quest/m3/sip-stack.md) - the telephone-network adapter this lab extends; the inbound-call product built on it is moq.pro (downstream) work -- [Room SDK](/quest/m2/room-sdk.md) - conferencing may eventually reuse its - participant model, but is not required by this experiment +- `@moq/room` - conferencing may eventually reuse its participant model, but is + not required by this experiment From c3187f5170c611e80a79c9bcae2331130f4e8e3a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 12 Sep 2026 07:51:37 -0700 Subject: [PATCH 3/5] feat(room): add native moq-room and iroh-live chat track Native counterpart of @moq/room: announce-derived roster, path convention, token claims, and the ordered UTF-8 chat track from iroh-rooms. Gossip, tickets, Call, and moq-media stay in iroh-live. Co-Authored-By: grok-4.6 --- Cargo.lock | 11 +++ Cargo.toml | 2 + doc/.vitepress/config.ts | 2 + doc/index.md | 2 +- doc/lib/js/index.md | 2 +- doc/lib/js/room.md | 8 +- doc/lib/rs/index.md | 1 + doc/lib/rs/moq-room.md | 43 +++++++++ js/room/README.md | 16 +++- js/room/package.json | 2 +- js/room/src/chat.test.ts | 44 +++++++++ js/room/src/chat.ts | 103 ++++++++++++++++++++ js/room/src/index.ts | 1 + js/room/src/metadata.ts | 6 +- rs/moq-room/CHANGELOG.md | 12 +++ rs/moq-room/Cargo.toml | 22 +++++ rs/moq-room/README.md | 27 ++++++ rs/moq-room/src/chat.rs | 194 ++++++++++++++++++++++++++++++++++++++ rs/moq-room/src/claims.rs | 46 +++++++++ rs/moq-room/src/lib.rs | 27 ++++++ rs/moq-room/src/path.rs | 132 ++++++++++++++++++++++++++ rs/moq-room/src/room.rs | 133 ++++++++++++++++++++++++++ 22 files changed, 826 insertions(+), 10 deletions(-) create mode 100644 doc/lib/rs/moq-room.md create mode 100644 js/room/src/chat.test.ts create mode 100644 js/room/src/chat.ts create mode 100644 rs/moq-room/CHANGELOG.md create mode 100644 rs/moq-room/Cargo.toml create mode 100644 rs/moq-room/README.md create mode 100644 rs/moq-room/src/chat.rs create mode 100644 rs/moq-room/src/claims.rs create mode 100644 rs/moq-room/src/lib.rs create mode 100644 rs/moq-room/src/path.rs create mode 100644 rs/moq-room/src/room.rs diff --git a/Cargo.lock b/Cargo.lock index f1c2bbcb11..1d54d5e09b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5069,6 +5069,17 @@ dependencies = [ "wiremock", ] +[[package]] +name = "moq-room" +version = "0.1.0" +dependencies = [ + "kio", + "moq-net", + "moq-token", + "tokio", + "tracing", +] + [[package]] name = "moq-rtc" version = "0.2.9" diff --git a/Cargo.toml b/Cargo.toml index 6277e9921b..845d45c5c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "rs/moq-net/fuzz", "rs/moq-nvenc", "rs/moq-relay", + "rs/moq-room", "rs/moq-rtc", "rs/moq-rtmp", "rs/moq-srt", @@ -51,6 +52,7 @@ default-members = [ "rs/moq-native", "rs/moq-nvenc", "rs/moq-relay", + "rs/moq-room", "rs/moq-rtc", "rs/moq-rtmp", "rs/moq-srt", 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/index.md b/doc/index.md index f4f9f45277..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 | [`@moq/room`](/lib/js/room) for the roster, [``](/lib/js/publish) and [``](/lib/js/watch) in the browser, 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 49f65b96a4..294b8a7d9d 100644 --- a/doc/lib/js/index.md +++ b/doc/lib/js/index.md @@ -16,7 +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. | +| [@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 index e203c51d8b..49788290a4 100644 --- a/doc/lib/js/room.md +++ b/doc/lib/js/room.md @@ -41,8 +41,12 @@ demo at [`demo/web`](https://github.com/moq-dev/moq/tree/main/demo/web) (`meet.h 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`. Chat and location stay -app-defined extensions of the same catalog `hang` section. +watch, and `hang/user.json` + `hang/preview.json`. Location stays an app-defined +catalog extension. The ordered UTF-8 chat track (`Chat`) matches iroh-live; +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..06f57ff341 --- /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 + +[![crates.io](https://img.shields.io/crates/v/moq-room)](https://crates.io/crates/moq-room) +[![docs.rs](https://docs.rs/moq-room/badge.svg)](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` / `screen`. + +Each participant publishes `{identity}/camera` (camera + mic) and +`{identity}/screen` (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 ordered UTF-8 `chat` track (from iroh-live's `iroh-rooms`) 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/js/room/README.md b/js/room/README.md index e6f9cbc247..c7cf4643eb 100644 --- a/js/room/README.md +++ b/js/room/README.md @@ -14,7 +14,7 @@ Participants are discovered from the announce stream. Identity is the path befor - `{identity}/camera` — camera + microphone, hd/sd renditions - `{identity}/screen` — screenshare; its announce/unannounce is the share lifecycle -This is the generic room layer extracted from [hang.live](https://hang.live) (and the announce-bus shape [iroh-live](https://github.com/n0-computer/iroh-live) is moving `iroh-rooms` onto). Memes, 3D layout, chat UI, and accounts stay in the app. +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 ordered `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 @@ -63,6 +63,16 @@ const room = new Room({ connection, identity }); // Members; assign member.canvas and member.muted from the UI. ``` -hang.live should depend on this package for `Room`, `Local`, `Remote`, and the `hang/*.json` metadata tracks. Chat and location stay app-defined extensions of the same catalog `hang` section (`TRACK.chat`, `TRACK.location`); call `broadcast.catalog.mutate` to advertise extra tracks next to `user` and `preview`. +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; iroh-live's ordered UTF-8 track is `Chat.TRACK` (`"chat"`). -A conferencing demo (no memes, no 3D) lives at [`demo/web/src/meet.html`](../../demo/web/src/meet.html). +```ts +import { Chat } from "@moq/room"; + +const publisher = Chat.Publisher.create(broadcast); +publisher.send("hello"); + +const subscriber = Chat.Subscriber.subscribe(broadcast.consume()); +const msg = await subscriber.recv(); +``` + +A conferencing demo (no memes, no 3D, no chat UI) lives at [`demo/web/src/meet.html`](../../demo/web/src/meet.html). diff --git a/js/room/package.json b/js/room/package.json index 7fbdde7285..31ab361b56 100644 --- a/js/room/package.json +++ b/js/room/package.json @@ -2,7 +2,7 @@ "name": "@moq/room", "type": "module", "version": "0.1.0", - "description": "Headless multi-participant rooms over MoQ: announce-derived roster, local publish, remote watch", + "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, diff --git a/js/room/src/chat.test.ts b/js/room/src/chat.test.ts new file mode 100644 index 0000000000..291194343d --- /dev/null +++ b/js/room/src/chat.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test"; +import { Broadcast } from "@moq/net"; +import { PRIORITY, Publisher, Subscriber, TRACK } from "./chat.ts"; + +async function pair(): Promise<{ publisher: Publisher; subscriber: Subscriber }> { + const producer = new Broadcast.Producer(); + const publisher = Publisher.create(producer); + const subscriber = Subscriber.subscribe(producer.consume()); + return { publisher, subscriber }; +} + +test("track name and priority", () => { + expect(TRACK).toBe("chat"); + expect(PRIORITY).toBe(10); +}); + +test("roundtrip", async () => { + const { publisher, subscriber } = await pair(); + + publisher.send("hello"); + publisher.send("world"); + + expect((await subscriber.recv())?.text).toBe("hello"); + expect((await subscriber.recv())?.text).toBe("world"); +}); + +test("empty messages are skipped", async () => { + const { publisher, subscriber } = await pair(); + + publisher.send(""); + publisher.send("after empty"); + + expect((await subscriber.recv())?.text).toBe("after empty"); +}); + +test("closed track returns undefined", async () => { + const { publisher, subscriber } = await pair(); + + publisher.send("last"); + publisher.finish(); + + expect((await subscriber.recv())?.text).toBe("last"); + expect(await subscriber.recv()).toBeUndefined(); +}); diff --git a/js/room/src/chat.ts b/js/room/src/chat.ts new file mode 100644 index 0000000000..0e1b8aff0e --- /dev/null +++ b/js/room/src/chat.ts @@ -0,0 +1,103 @@ +/** + * Text chat over a MoQ track, matching iroh-live's `iroh-rooms` and `moq-room::chat`. + * + * A broadcast carries chat on one well-known track, {@link TRACK}. Each message + * is a single group holding one frame of UTF-8 text. The sender's identity + * comes from the broadcast that carries the track, not from the payload. + * + * This is not hang.live's `hang/chat.json` snapshot; that stays an app-defined + * catalog extension (`TRACK.chat` in the metadata module). This track is an + * ordered append-log. + * + * @module + */ + +import type { Broadcast, Track } from "@moq/net"; + +/** Name of the track that carries chat messages. */ +export const TRACK = "chat"; + +/** + * Publisher tie-break priority for the chat track. + * + * Lower than audio and video, which are the tracks a viewer notices first when + * the link is congested. + */ +export const PRIORITY = 10; + +/** Track settings for the chat track: ordered, because chat is read oldest first. */ +export function info(): Pick { + return { priority: PRIORITY, ordered: true }; +} + +/** A received chat message, with the time it arrived. */ +export type Message = { + /** The message text. */ + text: string; + /** When this message was received locally. */ + receivedAt: Date; +}; + +/** Writer half of a chat track. */ +export class Publisher { + #track: Track.Producer; + + /** Creates the chat track on `broadcast` and returns a publisher for it. */ + static create(broadcast: Broadcast.Producer): Publisher { + return new Publisher(broadcast.createTrack(TRACK, info())); + } + + /** Creates a publisher over an existing track producer. */ + constructor(track: Track.Producer) { + this.#track = track; + } + + /** + * Sends a text message on the chat track. + * + * Empty messages are dropped rather than written, because a subscriber + * cannot tell them apart from a group it failed to read. + */ + send(text: string): void { + if (text === "") return; + this.#track.writeString(text); + } + + /** + * Ends the chat track so subscribers drain already-sent messages, then see close. + * + * Dropping a publisher without this first is an abrupt teardown. + */ + finish(): void { + this.#track.close(); + } +} + +/** Reader half of a chat track. */ +export class Subscriber { + #track: Track.Subscriber; + + /** Subscribes to the chat track of `broadcast`. */ + static subscribe(broadcast: Broadcast.Consumer): Subscriber { + return new Subscriber(broadcast.track(TRACK).subscribe({ ordered: true })); + } + + /** Creates a subscriber over an existing track subscriber. */ + constructor(track: Track.Subscriber) { + this.#track = track; + } + + /** + * Waits for the next chat message. + * + * Returns `undefined` once the track ends. + */ + async recv(): Promise { + for (;;) { + const text = await this.#track.readString(); + if (text === undefined) return undefined; + if (text === "") continue; + return { text, receivedAt: new Date() }; + } + } +} diff --git a/js/room/src/index.ts b/js/room/src/index.ts index e84b02babb..321fc4ce18 100644 --- a/js/room/src/index.ts +++ b/js/room/src/index.ts @@ -17,6 +17,7 @@ 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, diff --git a/js/room/src/metadata.ts b/js/room/src/metadata.ts index 773b469f1d..7af806429b 100644 --- a/js/room/src/metadata.ts +++ b/js/room/src/metadata.ts @@ -3,8 +3,10 @@ * snapshot tracks on the same broadcast. * * Core carries `user.json` (id, name, avatar) and `preview.json` (presence - * booleans). Chat and location ride the same catalog section as app-defined - * extensions; this module does not serve or consume them. + * 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 ordered UTF-8 `chat` track is `Chat.TRACK` (`"chat"`), not + * `hang/chat.json`. * * @module */ diff --git a/rs/moq-room/CHANGELOG.md b/rs/moq-room/CHANGELOG.md new file mode 100644 index 0000000000..bc1e9517c5 --- /dev/null +++ b/rs/moq-room/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Announce-derived room roster, path convention, token claims, and the iroh-live chat track. diff --git a/rs/moq-room/Cargo.toml b/rs/moq-room/Cargo.toml new file mode 100644 index 0000000000..818763f5e1 --- /dev/null +++ b/rs/moq-room/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "moq-room" +description = "Headless multi-participant rooms over MoQ: announce-derived roster, token claims, and a chat track." +authors = ["Luke Curley "] +repository = "https://github.com/moq-dev/moq" +license = "MIT OR Apache-2.0" + +version = "0.1.0" +edition = "2024" +rust-version.workspace = true + +keywords = ["quic", "webtransport", "conferencing", "live"] +categories = ["multimedia", "network-programming"] + +[dependencies] +kio = { workspace = true } +moq-net = { workspace = true } +moq-token = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/rs/moq-room/README.md b/rs/moq-room/README.md new file mode 100644 index 0000000000..71a9173134 --- /dev/null +++ b/rs/moq-room/README.md @@ -0,0 +1,27 @@ +# moq-room + +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. + +Participants are discovered from the announce stream. Identity is the path before `camera` / `screen`. Each participant publishes `{identity}/camera` and `{identity}/screen`. + +This is the native counterpart of [`@moq/room`](https://www.npmjs.com/package/@moq/room). hang.live and [iroh-live](https://github.com/n0-computer/iroh-live) (`iroh-rooms` is being redesigned onto the announce bus) can depend on it for roster, path convention, token claims, and the ordered `chat` track. Gossip, tickets, and 1:1 Call stay in iroh-live. Capture/encode stay in `moq-video` / `moq-audio`. Native `Local`/`Remote` media plumbing stays with those crates too; this crate is media-free. + +```rust +use moq_net::{Origin, Path}; +use moq_room::{Kind, Room, chat, claims}; + +let token = key.sign(&claims("meet/demo", "alice"), None)?; +// Dial the relay at meet/demo?jwt=... + +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 { + if let Some(broadcast) = &event.broadcast { + if let Ok(mut chat) = chat::Subscriber::subscribe(broadcast).await { + // ... + } + } + } +} +``` diff --git a/rs/moq-room/src/chat.rs b/rs/moq-room/src/chat.rs new file mode 100644 index 0000000000..6ba896fb59 --- /dev/null +++ b/rs/moq-room/src/chat.rs @@ -0,0 +1,194 @@ +//! Text chat over a MoQ track, ported from iroh-live's `iroh-rooms`. +//! +//! A broadcast carries chat on one well-known track, [`TRACK`]. Each message is +//! a single group holding one frame of UTF-8 text. The sender's identity comes +//! from the broadcast that carries the track, not from the payload. +//! +//! This is not hang.live's `hang/chat.json` snapshot; that stays an app-defined +//! catalog extension. This track is an ordered append-log. + +use std::{ + task::{Poll, ready}, + time::Instant, +}; + +use moq_net::{Error, Timestamp, broadcast, track}; + +/// Name of the track that carries chat messages. +pub const TRACK: &str = "chat"; + +/// Publisher tie-break priority for the chat track. +/// +/// Lower than audio and video, which are the tracks a viewer notices first when +/// the link is congested. +pub const PRIORITY: u8 = 10; + +/// Returns the track settings for the chat track. +/// +/// Groups are ordered, because chat only makes sense read oldest first, whereas +/// the moq-net default favours the newest group. +pub fn info() -> track::Info { + track::Info::default().with_priority(PRIORITY).with_ordered(true) +} + +/// A received chat message, with the time it arrived. +#[derive(Debug, Clone)] +pub struct Message { + /// The message text. + pub text: String, + /// When this message was received locally. + pub received_at: Instant, +} + +/// Writer half of a chat track. +pub struct Publisher { + track: track::Producer, +} + +impl std::fmt::Debug for Publisher { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Publisher").finish_non_exhaustive() + } +} + +impl Publisher { + /// Creates the chat track on `broadcast` and returns a publisher for it. + pub fn create(broadcast: &mut broadcast::Producer) -> Result { + Ok(Self::new(broadcast.create_track(TRACK, info())?)) + } + + /// Creates a publisher over an existing track producer. + pub fn new(track: track::Producer) -> Self { + Self { track } + } + + /// Sends a text message on the chat track. + /// + /// Empty messages are dropped rather than written, because a subscriber + /// cannot tell them apart from a group it failed to read. + pub fn send(&mut self, text: impl AsRef) -> Result<(), Error> { + let text = text.as_ref(); + if text.is_empty() { + return Ok(()); + } + self.track.write_frame(Timestamp::now(), text) + } + + /// Ends the chat track so subscribers drain already-sent messages, then see close. + /// + /// Dropping a [`Publisher`] without this first is an abrupt teardown: the + /// underlying track discards its cache, and a subscriber that has not yet + /// read the last message loses it. + pub fn finish(mut self) -> Result<(), Error> { + self.track.finish() + } +} + +/// Reader half of a chat track. +pub struct Subscriber { + track: track::Subscriber, +} + +impl std::fmt::Debug for Subscriber { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Subscriber").finish_non_exhaustive() + } +} + +impl Subscriber { + /// Subscribes to the chat track of `broadcast`. + /// + /// Fails if the broadcast has no track named [`TRACK`], which is the normal + /// outcome for a peer that publishes media without chat. + pub async fn subscribe(broadcast: &broadcast::Consumer) -> Result { + let track = broadcast.track(TRACK)?.subscribe(None).await?; + Ok(Self::new(track)) + } + + /// Creates a subscriber over an existing track subscriber. + pub fn new(track: track::Subscriber) -> Self { + Self { track } + } + + /// Poll for the next chat message. + /// + /// Returns `None` once the track ends. + pub fn poll_recv(&mut self, waiter: &kio::Waiter) -> Poll> { + loop { + let frame = match ready!(self.track.poll_read_frame(waiter)) { + Ok(Some(frame)) => frame, + Ok(None) => return Poll::Ready(None), + Err(err) => { + tracing::warn!("chat track read failed: {err:#}"); + return Poll::Ready(None); + } + }; + match std::str::from_utf8(&frame.payload) { + Ok(text) if !text.is_empty() => { + return Poll::Ready(Some(Message { + text: text.to_owned(), + received_at: Instant::now(), + })); + } + Ok(_) => continue, + Err(err) => { + tracing::warn!("chat message is not valid UTF-8: {err}"); + continue; + } + } + } + } + + /// Wait for the next chat message. + /// + /// Returns `None` once the track ends. + pub async fn recv(&mut self) -> Option { + kio::wait(|waiter| self.poll_recv(waiter)).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn pair() -> (broadcast::Producer, Publisher, Subscriber) { + let mut producer = broadcast::Info::new().produce(); + let publisher = Publisher::create(&mut producer).expect("create chat track"); + let subscriber = Subscriber::subscribe(&producer.consume()) + .await + .expect("subscribe to chat track"); + (producer, publisher, subscriber) + } + + #[tokio::test] + async fn roundtrip() { + let (_producer, mut publisher, mut subscriber) = pair().await; + + publisher.send("hello").unwrap(); + publisher.send("world").unwrap(); + + assert_eq!(subscriber.recv().await.unwrap().text, "hello"); + assert_eq!(subscriber.recv().await.unwrap().text, "world"); + } + + #[tokio::test] + async fn empty_messages_skipped() { + let (_producer, mut publisher, mut subscriber) = pair().await; + + publisher.send("").unwrap(); + publisher.send("after empty").unwrap(); + + assert_eq!(subscriber.recv().await.unwrap().text, "after empty"); + } + + #[tokio::test] + async fn closed_track_returns_none() { + let (_producer, mut publisher, mut subscriber) = pair().await; + + publisher.send("last").unwrap(); + publisher.finish().unwrap(); + + assert_eq!(subscriber.recv().await.unwrap().text, "last"); + assert!(subscriber.recv().await.is_none()); + } +} diff --git a/rs/moq-room/src/claims.rs b/rs/moq-room/src/claims.rs new file mode 100644 index 0000000000..13405d5eeb --- /dev/null +++ b/rs/moq-room/src/claims.rs @@ -0,0 +1,46 @@ +//! The LiveKit AccessToken analogue: minting a moq-token rooted at the room prefix. +//! +//! This crate has no service and no storage. Joining a room is signing a token +//! with these claims and dialing the relay at that root. + +/// Token claims for a participant in `room`. +/// +/// `root` is the room prefix, `subscribe` is `[""]` (everything under the room), +/// and `publish` is `["/"]` so a participant cannot publish at anyone +/// else's paths. +pub fn claims(room: impl Into, identity: &str) -> moq_token::Claims { + let put = if identity.ends_with('/') { + identity.to_string() + } else { + format!("{identity}/") + }; + moq_token::Claims::default() + .with_root(room) + .with_subscribe([""]) + .with_publish([put]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claims_root_the_token_and_scope_put_to_identity() { + let c = claims("meet/demo", "alice"); + assert_eq!(c.root, "meet/demo"); + assert_eq!(c.subscribe, [""]); + assert_eq!(c.publish, ["alice/"]); + } + + #[test] + fn claims_does_not_double_a_trailing_slash() { + assert_eq!(claims("meet/demo", "alice/").publish, ["alice/"]); + } + + #[test] + fn claims_accepts_a_multi_segment_identity() { + let c = claims("hang/room", "guest/uuid"); + assert_eq!(c.root, "hang/room"); + assert_eq!(c.publish, ["guest/uuid/"]); + } +} diff --git a/rs/moq-room/src/lib.rs b/rs/moq-room/src/lib.rs new file mode 100644 index 0000000000..b7517945e5 --- /dev/null +++ b/rs/moq-room/src/lib.rs @@ -0,0 +1,27 @@ +//! Headless multi-participant rooms over MoQ. +//! +//! 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` / `screen`. Each participant publishes: +//! +//! - `{identity}/camera` — camera + microphone +//! - `{identity}/screen` — screenshare; its announce/unannounce is the share lifecycle +//! +//! This is the native counterpart of [`@moq/room`](https://www.npmjs.com/package/@moq/room), +//! extracted from hang.live's roster and iroh-live's announce-bus redesign of +//! `iroh-rooms`. Gossip discovery, tickets, and 1:1 Call stay in iroh-live. +//! Capture and encode stay in `moq-video` / `moq-audio`. + +#![warn(missing_docs)] + +pub mod chat; +mod claims; +mod path; +mod room; + +pub use claims::claims; +pub use path::{Kind, Parsed, broadcast_path, kind_from_segment, parse}; +pub use room::{Event, Room}; diff --git a/rs/moq-room/src/path.rs b/rs/moq-room/src/path.rs new file mode 100644 index 0000000000..367ccb7cb2 --- /dev/null +++ b/rs/moq-room/src/path.rs @@ -0,0 +1,132 @@ +//! Room path convention: 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. + +use moq_net::{AsPath, Path, PathOwned}; + +/// The two broadcasts each participant may publish. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Kind { + /// Camera plus microphone. + Camera, + /// Screenshare; its announce/unannounce is the share lifecycle. + Screen, +} + +impl Kind { + /// The path segment this kind publishes as, without a catalog-format suffix. + pub const fn as_str(self) -> &'static str { + match self { + Self::Camera => "camera", + Self::Screen => "screen", + } + } +} + +/// An announced path split into identity and kind. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Parsed { + /// Path prefix identifying the participant; may be more than one segment. + pub identity: PathOwned, + /// `camera` (camera + mic) or `screen` (screenshare). + pub kind: Kind, +} + +/// Strip an optional `.hang` catalog-format suffix from a path segment. +pub fn kind_from_segment(segment: &str) -> Option { + let kind = segment.strip_suffix(".hang").unwrap_or(segment); + match kind { + "camera" => Some(Kind::Camera), + "screen" => Some(Kind::Screen), + _ => None, + } +} + +/// Split a room-relative broadcast path into identity and kind. +/// +/// Returns `None` when there is no identity, or the last segment is not a kind. +pub fn parse(path: impl AsPath) -> Option { + let path = path.as_path(); + let mut parts: Vec<&str> = path.parts().collect(); + let last = parts.pop()?; + if parts.is_empty() { + return None; + } + let kind = kind_from_segment(last)?; + let identity = Path::new(&parts.join("/")).to_owned(); + Some(Parsed { identity, kind }) +} + +/// The broadcast path a participant publishes for `kind`. +pub fn broadcast_path(identity: impl AsPath, kind: Kind) -> PathOwned { + identity.as_path().join(kind.as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(s: &str) -> PathOwned { + Path::new(s).to_owned() + } + + #[test] + fn parse_splits_identity_and_kind() { + assert_eq!( + parse(Path::new("alice/camera")), + Some(Parsed { + identity: p("alice"), + kind: Kind::Camera + }) + ); + assert_eq!( + parse(Path::new("alice/screen")), + Some(Parsed { + identity: p("alice"), + kind: Kind::Screen + }) + ); + } + + #[test] + fn parse_accepts_hang_suffix() { + assert_eq!( + parse(Path::new("alice/camera.hang")).map(|p| p.kind), + Some(Kind::Camera) + ); + assert_eq!( + parse(Path::new("alice/screen.hang")).map(|p| p.kind), + Some(Kind::Screen) + ); + } + + #[test] + fn parse_keeps_multi_segment_identity() { + assert_eq!( + parse(Path::new("guest/uuid/camera")), + Some(Parsed { + identity: p("guest/uuid"), + kind: Kind::Camera + }) + ); + } + + #[test] + fn parse_rejects_unknown() { + assert_eq!(parse(Path::new("alice")), None); + assert_eq!(parse(Path::new("alice/chat")), None); + assert_eq!(parse(Path::new("camera")), None); + assert_eq!(parse(Path::empty()), None); + } + + #[test] + fn broadcast_path_joins_without_hang_suffix() { + assert_eq!(broadcast_path(p("alice"), Kind::Camera).as_str(), "alice/camera"); + assert_eq!( + broadcast_path(p("guest/uuid"), Kind::Screen).as_str(), + "guest/uuid/screen" + ); + } +} diff --git a/rs/moq-room/src/room.rs b/rs/moq-room/src/room.rs new file mode 100644 index 0000000000..8d56304e05 --- /dev/null +++ b/rs/moq-room/src/room.rs @@ -0,0 +1,133 @@ +//! A room is a path prefix. Participants are discovered from the announce stream. + +use std::task::{Poll, ready}; + +use moq_net::{PathOwned, announce, broadcast, origin}; + +use crate::path::{Kind, parse}; + +/// One (un)announce of a participant broadcast. +#[derive(Clone)] +pub struct Event { + /// Participant identity (everything before `camera`/`screen`). + pub identity: PathOwned, + /// `camera` or `screen`. + pub kind: Kind, + /// Broadcast path relative to the room prefix. + pub path: PathOwned, + /// The live broadcast, or `None` when it went offline. + pub broadcast: Option, +} + +impl std::fmt::Debug for Event { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Event") + .field("identity", &self.identity) + .field("kind", &self.kind) + .field("path", &self.path) + .field("online", &self.broadcast.is_some()) + .finish() + } +} + +/// Runs the announce loop and yields remote participant broadcasts. +/// +/// Skips paths that are not `{identity}/camera` or `{identity}/screen`, and +/// skips the local identity so a publisher does not see itself as a remote. +pub struct Room { + announced: announce::Consumer, + local: Option, +} + +impl std::fmt::Debug for Room { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Room") + .field("local", &self.local) + .finish_non_exhaustive() + } +} + +impl Room { + /// Watch announcements on `origin`, skipping `local` when set. + pub fn new(origin: &origin::Consumer, local: Option) -> Self { + Self { + announced: origin.announced(), + local, + } + } + + /// Poll for the next remote camera/screen (un)announce. + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll> { + loop { + let Some(update) = ready!(self.announced.poll_next(waiter)) else { + return Poll::Ready(None); + }; + let Some(parsed) = parse(&update.path) else { + continue; + }; + if self.local.as_ref().is_some_and(|id| *id == parsed.identity) { + continue; + } + return Poll::Ready(Some(Event { + identity: parsed.identity, + kind: parsed.kind, + path: update.path, + broadcast: update.broadcast, + })); + } + } + + /// Wait for the next remote camera/screen (un)announce. + pub async fn next(&mut self) -> Option { + kio::wait(|waiter| self.poll_next(waiter)).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use moq_net::{Origin, Path, broadcast::Route}; + + #[tokio::test] + async fn yields_camera_and_skips_local_and_unknown() { + let origin = Origin::random().produce(); + let local = Path::new("alice").to_owned(); + let mut room = Room::new(&origin.consume(), Some(local)); + + let _alice = origin + .create_broadcast("alice/camera", Route::announced()) + .expect("alice camera"); + let _bob = origin + .create_broadcast("bob/camera", Route::announced()) + .expect("bob camera"); + let _noise = origin + .create_broadcast("bob/chat", Route::announced()) + .expect("unknown kind"); + + let event = room.next().await.expect("bob camera"); + assert_eq!(event.identity.as_str(), "bob"); + assert_eq!(event.kind, Kind::Camera); + assert!(event.broadcast.is_some()); + + drop(_bob); + let gone = room.next().await.expect("bob unannounce"); + assert_eq!(gone.identity.as_str(), "bob"); + assert_eq!(gone.kind, Kind::Camera); + assert!(gone.broadcast.is_none()); + } + + #[tokio::test] + async fn yields_screen() { + let origin = Origin::random().produce(); + let mut room = Room::new(&origin.consume(), None); + + let _screen = origin + .create_broadcast("bob/screen", Route::announced()) + .expect("bob screen"); + + let event = room.next().await.expect("bob screen"); + assert_eq!(event.identity.as_str(), "bob"); + assert_eq!(event.kind, Kind::Screen); + assert!(event.broadcast.is_some()); + } +} From 8463d368dff78d6373135c67bfbdf9711aa304ff Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 12 Sep 2026 09:49:19 -0700 Subject: [PATCH 4/5] feat(room): hang.live knobs for roster prefix and capture Room accepts an announce prefix so a preview connection can span several rooms. Local accepts the app's camera/mic/screen signals and exposes typing/chatting for preview.json. @moq/signals/solid keeps a default export for hang.live. Co-Authored-By: grok-4.6 --- js/room/src/local.ts | 21 ++++++++++++++++++--- js/room/src/room.ts | 13 +++++++++++-- js/signals/src/solid.ts | 3 +++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/js/room/src/local.ts b/js/room/src/local.ts index 20efceea27..79c1879306 100644 --- a/js/room/src/local.ts +++ b/js/room/src/local.ts @@ -20,6 +20,12 @@ export interface LocalProps { 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; } @@ -45,6 +51,11 @@ export class Local { /** 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; @@ -89,9 +100,11 @@ export class Local { constructor(props: LocalProps) { this.identity = getter(props.identity); this.enabled = Signal.from(props.enabled ?? false); - this.cameraEnabled = new Signal(false); - this.microphoneEnabled = new Signal(false); - this.screenEnabled = new Signal(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); @@ -229,6 +242,8 @@ export class Local { 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), }); }); diff --git a/js/room/src/room.ts b/js/room/src/room.ts index 401de97c8a..efbe2e801e 100644 --- a/js/room/src/room.ts +++ b/js/room/src/room.ts @@ -5,7 +5,7 @@ * @module */ -import type * as Moq from "@moq/net"; +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"; @@ -24,6 +24,12 @@ export interface RoomProps { 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; } /** @@ -34,6 +40,7 @@ export class Room { readonly connection: Moq.Connection.Reload; readonly identity: Getter; readonly enabled: Getter; + readonly prefix: Getter; #remotes = new Signal(new Map()); #signals = new Effect(); @@ -42,11 +49,13 @@ export class Room { 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; - const announced = this.connection.announced(); + 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)); diff --git a/js/signals/src/solid.ts b/js/signals/src/solid.ts index 4e7a4719b6..353c4da0db 100644 --- a/js/signals/src/solid.ts +++ b/js/signals/src/solid.ts @@ -39,3 +39,6 @@ export function createSetter(signal: Signal): SolidSetter { export function createPair(signal: Signal): SolidSignal { return [createAccessor(signal), createSetter(signal)]; } + +/** @deprecated Use {@link createAccessor}. Default export kept for hang.live. */ +export default createAccessor; From 2fc4f23b52b9f85024521e22f78cfd1ea4661939 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 12 Sep 2026 16:55:39 -0700 Subject: [PATCH 5/5] fix(room): bound chat history and repair room lifecycle Co-Authored-By: GPT-6 --- .gitignore | 1 - .taplo.toml | 2 +- Cargo.lock | 76 ++++---- biome.jsonc | 2 +- demo/web/src/meet.ts | 20 ++- doc/lib/js/room.md | 8 +- doc/lib/rs/moq-room.md | 10 +- drafts/draft-lcurley-moq-hang.md | 22 +++ js/net/src/connection/reload.test.ts | 29 +++ js/net/src/connection/reload.ts | 8 +- js/room/README.md | 17 +- js/room/src/chat.test.ts | 81 ++++----- js/room/src/chat.ts | 127 +++++++------- js/room/src/index.ts | 4 +- js/room/src/local.test.ts | 52 ++++++ js/room/src/local.ts | 12 +- js/room/src/metadata.test.ts | 64 +++++++ js/room/src/metadata.ts | 8 +- js/room/src/path.test.ts | 6 +- js/room/src/path.ts | 2 +- js/room/src/remote.ts | 20 ++- js/room/src/room.test.ts | 40 +++++ js/room/src/room.ts | 8 +- js/room/src/token.test.ts | 6 + js/room/src/token.ts | 9 +- js/signals/src/solid.ts | 3 - rs/moq-net/Cargo.toml | 2 +- rs/moq-room/Cargo.toml | 7 +- rs/moq-room/README.md | 11 +- rs/moq-room/src/chat.rs | 254 +++++++++++++-------------- rs/moq-room/src/claims.rs | 28 +-- rs/moq-room/src/lib.rs | 19 +- rs/moq-room/src/path.rs | 8 +- rs/moq-room/src/room.rs | 2 +- rs/moq-wasm/Cargo.toml | 2 +- 35 files changed, 612 insertions(+), 358 deletions(-) create mode 100644 js/room/src/local.test.ts create mode 100644 js/room/src/room.test.ts diff --git a/.gitignore b/.gitignore index 4dd3d6aa6b..8d90ed6bc9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ /.claude/cache/ /.claude/tmp/ /.claude/worktrees/ -/.worktrees/ # IDE .idea diff --git a/.taplo.toml b/.taplo.toml index f29768ddf0..f248b5f2cb 100644 --- a/.taplo.toml +++ b/.taplo.toml @@ -1,5 +1,5 @@ # Skip vendored / build trees so `taplo format` doesn't recurse into them. -exclude = ["**/node_modules/**", "**/target/**", "**/dist/**", "**/.venv/**", "**/.direnv/**", "**/.worktrees/**", "**/.claude/worktrees/**"] +exclude = ["**/node_modules/**", "**/target/**", "**/dist/**", "**/.venv/**", "**/.direnv/**"] # Match the existing repo style (4-space indent on Cargo.toml, lines up # to ~120 chars) so the initial format pass stays minimal. diff --git a/Cargo.lock b/Cargo.lock index 1d54d5e09b..070da483af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -219,7 +219,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -230,7 +230,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1914,7 +1914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 3.0.5", ] [[package]] @@ -2223,7 +2223,7 @@ dependencies = [ "cfg-if", "libc", "socket2 0.6.5", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2470,7 +2470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4068,7 +4068,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4240,13 +4240,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -5074,10 +5073,11 @@ name = "moq-room" version = "0.1.0" dependencies = [ "kio", + "moq-json", "moq-net", "moq-token", + "thiserror 2.0.20", "tokio", - "tracing", ] [[package]] @@ -5815,7 +5815,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7540,7 +7540,7 @@ dependencies = [ "once_cell", "socket2 0.6.5", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8020,7 +8020,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8079,7 +8079,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8822,7 +8822,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9312,10 +9312,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9324,7 +9324,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10064,7 +10064,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10391,9 +10391,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -10404,9 +10404,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -10414,9 +10414,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -10424,9 +10424,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -10437,18 +10437,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.71" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af5ec93229ad9ccd0a545a516dec76dc276613f278f6a91aa6b463d5b33d42d0" +checksum = "895a2607575412a4eda1df892084a375ea10dfeadc4d7d2ab87b854e4ddc7ba1" dependencies = [ "async-trait", "cast", @@ -10468,9 +10468,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.71" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c81b9fef827e575e0e54431736d1baa0d700315d8c62cfef1f61fa3aad0cbeb" +checksum = "4288cb0ebe215033bf949ae1fd046726daa4c32a157f24b9dc6ac387a52aa759" dependencies = [ "proc-macro2", "quote", @@ -10479,9 +10479,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-shared" -version = "0.2.121" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4d8ae7ad5440360e9799dfd42857d126454a88441ddf72d288ef83fa47f527" +checksum = "33ff1c1b360982e93b6d8ea9c04836f71dba0817a16f91e229cf3a51bdd9d987" [[package]] name = "wasm-streams" @@ -10632,9 +10632,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -11031,7 +11031,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/biome.jsonc b/biome.jsonc index 6866c5bb20..4d6b347e59 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -19,7 +19,7 @@ // indexes. Exclude two kinds it can't handle: // - MPEG transport-stream fixtures whose `.ts` extension collides with TypeScript. // - SVG assets, which Biome routes to its HTML parser and rejects. - "includes": ["**", "!rs/moq-mux/src/container/ts/test_data", "!**/*.svg", "!.worktrees", "!.claude/worktrees"] + "includes": ["**", "!rs/moq-mux/src/container/ts/test_data", "!**/*.svg"] }, "linter": { "rules": { diff --git a/demo/web/src/meet.ts b/demo/web/src/meet.ts index 5b2b318c88..089588acff 100644 --- a/demo/web/src/meet.ts +++ b/demo/web/src/meet.ts @@ -39,7 +39,7 @@ relayEl.value = RELAY_URL; const ui = new Signals.Effect(); const joined = new Signals.Signal(false); -const tiles = new Map(); +const tiles = new Map(); const tilesEl = $("tiles"); const emptyEl = $("tiles-empty"); @@ -65,14 +65,14 @@ function tile(id: string, title: string, canvas: HTMLCanvasElement, you = false) canvas.className = "w-full h-auto bg-black"; canvas.style.aspectRatio = "16 / 9"; el.append(label, canvas); - tiles.set(id, el); + tiles.set(id, { element: el, label }); tilesEl.append(el); emptyEl.hidden = true; return el; } function dropTile(id: string): void { - tiles.get(id)?.remove(); + tiles.get(id)?.element.remove(); tiles.delete(id); emptyEl.hidden = tiles.size > 0; } @@ -168,15 +168,23 @@ function join(): void { if (!member) continue; const key = `${id}/${member.kind}`; live.add(key); - if (members.has(key)) continue; - members.set(key, member); - const canvas = document.createElement("canvas"); 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); } } diff --git a/doc/lib/js/room.md b/doc/lib/js/room.md index 49788290a4..6537078013 100644 --- a/doc/lib/js/room.md +++ b/doc/lib/js/room.md @@ -9,9 +9,9 @@ description: Headless multi-participant rooms over MoQ 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` / -`screen`. Each participant publishes `{identity}/camera` (camera + mic, hd/sd) -and `{identity}/screen` (screenshare). +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"; @@ -42,7 +42,7 @@ 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 ordered UTF-8 chat track (`Chat`) matches iroh-live; +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. diff --git a/doc/lib/rs/moq-room.md b/doc/lib/rs/moq-room.md index 06f57ff341..f5b9779b8b 100644 --- a/doc/lib/rs/moq-room.md +++ b/doc/lib/rs/moq-room.md @@ -11,10 +11,10 @@ description: Headless multi-participant rooms over MoQ 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` / `screen`. +stream. Identity is the path before `camera.hang` / `screen.hang`. -Each participant publishes `{identity}/camera` (camera + mic) and -`{identity}/screen` (screenshare; its announce/unannounce is the share +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). @@ -26,7 +26,7 @@ cargo add moq-room use moq_net::{Origin, Path}; use moq_room::{Kind, Room, claims}; -let token = key.sign(&claims("meet/demo", "alice"), None)?; +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 { @@ -36,7 +36,7 @@ while let Some(event) = room.next().await { } ``` -The ordered UTF-8 `chat` track (from iroh-live's `iroh-rooms`) is +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: 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 index c7cf4643eb..6540ba0d12 100644 --- a/js/room/README.md +++ b/js/room/README.md @@ -9,12 +9,12 @@ 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` / `screen`. Each participant publishes: +Participants are discovered from the announce stream. Identity is the path before `camera.hang` / `screen.hang`. Each participant publishes: -- `{identity}/camera` — camera + microphone, hd/sd renditions -- `{identity}/screen` — screenshare; its announce/unannounce is the share lifecycle +- `{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 ordered `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). +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 @@ -60,10 +60,10 @@ 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 member.muted from the UI. +// 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; iroh-live's ordered UTF-8 track is `Chat.TRACK` (`"chat"`). +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"; @@ -72,7 +72,10 @@ const publisher = Chat.Publisher.create(broadcast); publisher.send("hello"); const subscriber = Chat.Subscriber.subscribe(broadcast.consume()); -const msg = await subscriber.recv(); +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/src/chat.test.ts b/js/room/src/chat.test.ts index 291194343d..9b025be278 100644 --- a/js/room/src/chat.test.ts +++ b/js/room/src/chat.test.ts @@ -1,44 +1,45 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { Broadcast } from "@moq/net"; -import { PRIORITY, Publisher, Subscriber, TRACK } from "./chat.ts"; - -async function pair(): Promise<{ publisher: Publisher; subscriber: Subscriber }> { - const producer = new Broadcast.Producer(); - const publisher = Publisher.create(producer); - const subscriber = Subscriber.subscribe(producer.consume()); - return { publisher, subscriber }; -} - -test("track name and priority", () => { - expect(TRACK).toBe("chat"); - expect(PRIORITY).toBe(10); -}); - -test("roundtrip", async () => { - const { publisher, subscriber } = await pair(); - - publisher.send("hello"); - publisher.send("world"); - - expect((await subscriber.recv())?.text).toBe("hello"); - expect((await subscriber.recv())?.text).toBe("world"); +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("empty messages are skipped", async () => { - const { publisher, subscriber } = await pair(); - - publisher.send(""); - publisher.send("after empty"); - - expect((await subscriber.recv())?.text).toBe("after empty"); -}); - -test("closed track returns undefined", async () => { - const { publisher, subscriber } = await pair(); - - publisher.send("last"); - publisher.finish(); - - expect((await subscriber.recv())?.text).toBe("last"); - expect(await subscriber.recv()).toBeUndefined(); +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 index 0e1b8aff0e..eef3351c09 100644 --- a/js/room/src/chat.ts +++ b/js/room/src/chat.ts @@ -1,103 +1,106 @@ /** - * Text chat over a MoQ track, matching iroh-live's `iroh-rooms` and `moq-room::chat`. - * - * A broadcast carries chat on one well-known track, {@link TRACK}. Each message - * is a single group holding one frame of UTF-8 text. The sender's identity - * comes from the broadcast that carries the track, not from the payload. - * - * This is not hang.live's `hang/chat.json` snapshot; that stays an app-defined - * catalog extension (`TRACK.chat` in the metadata module). This track is an - * ordered append-log. - * + * 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 that carries chat messages. */ +/** Name of the track carrying the chat window. */ export const TRACK = "chat"; - -/** - * Publisher tie-break priority for the chat track. - * - * Lower than audio and video, which are the tracks a viewer notices first when - * the link is congested. - */ +/** 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 chat track: ordered, because chat is read oldest first. */ +/** Track settings for the latest chat window. */ export function info(): Pick { - return { priority: PRIORITY, ordered: true }; + return { priority: PRIORITY, ordered: false }; } -/** A received chat message, with the time it arrived. */ -export type Message = { - /** The message text. */ - text: string; - /** When this message was received locally. */ - receivedAt: Date; -}; - -/** Writer half of a chat track. */ +/** Publishes chat messages and retires them after ten seconds, including while idle. */ export class Publisher { - #track: Track.Producer; + #producer: Json.Window.Producer; + #expires = new Signal([]); + #signals = new Effect(); - /** Creates the chat track on `broadcast` and returns a publisher for it. */ + /** Create the chat track on a broadcast. */ static create(broadcast: Broadcast.Producer): Publisher { return new Publisher(broadcast.createTrack(TRACK, info())); } - /** Creates a publisher over an existing track producer. */ + /** Publish a chat window over an existing track. */ constructor(track: Track.Producer) { - this.#track = track; + // 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())); + }); } - /** - * Sends a text message on the chat track. - * - * Empty messages are dropped rather than written, because a subscriber - * cannot tell them apart from a group it failed to read. - */ + /** Append nonempty text, first retiring messages whose history has elapsed. */ send(text: string): void { - if (text === "") return; - this.#track.writeString(text); + if (!text) return; + this.expire(); + this.#producer.push(text); + this.#expires.update((expires) => [...expires, performance.now() + HISTORY]); } - /** - * Ends the chat track so subscribers drain already-sent messages, then see close. - * - * Dropping a publisher without this first is an abrupt teardown. - */ + /** 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.#track.close(); + this.#signals.close(); + this.#producer.finish(); } } -/** Reader half of a chat track. */ +/** Reads changes to a participant's retained chat window. */ export class Subscriber { #track: Track.Subscriber; + #consumer: Json.Window.Consumer; - /** Subscribes to the chat track of `broadcast`. */ + /** Subscribe to the newest retained window on a broadcast. */ static subscribe(broadcast: Broadcast.Consumer): Subscriber { - return new Subscriber(broadcast.track(TRACK).subscribe({ ordered: true })); + return new Subscriber(broadcast.track(TRACK).subscribe({ ordered: false })); } - /** Creates a subscriber over an existing track subscriber. */ + /** 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); } - /** - * Waits for the next chat message. - * - * Returns `undefined` once the track ends. - */ - async recv(): Promise { - for (;;) { - const text = await this.#track.readString(); - if (text === undefined) return undefined; - if (text === "") continue; - return { text, receivedAt: new Date() }; + /** 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 index 321fc4ce18..1c8f9a3b10 100644 --- a/js/room/src/index.ts +++ b/js/room/src/index.ts @@ -4,8 +4,8 @@ * 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` (camera + mic, hd/sd) and - * `{identity}/screen` (screenshare, whose announce/unannounce is the share + * participant publishes `{identity}/camera.hang` (camera + mic, hd/sd) and + * `{identity}/screen.hang` (screenshare, whose announce/unannounce is the share * lifecycle). * * @module 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 index 79c1879306..61520b37c8 100644 --- a/js/room/src/local.ts +++ b/js/room/src/local.ts @@ -16,7 +16,7 @@ type Established = Moq.Connection.Established; export interface LocalProps { /** Live session, usually a `Connection.Reload`'s `established`. */ connection: GetterInit; - /** Participant identity; broadcast names are `{identity}/camera` and `{identity}/screen`. */ + /** 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; @@ -33,7 +33,7 @@ export interface LocalProps { /** * Local camera and screen broadcasts for one participant. * - * Enable {@link enabled} to join (announce `{identity}/camera`). Camera and + * 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. */ @@ -66,9 +66,9 @@ export class Local { /** Screen capture source. */ readonly share: Publish.Source.Screen; - /** Camera+mic broadcast at `{identity}/camera`. */ + /** Camera+mic broadcast at `{identity}/camera.hang`. */ readonly camera: Publish.Broadcast; - /** Screenshare broadcast at `{identity}/screen`. */ + /** Screenshare broadcast at `{identity}/screen.hang`. */ readonly screen: Publish.Broadcast; /** Shared capture feeding the camera renditions. */ @@ -229,8 +229,9 @@ export class Local { 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 && effect.get(this.screenEnabled)) { + if (!live && wasLive) { this.screenEnabled.set(false); } }); @@ -255,6 +256,7 @@ export class Local { 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 index ae0b0eeebb..473c286587 100644 --- a/js/room/src/metadata.test.ts +++ b/js/room/src/metadata.test.ts @@ -15,3 +15,67 @@ test("core tracks are hang/*.json and extras share the section", () => { 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 index 7af806429b..fa507fc643 100644 --- a/js/room/src/metadata.ts +++ b/js/room/src/metadata.ts @@ -5,7 +5,7 @@ * 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 ordered UTF-8 `chat` track is `Chat.TRACK` (`"chat"`), not + * them. The JSON window `chat` track is `Chat.TRACK` (`"chat"`), not * `hang/chat.json`. * * @module @@ -155,7 +155,7 @@ function serveSnapshot( const track = net.createTrack(name, { latencyMax: 86_400_000, priority: PRIORITY }); effect.cleanup(() => track.close()); - const producer = new Json.Snapshot.Producer({ track, initial: value(effect) }); + const producer = new Json.Snapshot.Producer({ track }); effect.cleanup(() => producer.finish()); effect.run((effect) => { @@ -190,6 +190,10 @@ export function consume(broadcast: Watch.Broadcast): Consumed { 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) { diff --git a/js/room/src/path.test.ts b/js/room/src/path.test.ts index 767834d9ff..dd0e705c2e 100644 --- a/js/room/src/path.test.ts +++ b/js/room/src/path.test.ts @@ -34,7 +34,7 @@ test("kindFromSegment and isKind", () => { expect(isKind("chat")).toBe(false); }); -test("broadcastPath joins identity and kind without a .hang suffix", () => { - expect(broadcastPath(p("alice"), KIND.camera)).toBe(p("alice/camera")); - expect(broadcastPath(p("guest/uuid"), KIND.screen)).toBe(p("guest/uuid/screen")); +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 index 8a71288ae5..0edbf4a9af 100644 --- a/js/room/src/path.ts +++ b/js/room/src/path.ts @@ -61,5 +61,5 @@ export function parse(path: Path.Valid): Parsed | undefined { /** 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)); + return Path.join(identity, Path.from(`${kind}.hang`)); } diff --git a/js/room/src/remote.ts b/js/room/src/remote.ts index e5df465bdf..bc87cab75e 100644 --- a/js/room/src/remote.ts +++ b/js/room/src/remote.ts @@ -16,23 +16,30 @@ type Established = Moq.Connection.Established; export class Member { /** `camera` or `screen`. */ readonly kind: Kind; - /** Broadcast path relative to the room prefix. */ + /** 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. */ - readonly muted = new Signal(false); + /** 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); @@ -82,7 +89,6 @@ export class Member { this.renderer = new Watch.Video.Renderer(this.video, { canvas: this.canvas, - visible: "always", }); this.emitter = new Watch.Audio.Emitter(this.audio, { volume: this.volume, @@ -106,6 +112,7 @@ export class Member { this.#signals.cleanup(() => this.#metadata.close()); } + /** Release this participant's subscriptions and media resources. */ close() { this.#signals.close(); } @@ -139,9 +146,13 @@ export class Remote { }; 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; @@ -192,6 +203,7 @@ export class Remote { return !this.#camera.peek() && !this.#screen.peek(); } + /** Release this participant's subscriptions and media resources. */ close() { this.#camera.peek()?.close(); this.#screen.peek()?.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 index efbe2e801e..203bc0d80c 100644 --- a/js/room/src/room.ts +++ b/js/room/src/room.ts @@ -37,9 +37,13 @@ export interface RoomProps { * 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()); @@ -54,6 +58,7 @@ export class Room { 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()); @@ -83,7 +88,7 @@ export class Room { if (local && parsed.identity === local) continue; if (update.active) { - this.#add(parsed.identity, parsed.kind, update.path); + this.#add(parsed.identity, parsed.kind, Moq.Path.join(announced.prefix, update.path)); } else { this.#remove(parsed.identity, parsed.kind); } @@ -110,6 +115,7 @@ export class Room { } } + /** Release this participant's subscriptions and media resources. */ close() { this.#signals.close(); } diff --git a/js/room/src/token.test.ts b/js/room/src/token.test.ts index aa367a2397..049459af2f 100644 --- a/js/room/src/token.test.ts +++ b/js/room/src/token.test.ts @@ -20,3 +20,9 @@ test("claims accepts a multi-segment identity", () => { put: "guest/uuid/", }); }); + +test("claims rejects empty normalized identities", () => { + for (const identity of ["", "/", "///"]) { + expect(() => claims("room", identity)).toThrow(); + } +}); diff --git a/js/room/src/token.ts b/js/room/src/token.ts index 1d63129f13..6c7077f5a4 100644 --- a/js/room/src/token.ts +++ b/js/room/src/token.ts @@ -7,6 +7,8 @@ * @module */ +import { Path } from "@moq/net"; + /** Claims a room participant should present. Compatible with `@moq/token` `Claims`. */ export type Claims = { /** Room prefix. Broadcast paths are relative to this. */ @@ -22,13 +24,14 @@ export type Claims = { * * `root` is the room prefix, `get` is `""` (everything under the room), and * `put` is `"/"` so a participant cannot publish at anyone else's - * paths. hang.live grants `put` on the whole room subtree today; this is the - * narrower grant. + * paths. Empty identities are rejected after normalization. */ export function claims(room: string, identity: string): Claims { + identity = Path.from(identity); + if (!identity) throw new Error("participant identity must not be empty"); return { root: room, get: "", - put: identity.endsWith("/") ? identity : `${identity}/`, + put: `${identity}/`, }; } diff --git a/js/signals/src/solid.ts b/js/signals/src/solid.ts index 353c4da0db..4e7a4719b6 100644 --- a/js/signals/src/solid.ts +++ b/js/signals/src/solid.ts @@ -39,6 +39,3 @@ export function createSetter(signal: Signal): SolidSetter { export function createPair(signal: Signal): SolidSignal { return [createAccessor(signal), createSetter(signal)]; } - -/** @deprecated Use {@link createAccessor}. Default export kept for hang.live. */ -export default createAccessor; diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index c68ab9a22c..9671a9ee88 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -52,7 +52,7 @@ loom = { workspace = true } [target.'cfg(target_family = "wasm")'.dev-dependencies] getrandom = { workspace = true } # Keep this in lockstep with rs/moq-wasm's pinned wasm-bindgen CLI schema. -wasm-bindgen-test = "=0.3.71" +wasm-bindgen-test = "=0.3.77" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(loom)'] } diff --git a/rs/moq-room/Cargo.toml b/rs/moq-room/Cargo.toml index 818763f5e1..b5d6bdf8f6 100644 --- a/rs/moq-room/Cargo.toml +++ b/rs/moq-room/Cargo.toml @@ -13,10 +13,11 @@ keywords = ["quic", "webtransport", "conferencing", "live"] categories = ["multimedia", "network-programming"] [dependencies] -kio = { workspace = true } +kio = { workspace = true, features = ["time"] } +moq-json = { workspace = true } moq-net = { workspace = true } moq-token = { workspace = true } -tracing = { workspace = true } +thiserror = { workspace = true } [dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt"] } +tokio = { workspace = true, features = ["macros", "rt", "test-util"] } diff --git a/rs/moq-room/README.md b/rs/moq-room/README.md index 71a9173134..392127862a 100644 --- a/rs/moq-room/README.md +++ b/rs/moq-room/README.md @@ -2,15 +2,15 @@ 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. -Participants are discovered from the announce stream. Identity is the path before `camera` / `screen`. Each participant publishes `{identity}/camera` and `{identity}/screen`. +Participants are discovered from the announce stream. Identity is the path before `camera.hang` / `screen.hang`. Each participant publishes `{identity}/camera.hang` and `{identity}/screen.hang`. -This is the native counterpart of [`@moq/room`](https://www.npmjs.com/package/@moq/room). hang.live and [iroh-live](https://github.com/n0-computer/iroh-live) (`iroh-rooms` is being redesigned onto the announce bus) can depend on it for roster, path convention, token claims, and the ordered `chat` track. Gossip, tickets, and 1:1 Call stay in iroh-live. Capture/encode stay in `moq-video` / `moq-audio`. Native `Local`/`Remote` media plumbing stays with those crates too; this crate is media-free. +This is the native counterpart of [`@moq/room`](https://www.npmjs.com/package/@moq/room). hang.live and [iroh-live](https://github.com/n0-computer/iroh-live) (`iroh-rooms` is being redesigned onto the announce bus) can depend on it for roster, path convention, token claims, and the `chat` track. Gossip, tickets, and 1:1 Call stay in iroh-live. Capture/encode stay in `moq-video` / `moq-audio`. Native `Local`/`Remote` media plumbing stays with those crates too; this crate is media-free. ```rust use moq_net::{Origin, Path}; use moq_room::{Kind, Room, chat, claims}; -let token = key.sign(&claims("meet/demo", "alice"), None)?; +let token = key.sign(&claims("meet/demo", "alice")?, None)?; // Dial the relay at meet/demo?jwt=... let origin = Origin::random().produce(); @@ -25,3 +25,8 @@ while let Some(event) = room.next().await { } } ``` + +Chat uses `moq-json::window` with ten seconds of history. Drive +`Publisher::expire()` alongside incoming sends (or call `poll_expire` from a poll +loop) so messages expire while idle. `Subscriber::recv` returns push/pop/skip +events and propagates errors. The format differs from iroh-live's raw UTF-8 chat. diff --git a/rs/moq-room/src/chat.rs b/rs/moq-room/src/chat.rs index 6ba896fb59..51658c5413 100644 --- a/rs/moq-room/src/chat.rs +++ b/rs/moq-room/src/chat.rs @@ -1,148 +1,122 @@ -//! Text chat over a MoQ track, ported from iroh-live's `iroh-rooms`. -//! -//! A broadcast carries chat on one well-known track, [`TRACK`]. Each message is -//! a single group holding one frame of UTF-8 text. The sender's identity comes -//! from the broadcast that carries the track, not from the payload. -//! -//! This is not hang.live's `hang/chat.json` snapshot; that stays an app-defined -//! catalog extension. This track is an ordered append-log. +//! Chat over an uncompressed JSON window on `chat`, retaining ten seconds of messages. +//! Sender identity comes from the broadcast, not the payload. +use crate::Error; +use kio::time::{Deadline, Instant}; +use moq_net::{broadcast, track}; use std::{ + collections::VecDeque, task::{Poll, ready}, - time::Instant, + time::Duration, }; -use moq_net::{Error, Timestamp, broadcast, track}; - -/// Name of the track that carries chat messages. +/// Name of the track carrying the chat window. pub const TRACK: &str = "chat"; - -/// Publisher tie-break priority for the chat track. -/// -/// Lower than audio and video, which are the tracks a viewer notices first when -/// the link is congested. +/// Delivery priority, below audio and video. pub const PRIORITY: u8 = 10; +/// How long a published message stays in the window. +pub const HISTORY: Duration = Duration::from_secs(10); +/// A message entering, leaving, or missed from the window. +pub type Event = moq_json::window::Event; -/// Returns the track settings for the chat track. -/// -/// Groups are ordered, because chat only makes sense read oldest first, whereas -/// the moq-net default favours the newest group. +/// Track settings for the latest chat window. pub fn info() -> track::Info { - track::Info::default().with_priority(PRIORITY).with_ordered(true) -} - -/// A received chat message, with the time it arrived. -#[derive(Debug, Clone)] -pub struct Message { - /// The message text. - pub text: String, - /// When this message was received locally. - pub received_at: Instant, + track::Info::default().with_priority(PRIORITY).with_ordered(false) } -/// Writer half of a chat track. +/// Publishes chat messages; drive `poll_expire` or `expire` to retire them while idle. pub struct Publisher { - track: track::Producer, -} - -impl std::fmt::Debug for Publisher { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Publisher").finish_non_exhaustive() - } + producer: moq_json::window::Producer, + expires: VecDeque, + deadline: Deadline, } impl Publisher { - /// Creates the chat track on `broadcast` and returns a publisher for it. + /// Create the chat track on a broadcast. pub fn create(broadcast: &mut broadcast::Producer) -> Result { Ok(Self::new(broadcast.create_track(TRACK, info())?)) } - /// Creates a publisher over an existing track producer. + /// Publish a chat window over an existing track. pub fn new(track: track::Producer) -> Self { - Self { track } + Self { + producer: moq_json::window::Producer::new( + track, + moq_json::window::ProducerConfig::default().with_op_ratio(0), + ), + expires: VecDeque::new(), + deadline: Deadline::new(), + } } - /// Sends a text message on the chat track. - /// - /// Empty messages are dropped rather than written, because a subscriber - /// cannot tell them apart from a group it failed to read. - pub fn send(&mut self, text: impl AsRef) -> Result<(), Error> { - let text = text.as_ref(); + /// Append nonempty text, first retiring messages whose history has elapsed. + pub fn send(&mut self, text: &str) -> Result<(), Error> { if text.is_empty() { return Ok(()); } - self.track.write_frame(Timestamp::now(), text) + self.prune()?; + self.producer.push(&text.to_owned())?; + self.expires.push_back(Instant::now() + HISTORY); + Ok(()) } - /// Ends the chat track so subscribers drain already-sent messages, then see close. - /// - /// Dropping a [`Publisher`] without this first is an abrupt teardown: the - /// underlying track discards its cache, and a subscriber that has not yet - /// read the last message loses it. - pub fn finish(mut self) -> Result<(), Error> { - self.track.finish() + fn prune(&mut self) -> Result<(), Error> { + let now = Instant::now(); + let count = self.expires.iter().take_while(|expires| **expires <= now).count(); + if count == 0 { + return Ok(()); + } + self.producer.pop(count as u64)?; + self.expires.drain(..count); + Ok(()) } -} -/// Reader half of a chat track. -pub struct Subscriber { - track: track::Subscriber, -} + /// Poll until the oldest retained messages expire; drive this alongside incoming sends. + pub fn poll_expire(&mut self, waiter: &kio::Waiter) -> Poll> { + self.deadline.set(self.expires.front().copied()); + ready!(self.deadline.poll(waiter)); + Poll::Ready(self.prune()) + } + + /// Wait until the oldest retained messages expire; call again while the publisher is open. + pub async fn expire(&mut self) -> Result<(), Error> { + kio::wait(|waiter| self.poll_expire(waiter)).await + } -impl std::fmt::Debug for Subscriber { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Subscriber").finish_non_exhaustive() + /// Finish the track, preserving the final retained window for current readers. + pub fn finish(self) -> Result<(), Error> { + Ok(self.producer.finish()?) } } +/// Reads changes to a participant's retained chat window. +pub struct Subscriber { + consumer: moq_json::window::Consumer, +} + impl Subscriber { - /// Subscribes to the chat track of `broadcast`. - /// - /// Fails if the broadcast has no track named [`TRACK`], which is the normal - /// outcome for a peer that publishes media without chat. + /// Subscribe to the newest retained window on a broadcast. pub async fn subscribe(broadcast: &broadcast::Consumer) -> Result { - let track = broadcast.track(TRACK)?.subscribe(None).await?; - Ok(Self::new(track)) + Ok(Self::new(broadcast.track(TRACK)?.subscribe(None).await?)) } - /// Creates a subscriber over an existing track subscriber. - pub fn new(track: track::Subscriber) -> Self { - Self { track } + /// Read window changes from an existing subscription. + pub fn new(mut track: track::Subscriber) -> Self { + if let Some(latest) = track.latest() { + track.start_at(latest); + } + Self { + consumer: moq_json::window::Consumer::new(track, Default::default()), + } } - /// Poll for the next chat message. - /// - /// Returns `None` once the track ends. - pub fn poll_recv(&mut self, waiter: &kio::Waiter) -> Poll> { - loop { - let frame = match ready!(self.track.poll_read_frame(waiter)) { - Ok(Some(frame)) => frame, - Ok(None) => return Poll::Ready(None), - Err(err) => { - tracing::warn!("chat track read failed: {err:#}"); - return Poll::Ready(None); - } - }; - match std::str::from_utf8(&frame.payload) { - Ok(text) if !text.is_empty() => { - return Poll::Ready(Some(Message { - text: text.to_owned(), - received_at: Instant::now(), - })); - } - Ok(_) => continue, - Err(err) => { - tracing::warn!("chat message is not valid UTF-8: {err}"); - continue; - } - } - } + /// Poll the next window change, returning errors separately from clean completion. + pub fn poll_recv(&mut self, waiter: &kio::Waiter) -> Poll, Error>> { + Poll::Ready(Ok(ready!(self.consumer.poll_next(waiter))?)) } - /// Wait for the next chat message. - /// - /// Returns `None` once the track ends. - pub async fn recv(&mut self) -> Option { + /// Wait for the next window change, or None on clean completion. + pub async fn recv(&mut self) -> Result, Error> { kio::wait(|waiter| self.poll_recv(waiter)).await } } @@ -151,44 +125,52 @@ impl Subscriber { mod tests { use super::*; - async fn pair() -> (broadcast::Producer, Publisher, Subscriber) { - let mut producer = broadcast::Info::new().produce(); - let publisher = Publisher::create(&mut producer).expect("create chat track"); - let subscriber = Subscriber::subscribe(&producer.consume()) - .await - .expect("subscribe to chat track"); - (producer, publisher, subscriber) - } - #[tokio::test] - async fn roundtrip() { - let (_producer, mut publisher, mut subscriber) = pair().await; - - publisher.send("hello").unwrap(); - publisher.send("world").unwrap(); - - assert_eq!(subscriber.recv().await.unwrap().text, "hello"); - assert_eq!(subscriber.recv().await.unwrap().text, "world"); + async fn expires_idle_history_and_late_readers_only_see_retained_messages() { + tokio::time::pause(); + let mut broadcast = broadcast::Info::new().produce(); + let mut publisher = Publisher::create(&mut broadcast).unwrap(); + let mut subscriber = Subscriber::subscribe(&broadcast.consume()).await.unwrap(); + publisher.send("first").unwrap(); + assert_eq!( + subscriber.recv().await.unwrap(), + Some(Event::Push { + index: 0, + value: "first".into() + }) + ); + publisher.expire().await.unwrap(); + assert_eq!(subscriber.recv().await.unwrap(), Some(Event::Pop(0..1))); + publisher.send("second").unwrap(); + let mut late = Subscriber::subscribe(&broadcast.consume()).await.unwrap(); + assert_eq!( + late.recv().await.unwrap(), + Some(Event::Push { + index: 1, + value: "second".into() + }) + ); + publisher.finish().unwrap(); + assert!(late.recv().await.unwrap().is_none()); } #[tokio::test] - async fn empty_messages_skipped() { - let (_producer, mut publisher, mut subscriber) = pair().await; - - publisher.send("").unwrap(); - publisher.send("after empty").unwrap(); - - assert_eq!(subscriber.recv().await.unwrap().text, "after empty"); + async fn rejects_malformed_records() { + let mut broadcast = broadcast::Info::new().produce(); + let mut track = broadcast.create_track(TRACK, info()).unwrap(); + let mut subscriber = Subscriber::subscribe(&broadcast.consume()).await.unwrap(); + track + .write_frame(moq_net::Timestamp::now(), r#"{"offset":0,"records":[42]}"#) + .unwrap(); + assert!(matches!(subscriber.recv().await, Err(Error::Json(_)))); } #[tokio::test] - async fn closed_track_returns_none() { - let (_producer, mut publisher, mut subscriber) = pair().await; - - publisher.send("last").unwrap(); - publisher.finish().unwrap(); - - assert_eq!(subscriber.recv().await.unwrap().text, "last"); - assert!(subscriber.recv().await.is_none()); + async fn propagates_track_failure() { + let mut broadcast = broadcast::Info::new().produce(); + let publisher = Publisher::create(&mut broadcast).unwrap(); + let mut subscriber = Subscriber::subscribe(&broadcast.consume()).await.unwrap(); + drop(publisher); + assert!(subscriber.recv().await.is_err()); } } diff --git a/rs/moq-room/src/claims.rs b/rs/moq-room/src/claims.rs index 13405d5eeb..e7ad3e631f 100644 --- a/rs/moq-room/src/claims.rs +++ b/rs/moq-room/src/claims.rs @@ -8,25 +8,31 @@ /// `root` is the room prefix, `subscribe` is `[""]` (everything under the room), /// and `publish` is `["/"]` so a participant cannot publish at anyone /// else's paths. -pub fn claims(room: impl Into, identity: &str) -> moq_token::Claims { - let put = if identity.ends_with('/') { - identity.to_string() - } else { - format!("{identity}/") - }; - moq_token::Claims::default() +pub fn claims(room: impl Into, identity: &str) -> Result { + let identity = moq_net::Path::new(identity); + if identity.is_empty() { + return Err(crate::Error::EmptyIdentity); + } + Ok(moq_token::Claims::default() .with_root(room) .with_subscribe([""]) - .with_publish([put]) + .with_publish([format!("{identity}/")])) } #[cfg(test)] mod tests { use super::*; + #[test] + fn rejects_empty_identity() { + for identity in ["", "/", "///"] { + assert!(matches!(claims("room", identity), Err(crate::Error::EmptyIdentity))); + } + } + #[test] fn claims_root_the_token_and_scope_put_to_identity() { - let c = claims("meet/demo", "alice"); + let c = claims("meet/demo", "alice").unwrap(); assert_eq!(c.root, "meet/demo"); assert_eq!(c.subscribe, [""]); assert_eq!(c.publish, ["alice/"]); @@ -34,12 +40,12 @@ mod tests { #[test] fn claims_does_not_double_a_trailing_slash() { - assert_eq!(claims("meet/demo", "alice/").publish, ["alice/"]); + assert_eq!(claims("meet/demo", "alice/").unwrap().publish, ["alice/"]); } #[test] fn claims_accepts_a_multi_segment_identity() { - let c = claims("hang/room", "guest/uuid"); + let c = claims("hang/room", "guest/uuid").unwrap(); assert_eq!(c.root, "hang/room"); assert_eq!(c.publish, ["guest/uuid/"]); } diff --git a/rs/moq-room/src/lib.rs b/rs/moq-room/src/lib.rs index b7517945e5..9926673862 100644 --- a/rs/moq-room/src/lib.rs +++ b/rs/moq-room/src/lib.rs @@ -7,8 +7,8 @@ //! Participants are discovered from the announce stream. Identity is the path //! before `camera` / `screen`. Each participant publishes: //! -//! - `{identity}/camera` — camera + microphone -//! - `{identity}/screen` — screenshare; its announce/unannounce is the share lifecycle +//! - `{identity}/camera.hang`: camera + microphone +//! - `{identity}/screen.hang`: screenshare; its announce/unannounce is the share lifecycle //! //! This is the native counterpart of [`@moq/room`](https://www.npmjs.com/package/@moq/room), //! extracted from hang.live's roster and iroh-live's announce-bus redesign of @@ -25,3 +25,18 @@ mod room; pub use claims::claims; pub use path::{Kind, Parsed, broadcast_path, kind_from_segment, parse}; pub use room::{Event, Room}; + +/// Errors constructing or consuming a room participant. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// A room track could not be opened or read. + #[error(transparent)] + Net(#[from] moq_net::Error), + /// A chat record could not be encoded or decoded. + #[error(transparent)] + Json(#[from] moq_json::Error), + /// A participant identity must contain a nonempty path. + #[error("participant identity must not be empty")] + EmptyIdentity, +} diff --git a/rs/moq-room/src/path.rs b/rs/moq-room/src/path.rs index 367ccb7cb2..4bd2bc15eb 100644 --- a/rs/moq-room/src/path.rs +++ b/rs/moq-room/src/path.rs @@ -61,7 +61,7 @@ pub fn parse(path: impl AsPath) -> Option { /// The broadcast path a participant publishes for `kind`. pub fn broadcast_path(identity: impl AsPath, kind: Kind) -> PathOwned { - identity.as_path().join(kind.as_str()) + identity.as_path().join(format!("{}.hang", kind.as_str())) } #[cfg(test)] @@ -122,11 +122,11 @@ mod tests { } #[test] - fn broadcast_path_joins_without_hang_suffix() { - assert_eq!(broadcast_path(p("alice"), Kind::Camera).as_str(), "alice/camera"); + fn broadcast_path_joins_with_hang_suffix() { + assert_eq!(broadcast_path(p("alice"), Kind::Camera).as_str(), "alice/camera.hang"); assert_eq!( broadcast_path(p("guest/uuid"), Kind::Screen).as_str(), - "guest/uuid/screen" + "guest/uuid/screen.hang" ); } } diff --git a/rs/moq-room/src/room.rs b/rs/moq-room/src/room.rs index 8d56304e05..7add6d1558 100644 --- a/rs/moq-room/src/room.rs +++ b/rs/moq-room/src/room.rs @@ -32,7 +32,7 @@ impl std::fmt::Debug for Event { /// Runs the announce loop and yields remote participant broadcasts. /// -/// Skips paths that are not `{identity}/camera` or `{identity}/screen`, and +/// Skips paths that are not `{identity}/camera.hang` or `{identity}/screen.hang`, and /// skips the local identity so a publisher does not see itself as a remote. pub struct Room { announced: announce::Consumer, diff --git a/rs/moq-wasm/Cargo.toml b/rs/moq-wasm/Cargo.toml index 86f28051b7..566e7d876e 100644 --- a/rs/moq-wasm/Cargo.toml +++ b/rs/moq-wasm/Cargo.toml @@ -35,7 +35,7 @@ tracing-wasm = "0.2" url = { workspace = true } # Pinned to the wasm-bindgen-cli version in the Nix dev shell: the bindgen # schema must match exactly between crate and CLI. Bump both together. -wasm-bindgen = "=0.2.121" +wasm-bindgen = "=0.2.127" wasm-bindgen-futures = "0.4" web-async = { workspace = true } web-transport-trait = { workspace = true }