diff --git a/CLAUDE.md b/CLAUDE.md index fef7768..76e0915 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,11 @@ either site refers to the same broadcast — publish at `moq.pub/anon/x.hang` an watch it back at `moq.watch/anon/x.hang`. Anything that *isn't* part of the broadcast's identity stays in the query -string: `?relay=`, `?jwt=`, and (moq.pub only) `?source=camera`. +string: `?relay=`, `?cloudflare=`, `?jwt=`, and +(moq.pub only) `?source=camera`. `?cloudflare=draft-16` is shorthand for +`https://draft-16.cloudflare.mediaoverquic.com`; the project path segment still +carries Cloudflare's relay token. `cloudflare` and `relay` are mutually +exclusive. **moq.pub deliberately doesn't link to moq.watch.** The path symmetry is the feature; a link on top of it isn't. It also can't be built honestly: a link has diff --git a/README.md b/README.md index b2b9e42..16d6999 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ The player sites use the path to name a broadcast: publish at `moq.watch/anon/lazy-otter-4f21.hang`. Visiting [moq.pub](https://moq.pub) with no path picks a random name for you. +Use `?cloudflare=draft-16` to connect either player to +`https://draft-16.cloudflare.mediaoverquic.com` without spelling out the full +`?relay=` value. The first path segment remains the Cloudflare relay token. + These are clients only. You'll either need to run a local server using [moq](https://github.com/moq-dev/moq) or use a public server such as `cdn.moq.pro`. diff --git a/bun.lock b/bun.lock index 05dbbbd..006adf0 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "@biomejs/biome": "^2.5.0", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.20", + "@types/bun": "^1.3.14", "@types/node": "^22.19.21", "tailwindcss": "^3.4.19", "vite": "^6.4.1", @@ -361,6 +362,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -439,6 +442,8 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], diff --git a/package.json b/package.json index 651368c..6665138 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@biomejs/biome": "^2.5.0", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.20", + "@types/bun": "^1.3.14", "@types/node": "^22.19.21", "tailwindcss": "^3.4.19", "vite": "^6.4.1", diff --git a/sites/lib/broadcast.test.ts b/sites/lib/broadcast.test.ts new file mode 100644 index 0000000..7890eb7 --- /dev/null +++ b/sites/lib/broadcast.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { relay } from "./broadcast"; + +const broadcast = { project: "cloudflare-token", name: "room/camera.hang" }; +const fallback = "https://cdn.moq.pro"; + +describe("relay", () => { + test("uses the default relay", () => { + expect(relay(broadcast, new URLSearchParams(), fallback)?.toString()).toBe("https://cdn.moq.pro/cloudflare-token"); + }); + + test("builds a Cloudflare relay from one subdomain label", () => { + const params = new URLSearchParams({ cloudflare: "draft-16" }); + expect(relay(broadcast, params, fallback)?.toString()).toBe( + "https://draft-16.cloudflare.mediaoverquic.com/cloudflare-token", + ); + }); + + test("rejects malformed Cloudflare labels", () => { + for (const value of ["", "Draft-16", ".draft-16", "draft-16.example", "-draft-16", "draft-16-"]) { + expect(relay(broadcast, new URLSearchParams({ cloudflare: value }), fallback)).toBeUndefined(); + } + }); + + test("rejects conflicting relay selectors", () => { + const params = new URLSearchParams({ cloudflare: "draft-16", relay: "relay.example.com" }); + expect(relay(broadcast, params, fallback)).toBeUndefined(); + }); + + test("preserves explicit relay and JWT support", () => { + const params = new URLSearchParams({ relay: "http://localhost:4443/base", jwt: "token" }); + expect(relay(broadcast, params, fallback)?.toString()).toBe( + "http://localhost:4443/base/cloudflare-token?jwt=token", + ); + }); +}); diff --git a/sites/lib/broadcast.ts b/sites/lib/broadcast.ts index f615d8b..22e9dba 100644 --- a/sites/lib/broadcast.ts +++ b/sites/lib/broadcast.ts @@ -15,6 +15,9 @@ export interface Broadcast { name: string; } +const CLOUDFLARE_RELAY_DOMAIN = "cloudflare.mediaoverquic.com"; +const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + /** Parse `//`, or undefined when the path isn't a broadcast. */ export function parse(pathname: string): Broadcast | undefined { const raw = pathname.split("/").filter((part) => part !== ""); @@ -44,10 +47,30 @@ export function path({ project, name }: Broadcast): string { /** * The relay URL to connect to: `/`, carrying `?jwt=` when the - * page was given a token. + * page was given a token. `?cloudflare=