Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<url>`, `?jwt=<token>`, and (moq.pub only) `?source=camera`.
string: `?relay=<url>`, `?cloudflare=<subdomain>`, `?jwt=<token>`, 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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 36 additions & 0 deletions sites/lib/broadcast.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
29 changes: 26 additions & 3 deletions sites/lib/broadcast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `/<project>/<name>`, or undefined when the path isn't a broadcast. */
export function parse(pathname: string): Broadcast | undefined {
const raw = pathname.split("/").filter((part) => part !== "");
Expand Down Expand Up @@ -44,10 +47,30 @@ export function path({ project, name }: Broadcast): string {

/**
* The relay URL to connect to: `<relay>/<project>`, carrying `?jwt=` when the
* page was given a token.
* page was given a token. `?cloudflare=<label>` is shorthand for Cloudflare's
* relay hostname and cannot be combined with `?relay=`.
*/
export function relay(broadcast: Broadcast, params: URLSearchParams, fallback: string): URL {
const url = new URL(override(params.get("relay")) ?? fallback);
export function relay(broadcast: Broadcast, params: URLSearchParams, fallback: string): URL | undefined {
const relay = params.get("relay");
const cloudflare = params.get("cloudflare");
if (relay !== null && cloudflare !== null) {
console.warn("rejecting conflicting ?relay= and ?cloudflare= parameters");
return undefined;
}

let base: string;
if (cloudflare !== null) {
if (!DNS_LABEL.test(cloudflare)) {
console.warn(`rejecting ?cloudflare=${cloudflare}: expected one lowercase DNS label`);
return undefined;
}
base = `https://${cloudflare}.${CLOUDFLARE_RELAY_DOMAIN}`;
} else {
const custom = override(relay);
base = custom ?? fallback;
}

const url = new URL(base);
url.pathname = `${url.pathname.replace(/\/+$/, "")}/${broadcast.project}`;

const jwt = params.get("jwt");
Expand Down
11 changes: 8 additions & 3 deletions sites/pub/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
// the URL doesn't carry one, so this page always loads on the shareable URL.
//
// Everything that isn't part of the broadcast's identity stays in the query:
// ?relay=<url> Relay server URL (default: the relay this site was built for)
// ?jwt=<token> Appended to the relay URL as ?jwt=<token>
// ?source=<kind> Preselect a capture source: camera, screen, or file
// ?relay=<url> Relay server URL (default: the relay this site was built for)
// ?cloudflare=<label> Shorthand for <label>.cloudflare.mediaoverquic.com
// ?jwt=<token> Appended to the relay URL as ?jwt=<token>
// ?source=<kind> Preselect a capture source: camera, screen, or file
import "@moq/publish/element";
import "@moq/publish/ui";

Expand All @@ -26,6 +27,10 @@ if (broadcast) {
function mount(broadcast: Broadcast.Broadcast) {
const params = new URLSearchParams(location.search);
const relay = Broadcast.relay(broadcast, params, DEFAULT_RELAY);
if (!relay) {
document.body.textContent = "Invalid relay configuration.";
return;
}

const publish = document.createElement("moq-publish");
publish.setAttribute("url", relay.toString());
Expand Down
10 changes: 8 additions & 2 deletions sites/watch/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// moq.watch — play the broadcast named by the path, e.g. /demo/bbb.hang.
//
// Everything that isn't part of the broadcast's identity stays in the query:
// ?relay=<url> Relay server URL (default: the relay this site was built for)
// ?jwt=<token> Appended to the relay URL as ?jwt=<token>
// ?relay=<url> Relay server URL (default: the relay this site was built for)
// ?cloudflare=<label> Shorthand for <label>.cloudflare.mediaoverquic.com
// ?jwt=<token> Appended to the relay URL as ?jwt=<token>
import "@moq/watch/element";
import "@moq/watch/ui";

Expand All @@ -20,6 +21,10 @@ if (broadcast) {
function mount(broadcast: Broadcast.Broadcast) {
const params = new URLSearchParams(location.search);
const relay = Broadcast.relay(broadcast, params, DEFAULT_RELAY);
if (!relay) {
document.body.textContent = "Invalid relay configuration.";
return;
}

const watch = document.createElement("moq-watch");
watch.setAttribute("url", relay.toString());
Expand Down Expand Up @@ -48,6 +53,7 @@ function usage() {
it back at the same path here.</p>
<p>Optional:<br />
<code>?relay=https://cdn.moq.pro</code> &nbsp;
<code>?cloudflare=draft-16</code> &nbsp;
<code>?jwt=&lt;token&gt;</code></p>
</div>`;
}
Loading