diff --git a/Cargo.lock b/Cargo.lock index f1c2bbcb11..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", ] @@ -5069,6 +5068,18 @@ dependencies = [ "wiremock", ] +[[package]] +name = "moq-room" +version = "0.1.0" +dependencies = [ + "kio", + "moq-json", + "moq-net", + "moq-token", + "thiserror 2.0.20", + "tokio", +] + [[package]] name = "moq-rtc" version = "0.2.9" @@ -5804,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]] @@ -7529,7 +7540,7 @@ dependencies = [ "once_cell", "socket2 0.6.5", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8009,7 +8020,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8068,7 +8079,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8811,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]] @@ -9301,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]] @@ -9313,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]] @@ -10053,7 +10064,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10380,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", @@ -10393,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", @@ -10403,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", @@ -10413,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", @@ -10426,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", @@ -10457,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", @@ -10468,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" @@ -10621,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", @@ -11020,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/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/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..089588acff --- /dev/null +++ b/demo/web/src/meet.ts @@ -0,0 +1,231 @@ +/** + * Conferencing demo on @moq/room: a room is a path prefix, participants are + * discovered from the announce stream, each publishes camera and optional screen. + */ + +import "@moq/publish/support/element"; +import "@moq/watch/support/element"; +import { Local, type Member, Net, Publish, Room, Signals } from "@moq/room"; + +const RELAY_URL = import.meta.env.VITE_RELAY_URL ?? "http://localhost:4443"; + +const $ = (id: string): T => { + const el = document.getElementById(id); + if (!el) throw new Error(`missing #${id}`); + return el as T; +}; + +function segment(raw: string, fallback: string): string { + const cleaned = raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32); + return cleaned || fallback; +} + +function randomName(): string { + return `p-${Math.random().toString(36).slice(2, 8)}`; +} + +const params = new URLSearchParams(location.search); +const roomInput = $("room"); +const nameInput = $("name"); +const relayEl = $("relay-url"); +roomInput.value = params.get("room") ?? "demo"; +nameInput.value = params.get("name") ?? randomName(); +relayEl.value = RELAY_URL; + +const ui = new Signals.Effect(); +const joined = new Signals.Signal(false); +const tiles = new Map(); +const tilesEl = $("tiles"); +const emptyEl = $("tiles-empty"); + +let session: Signals.Effect | undefined; +let connection: Net.Connection.Reload | undefined; +let local: Local | undefined; +let room: Room | undefined; +let localPreview: Publish.Preview.Renderer | undefined; + +function setPill(label: string, state: "ok" | "wait" | "bad"): void { + $("conn-text").textContent = label; + const dot = $("conn-status").querySelector(".dot") as HTMLElement; + const color = state === "ok" ? "bg-emerald-500" : state === "wait" ? "bg-amber-400" : "bg-red-500"; + dot.className = `dot w-2 h-2 rounded-full ${color}`; +} + +function tile(id: string, title: string, canvas: HTMLCanvasElement, you = false): HTMLElement { + const el = document.createElement("div"); + el.className = "rounded-lg overflow-hidden border border-neutral-800 bg-neutral-900"; + const label = document.createElement("div"); + label.className = "px-3 py-1.5 text-xs font-mono text-neutral-300 border-b border-neutral-800 truncate"; + label.textContent = you ? `${title} (you)` : title; + canvas.className = "w-full h-auto bg-black"; + canvas.style.aspectRatio = "16 / 9"; + el.append(label, canvas); + tiles.set(id, { element: el, label }); + tilesEl.append(el); + emptyEl.hidden = true; + return el; +} + +function dropTile(id: string): void { + tiles.get(id)?.element.remove(); + tiles.delete(id); + emptyEl.hidden = tiles.size > 0; +} + +function roomUrl(relay: string, roomName: string): URL { + const base = relay.replace(/\/+$/, ""); + return new URL(`${base}/anon/meet/${roomName}`); +} + +function leave(): void { + session?.close(); + session = undefined; + localPreview?.close(); + localPreview = undefined; + room?.close(); + room = undefined; + local?.close(); + local = undefined; + connection?.close(); + connection = undefined; + for (const id of [...tiles.keys()]) dropTile(id); + $("controls").hidden = true; + $("join").textContent = "Join"; + joined.set(false); + setPill("Disconnected", "bad"); +} + +function join(): void { + leave(); + + const roomName = segment(roomInput.value, "demo"); + const name = segment(nameInput.value, randomName()); + roomInput.value = roomName; + nameInput.value = name; + + const next = new URL(location.href); + next.searchParams.set("room", roomName); + next.searchParams.set("name", name); + history.replaceState(undefined, "", next); + + let relay: URL; + try { + relay = new URL(relayEl.value.trim()); + } catch { + relayEl.value = RELAY_URL; + relay = new URL(RELAY_URL); + } + + const identity = Net.Path.from(name); + connection = new Net.Connection.Reload({ + url: roomUrl(relay.toString(), roomName), + enabled: true, + }); + local = new Local({ + connection: connection.established, + identity, + enabled: true, + user: { id: name, name }, + }); + local.cameraEnabled.set(true); + local.microphoneEnabled.set(true); + room = new Room({ connection, identity }); + + const localCanvas = document.createElement("canvas"); + tile("local", name, localCanvas, true); + localPreview = new Publish.Preview.Renderer({ + canvas: localCanvas, + frame: local.cameraCapture.out.frame, + display: local.cameraCapture.out.display, + flip: true, + }); + + $("controls").hidden = false; + $("join").textContent = "Leave"; + joined.set(true); + + session = new Signals.Effect(); + session.run((effect) => { + if (!connection) return; + const status = effect.get(connection.status); + const label = status.charAt(0).toUpperCase() + status.slice(1); + setPill(label, status === "connected" ? "ok" : status === "connecting" ? "wait" : "bad"); + }); + + const members = new Map(); + session.run((effect) => { + if (!room) return; + const remotes = effect.get(room.remotes); + const live = new Set(); + + for (const [id, remote] of remotes) { + for (const member of [effect.get(remote.camera), effect.get(remote.screen)]) { + if (!member) continue; + const key = `${id}/${member.kind}`; + live.add(key); + const title = + member.kind === "screen" + ? `${effect.get(remote.user.name) ?? id} screen` + : (effect.get(remote.user.name) ?? id); + const previous = members.get(key); + if (previous === member) { + const existing = tiles.get(key); + if (existing) existing.label.textContent = title; + continue; + } + previous?.canvas.set(undefined); + dropTile(key); + members.set(key, member); + const canvas = document.createElement("canvas"); + tile(key, title, canvas); + member.canvas.set(canvas); + member.muted.set(false); + } + } + + for (const [key, member] of members) { + if (live.has(key)) continue; + member.canvas.set(undefined); + members.delete(key); + dropTile(key); + } + }); +} + +$("join").addEventListener("click", () => { + if (joined.peek()) leave(); + else join(); +}); + +function arm(id: string, pick: () => Signals.Signal | undefined): void { + const button = $(id); + button.addEventListener("click", () => { + const s = pick(); + if (!s) return; + s.set(!s.peek()); + }); + ui.run((effect) => { + effect.get(joined); + const s = pick(); + const on = s ? effect.get(s) : false; + button.classList.toggle("bg-emerald-700", on); + button.classList.toggle("hover:bg-emerald-600", on); + button.classList.toggle("bg-neutral-800", !on); + }); +} + +arm("toggle-camera", () => local?.cameraEnabled); +arm("toggle-mic", () => local?.microphoneEnabled); +arm("toggle-screen", () => local?.screenEnabled); + +if (import.meta.hot) { + import.meta.hot.dispose(() => { + leave(); + ui.close(); + }); +} diff --git a/demo/web/vite.config.ts b/demo/web/vite.config.ts index 3210e42f3c..7c21a5a507 100644 --- a/demo/web/vite.config.ts +++ b/demo/web/vite.config.ts @@ -28,6 +28,7 @@ export default defineConfig({ watch: resolve(__dirname, "src/watch.html"), publish: resolve(__dirname, "src/publish.html"), stats: resolve(__dirname, "src/stats.html"), + meet: resolve(__dirname, "src/meet.html"), }, }, }, diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index 10c5038fce..775556145b 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -165,6 +165,7 @@ export default defineConfig({ { text: "moq-video", link: "/lib/rs/moq-video" }, { text: "moq-audio", link: "/lib/rs/moq-audio" }, { text: "moq-token", link: "/lib/rs/moq-token" }, + { text: "moq-room", link: "/lib/rs/moq-room" }, ], }, { @@ -175,6 +176,7 @@ export default defineConfig({ { text: "@moq/hang", link: "/lib/js/hang" }, { text: "@moq/watch", link: "/lib/js/watch" }, { text: "@moq/publish", link: "/lib/js/publish" }, + { text: "@moq/room", link: "/lib/js/room" }, { text: "@moq/token", link: "/lib/js/token" }, { text: "@moq/signals", link: "/lib/js/signals" }, ], diff --git a/doc/bin/demo.md b/doc/bin/demo.md index 0a63fe1c6d..f2bfbe7c67 100644 --- a/doc/bin/demo.md +++ b/doc/bin/demo.md @@ -19,6 +19,7 @@ that uses the [``](/lib/js/watch) and - **Watching** a live broadcast with an adjustable latency budget and a stats overlay. - **Publishing** your camera, microphone, screen, or a file from the browser with WebCodecs. +- **Meeting**: a multi-participant room (`meet.html`) using `@moq/room`. Open the same room in two tabs. - **Discovery**: broadcasts appear as they're announced under the prefix. `just web serve https://cdn.moq.dev/anon` points it at the public relay diff --git a/doc/index.md b/doc/index.md index bcac65f56f..e217716fcf 100644 --- a/doc/index.md +++ b/doc/index.md @@ -83,7 +83,7 @@ See the [concepts](/concept/) page for a breakdown of the layering, rationale, a | Use case | Reach for | | --- | --- | | Live streaming | Ingest with [OBS](/bin/obs), [RTMP](/bin/rtmp), or [SRT](/bin/srt); distribute with [moq-relay](/bin/relay/); watch with [``](/lib/js/watch); keep legacy players via [HLS](/bin/hls). | -| Conferencing | [``](/lib/js/publish) and [``](/lib/js/watch) in the browser, one broadcast per participant, plus [WebRTC](/bin/rtc) for WHIP/WHEP clients. | +| Conferencing | [`@moq/room`](/lib/js/room) / [`moq-room`](/lib/rs/moq-room) for the roster, [``](/lib/js/publish) and [``](/lib/js/watch) in the browser, plus [WebRTC](/bin/rtc) for WHIP/WHEP clients. | | Voice and video AI | Server-side media in [Rust](/lib/rs/) or [Python](/lib/py/), faster-than-real-time playback in the browser. See [MoQ for AI](/concept/use-case/ai). | | Real-time data | Chat, game state, telemetry, and control channels over the same relays with [`moq-net`](/lib/rs/moq-net) or [`@moq/net`](/lib/js/net). | | Interactive streams | Media down, input up. [MoQ Boy](/bin/demo) is a crowd-controlled Game Boy built this way. | diff --git a/doc/lib/js/index.md b/doc/lib/js/index.md index b95245f4d8..294b8a7d9d 100644 --- a/doc/lib/js/index.md +++ b/doc/lib/js/index.md @@ -16,6 +16,7 @@ and WebAudio. `@moq/net` also runs in Node, Bun, and Deno. | [@moq/hang](/lib/js/hang) | The media layer: catalog types and containers. | | [@moq/watch](/lib/js/watch) | Subscribe, decode, and render. `` plus an optional UI overlay. | | [@moq/publish](/lib/js/publish) | Capture, encode, and publish. `` plus an optional UI overlay. | +| [@moq/room](/lib/js/room) | Headless rooms: announce-derived roster, local publish, remote watch, and a chat track. | | [@moq/token](/lib/js/token) | Mint and verify relay JWTs. | | [@moq/signals](/lib/js/signals) | The reactive primitives every package exposes its state through. | | [@moq/json](https://www.npmjs.com/package/@moq/json) | JSON over tracks: snapshots with merge-patch deltas, or append logs. | diff --git a/doc/lib/js/room.md b/doc/lib/js/room.md new file mode 100644 index 0000000000..6537078013 --- /dev/null +++ b/doc/lib/js/room.md @@ -0,0 +1,52 @@ +--- +title: "@moq/room" +description: Headless multi-participant rooms over MoQ +--- + +# @moq/room + +[![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.hang` / +`screen.hang`. Each participant publishes `{identity}/camera.hang` (camera + mic, hd/sd) +and `{identity}/screen.hang` (screenshare). + +```ts +import { Local, Room } from "@moq/room"; +import { Connection, Path } from "@moq/net"; +import { claims } from "@moq/room"; +import { sign } from "@moq/token"; + +const token = await sign(key, claims("meet/demo", "alice")); +const connection = new Connection.Reload({ + url: new URL(`https://relay.example.com/meet/demo?jwt=${token}`), + enabled: true, +}); + +const local = new Local({ + connection: connection.established, + identity: Path.from("alice"), + user: { name: "Alice" }, +}); +local.enabled.set(true); +local.cameraEnabled.set(true); + +const room = new Room({ connection, identity: Path.from("alice") }); +``` + +On a public prefix, skip the token and dial that path directly. The conferencing +demo at [`demo/web`](https://github.com/moq-dev/moq/tree/main/demo/web) (`meet.html`) +does that under `anon/meet/{room}`. + +hang.live should depend on this package for the roster, local publish, remote +watch, and `hang/user.json` + `hang/preview.json`. Location stays an app-defined +catalog extension. The JSON window chat track (`Chat`) uses `@moq/json` Window; +hang.live's JSON chat (`hang/chat.json`) stays an extension of the same catalog +`hang` section. + +The native twin is [`moq-room`](/lib/rs/moq-room). + +See the package [README](https://github.com/moq-dev/moq/blob/main/js/room/README.md) +for the full API. diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md index e60c801b21..e865dd1595 100644 --- a/doc/lib/rs/index.md +++ b/doc/lib/rs/index.md @@ -20,6 +20,7 @@ The reference implementation. Every crate is on | [moq-audio](/lib/rs/moq-audio) | Microphone and speaker, Opus/PCM/AAC codecs, echo cancellation. | | [moq-transcode](https://docs.rs/moq-transcode) | Just-in-time rendition ladders, GPU-resident on NVIDIA. | | [moq-token](/lib/rs/moq-token) | JWT keys, signing, verification, path authorization. | +| [moq-room](/lib/rs/moq-room) | Headless rooms: announce-derived roster, token claims, and a chat track. | | [moq-json](https://docs.rs/moq-json) | JSON over tracks: snapshots with merge-patch deltas, or append logs. | | [moq-flate](https://docs.rs/moq-flate) | Group-scoped DEFLATE for any track. | | [moq-loc](https://docs.rs/moq-loc), [moq-msf](https://docs.rs/moq-msf) | The IETF LOC container and MSF catalog. | diff --git a/doc/lib/rs/moq-room.md b/doc/lib/rs/moq-room.md new file mode 100644 index 0000000000..f5b9779b8b --- /dev/null +++ b/doc/lib/rs/moq-room.md @@ -0,0 +1,43 @@ +--- +title: moq-room +description: Headless multi-participant rooms over MoQ +--- + +# moq-room + +[![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.hang` / `screen.hang`. + +Each participant publishes `{identity}/camera.hang` (camera + mic) and +`{identity}/screen.hang` (screenshare; its announce/unannounce is the share +lifecycle). Capture and encode stay in [`moq-video`](/lib/rs/moq-video) and +[`moq-audio`](/lib/rs/moq-audio). + +```bash +cargo add moq-room +``` + +```rust +use moq_net::{Origin, Path}; +use moq_room::{Kind, Room, claims}; + +let token = key.sign(&claims("meet/demo", "alice")?, None)?; +let origin = Origin::random().produce(); +let mut room = Room::new(&origin.consume(), Some(Path::new("alice").to_owned())); +while let Some(event) = room.next().await { + if event.kind == Kind::Camera { + // subscribe to event.broadcast + } +} +``` + +The JSON window `chat` track (using `moq-json::window`) is +`moq_room::chat`. That is not hang.live's `hang/chat.json` catalog extension. + +Gossip, tickets, and 1:1 Call stay in iroh-live. API: +[docs.rs/moq-room](https://docs.rs/moq-room). diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index 84504046dd..c2ddf04a4c 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -420,6 +420,28 @@ A consumer looks up a time by finding the record whose span covers it, which nam To locate an individual unrecorded group within a span, a consumer MAY extrapolate from the surrounding records when the media track's group sequence numbers are contiguous, or inspect the fetched media itself. +# Rooms + +A room is a broadcast path prefix. A participant publishes camera and microphone +at `{identity}/camera.hang` and a screen share at `{identity}/screen.hang`, relative +to that prefix. Identity MUST contain at least one nonempty path segment. +Consumers MAY also recognize the unsuffixed `camera` and `screen` forms. + +A participant MAY publish a `chat` track containing a JSON window of messages +from the last ten seconds. Each edit opens a new group containing one +uncompressed UTF-8 JSON header of the form `{"offset": N, "records": ["text", ...]}`. +The records are the complete retained window, oldest first. The offset is the +absolute index of its first record and advances as records expire. Offsets MUST +be nonnegative safe JSON integers (at most 2^53 - 1). + +Publishers MUST retire records after ten seconds, including while idle. Consumers +start at the latest group and report records entering or leaving the window; +missing index ranges indicate messages that expired before being received. +Consumers MUST report malformed JSON, non-string records, and transport failures +as errors, distinct from the clean end of the track. Sender identity comes from +the broadcast path, not the payload. This track is distinct from an application's +`hang/chat.json` snapshot extension. + # Security Considerations TODO Security diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 18d7ae7d18..bf73b79dea 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -174,3 +174,32 @@ test("announcedBroadcast follows the reconnect loop", async () => { globalThis.WebTransport = original; } }); + +test("closing an announce consumer during upstream teardown does not append retractions", async () => { + const { Producer } = await import("../announced.ts"); + const { Signal } = await import("@moq/signals"); + const { spyOn } = await import("bun:test"); + const upstream = new Producer(); + const reload = new Reload({ enabled: false }); + reload.established.set({ + probe: new Signal(undefined), + discovery: true, + announced: () => upstream.consume(), + } as unknown as import("./established.ts").Established); + const consumer = reload.announced(); + const errors = spyOn(console, "error").mockImplementation(() => {}); + try { + upstream.append({ path: Path.from("alice/camera.hang"), active: true }); + await consumer.next(); + upstream.close(); + // Let the upstream read settle, but close before the pump's finally callback runs. + await Promise.resolve(); + consumer.close(); + await settle(); + expect(errors.mock.calls).toEqual([]); + } finally { + consumer.close(); + reload.close(); + errors.mockRestore(); + } +}); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index e8b0bd9969..d4be26d6d8 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -264,12 +264,6 @@ export class Reload { const producer = new Announce.Producer(prefix); const consumer = producer.consume(); - // Closing the consumer closes the shared state, so stop appending after that. - let closed = false; - void consumer.closed.then(() => { - closed = true; - }); - const pump = new Effect(); pump.run((effect) => { const conn = effect.get(this.established); @@ -299,7 +293,7 @@ export class Reload { } finally { // Retract everything from the connection that just went away, so a per-broadcast // watcher tears down instead of clinging to the dead route. - if (!closed) { + if (consumer.closed.peek() === undefined) { for (const path of active) { producer.append({ path, active: false }); } diff --git a/js/room/README.md b/js/room/README.md new file mode 100644 index 0000000000..6540ba0d12 --- /dev/null +++ b/js/room/README.md @@ -0,0 +1,81 @@ +

