Skip to content

Commit 24ab2b3

Browse files
committed
feat(examples): discover relay endpoints
1 parent fd04687 commit 24ab2b3

58 files changed

Lines changed: 2114 additions & 388 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,16 @@ joining FETCH; degrades to a normal live join otherwise), `?log=info|debug`
322322
(player logs on the console), `?catalog=<base64 JSON>` (inject a catalog), and
323323
`?fetchCatalog=1` (FETCH the catalog instead of subscribing).
324324

325+
`?url=` is the complete WebTransport endpoint, including its deployment-specific
326+
path. For example, use `?url=https%3A%2F%2Frelay.example.com%3A4433%2Fmoq-relay`
327+
when a relay is mounted at `/moq-relay`. When omitted, the browser examples
328+
discover the endpoint by probing `https://<page-host>:4433` at `/moq`,
329+
`/moq-relay`, then `/` and selecting the first successful path in that order.
330+
`/moq` and `/moq-relay` are deployment conventions, not
331+
MOQT-standard paths (a relay that accepts any path simply matches on `/moq`).
332+
Discovery is a convenience for these examples: a deployed application knows its
333+
relay endpoint and should configure the complete URL.
334+
325335
---
326336

327337
## Testing

conformance/media/runner/src/fuzz/catalog-fuzz.properties.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ describe('Catalog crash fuzz — parseCatalogAuto', () => {
152152

153153
const proj = catalogProjection(cat) as { tracks: Record<string, unknown>[] };
154154
const t = proj.tracks[0]!;
155-
// The projected key set must be EXACTLY the CatalogTrack field set — no field
156-
// silently dropped (finding 2), none accidentally added.
155+
// The projected key set must exactly match the CatalogTrack field set, with
156+
// no fields silently dropped or accidentally added.
157157
expect(Object.keys(t).sort()).toEqual(Object.keys(track).sort());
158158
// A spot-check of the string-rendered numerics and the array field.
159159
expect(t['displayWidth']).toBe('1920');

conformance/media/runner/src/validate.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ describe('validateEntry — discriminating failures', () => {
6363
}
6464
});
6565

66-
describe('validateEntry — strictness (the cases Codex exercised)', () => {
66+
describe('validateEntry — strictness', () => {
6767
const encEntry = (pm: unknown): CorpusEntry => ({
6868
...validDecodeEntry(), id: 'properties/strict',
6969
input: { propertyMap: pm } as never,

conformance/media/schema/scenario.schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://json-schema.org/draft/2020-12/schema",
33
"$id": "https://moq-playa/conformance/media/schema/scenario.schema.json",
44
"title": "moq-media-scenario/1",
5-
"description": "Deterministic, implementation-neutral media scenario. Finalized in Slice -1; its runner lands in Slice 1. Steps are an ordered list; advanceTimeUs is the ONLY way virtual time moves. Traces preserve actual emission order. Wide integers are decimal strings. Every nested structure is exact-keyed (additionalProperties:false).",
5+
"description": "Deterministic, implementation-neutral media scenario. Steps are an ordered list; advanceTimeUs is the only way virtual time moves. Traces preserve actual emission order. Wide integers are decimal strings. Every nested structure is exact-keyed (additionalProperties:false).",
66
"type": "object",
77
"required": ["scenarioSchema", "id", "description", "expectationBasis", "provenance", "input", "steps", "expect"],
88
"additionalProperties": false,

docs/catalog-testing.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ You can also prefill the harness with query parameters:
7171
Supported parameters:
7272

7373
- `url`
74-
- Full WebTransport relay URL
74+
- Full WebTransport relay URL, including its endpoint path (for example,
75+
`https://relay.example.com:4433/moq-relay` — the path is
76+
deployment-specific; `/moq` and `/moq-relay` are conventions, not
77+
MOQT-standard paths). When omitted, the form prefills from endpoint
78+
discovery against the page host.
7579
- `ns`
7680
- Broadcast namespace
7781
- `v`

examples/broadcast/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ <h2>Share Viewer Link</h2>
286286
<div class="modal">
287287
<h2>Broadcast Settings</h2>
288288
<label for="s-url">Relay URL</label>
289-
<input id="s-url" type="text" placeholder="https://localhost:4443">
289+
<input id="s-url" type="text" placeholder="auto-discovered (/moq, /moq-relay, /)">
290290
<div class="hint">WebTransport relay endpoint</div>
291291

292292
<label for="s-ns">Namespace</label>

examples/broadcast/main.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import type { BroadcastSessionConnection } from './broadcast-session.js';
2020
import { BroadcastAttempt } from './broadcast-attempt.js';
2121
import type { AttemptResources } from './broadcast-attempt.js';
2222
import { log } from '../shared/log.js';
23-
import { relayUrl, namespace, certHash, draftVersion } from '../shared/cert.js';
23+
import { namespace, certHash, draftVersion } from '../shared/cert.js';
24+
import { resolveRelayEndpoint, discoveredRelayUrl } from '../shared/relay-endpoint.js';
2425
import {
2526
WebCodecsVideoEncoder,
2627
WebCodecsAudioEncoder,
@@ -50,8 +51,26 @@ const keyframeInterval = parseInt(params.get('keyframe') ?? '60', 10);
5051
const applyBtn = document.getElementById('settings-apply')!;
5152
const cancelBtn = document.getElementById('settings-cancel')!;
5253

54+
// Modal-scoped lazy discovery: opening settings with no explicit ?url= and
55+
// no cached result starts its own discovery consumer, aborted on
56+
// close/Apply. Independent of the Go Live flow's consumer — neither can
57+
// block the other (Stop never waits on this, and vice versa).
58+
let modalDiscovery: AbortController | undefined;
59+
60+
function abortModalDiscovery() {
61+
modalDiscovery?.abort(new Error('settings closed'));
62+
modalDiscovery = undefined;
63+
}
64+
5365
function populateFields() {
54-
sUrl.value = params.get('url') ?? 'https://localhost:4443';
66+
sUrl.value = params.get('url') ?? discoveredRelayUrl() ?? '';
67+
if (!sUrl.value && !modalDiscovery) {
68+
modalDiscovery = new AbortController();
69+
void resolveRelayEndpoint({ signal: modalDiscovery.signal }).then(
70+
(url) => { if (!sUrl.value) sUrl.value = url; },
71+
() => { /* aborted or failed — the field stays editable */ },
72+
);
73+
}
5574
sNs.value = params.get('ns') ?? 'live';
5675
sHash.value = params.get('hash') ?? '';
5776
sVersion.value = params.get('v') ?? '';
@@ -61,14 +80,17 @@ const keyframeInterval = parseInt(params.get('keyframe') ?? '60', 10);
6180
}
6281

6382
settingsBtn.addEventListener('click', () => { populateFields(); backdrop.classList.add('visible'); });
64-
cancelBtn.addEventListener('click', () => backdrop.classList.remove('visible'));
65-
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) backdrop.classList.remove('visible'); });
83+
cancelBtn.addEventListener('click', () => { abortModalDiscovery(); backdrop.classList.remove('visible'); });
84+
backdrop.addEventListener('click', (e) => {
85+
if (e.target === backdrop) { abortModalDiscovery(); backdrop.classList.remove('visible'); }
86+
});
6687

6788
applyBtn.addEventListener('click', () => {
89+
abortModalDiscovery();
6890
const np = new URLSearchParams();
6991
const url = sUrl.value.trim();
7092
const ns = sNs.value.trim();
71-
if (url && url !== 'https://localhost:4443') np.set('url', url);
93+
if (url) np.set('url', url);
7294
if (ns && ns !== 'live') np.set('ns', ns);
7395
if (sHash.value.trim()) np.set('hash', sHash.value.trim());
7496
if (sVersion.value) np.set('v', sVersion.value);
@@ -151,6 +173,7 @@ async function startBroadcast(source: 'camera' | 'screen'): Promise<void> {
151173
let videoEncoder: WebCodecsVideoEncoder | null = null;
152174
let audioEncoder: WebCodecsAudioEncoder | null = null;
153175
let connection: MoqtConnection | null = null;
176+
let resolvedRelayUrl = ''; // set by openSession; feeds the viewer link
154177
let audio: { sampleRate: number; channels: number } | undefined;
155178
let width = 1280;
156179
let height = 720;
@@ -229,6 +252,17 @@ async function startBroadcast(source: 'camera' | 'screen'): Promise<void> {
229252
// behavior binds to the NEGOTIATED draft (connection.draftVersion after
230253
// connect), not the configured preference.
231254
openSession: async (ctx: AttemptResources) => {
255+
// Resolve the relay endpoint (explicit ?url= as-is, discovery
256+
// otherwise). The consumer aborts with the attempt: Stop during
257+
// probing closes the connecting probe transport once no other
258+
// consumer remains — it never hangs the cancellation.
259+
const discoveryAbort = new AbortController();
260+
ctx.onCancel(() => discoveryAbort.abort(new Error('broadcast stopped')));
261+
log('Discovering relay endpoint...');
262+
const relayUrl = await resolveRelayEndpoint({ signal: discoveryAbort.signal });
263+
ctx.throwIfCancelled();
264+
resolvedRelayUrl = relayUrl;
265+
232266
log(`Connecting to ${relayUrl}...`);
233267
const transportFactory = createWebTransport({ ...(certHash ? { certHash } : {}), ...(draftVersion ? { draftVersion } : {}) });
234268
// Each resource is adopted the moment it exists — a cancellation or a
@@ -345,12 +379,14 @@ async function startBroadcast(source: 'camera' | 'screen'): Promise<void> {
345379
data.close();
346380
};
347381

348-
// Show viewer URL + resolution
382+
// Carry the resolved endpoint and certificate hash into the viewer link.
349383
const viewerBase = window.location.origin + '/player/';
350384
const viewerParams = new URLSearchParams();
351-
viewerParams.set('url', relayUrl);
385+
viewerParams.set('url', resolvedRelayUrl);
352386
viewerParams.set('ns', namespace);
353387
if (draftVersion) viewerParams.set('v', String(draftVersion));
388+
const hashParam = params.get('hash');
389+
if (hashParam) viewerParams.set('hash', hashParam);
354390
currentViewerLink = `${viewerBase}?${viewerParams.toString()}`;
355391
viewerCard.style.display = 'block';
356392
statRes.textContent = `${width}x${height}`;

examples/catalog/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@
171171
<form id="config" class="controls">
172172
<div class="field field-url">
173173
<label for="relay-url">Relay URL</label>
174-
<input type="url" id="relay-url" placeholder="https://localhost:4433" spellcheck="false" />
174+
<input type="url" id="relay-url" placeholder="https://localhost:4433/moq" spellcheck="false" />
175175
</div>
176176
<div class="field">
177177
<label for="namespace">Namespace</label>

examples/catalog/main.ts

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
import { MoqtConnection } from '@moqt/webtransport';
1616
import { varint } from '@moqt/transport';
1717
import { createWebTransport } from '../shared/browser/index.js';
18+
import { resolveRelayEndpoint } from '../shared/relay-endpoint.js';
19+
import { parseCertHashHex, relayCandidates } from '../shared/relay-url.js';
20+
import { discoverEndpoint } from '../shared/discover-endpoint.js';
21+
import { probeTransport } from '../shared/probe-transport.js';
1822
import {
1923
CATALOG_TRACK_NAME,
2024
applyCatalogUpdate,
@@ -146,6 +150,17 @@ class CatalogAccumulator {
146150

147151
let activeRun = 0;
148152
let activeConnection: InstanceType<typeof MoqtConnection> | null = null;
153+
// Keep at most one discovery flight active. A submission replaces the page
154+
// prefill or an earlier blank-URL discovery and closes its probes.
155+
let prefillAbort: AbortController | null = null;
156+
let manualDiscoveryAbort: AbortController | null = null;
157+
158+
function abortDiscoveryFlights(reason: string): void {
159+
prefillAbort?.abort(new Error(reason));
160+
prefillAbort = null;
161+
manualDiscoveryAbort?.abort(new Error(reason));
162+
manualDiscoveryAbort = null;
163+
}
149164

150165
form.addEventListener('submit', (e) => {
151166
e.preventDefault();
@@ -159,13 +174,54 @@ if (!('WebTransport' in window)) {
159174

160175
async function run(): Promise<void> {
161176
const runId = ++activeRun;
162-
const url = (document.getElementById('relay-url') as HTMLInputElement).value.trim();
177+
// Every submission supersedes any in-flight discovery (prefill or a
178+
// previous blank-URL run) — their probes are aborted and closed.
179+
abortDiscoveryFlights('superseded by run');
180+
const urlInput = document.getElementById('relay-url') as HTMLInputElement;
181+
let url = urlInput.value.trim();
163182
const ns = (document.getElementById('namespace') as HTMLInputElement).value.trim();
164183
const vRaw = (document.getElementById('draft-version') as HTMLSelectElement).value;
165184
const hashHex = (document.getElementById('cert-hash') as HTMLInputElement).value.trim();
166185
const v: 14 | 16 | 18 | undefined = vRaw === '14' ? 14 : vRaw === '16' ? 16 : vRaw === '18' ? 18 : undefined;
167186

168-
if (!url || !ns) { setStatus('URL and namespace required.', 'error'); return; }
187+
if (!ns) { setStatus('Namespace required.', 'error'); return; }
188+
if (!url) {
189+
// Use the current form values for a fresh discovery. This bypasses the
190+
// page-query singleton so a newly entered hash or draft cannot share an
191+
// incompatible probe.
192+
if (!('WebTransport' in window)) {
193+
setStatus('WebTransport not available. Use Chrome 97+.', 'error');
194+
return;
195+
}
196+
setStatus('Discovering relay endpoint…');
197+
const discoveryController = new AbortController();
198+
manualDiscoveryAbort = discoveryController;
199+
try {
200+
const certHashBuf = hashHex ? parseCertHashHex(hashHex) : undefined;
201+
url = await discoverEndpoint({
202+
candidates: relayCandidates(window.location.hostname),
203+
connect: (candidate, signal) => probeTransport(candidate, {
204+
...(certHashBuf ? { certHash: certHashBuf } : {}),
205+
...(v ? { draftVersion: v } : {}),
206+
signal,
207+
}),
208+
signal: discoveryController.signal,
209+
onAttempt: (candidate, outcome) => {
210+
if (runId === activeRun) log(`probe ${candidate}: ${outcome}`);
211+
},
212+
});
213+
} catch (err) {
214+
if (runId === activeRun && !discoveryController.signal.aborted) {
215+
setStatus((err as Error).message, 'error');
216+
}
217+
return;
218+
} finally {
219+
// Identity-safe: a superseding run owns the slot by now.
220+
if (manualDiscoveryAbort === discoveryController) manualDiscoveryAbort = null;
221+
}
222+
if (runId !== activeRun) return; // superseded while discovering
223+
urlInput.value = url;
224+
}
169225

170226
const method = (document.getElementById('read-method') as HTMLSelectElement).value as 'subscribe' | 'fetch';
171227
const groupId = BigInt((document.getElementById('fetch-group') as HTMLInputElement).value || '0');
@@ -186,12 +242,7 @@ async function run(): Promise<void> {
186242

187243
try {
188244
// WebTransport — use shared factory for protocol negotiation
189-
const certHashBuf = hashHex ? (() => {
190-
const clean = hashHex.replace(/[^0-9a-fA-F]/g, '');
191-
const bytes = new Uint8Array(clean.length / 2);
192-
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
193-
return bytes.buffer as ArrayBuffer;
194-
})() : undefined;
245+
const certHashBuf = hashHex ? parseCertHashHex(hashHex) : undefined;
195246
const transportFactory = createWebTransport({ ...(certHashBuf ? { certHash: certHashBuf } : {}), ...(v ? { draftVersion: v } : {}) });
196247
const transport = await transportFactory(url);
197248
log(`WebTransport connected${(transport as any).protocol ? ` (${(transport as any).protocol})` : ''}`);
@@ -449,8 +500,39 @@ function setStatus(msg: string, type: 'info' | 'error' | 'success' = 'info'): vo
449500

450501
function seedForm(): void {
451502
const p = new URLSearchParams(window.location.search);
452-
(document.getElementById('relay-url') as HTMLInputElement).value =
453-
p.get('url') ?? `${window.location.origin.replace(/\/$/, '')}:4433`;
503+
const urlInput = document.getElementById('relay-url') as HTMLInputElement;
504+
const explicit = p.get('url');
505+
if (explicit) {
506+
urlInput.value = explicit;
507+
} else if (!('WebTransport' in window)) {
508+
// No discovery without the API — and never overwrite the capability
509+
// diagnostic with a "no endpoint found" message.
510+
urlInput.placeholder = 'https://relay.example.com:4433/moq';
511+
} else {
512+
// Prefill asynchronously from the shared discovery. The user can type
513+
// while it runs — a typed value always wins over the discovered one —
514+
// and a run started meanwhile owns the status bar AND aborts this
515+
// flight (identity-safe controller; a late failure of a superseded
516+
// prefill must not overwrite an active run's status).
517+
const prefillRun = activeRun;
518+
const controller = new AbortController();
519+
prefillAbort = controller;
520+
urlInput.placeholder = 'discovering…';
521+
void resolveRelayEndpoint({ signal: controller.signal }).then(
522+
(url) => {
523+
if (prefillAbort === controller) prefillAbort = null;
524+
if (!urlInput.value) urlInput.value = url;
525+
urlInput.placeholder = 'https://relay.example.com:4433/moq';
526+
},
527+
(err: unknown) => {
528+
if (prefillAbort === controller) prefillAbort = null;
529+
urlInput.placeholder = 'https://relay.example.com:4433/moq';
530+
if (activeRun === prefillRun && !controller.signal.aborted) {
531+
setStatus((err as Error).message, 'error');
532+
}
533+
},
534+
);
535+
}
454536
(document.getElementById('namespace') as HTMLInputElement).value = p.get('ns') ?? 'live';
455537
(document.getElementById('draft-version') as HTMLSelectElement).value = p.get('v') ?? '';
456538
(document.getElementById('cert-hash') as HTMLInputElement).value = p.get('hash') ?? '';

examples/connect/main.ts

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import { varint } from '@moqt/transport';
2020
import { parseCatalogAuto } from '@moqt/msf';
2121
import type { CatalogTrack } from '@moqt/msf';
2222
import { log } from '../shared/log.js';
23-
import { relayUrl, namespace, certHash, draftVersion } from '../shared/cert.js';
23+
import { namespace, certHash, draftVersion } from '../shared/cert.js';
24+
import { resolveRelayEndpoint, onDiscoveryAttempt } from '../shared/relay-endpoint.js';
25+
import { createWebTransport } from '../shared/browser/index.js';
2426

2527
// ─── Capability check ────────────────────────────────────────────────
2628

@@ -34,32 +36,27 @@ if (!('WebTransport' in window)) {
3436
const enc = new TextEncoder();
3537

3638
async function main(): Promise<void> {
39+
// Resolve the relay endpoint: explicit ?url= is used as-is; otherwise the
40+
// shared discovery probes the page host's common endpoint paths.
41+
log('Discovering relay endpoint...');
42+
onDiscoveryAttempt((url, outcome) => log(` probe ${url}: ${outcome}`));
43+
const relayUrl = await resolveRelayEndpoint();
44+
3745
log(`Relay: ${relayUrl}`);
3846
log(`Namespace: ${namespace}`);
3947
log(`Cert hash: ${certHash ? 'provided' : 'none (using system trust)'}`);
4048
log('');
4149

42-
// 1. Create WebTransport connection
43-
// serverCertificateHashes pins the relay's self-signed cert.
50+
// 1. Create WebTransport connection via the shared factory (cert-hash
51+
// pinning + WT-Available-Protocols offer with strict-UA fallback).
52+
// Connect to the relay URL as-is: the namespace is communicated via
53+
// SUBSCRIBE, never appended to a deployment-specific endpoint path.
4454
// @see draft-ietf-moq-transport-16 §3.1
4555
log('Creating WebTransport connection...');
46-
const transportOptions: WebTransportOptions = {};
47-
if (certHash) {
48-
transportOptions.serverCertificateHashes = [{
49-
algorithm: 'sha-256',
50-
value: certHash,
51-
}];
52-
}
53-
// §3.1: WT-Available-Protocols for MOQT version negotiation
54-
if (draftVersion) {
55-
(transportOptions as any).protocols = [`moqt-${draftVersion}`];
56-
}
57-
// Connect to relay URL as-is. Namespace is communicated via SUBSCRIBE,
58-
// not the connection URL. Some relays (moquito) accept ?ns= but others
59-
// (Red5) reject unrecognized URL paths.
60-
const connectUrl = relayUrl;
61-
const transport = new WebTransport(connectUrl, transportOptions);
62-
await transport.ready;
56+
const transport = await createWebTransport({
57+
...(certHash ? { certHash } : {}),
58+
...(draftVersion ? { draftVersion } : {}),
59+
})(relayUrl);
6360
log('WebTransport connected.');
6461

6562
// 2. Create MoqtConnection — internally creates Session(EndpointRole.CLIENT)

0 commit comments

Comments
 (0)