1515import { MoqtConnection } from '@moqt/webtransport' ;
1616import { varint } from '@moqt/transport' ;
1717import { 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' ;
1822import {
1923 CATALOG_TRACK_NAME ,
2024 applyCatalogUpdate ,
@@ -146,6 +150,17 @@ class CatalogAccumulator {
146150
147151let activeRun = 0 ;
148152let 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
150165form . addEventListener ( 'submit' , ( e ) => {
151166 e . preventDefault ( ) ;
@@ -159,13 +174,54 @@ if (!('WebTransport' in window)) {
159174
160175async 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 - 9 a - f A - 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
450501function 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' ) ?? '' ;
0 commit comments