+ 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.hang` / `screen.hang`. Each participant publishes: + +- `{identity}/camera.hang`: camera + microphone, hd/sd renditions +- `{identity}/screen.hang`: screenshare; its announce/unannounce is the share lifecycle + +This is the generic room layer extracted from [hang.live](https://hang.live) (roster, local/remote, `hang/*.json` metadata) and [iroh-live](https://github.com/n0-computer/iroh-live) (the `chat` track `iroh-rooms` is moving onto the announce bus). Memes, 3D layout, chat UI, and accounts stay in the app. The native twin is [`moq-room`](../../rs/moq-room). + +## Install + +```bash +bun add @moq/room +``` + +## Token + +Sign with [`@moq/token`](../token). `root` is the room, `get: ""` subscribes to everyone, `put: "/"` so a participant cannot publish at someone else's paths. + +```ts +import { claims } from "@moq/room"; +import { sign } from "@moq/token"; + +const token = await sign(key, claims("meet/demo", "alice")); +// Dial https://relay.example.com/meet/demo?jwt= +``` + +On a public prefix (`anon/`), skip the token and dial that path directly. + +## Usage + +```ts +import { Local, Room } from "@moq/room"; +import { Connection, Path } from "@moq/net"; + +const connection = new Connection.Reload({ + url: new URL("https://relay.example.com/anon/meet/demo"), + enabled: true, +}); + +const identity = Path.from("alice"); +const local = new Local({ + connection: connection.established, + identity, + user: { name: "Alice" }, +}); +local.enabled.set(true); +local.cameraEnabled.set(true); +local.microphoneEnabled.set(true); + +const room = new Room({ connection, identity }); + +// room.remotes is a Map. Each Remote has camera/screen +// Members; assign member.canvas and set member.muted to false to play audio. +``` + +hang.live should depend on this package for `Room`, `Local`, `Remote`, and the `hang/*.json` metadata tracks. Location stays an app-defined catalog extension (`TRACK.location`). hang.live's JSON chat (`TRACK.chat` = `hang/chat.json`) is also an extension; the JSON window track is `Chat.TRACK` (`"chat"`). + +```ts +import { Chat } from "@moq/room"; + +const publisher = Chat.Publisher.create(broadcast); +publisher.send("hello"); + +const subscriber = Chat.Subscriber.subscribe(broadcast.consume()); +const event = await subscriber.recv(); // push, pop, or skip +// Call publisher.finish() and subscriber.close() when done. +``` + +A conferencing demo (no memes, no 3D, no chat UI) lives at [`demo/web/src/meet.html`](../../demo/web/src/meet.html). + +Room members start muted; set `member.muted` to `false` to play audio. Chat uses uncompressed JSON strings, a retained ten-second window with push/pop/skip events; it is not compatible with the raw UTF-8 iroh-live track. Empty normalized identities are rejected by `claims`. diff --git a/js/room/package.json b/js/room/package.json new file mode 100644 index 0000000000..31ab361b56 --- /dev/null +++ b/js/room/package.json @@ -0,0 +1,34 @@ +{ + "name": "@moq/room", + "type": "module", + "version": "0.1.0", + "description": "Headless multi-participant rooms over MoQ: announce-derived roster, local publish, remote watch, and a chat track", + "license": "(MIT OR Apache-2.0)", + "repository": "github:moq-dev/moq", + "sideEffects": false, + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "rimraf dist && tsc -b tsconfig.build.json && bun ../common/package.ts", + "check": "tsc --noEmit", + "test": "bun test --only-failures", + "release": "bun ../common/release.ts" + }, + "dependencies": { + "@moq/hang": "workspace:^", + "@moq/json": "workspace:^", + "@moq/net": "workspace:^", + "@moq/publish": "workspace:^", + "@moq/signals": "workspace:^", + "@moq/watch": "workspace:^" + }, + "devDependencies": { + "@types/audioworklet": "^0.0.100", + "@types/bun": "^1.4.0", + "@typescript/lib-dom": "npm:@types/web@^0.0.350", + "rimraf": "^6.1.3", + "typescript": "7.0.2", + "vite": "^8.2.2" + } +} diff --git a/js/room/src/chat.test.ts b/js/room/src/chat.test.ts new file mode 100644 index 0000000000..9b025be278 --- /dev/null +++ b/js/room/src/chat.test.ts @@ -0,0 +1,45 @@ +import { expect, spyOn, test } from "bun:test"; +import { Broadcast } from "@moq/net"; +import { HISTORY, Publisher, Subscriber, TRACK } from "./chat.ts"; + +test("chat expires ten-second history and late readers only see retained messages", async () => { + const clock = spyOn(performance, "now").mockReturnValue(0); + const broadcast = new Broadcast.Producer(); + const publisher = Publisher.create(broadcast); + const subscriber = Subscriber.subscribe(broadcast.consume()); + try { + publisher.send("first"); + expect(await subscriber.recv()).toEqual({ push: { index: 0, value: "first" } }); + clock.mockReturnValue(HISTORY - 1); + publisher.expire(); + clock.mockReturnValue(HISTORY); + publisher.expire(); + expect(await subscriber.recv()).toEqual({ pop: { start: 0, end: 1 } }); + publisher.send("second"); + const late = Subscriber.subscribe(broadcast.consume()); + expect(await late.recv()).toEqual({ push: { index: 1, value: "second" } }); + publisher.finish(); + expect(await late.recv()).toBeUndefined(); + late.close(); + } finally { + publisher.finish(); + subscriber.close(); + broadcast.close(); + clock.mockRestore(); + } +}); + +test("chat rejects non-string window records and propagates track failures", async () => { + const broadcast = new Broadcast.Producer(); + const track = broadcast.createTrack(TRACK); + const subscriber = Subscriber.subscribe(broadcast.consume()); + try { + track.writeString('{"offset":0,"records":[42]}'); + await expect(subscriber.recv()).rejects.toThrow("chat record must be a string"); + track.close(new Error("chat aborted")); + await expect(subscriber.recv()).rejects.toThrow("chat aborted"); + } finally { + subscriber.close(); + broadcast.close(); + } +}); diff --git a/js/room/src/chat.ts b/js/room/src/chat.ts new file mode 100644 index 0000000000..eef3351c09 --- /dev/null +++ b/js/room/src/chat.ts @@ -0,0 +1,106 @@ +/** + * Chat over an uncompressed JSON window on `chat`, retaining ten seconds of messages. + * Sender identity comes from the broadcast, not the payload. + * @module + */ + +import * as Json from "@moq/json"; +import type { Broadcast, Track } from "@moq/net"; +import { Effect, Signal } from "@moq/signals"; + +/** Name of the track carrying the chat window. */ +export const TRACK = "chat"; +/** Delivery priority, below audio and video. */ +export const PRIORITY = 10; +/** Milliseconds a published message stays in the window. */ +export const HISTORY = 10_000; +/** A message entering, leaving, or missed from the window. */ +export type Event = Json.Window.Event; + +/** Track settings for the latest chat window. */ +export function info(): Pick { + return { priority: PRIORITY, ordered: false }; +} + +/** Publishes chat messages and retires them after ten seconds, including while idle. */ +export class Publisher { + #producer: Json.Window.Producer; + #expires = new Signal([]); + #signals = new Effect(); + + /** Create the chat track on a broadcast. */ + static create(broadcast: Broadcast.Producer): Publisher { + return new Publisher(broadcast.createTrack(TRACK, info())); + } + + /** Publish a chat window over an existing track. */ + constructor(track: Track.Producer) { + // Every edit restates the retained window, so a late reader never replays expired records. + this.#producer = new Json.Window.Producer(track, { opRatio: 0 }); + this.#signals.run((effect) => { + const next = effect.get(this.#expires)[0]; + if (next === undefined) return; + effect.timer(() => this.expire(), Math.max(0, next - performance.now())); + }); + } + + /** Append nonempty text, first retiring messages whose history has elapsed. */ + send(text: string): void { + if (!text) return; + this.expire(); + this.#producer.push(text); + this.#expires.update((expires) => [...expires, performance.now() + HISTORY]); + } + + /** Retire elapsed messages now; the publisher also schedules this automatically. */ + expire(): void { + const now = performance.now(); + const expires = this.#expires.peek(); + let count = 0; + while (count < expires.length && expires[count] <= now) count++; + if (!count) return; + this.#producer.pop(count); + this.#expires.set(expires.slice(count)); + } + + /** Finish the track and cancel expiry timers. */ + finish(): void { + this.#signals.close(); + this.#producer.finish(); + } +} + +/** Reads changes to a participant's retained chat window. */ +export class Subscriber { + #track: Track.Subscriber; + #consumer: Json.Window.Consumer; + + /** Subscribe to the newest retained window on a broadcast. */ + static subscribe(broadcast: Broadcast.Consumer): Subscriber { + return new Subscriber(broadcast.track(TRACK).subscribe({ ordered: false })); + } + + /** Read window changes from an existing subscription. */ + constructor(track: Track.Subscriber) { + const latest = track.latest(); + if (latest !== undefined) track.startAt(latest); + this.#track = track; + this.#consumer = new Json.Window.Consumer(track); + } + + /** Return the next window change, or undefined on clean completion; failures throw. */ + async recv(): Promise { + const event = await this.#consumer.next(); + if (!event) return undefined; + if ("push" in event) { + if (typeof event.push.value !== "string") throw new Error("chat record must be a string"); + return { push: { index: event.push.index, value: event.push.value } }; + } + return event; + } + + /** Cancel the subscription and release buffered records. */ + close(): void { + this.#track.close(); + } +} diff --git a/js/room/src/index.ts b/js/room/src/index.ts new file mode 100644 index 0000000000..1c8f9a3b10 --- /dev/null +++ b/js/room/src/index.ts @@ -0,0 +1,38 @@ +/** + * Headless multi-participant rooms over MoQ. + * + * A room is a path prefix. The connection URL and token root already carry it; + * this package has no service and no storage. Participants are discovered from + * the announce stream, identity is the path before `camera`/`screen`, and each + * participant publishes `{identity}/camera.hang` (camera + mic, hd/sd) and + * `{identity}/screen.hang` (screenshare, whose announce/unannounce is the share + * lifecycle). + * + * @module + */ + +export * as Hang from "@moq/hang"; +export * as Net from "@moq/net"; +export * as Publish from "@moq/publish"; +export * as Signals from "@moq/signals"; +export * as Watch from "@moq/watch"; + +export * as Chat from "./chat.ts"; +export { Local, type LocalProps } from "./local.ts"; +export { + consume, + type ExtendedCatalog, + type HangCatalog, + PRIORITY, + type Preview, + serve, + TRACK, + type User, + type UserFields, + type UserProps, + userFields, +} from "./metadata.ts"; +export { broadcastPath, isKind, KIND, type Kind, kindFromSegment, type Parsed, parse } from "./path.ts"; +export { Member, Remote, type RemoteProps } from "./remote.ts"; +export { Room, type RoomProps } from "./room.ts"; +export { type Claims, claims } from "./token.ts"; diff --git a/js/room/src/local.test.ts b/js/room/src/local.test.ts new file mode 100644 index 0000000000..2d9a5ddf04 --- /dev/null +++ b/js/room/src/local.test.ts @@ -0,0 +1,52 @@ +import { expect, mock, test } from "bun:test"; +import { Path } from "@moq/net"; +import { Signal } from "@moq/signals"; + +const sources: FakeSource[] = []; +class FakeSource { + out = { source: new Signal(undefined) }; + constructor() { + sources.push(this); + } + close() {} +} +class FakePipeline { + out = { display: new Signal(undefined), frame: new Signal(undefined) }; + close() {} +} +class FakeBroadcast { + net = new Signal(undefined); + catalog = { mutate: (fn: (value: object) => void) => fn({}) }; + close() {} +} +// Exercise room lifecycle wiring independently of camera drivers and browser workers. +mock.module("../../publish/src/index.ts", () => ({ + Source: { Camera: FakeSource, Microphone: FakeSource, Screen: FakeSource }, + Video: { Capture: FakePipeline, Encoder: FakePipeline }, + Audio: { Encoder: FakePipeline }, + Broadcast: FakeBroadcast, +})); +const { Local } = await import("./local.ts"); +async function flush() { + for (let i = 0; i < 30; i++) await Promise.resolve(); +} + +test("screen capture stays enabled while pending and resets after a live share ends", async () => { + const local = new Local({ connection: undefined, identity: Path.from("alice") }); + try { + await flush(); + local.screenEnabled.set(true); + await flush(); + expect(local.screenEnabled.peek()).toBe(true); + const screen = sources.at(-1); + screen?.out.source.set({ video: {} }); + await flush(); + expect(local.preview.peek().screen).toBe(true); + screen?.out.source.set(undefined); + await flush(); + expect(local.screenEnabled.peek()).toBe(false); + expect(local.preview.peek().screen).toBe(false); + } finally { + local.close(); + } +}); diff --git a/js/room/src/local.ts b/js/room/src/local.ts new file mode 100644 index 0000000000..61520b37c8 --- /dev/null +++ b/js/room/src/local.ts @@ -0,0 +1,263 @@ +/** + * The local participant: camera+mic and screenshare publishers. + * + * @module + */ + +import type * as Moq from "@moq/net"; +import * as Publish from "@moq/publish"; +import { Effect, type Getter, type GetterInit, getter, Signal } from "@moq/signals"; +import { type Preview, serve, type UserProps, userFields } from "./metadata.ts"; +import { broadcastPath, KIND } from "./path.ts"; + +type Established = Moq.Connection.Established; + +/** Constructor options for {@link Local}. */ +export interface LocalProps { + /** Live session, usually a `Connection.Reload`'s `established`. */ + connection: GetterInit; + /** Participant identity; broadcast names are `{identity}/camera.hang` and `{identity}/screen.hang`. */ + identity: GetterInit; + /** When true, announce the camera broadcast (joining the room). Defaults to false. */ + enabled?: boolean | Signal; + /** Capture the camera. Pass a Signal to share it with the app (hang.live Settings). */ + cameraEnabled?: boolean | Signal; + /** Capture the microphone. Pass a Signal to share it with the app. */ + microphoneEnabled?: boolean | Signal; + /** Prompt for and capture a screen. Pass a Signal to share it with the app. */ + screenEnabled?: boolean | Signal; + /** Seed the published user.json fields. */ + user?: UserProps; +} + +/** + * Local camera and screen broadcasts for one participant. + * + * Enable {@link enabled} to join (announce `{identity}/camera.hang`). Camera and + * microphone capture are separate knobs; the screenshare broadcast is + * announced only while a share is live. + */ +export class Local { + /** Participant identity. */ + readonly identity: Getter; + + /** Announce the camera broadcast (join the room). */ + readonly enabled: Signal; + + /** Capture the camera. */ + readonly cameraEnabled: Signal; + /** Capture the microphone. */ + readonly microphoneEnabled: Signal; + /** Prompt for and capture a screen. Unannounce when the share ends. */ + readonly screenEnabled: Signal; + + /** True while the local participant is composing a chat message. */ + readonly typing: Signal; + /** True while a chat message is live. */ + readonly chatting: Signal; + + /** Published user.json fields. */ + readonly user: ReturnType; + + /** Camera capture source. */ + readonly webcam: Publish.Source.Camera; + /** Microphone capture source. */ + readonly microphone: Publish.Source.Microphone; + /** Screen capture source. */ + readonly share: Publish.Source.Screen; + + /** Camera+mic broadcast at `{identity}/camera.hang`. */ + readonly camera: Publish.Broadcast; + /** Screenshare broadcast at `{identity}/screen.hang`. */ + readonly screen: Publish.Broadcast; + + /** Shared capture feeding the camera renditions. */ + readonly cameraCapture: Publish.Video.Capture; + /** Shared capture feeding the screen renditions. */ + readonly screenCapture: Publish.Video.Capture; + + /** Camera HD encoder. */ + readonly cameraHd: Publish.Video.Encoder; + /** Camera SD encoder. */ + readonly cameraSd: Publish.Video.Encoder; + /** Camera microphone encoder. */ + readonly cameraAudio: Publish.Audio.Encoder; + + /** Screen HD encoder. */ + readonly screenHd: Publish.Video.Encoder; + /** Screen SD encoder. */ + readonly screenSd: Publish.Video.Encoder; + /** Screen audio encoder (tab/system audio when the share includes it). */ + readonly screenAudio: Publish.Audio.Encoder; + + #preview = new Signal({}); + #screenVideo = new Signal(undefined); + #screenAudioSource = new Signal(undefined); + #screenLive = new Signal(false); + + #signals = new Effect(); + + constructor(props: LocalProps) { + this.identity = getter(props.identity); + this.enabled = Signal.from(props.enabled ?? false); + this.cameraEnabled = Signal.from(props.cameraEnabled ?? false); + this.microphoneEnabled = Signal.from(props.microphoneEnabled ?? false); + this.screenEnabled = Signal.from(props.screenEnabled ?? false); + this.typing = new Signal(false); + this.chatting = new Signal(false); + this.user = userFields(props.user); + + const connection = getter(props.connection); + + this.webcam = new Publish.Source.Camera({ + enabled: this.cameraEnabled, + constraints: { + width: { ideal: 1280 }, + height: { ideal: 720 }, + frameRate: { ideal: 30 }, + facingMode: { ideal: "user" }, + }, + }); + this.#signals.cleanup(() => this.webcam.close()); + + this.microphone = new Publish.Source.Microphone({ + enabled: this.microphoneEnabled, + constraints: { + channelCount: { ideal: 1, max: 2 }, + autoGainControl: { ideal: true }, + noiseSuppression: { ideal: true }, + echoCancellation: { ideal: true }, + }, + }); + this.#signals.cleanup(() => this.microphone.close()); + + this.share = new Publish.Source.Screen({ + enabled: this.screenEnabled, + video: { + frameRate: { ideal: 30 }, + width: { max: 1920 }, + height: { max: 1080 }, + }, + audio: { + channelCount: { ideal: 2, max: 2 }, + autoGainControl: { ideal: false }, + echoCancellation: { ideal: false }, + noiseSuppression: { ideal: false }, + }, + }); + this.#signals.cleanup(() => this.share.close()); + + this.cameraCapture = new Publish.Video.Capture({ source: this.webcam.out.source }); + this.#signals.cleanup(() => this.cameraCapture.close()); + + this.screenCapture = new Publish.Video.Capture({ source: this.#screenVideo }); + this.#signals.cleanup(() => this.screenCapture.close()); + + const cameraName = new Signal(broadcastPath(this.identity.peek(), KIND.camera)); + const screenName = new Signal(broadcastPath(this.identity.peek(), KIND.screen)); + this.#signals.run((effect) => { + const identity = effect.get(this.identity); + cameraName.set(broadcastPath(identity, KIND.camera)); + screenName.set(broadcastPath(identity, KIND.screen)); + }); + + this.camera = new Publish.Broadcast({ + connection, + enabled: this.enabled, + name: cameraName, + display: this.cameraCapture.out.display, + flip: true, + }); + this.#signals.cleanup(() => this.camera.close()); + + this.screen = new Publish.Broadcast({ + connection, + enabled: this.#screenLive, + name: screenName, + display: this.screenCapture.out.display, + }); + this.#signals.cleanup(() => this.screen.close()); + + this.cameraHd = new Publish.Video.Encoder("video/hd", { + broadcast: this.camera, + capture: this.cameraCapture, + enabled: this.cameraEnabled, + config: { maxPixels: 1280 * 720 }, + }); + this.#signals.cleanup(() => this.cameraHd.close()); + + this.cameraSd = new Publish.Video.Encoder("video/sd", { + broadcast: this.camera, + capture: this.cameraCapture, + enabled: this.cameraEnabled, + config: { maxPixels: 640 * 360 }, + }); + this.#signals.cleanup(() => this.cameraSd.close()); + + this.cameraAudio = new Publish.Audio.Encoder("audio", { + broadcast: this.camera, + source: this.microphone.out.source, + enabled: this.microphoneEnabled, + }); + this.#signals.cleanup(() => this.cameraAudio.close()); + + this.screenHd = new Publish.Video.Encoder("video/hd", { + broadcast: this.screen, + capture: this.screenCapture, + enabled: this.#screenLive, + config: { maxPixels: 1920 * 1080 }, + }); + this.#signals.cleanup(() => this.screenHd.close()); + + this.screenSd = new Publish.Video.Encoder("video/sd", { + broadcast: this.screen, + capture: this.screenCapture, + enabled: this.#screenLive, + config: { maxPixels: 960 * 540 }, + }); + this.#signals.cleanup(() => this.screenSd.close()); + + this.screenAudio = new Publish.Audio.Encoder("audio", { + broadcast: this.screen, + source: this.#screenAudioSource, + enabled: this.#screenLive, + }); + this.#signals.cleanup(() => this.screenAudio.close()); + + this.#signals.run((effect) => { + const source = effect.get(this.share.out.source); + this.#screenVideo.set(source?.video); + this.#screenAudioSource.set(source?.audio); + const live = !!source?.video || !!source?.audio; + const wasLive = this.#screenLive.peek(); + this.#screenLive.set(live); + if (!live && wasLive) { + this.screenEnabled.set(false); + } + }); + + this.#signals.run((effect) => { + this.#preview.set({ + video: !!effect.get(this.webcam.out.source), + audio: !!effect.get(this.microphone.out.source), + screen: effect.get(this.#screenLive), + name: effect.get(this.user.name), + avatar: effect.get(this.user.avatar), + chat: effect.get(this.chatting), + typing: effect.get(this.typing), + }); + }); + + serve(this.camera, this.user, this.#preview, this.#signals); + } + + /** Latest preview.json value this participant is publishing. */ + get preview(): Getter { + return this.#preview; + } + + /** Release this participant's subscriptions and media resources. */ + close() { + this.#signals.close(); + } +} diff --git a/js/room/src/metadata.test.ts b/js/room/src/metadata.test.ts new file mode 100644 index 0000000000..473c286587 --- /dev/null +++ b/js/room/src/metadata.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "bun:test"; +import { TRACK, userFields } from "./metadata.ts"; + +test("userFields seeds from values", () => { + const user = userFields({ id: "a", name: "Ada", avatar: "ada.png" }); + expect(user.id.peek()).toBe("a"); + expect(user.name.peek()).toBe("Ada"); + expect(user.avatar.peek()).toBe("ada.png"); + expect(user.color.peek()).toBeUndefined(); +}); + +test("core tracks are hang/*.json and extras share the section", () => { + expect(TRACK.user).toBe("hang/user.json"); + expect(TRACK.preview).toBe("hang/preview.json"); + expect(TRACK.chat).toBe("hang/chat.json"); + expect(TRACK.location).toBe("hang/location.json"); +}); + +import * as Json from "@moq/json"; +import * as Net from "@moq/net"; +import type * as Publish from "@moq/publish"; +import { Effect, Signal } from "@moq/signals"; +import type * as Watch from "@moq/watch"; +import { consume, type ExtendedCatalog, type Preview, serve } from "./metadata.ts"; + +// Drain queued effects and their asynchronous subscription continuations. +async function flush() { + for (let i = 0; i < 30; i++) await Promise.resolve(); +} + +test("metadata updates keep the same track and reach an existing subscriber", async () => { + const net = new Net.Broadcast.Producer(); + const catalog = new Signal({}); + const broadcast = { net: new Signal(net), catalog } as unknown as Publish.Broadcast; + const user = userFields({ name: "Alice" }); + const effect = new Effect(); + try { + serve(broadcast, user, new Signal({}), effect); + await flush(); + const track = net.consume().track(TRACK.user).subscribe(); + const consumer = new Json.Snapshot.Consumer<{ name: string }>(track); + expect((await consumer.next())?.name).toBe("Alice"); + user.name.set("Bob"); + await flush(); + expect((await consumer.next())?.name).toBe("Bob"); + track.close(); + } finally { + effect.close(); + net.close(); + } +}); + +test("metadata clears when entries disappear or broadcast becomes inactive", async () => { + const net = new Net.Broadcast.Producer(); + const catalog = new Signal({ + hang: { user: { track: TRACK.user }, preview: { track: TRACK.preview } }, + }); + const active = new Signal(net.consume()); + const user = new Json.Snapshot.Producer({ track: net.createTrack(TRACK.user) }); + const preview = new Json.Snapshot.Producer({ track: net.createTrack(TRACK.preview) }); + user.update({ name: "Alice" }); + preview.update({ info: { video: true } }); + const consumed = consume({ out: { catalog, active } } as unknown as Watch.Broadcast); + try { + await flush(); + expect(consumed.user.name.peek()).toBe("Alice"); + expect(consumed.preview.peek()).toEqual({ video: true }); + catalog.set({ hang: { preview: { track: TRACK.preview } } }); + await flush(); + expect(consumed.user.name.peek()).toBeUndefined(); + expect(consumed.preview.peek()).toEqual({ video: true }); + active.set(undefined); + await flush(); + expect(consumed.preview.peek()).toEqual({}); + } finally { + consumed.close(); + user.finish(); + preview.finish(); + net.close(); + } +}); diff --git a/js/room/src/metadata.ts b/js/room/src/metadata.ts new file mode 100644 index 0000000000..fa507fc643 --- /dev/null +++ b/js/room/src/metadata.ts @@ -0,0 +1,239 @@ +/** + * The `hang/*.json` metadata convention: a catalog section pointing at JSON + * snapshot tracks on the same broadcast. + * + * Core carries `user.json` (id, name, avatar) and `preview.json` (presence + * booleans). Location (and hang.live's JSON chat) ride the same catalog + * section as app-defined extensions; this module does not serve or consume + * them. The JSON window `chat` track is `Chat.TRACK` (`"chat"`), not + * `hang/chat.json`. + * + * @module + */ + +import type { Root as CatalogRoot } from "@moq/hang/catalog"; +import * as Json from "@moq/json"; +import type * as Moq from "@moq/net"; +import type * as Publish from "@moq/publish"; +import { Effect, type Getter, type Readonlys, readonlys, Signal } from "@moq/signals"; +import type * as Watch from "@moq/watch"; + +/** Delivery priority for hang metadata tracks: below the catalog, above audio. */ +export const PRIORITY = 90; + +/** + * Well-known hang catalog tracks. + * + * `user` and `preview` are served by this package. `chat` and `location` are + * app-defined extensions of the same catalog section (hang.live uses both). + */ +export const TRACK = { + user: "hang/user.json", + preview: "hang/preview.json", + chat: "hang/chat.json", + location: "hang/location.json", +} as const; + +/** Display name, id, and avatar published on `hang/user.json`. */ +export type User = { + id?: string; + name?: string; + avatar?: string; + color?: string; +}; + +/** Presence published on `hang/preview.json`. */ +export type Preview = { + audio?: boolean; + video?: boolean; + screen?: boolean; + name?: string; + avatar?: string; + chat?: boolean; + typing?: boolean; +}; + +type TrackRef = { + track: string; +}; + +/** The `hang` catalog section. Extra keys (chat, location) pass through. */ +export type HangCatalog = { + user?: TrackRef; + preview?: TrackRef; + chat?: TrackRef; + location?: TrackRef; +}; + +/** A hang catalog with the optional `hang` section. */ +export type ExtendedCatalog = CatalogRoot & { + hang?: HangCatalog; +}; + +/** Signals a publisher reads when serving user metadata. */ +export type UserInput = { + id: Getter; + name: Getter; + avatar: Getter; + color: Getter; +}; + +/** Writable user fields a publisher owns. */ +export type UserFields = { + id: Signal; + name: Signal; + avatar: Signal; + color: Signal; +}; + +/** Seed values for {@link UserFields}. */ +export type UserProps = { + id?: string | Signal; + name?: string | Signal; + avatar?: string | Signal; + color?: string | Signal; +}; + +/** Create the writable user signals a publisher owns. */ +export function userFields(props?: UserProps): UserFields { + return { + id: Signal.from(props?.id), + name: Signal.from(props?.name), + avatar: Signal.from(props?.avatar), + color: Signal.from(props?.color), + }; +} + +/** + * Publish `user.json` and `preview.json` on `broadcast`, and advertise them in + * the catalog's `hang` section. Extra hang keys already on the catalog are left + * alone so an app can add chat/location without fighting this. + */ +export function serve(broadcast: Publish.Broadcast, user: UserInput, preview: Getter, effect: Effect): void { + broadcast.catalog.mutate((catalog) => { + const extended = catalog as ExtendedCatalog; + if (!extended.hang) extended.hang = {}; + extended.hang.user = { track: TRACK.user }; + extended.hang.preview = { track: TRACK.preview }; + }); + + effect.cleanup(() => { + broadcast.catalog.mutate((catalog) => { + const hang = (catalog as ExtendedCatalog).hang; + if (!hang) return; + delete hang.user; + delete hang.preview; + if (!hang.chat && !hang.location) { + delete (catalog as ExtendedCatalog).hang; + } + }); + }); + + serveSnapshot(broadcast, TRACK.user, effect, (effect) => ({ + id: effect.get(user.id), + name: effect.get(user.name), + avatar: effect.get(user.avatar), + color: effect.get(user.color), + })); + + serveSnapshot(broadcast, TRACK.preview, effect, (effect) => ({ + info: effect.get(preview), + })); +} + +function serveSnapshot( + broadcast: Publish.Broadcast, + name: string, + effect: Effect, + value: (effect: Effect) => T, +): void { + effect.run((effect) => { + const net = effect.get(broadcast.net); + if (!net) return; + + // A day-long cache so a late joiner still replays the latest value. + const track = net.createTrack(name, { latencyMax: 86_400_000, priority: PRIORITY }); + effect.cleanup(() => track.close()); + + const producer = new Json.Snapshot.Producer({ track }); + effect.cleanup(() => producer.finish()); + + effect.run((effect) => { + producer.update(value(effect)); + }); + }); +} + +type Consumed = { + user: Readonlys; + preview: Getter; + close: () => void; +}; + +/** + * Subscribe to `user.json` and `preview.json` on a watched broadcast. + * + * Track names come from the catalog's `hang` section so a publisher that + * renamed them still works. Missing sections leave the signals empty. + */ +export function consume(broadcast: Watch.Broadcast): Consumed { + const user = { + id: new Signal(undefined), + name: new Signal(undefined), + avatar: new Signal(undefined), + color: new Signal(undefined), + }; + const preview = new Signal({}); + const signals = new Effect(); + + signals.run((effect) => { + const catalog = effect.get(broadcast.out.catalog) as ExtendedCatalog | undefined; + const hang = catalog?.hang; + const active = effect.get(broadcast.out.active); + effect.cleanup(() => { + for (const field of Object.values(user)) field.set(undefined); + preview.set({}); + }); + if (!active || !hang) return; + + if (hang.user) { + subscribeJson(active, hang.user.track, effect, (value) => { + user.id.set(value.id); + user.name.set(value.name); + user.avatar.set(value.avatar); + user.color.set(value.color); + }); + } + + if (hang.preview) { + subscribeJson<{ info?: Preview }>(active, hang.preview.track, effect, (value) => { + preview.set(value.info ?? {}); + }); + } + }); + + return { + user: readonlys(user), + preview, + close: () => signals.close(), + }; +} + +function subscribeJson( + broadcast: Moq.Broadcast.Consumer, + name: string, + effect: Effect, + update: (value: T) => void, +): void { + const track = broadcast.track(name).subscribe({ priority: PRIORITY }); + effect.cleanup(() => track.close()); + + const consumer = new Json.Snapshot.Consumer(track); + effect.spawn(async () => { + for (;;) { + const value = await Promise.race([effect.cancel, consumer.next()]); + if (value === undefined) break; + update(value); + } + }); +} diff --git a/js/room/src/path.test.ts b/js/room/src/path.test.ts new file mode 100644 index 0000000000..dd0e705c2e --- /dev/null +++ b/js/room/src/path.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test"; +import { Path } from "@moq/net"; +import { broadcastPath, isKind, KIND, kindFromSegment, parse } from "./path.ts"; + +const p = (s: string) => Path.from(s); + +test("parse splits identity and kind", () => { + expect(parse(p("alice/camera"))).toEqual({ identity: p("alice"), kind: "camera" }); + expect(parse(p("alice/screen"))).toEqual({ identity: p("alice"), kind: "screen" }); +}); + +test("parse accepts a .hang suffix on the kind", () => { + expect(parse(p("alice/camera.hang"))).toEqual({ identity: p("alice"), kind: "camera" }); + expect(parse(p("alice/screen.hang"))).toEqual({ identity: p("alice"), kind: "screen" }); +}); + +test("parse keeps a multi-segment identity", () => { + expect(parse(p("guest/uuid/camera"))).toEqual({ identity: p("guest/uuid"), kind: "camera" }); +}); + +test("parse rejects a path with no kind or no identity", () => { + expect(parse(p("alice"))).toBeUndefined(); + expect(parse(p("alice/chat"))).toBeUndefined(); + expect(parse(p("camera"))).toBeUndefined(); + expect(parse(p(""))).toBeUndefined(); +}); + +test("kindFromSegment and isKind", () => { + expect(kindFromSegment("camera")).toBe("camera"); + expect(kindFromSegment("camera.hang")).toBe("camera"); + expect(kindFromSegment("chat")).toBeUndefined(); + expect(isKind("camera")).toBe(true); + expect(isKind("screen")).toBe(true); + expect(isKind("chat")).toBe(false); +}); + +test("broadcastPath joins identity and kind with a .hang suffix", () => { + expect(broadcastPath(p("alice"), KIND.camera)).toBe(p("alice/camera.hang")); + expect(broadcastPath(p("guest/uuid"), KIND.screen)).toBe(p("guest/uuid/screen.hang")); +}); diff --git a/js/room/src/path.ts b/js/room/src/path.ts new file mode 100644 index 0000000000..0edbf4a9af --- /dev/null +++ b/js/room/src/path.ts @@ -0,0 +1,65 @@ +/** + * Room path convention: a participant identity is everything before the last + * segment, and that last segment is the broadcast kind (`camera` or `screen`). + * + * `alice/camera`, `alice/camera.hang`, and `guest/uuid/screen` are all valid. + * A `.hang` suffix on the kind is optional and equivalent. + * + * @module + */ + +import { Path } from "@moq/net"; + +/** The two broadcasts each participant may publish. */ +export const KIND = { + camera: "camera", + screen: "screen", +} as const; + +/** A participant broadcast kind. */ +export type Kind = (typeof KIND)[keyof typeof KIND]; + +/** An announced path split into identity and kind, or `undefined` if it is not a room broadcast. */ +export type Parsed = { + /** Path prefix identifying the participant; may be more than one segment. */ + identity: Path.Valid; + /** `camera` (camera + mic) or `screen` (screenshare). */ + kind: Kind; +}; + +/** True when `value` is a {@link Kind}. */ +export function isKind(value: string): value is Kind { + return value === KIND.camera || value === KIND.screen; +} + +/** + * Strip an optional `.hang` catalog-format suffix from a path segment. + * + * `camera` and `camera.hang` are the same kind; publishers may omit the suffix + * (hang.live does) because hang is the default catalog format. + */ +export function kindFromSegment(segment: string): Kind | undefined { + const kind = segment.endsWith(".hang") ? segment.slice(0, -".hang".length) : segment; + return isKind(kind) ? kind : undefined; +} + +/** + * Split a room-relative broadcast path into identity and kind. + * + * The last segment is the kind; everything before it is the identity. Returns + * `undefined` when there is no identity, or the last segment is not a kind. + */ +export function parse(path: Path.Valid): Parsed | undefined { + const parts = Path.parts(path); + if (parts.length < 2) return undefined; + + const kind = kindFromSegment(parts[parts.length - 1]); + if (!kind) return undefined; + + return { identity: Path.from(...parts.slice(0, -1)), kind }; +} + +/** The broadcast path a participant publishes for `kind`. */ +export function broadcastPath(identity: Path.Valid, kind: Kind): Path.Valid { + return Path.join(identity, Path.from(`${kind}.hang`)); +} diff --git a/js/room/src/remote.ts b/js/room/src/remote.ts new file mode 100644 index 0000000000..bc87cab75e --- /dev/null +++ b/js/room/src/remote.ts @@ -0,0 +1,214 @@ +/** + * A remote participant: camera and screen watch pipelines plus metadata. + * + * @module + */ + +import type * as Moq from "@moq/net"; +import { Effect, type Getter, type GetterInit, getter, type Readonlys, readonlys, Signal } from "@moq/signals"; +import * as Watch from "@moq/watch"; +import { consume, type Preview, type UserInput } from "./metadata.ts"; +import { KIND, type Kind } from "./path.ts"; + +type Established = Moq.Connection.Established; + +/** One watched broadcast (camera or screen) for a remote participant. */ +export class Member { + /** `camera` or `screen`. */ + readonly kind: Kind; + /** Broadcast path relative to the connection root. */ + readonly path: Moq.Path.Valid; + + /** Canvas to paint into; assign from the app. */ + readonly canvas = new Signal(undefined); + /** Mute this member's audio; defaults to true. */ + readonly muted = new Signal(true); + /** Playback volume, 0..1. */ + readonly volume = new Signal(0.5); + + /** Watched broadcast and catalog. */ + readonly broadcast: Watch.Broadcast; + /** Video decoding pipeline. */ + readonly video: Watch.Video.Decoder; + /** Audio decoding pipeline. */ + readonly audio: Watch.Audio.Decoder; + /** Canvas video renderer. */ + readonly renderer: Watch.Video.Renderer; + /** Speaker audio output. */ + readonly emitter: Watch.Audio.Emitter; + + /** Published participant identity and display fields. */ + readonly user: Readonlys; + /** Published presence fields. */ + readonly preview: Getter; + + #videoEnabled = new Signal(false); + #audioEnabled = new Signal(false); + #metadata: ReturnType; + #signals = new Effect(); + + constructor(kind: Kind, path: Moq.Path.Valid, connection: Getter) { + this.kind = kind; + this.path = path; + + this.broadcast = new Watch.Broadcast({ + connection, + enabled: true, + name: path, + reload: true, + }); + this.#signals.cleanup(() => this.broadcast.close()); + + const videoSource = new Watch.Video.Source({ + broadcast: this.broadcast, + supported: Watch.Video.Decoder.supported, + }); + const audioSource = new Watch.Audio.Source({ + broadcast: this.broadcast, + supported: Watch.Audio.Decoder.supported, + }); + this.#signals.cleanup(() => { + videoSource.close(); + audioSource.close(); + }); + + const sync = new Watch.Sync({ + latency: "real-time", + connection, + video: videoSource.out.jitter, + audio: audioSource.out.jitter, + }); + this.#signals.cleanup(() => sync.close()); + + this.video = new Watch.Video.Decoder(videoSource, sync, { enabled: this.#videoEnabled }); + this.audio = new Watch.Audio.Decoder(audioSource, sync, { enabled: this.#audioEnabled }); + this.#signals.cleanup(() => { + this.video.close(); + this.audio.close(); + }); + + this.renderer = new Watch.Video.Renderer(this.video, { + canvas: this.canvas, + }); + this.emitter = new Watch.Audio.Emitter(this.audio, { + volume: this.volume, + muted: this.muted, + }); + this.#signals.cleanup(() => { + this.renderer.close(); + this.emitter.close(); + }); + + this.#signals.run((effect) => { + this.#videoEnabled.set(effect.get(this.renderer.out.visible)); + }); + this.#signals.run((effect) => { + this.#audioEnabled.set(effect.get(this.emitter.out.enabled)); + }); + + this.#metadata = consume(this.broadcast); + this.user = this.#metadata.user; + this.preview = this.#metadata.preview; + this.#signals.cleanup(() => this.#metadata.close()); + } + + /** Release this participant's subscriptions and media resources. */ + close() { + this.#signals.close(); + } +} + +/** Constructor options for {@link Remote}. */ +export interface RemoteProps { + /** Participant identity. */ + identity: Moq.Path.Valid; + /** Live session, usually a `Connection.Reload`'s `established`. */ + connection: GetterInit; +} + +/** + * Groups one identity's `camera` and `screen` broadcasts. + * + * User and preview metadata come from the camera broadcast when it is live, + * otherwise from the screen. + */ +export class Remote { + /** Participant identity. */ + readonly identity: Moq.Path.Valid; + + readonly #camera = new Signal(undefined); + readonly #screen = new Signal(undefined); + readonly #user = { + id: new Signal(undefined), + name: new Signal(undefined), + avatar: new Signal(undefined), + color: new Signal(undefined), + }; + readonly #preview = new Signal({}); + + /** The live camera member, if announced. */ + readonly camera: Getter; + /** The live screen member, if announced. */ + readonly screen: Getter; + /** Published participant identity and display fields. */ + readonly user: Readonlys; + /** Published presence fields. */ + readonly preview: Getter; + + #connection: Getter; + #signals = new Effect(); + + constructor(props: RemoteProps) { + this.identity = props.identity; + this.#connection = getter(props.connection); + this.camera = this.#camera; + this.screen = this.#screen; + this.user = readonlys(this.#user); + this.preview = this.#preview; + + this.#signals.run((effect) => { + const member = effect.get(this.#camera) ?? effect.get(this.#screen); + if (!member) { + this.#user.id.set(undefined); + this.#user.name.set(undefined); + this.#user.avatar.set(undefined); + this.#user.color.set(undefined); + this.#preview.set({}); + return; + } + this.#user.id.set(effect.get(member.user.id)); + this.#user.name.set(effect.get(member.user.name)); + this.#user.avatar.set(effect.get(member.user.avatar)); + this.#user.color.set(effect.get(member.user.color)); + this.#preview.set(effect.get(member.preview)); + }); + } + + /** Attach a live camera or screen broadcast. */ + attach(kind: Kind, path: Moq.Path.Valid): void { + const slot = kind === KIND.camera ? this.#camera : this.#screen; + slot.peek()?.close(); + slot.set(new Member(kind, path, this.#connection)); + } + + /** Detach a camera or screen broadcast that went offline. */ + detach(kind: Kind): void { + const slot = kind === KIND.camera ? this.#camera : this.#screen; + slot.peek()?.close(); + slot.set(undefined); + } + + /** True when neither camera nor screen is live. */ + empty(): boolean { + return !this.#camera.peek() && !this.#screen.peek(); + } + + /** Release this participant's subscriptions and media resources. */ + close() { + this.#camera.peek()?.close(); + this.#screen.peek()?.close(); + this.#camera.set(undefined); + this.#screen.set(undefined); + this.#signals.close(); + } +} diff --git a/js/room/src/room.test.ts b/js/room/src/room.test.ts new file mode 100644 index 0000000000..ba9fdefbd9 --- /dev/null +++ b/js/room/src/room.test.ts @@ -0,0 +1,40 @@ +import { expect, mock, spyOn, test } from "bun:test"; +import * as Net from "@moq/net"; +import { Signal } from "@moq/signals"; + +// Vite's worklet loader is not available in Bun; discovery does not start audio. +mock.module("../../watch/src/audio/render-worklet.ts?worklet", () => ({ default: "blob:fake-render" })); +const { Remote } = await import("./remote.ts"); +const { Room } = await import("./room.ts"); + +async function flush() { + for (let i = 0; i < 30; i++) await Promise.resolve(); +} + +test("room restores the announce prefix and reconciles local identity changes", async () => { + const streams: Net.Announce.Producer[] = []; + const connection = { + established: new Signal(undefined), + announced(prefix: Net.Path.Valid) { + const stream = new Net.Announce.Producer(prefix); + streams.push(stream); + stream.append({ path: Net.Path.from("bob/camera.hang"), active: true }); + return stream.consume(); + }, + } as unknown as Net.Connection.Reload; + const attach = spyOn(Remote.prototype, "attach").mockImplementation(() => {}); + const identity = new Signal(Net.Path.from("alice")); + const room = new Room({ connection, identity, prefix: Net.Path.from("room-a") }); + try { + await flush(); + expect(attach).toHaveBeenCalledWith("camera", Net.Path.from("room-a/bob/camera.hang")); + expect(room.remotes.peek().has(Net.Path.from("bob"))).toBe(true); + identity.set(Net.Path.from("bob")); + await flush(); + expect(streams).toHaveLength(2); + expect(room.remotes.peek().size).toBe(0); + } finally { + room.close(); + attach.mockRestore(); + } +}); diff --git a/js/room/src/room.ts b/js/room/src/room.ts new file mode 100644 index 0000000000..203bc0d80c --- /dev/null +++ b/js/room/src/room.ts @@ -0,0 +1,122 @@ +/** + * A room is a path prefix. Participants are discovered from the announce + * stream; identity is the path before `camera`/`screen`. + * + * @module + */ + +import * as Moq from "@moq/net"; +import { Effect, type Getter, type GetterInit, getter, Signal } from "@moq/signals"; +import { type Kind, parse } from "./path.ts"; +import { Remote } from "./remote.ts"; + +/** Constructor options for {@link Room}. */ +export interface RoomProps { + /** + * Reconnecting connection whose URL (and token root) already carry the room + * prefix. Announcements are relative to that prefix. + */ + connection: Moq.Connection.Reload; + /** + * Local participant identity. Announcements under this identity are skipped + * so the local camera/screen do not appear as remotes. + */ + identity?: GetterInit; + /** When false, the announce loop is idle. Defaults to true. */ + enabled?: GetterInit; + /** + * Announce prefix relative to the connection URL. Defaults to empty (the + * whole root). A connection whose URL is broader than one room (a preview + * of several rooms) passes the room name here. + */ + prefix?: GetterInit; +} + +/** + * Runs the announce loop and exposes remote participants as a signal map keyed + * by identity. + */ +export class Room { + /** Connection supplying room announcements. */ + readonly connection: Moq.Connection.Reload; + /** Local identity excluded from the roster. */ + readonly identity: Getter; + /** Whether room discovery is active. */ + readonly enabled: Getter; + /** Room prefix relative to the connection root. */ + readonly prefix: Getter; + + #remotes = new Signal(new Map()); + #signals = new Effect(); + + constructor(props: RoomProps) { + this.connection = props.connection; + this.identity = getter(props.identity); + this.enabled = getter(props.enabled ?? true); + this.prefix = getter(props.prefix); + + this.#signals.run((effect) => { + if (!effect.get(this.enabled)) return; + + effect.get(this.identity); + const prefix = effect.get(this.prefix) ?? Moq.Path.empty(); + const announced = this.connection.announced(prefix); + effect.cleanup(() => announced.close()); + + effect.spawn(this.#run.bind(this, announced, effect)); + effect.cleanup(() => { + for (const remote of this.#remotes.peek().values()) remote.close(); + this.#remotes.set(new Map()); + }); + }); + } + + /** Remote participants, keyed by identity. */ + get remotes(): Getter> { + 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, Moq.Path.join(announced.prefix, 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)); + } + } + + /** 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 new file mode 100644 index 0000000000..049459af2f --- /dev/null +++ b/js/room/src/token.test.ts @@ -0,0 +1,28 @@ +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/", + }); +}); + +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 new file mode 100644 index 0000000000..6c7077f5a4 --- /dev/null +++ b/js/room/src/token.ts @@ -0,0 +1,37 @@ +/** + * 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 + */ + +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. */ + 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. 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}/`, + }; +} 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 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/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..b5d6bdf8f6 --- /dev/null +++ b/rs/moq-room/Cargo.toml @@ -0,0 +1,23 @@ +[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, features = ["time"] } +moq-json = { workspace = true } +moq-net = { workspace = true } +moq-token = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "test-util"] } diff --git a/rs/moq-room/README.md b/rs/moq-room/README.md new file mode 100644 index 0000000000..392127862a --- /dev/null +++ b/rs/moq-room/README.md @@ -0,0 +1,32 @@ +# 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.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 `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 { + // ... + } + } + } +} +``` + +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 new file mode 100644 index 0000000000..51658c5413 --- /dev/null +++ b/rs/moq-room/src/chat.rs @@ -0,0 +1,176 @@ +//! 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::Duration, +}; + +/// Name of the track carrying the chat window. +pub const TRACK: &str = "chat"; +/// 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; + +/// Track settings for the latest chat window. +pub fn info() -> track::Info { + track::Info::default().with_priority(PRIORITY).with_ordered(false) +} + +/// Publishes chat messages; drive `poll_expire` or `expire` to retire them while idle. +pub struct Publisher { + producer: moq_json::window::Producer, + expires: VecDeque, + deadline: Deadline, +} + +impl Publisher { + /// Create the chat track on a broadcast. + pub fn create(broadcast: &mut broadcast::Producer) -> Result { + Ok(Self::new(broadcast.create_track(TRACK, info())?)) + } + + /// Publish a chat window over an existing track. + pub fn new(track: track::Producer) -> Self { + Self { + producer: moq_json::window::Producer::new( + track, + moq_json::window::ProducerConfig::default().with_op_ratio(0), + ), + expires: VecDeque::new(), + deadline: Deadline::new(), + } + } + + /// 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.prune()?; + self.producer.push(&text.to_owned())?; + self.expires.push_back(Instant::now() + HISTORY); + Ok(()) + } + + 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(()) + } + + /// 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 + } + + /// 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 { + /// Subscribe to the newest retained window on a broadcast. + pub async fn subscribe(broadcast: &broadcast::Consumer) -> Result { + Ok(Self::new(broadcast.track(TRACK)?.subscribe(None).await?)) + } + + /// 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 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 window change, or None on clean completion. + pub async fn recv(&mut self) -> Result, Error> { + kio::wait(|waiter| self.poll_recv(waiter)).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + 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 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 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 new file mode 100644 index 0000000000..e7ad3e631f --- /dev/null +++ b/rs/moq-room/src/claims.rs @@ -0,0 +1,52 @@ +//! 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) -> 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([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").unwrap(); + 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/").unwrap().publish, ["alice/"]); + } + + #[test] + fn claims_accepts_a_multi_segment_identity() { + 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 new file mode 100644 index 0000000000..9926673862 --- /dev/null +++ b/rs/moq-room/src/lib.rs @@ -0,0 +1,42 @@ +//! 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.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 +//! `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}; + +/// 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 new file mode 100644 index 0000000000..4bd2bc15eb --- /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(format!("{}.hang", 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_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.hang" + ); + } +} diff --git a/rs/moq-room/src/room.rs b/rs/moq-room/src/room.rs new file mode 100644 index 0000000000..7add6d1558 --- /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.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, + 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()); + } +} 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 }