From d240de71de7695a743bdd282c6127e2897afd77b Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:59:50 -0700 Subject: [PATCH 1/5] chore: claim shared browser protocol preference Co-Authored-By: GPT-6 From 8361ca51c524cb576e36804a29e3269bcf33f0fe Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:11:01 -0700 Subject: [PATCH 2/5] feat(net): share browser protocol preferences across transports Co-Authored-By: GPT-6 --- js/net/src/connection/connect.test.ts | 77 +++++++++++++++++++ js/net/src/connection/connect.ts | 104 ++++++++++++++++---------- js/net/src/connection/pool.test.ts | 83 ++++++++++++++++++++ js/net/src/connection/pool.ts | 39 +++++++--- js/net/src/connection/reload.ts | 5 ++ 5 files changed, 258 insertions(+), 50 deletions(-) diff --git a/js/net/src/connection/connect.test.ts b/js/net/src/connection/connect.test.ts index 1ebf9f65845..3b6002f003a 100644 --- a/js/net/src/connection/connect.test.ts +++ b/js/net/src/connection/connect.test.ts @@ -1,7 +1,10 @@ import { expect, jest, test } from "bun:test"; +import { Manual, Nano } from "@moq/clock"; import { SessionCode, SessionError } from "../error.ts"; +import * as Ietf from "../ietf/index.ts"; import { ALPN_05, ALPN_06, ALPN_07_WIP } from "../lite/version.ts"; import { createMockTransportPair } from "../mock.ts"; +import { accept } from "./accept.ts"; import { type ConnectProps, connect as connectSession } from "./connect.ts"; const url = new URL("https://example.com/test"); @@ -292,3 +295,77 @@ test("SETUP preserves a delayed session close code", async () => { jest.useRealTimers(); } }); + +test("one protocol preference reaches WebTransport", async () => { + const protocols = [ALPN_07_WIP, ALPN_06]; + expect((await offered(ALPN_07_WIP, { protocols })).protocols).toEqual(protocols); +}); + +test("one protocol preference reaches the WebSocket fallback", async () => { + const originalTransport = globalThis.WebTransport; + const originalSocket = globalThis.WebSocket; + const clock = new Manual(); + const restore = clock.install(); + let protocols: string[] | undefined; + globalThis.WebTransport = undefined as unknown as typeof WebTransport; + globalThis.WebSocket = function CaptureSocket(_url: string, offered: string[]) { + protocols = offered; + throw new Error("captured WebSocket offer"); + } as unknown as typeof WebSocket; + try { + const result = connect(url, { protocols: [ALPN_07_WIP, ALPN_06] }).catch((err: unknown) => err); + await clock.advanceAsync(Nano.zero); + expect(await result).toBeInstanceOf(AggregateError); + const application = protocols + ?.filter((protocol) => protocol.includes(".moq-")) + .map((protocol) => protocol.slice(protocol.indexOf(".") + 1)); + // QMux may support more drafts later; every variant keeps the app preference. + expect(application?.filter((protocol, index) => index === 0 || protocol !== application[index - 1])).toEqual([ + ALPN_07_WIP, + ALPN_06, + ]); + } finally { + globalThis.WebTransport = originalTransport; + globalThis.WebSocket = originalSocket; + restore(); + } +}); + +test("protocol preferences reject empty, duplicate and unsupported offers", async () => { + for (const protocols of [[], [ALPN_06, ALPN_06], ["unknown"]]) { + expect(await connect(url, { protocols }).catch((err: unknown) => err)).toBeInstanceOf(TypeError); + } + expect( + await connect(url, { protocols: [ALPN_06], webtransport: { protocols: [ALPN_05] } }).catch( + (err: unknown) => err, + ), + ).toBeInstanceOf(TypeError); +}); + +test("a peer cannot select outside the explicit protocol offer", async () => { + const pair = createMockTransportPair(ALPN_05); + const restore = stubWebTransport(pair.client); + try { + expect( + await connect(url, { protocols: [ALPN_06], websocket: { enabled: false } }).catch((err: unknown) => err), + ).toHaveProperty("message", `server selected an unoffered protocol: ${ALPN_05}`); + } finally { + restore(); + } +}); + +test("IETF ALPN preferences are not confused with display names", async () => { + const pair = createMockTransportPair(Ietf.ALPN.DRAFT_22); + const restore = stubWebTransport(pair.client); + try { + const [client, server] = await Promise.all([ + connect(url, { protocols: [Ietf.ALPN.DRAFT_22], websocket: { enabled: false } }), + accept({ url, transport: pair.server }), + ]); + expect(client.version).toBe("moq-transport-22"); + client.abort(); + server.abort(); + } finally { + restore(); + } +}); diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 5a73415146b..81d32f786e4 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -63,11 +63,55 @@ export interface WebTransportProps extends Omit; + +// Experimental lite-07 stays opt-in on both transports. +const DEFAULT_PROTOCOLS = Object.keys(PROTOCOL_VERSIONS).filter((protocol) => protocol !== Lite.ALPN_07_WIP); + +/** Validate and snapshot an ordered protocol offer before acquiring a connection. @internal */ +export function normalizeProtocols( + protocols?: readonly string[], + webtransport?: WebTransportProps, +): string[] | undefined { + if (protocols === undefined) return undefined; + if (webtransport?.protocols !== undefined) + throw new TypeError("set protocols on the connection instead of webtransport"); + if (protocols.length === 0) throw new TypeError("protocols must not be empty"); + const seen = new Set(); + for (const protocol of protocols) { + if (!Object.hasOwn(PROTOCOL_VERSIONS, protocol)) throw new TypeError(`unsupported protocol: ${protocol}`); + if (seen.has(protocol)) throw new TypeError(`duplicate protocol: ${protocol}`); + seen.add(protocol); + } + return [...protocols]; +} + /** Options for {@link connect}. */ export interface ConnectProps { /** The relay URL. */ url: URL; + /** + * Ordered ALPNs to offer on both transports, including any desired fallbacks; omit for stable defaults. + * A legacy peer that negotiates no application ALPN still uses the compatibility handshake. + */ + protocols?: readonly string[]; + /** WebTransport options. */ webtransport?: WebTransportProps; @@ -142,6 +186,8 @@ const NEVER_ABORTED = new AbortController().signal; */ export async function connect({ url, ...props }: ConnectProps): Promise { if ("discovery" in props) throw new TypeError(DISCOVERY_REMOVED); + props.protocols = normalizeProtocols(props.protocols, props.webtransport); + if (props.transport && props.protocols) throw new TypeError("protocols cannot reconfigure a supplied transport"); const signal = props.signal ?? NEVER_ABORTED; signal.throwIfAborted(); @@ -184,7 +230,13 @@ async function connectInner(url: URL, props: Omit, abort: P const { promise: raced, resolve: done } = Promise.withResolvers(); const cancel = Promise.race([raced, abort]); - const webtransport = isWebTransportSupported() ? connectWebTransport(url, cancel, props.webtransport) : undefined; + const webtransport = isWebTransportSupported() + ? connectWebTransport( + url, + cancel, + props.protocols ? { ...props.webtransport, protocols: [...props.protocols] } : props.webtransport, + ) + : undefined; // Give QUIC a head start to connect before trying WebSocket, unless WebSocket has won in the past. // NOTE that QUIC should be faster because it involves 1/2 fewer RTTs. @@ -192,7 +244,7 @@ async function connectInner(url: URL, props: Omit, abort: P !webtransport || websocketWon.has(url.toString()) ? 0 : (props.websocket?.delay ?? DEFAULT_WEBSOCKET_DELAY_MS); const websocket = props.websocket?.enabled !== false - ? connectWebSocket(props.websocket?.url ?? url, headstart, cancel) + ? connectWebSocket(props.websocket?.url ?? url, { delay: headstart, cancel, protocols: props.protocols }) : undefined; if (!websocket && !webtransport) { @@ -225,6 +277,11 @@ async function connectInner(url: URL, props: Omit, abort: P } // The remaining setup is identical whether the transport was raced or supplied. + const protocol = (session as { protocol?: string }).protocol; + if (props.protocols && protocol && !props.protocols.includes(protocol)) { + session.close(); + throw new Error(`server selected an unoffered protocol: ${protocol}`); + } return await connectTransport(url, session as WebTransport, wiring); } @@ -453,23 +510,7 @@ async function connectWebTransport( const finalOptions: WebTransportOptions = { allowPooling: false, congestionControl: "low-latency", - protocols: [ - // Lite.ALPN_07_WIP is intentionally omitted: lite-07 is work-in-progress and - // not advertised by default (negotiate still accepts it if a server selects it). - Lite.ALPN_06, - Lite.ALPN_05, - Lite.ALPN_04, - Lite.ALPN_03, - Lite.ALPN, - Ietf.ALPN.DRAFT_22, - Ietf.ALPN.DRAFT_21, - Ietf.ALPN.DRAFT_20, - Ietf.ALPN.DRAFT_19, - Ietf.ALPN.DRAFT_18, - Ietf.ALPN.DRAFT_17, - Ietf.ALPN.DRAFT_16, - Ietf.ALPN.DRAFT_15, - ], + protocols: DEFAULT_PROTOCOLS, ...webtransport, }; @@ -525,7 +566,10 @@ async function connectWebTransport( } // TODO accept arguments to control the port/path used. -async function connectWebSocket(url: URL, delay: number, cancel: Promise): Promise { +async function connectWebSocket( + url: URL, + { delay, cancel, protocols }: { delay: number; cancel: Promise; protocols?: readonly string[] }, +): Promise { const timer = sleep(Nano.fromMilli(Milli(delay))); const active = await Promise.race([cancel, timer.then(() => true)]); @@ -536,30 +580,14 @@ async function connectWebSocket(url: URL, delay: number, cancel: Promise): // don't want to lock that in: set the value to `null` so the polyfill // advertises every QMux draft it knows about and the server picks one. // Insertion order is the negotiation preference on the wire. - const versions = { - // Lite.ALPN_07_WIP omitted on purpose: lite-07 is work-in-progress, not advertised by default. - [Lite.ALPN_06]: null, - [Lite.ALPN_05]: null, - [Lite.ALPN_04]: null, - [Lite.ALPN_03]: null, - [Lite.ALPN]: null, - [Ietf.ALPN.DRAFT_22]: "qmux-01", - [Ietf.ALPN.DRAFT_21]: "qmux-01", - [Ietf.ALPN.DRAFT_20]: "qmux-01", - [Ietf.ALPN.DRAFT_19]: "qmux-01", - [Ietf.ALPN.DRAFT_18]: "qmux-01", - [Ietf.ALPN.DRAFT_17]: null, - [Ietf.ALPN.DRAFT_16]: null, - [Ietf.ALPN.DRAFT_15]: null, - } as const satisfies Record; // The default (`requireProtocol: false`) also advertises bare `qmux-01`, // `qmux-00`, and `webtransport` so we still interop with relays that only // know a wire-format version (today's moq-relay only accepts bare // `webtransport`). const quic = new Session(url, { - protocols: Object.keys(versions), - versions, + protocols: [...(protocols ?? DEFAULT_PROTOCOLS)], + versions: PROTOCOL_VERSIONS, }); // Wait for the WebSocket to connect, or for the cancel promise to resolve. diff --git a/js/net/src/connection/pool.test.ts b/js/net/src/connection/pool.test.ts index 3a8a4451c74..e7cceacbe68 100644 --- a/js/net/src/connection/pool.test.ts +++ b/js/net/src/connection/pool.test.ts @@ -6,6 +6,7 @@ import * as Lite from "../lite/index.ts"; import { createMockTransportPair } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; +import { Stream } from "../stream.ts"; import * as Time from "../time.ts"; import { connect } from "./connect.ts"; import { type AcceptProps, accept as acceptSession } from "./index.ts"; @@ -624,3 +625,85 @@ test("a handle's announcements sync with the shared origin", async () => { watch.close(); handle.close(); }); + +test("shared connections distinguish ordered protocol preferences", async () => { + const dials = stubTransports(); + const protocols = [Lite.ALPN_07_WIP, Lite.ALPN_06, Lite.ALPN_05]; + const first = new Connection({ url, protocols, share: true, linger }); + const same = new Connection({ url, protocols: [...protocols], share: true, linger }); + const reversed = new Connection({ url, protocols: [...protocols].reverse(), share: true, linger }); + const defaults = new Connection({ url, linger }); + try { + await advance(); + expect(first.status.peek()).toBe("connected"); + expect(same.origin.peek()).toBe(first.origin.peek()); + expect(reversed.origin.peek()).not.toBe(first.origin.peek()); + expect(defaults.origin.peek()).not.toBe(first.origin.peek()); + expect(dials.count()).toBe(3); + } finally { + first.close(); + same.close(); + reversed.close(); + defaults.close(); + } +}); + +test("a caller cannot mutate an acquired protocol preference", async () => { + const dials = stubTransports(); + const protocols = [Lite.ALPN_07_WIP, Lite.ALPN_05]; + const first = new Connection({ url, protocols, linger }); + protocols.reverse(); + const original = new Connection({ url, protocols: [Lite.ALPN_07_WIP, Lite.ALPN_05], linger }); + const changed = new Connection({ url, protocols, linger }); + try { + await advance(); + expect(first.origin.peek()).toBe(original.origin.peek()); + expect(changed.origin.peek()).not.toBe(first.origin.peek()); + expect(dials.count()).toBe(2); + } finally { + first.close(); + original.close(); + changed.close(); + } +}); + +test("GOAWAY keeps protocol pools distinct at the redirected URL", async () => { + const moved = new URL("https://example.com:5443/pool"); + const peers: WebTransport[] = []; + globalThis.WebTransport = function RedirectTransport(address: string | URL) { + const pair = createMockTransportPair(Lite.ALPN_06); + peers.push(pair.server); + void accept(pair.server, new URL(address)); + return pair.client; + } as unknown as typeof WebTransport; + const protocols = [Lite.ALPN_07_WIP, Lite.ALPN_06]; + const first = new Connection({ url, protocols, linger }); + const resident = new Connection({ url: moved, linger }); + let joined: Connection | undefined; + let defaults: Connection | undefined; + try { + await advance(); + const origin = first.origin.peek(); + // Exercise a healthy migration after the immediate-redirect backoff guard. + await advance(2_000); + const peer = peers[0]; + if (!peer) throw new Error("missing initial peer"); + const stream = await Stream.open(peer, { version: Lite.Version.DRAFT_06 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway(moved.href).encode(stream.writer, Lite.Version.DRAFT_06); + await advance(); + joined = new Connection({ url: moved, protocols, linger }); + defaults = new Connection({ url: moved, linger }); + await advance(); + expect(joined.status.peek()).toBe("connected"); + expect(joined.origin.peek()).toBe(origin); + expect(defaults.origin.peek()).toBe(resident.origin.peek()); + expect(defaults.origin.peek()).not.toBe(origin); + expect(peers).toHaveLength(3); + } finally { + first.close(); + resident.close(); + joined?.close(); + defaults?.close(); + } +}); diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index ac37be11839..fd8186668b1 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -1,5 +1,5 @@ /** - * A reconnecting, shareable connection: one origin and one reconnect loop per relay URL. + * A reconnecting, shareable connection: one origin and reconnect loop per URL and protocol offer. * * @module */ @@ -19,6 +19,7 @@ import { type ConnectProps as ConnectPropsType, certificateHash, connect, + normalizeProtocols, type WebSocketProps as WebSocketPropsType, type WebTransportProps as WebTransportPropsType, } from "./connect.ts"; @@ -51,12 +52,15 @@ export interface ConnectionProps { linger?: Milli; /** - * Share a pooled transport keyed on the URL. By default, options specific to this - * handle use a private reconnect loop; otherwise connections share by URL. + * Share a pooled transport keyed on the URL and ordered protocol offer. By default, options specific to this + * handle use a private reconnect loop; otherwise connections share by URL and protocol offer. * Explicit `true` refuses options a pool cannot honor. */ share?: boolean; + /** Ordered protocols shared by both transports; connections pool by URL and this offer. */ + protocols?: readonly string[]; + /** WebTransport options applied to each connection attempt (not reactive). */ webtransport?: WebTransportPropsType; @@ -88,7 +92,7 @@ export interface ConnectionProps { /** * A cloneable handle on a MoQ connection: `new Connection({ url })` reconnects and, by - * default, shares one origin and one reconnect loop with every other handle on that URL. + * default, shares one origin and reconnect loop with handles using the same URL and protocols. * * The shared {@link origin} is wired to both directions. Everything the relay announces * lands in it, a publish into it is announced to the relay, and a page that publishes and @@ -202,6 +206,7 @@ export class Connection { * (plus the linger window) wants them. */ constructor(props?: ConnectionProps) { + const protocols = normalizeProtocols(props?.protocols, props?.webtransport); refuse(props); this.#signals = new Effect(); @@ -221,7 +226,7 @@ export class Connection { const href = this.#signals.computed((effect) => effect.get(this.url)?.href); if (props && (props.share === false || requiresPrivate(props))) { - this.#runPrivate(props, href); + this.#runPrivate({ ...props, protocols }, href); return; } @@ -230,7 +235,7 @@ export class Connection { const key = effect.get(href); if (!key) return; - const lease = acquire(key, linger); + const lease = acquire(key, protocols, linger); effect.cleanup(lease.release); effect.set(this.#origin, lease.origin, undefined); @@ -254,6 +259,7 @@ export class Connection { enabled: this.enabled, publish: props.publish ?? (owned ? origin.consume() : undefined), consume: origin, + protocols: props.protocols, webtransport: props.webtransport, websocket: props.websocket, delay: props.delay, @@ -400,7 +406,7 @@ function requiresPrivate(props: ConnectionProps): boolean { /** One shared connection and the handles keeping it alive. */ interface Entry { - /** The pool key: the dialed URL, which a GOAWAY redirect moves. */ + /** The pool key: the dialed URL and protocols, moved together on GOAWAY. */ key: string; origin: Origin.Producer; connection: Reload; @@ -412,13 +418,20 @@ interface Entry { /** The process-wide pool backing {@link Connection}. */ const pool = new Map(); -/** Take a reference on the shared entry for `key`, creating it on first use. */ -function acquire(key: string, linger?: Milli): Entry & { release: () => void } { +// Preserve ordered protocol offers when a redirect moves the URL. +function poolKey(url: string, protocols?: readonly string[]): string { + return JSON.stringify([url, protocols]); +} + +/** Take a reference on the shared entry, creating it on first use. */ +function acquire(url: string, protocols?: readonly string[], linger?: Milli): Entry & { release: () => void } { + const key = poolKey(url, protocols); let entry = pool.get(key); if (!entry) { const origin = new Origin.Producer(); const connection = new Reload({ - url: new URL(key), + url: new URL(url), + protocols, enabled: true, publish: origin.consume(), consume: origin, @@ -439,9 +452,11 @@ function acquire(key: string, linger?: Milli): Entry & { release: () => void } { // A live entry already at the target wins: this one leaves the pool and serves only // the handles it has until they release it. connection.redirect.subscribe((redirect) => { - if (!redirect || redirect.href === created.key) return; + if (!redirect) return; + const key = poolKey(redirect.href, protocols); + if (key === created.key) return; if (pool.get(created.key) === created) pool.delete(created.key); - created.key = redirect.href; + created.key = key; if (!pool.has(created.key)) pool.set(created.key, created); }); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 9570ddb5927..ab20baa94b2 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -136,6 +136,9 @@ export class Reload { */ readonly bandwidth: Allocator; + /** Ordered protocol offer retained across retries and redirects. */ + readonly protocols?: readonly string[]; + /** WebTransport options applied to each connection attempt (not reactive). */ webtransport?: WebTransportProps; @@ -232,6 +235,7 @@ export class Reload { this.delay = props?.delay ?? {}; this.goaway = props?.goaway ?? {}; this.redirect = this.#redirect; + this.protocols = props?.protocols ? [...props.protocols] : undefined; this.webtransport = props?.webtransport; this.websocket = props?.websocket; this.publish = props?.publish; @@ -389,6 +393,7 @@ export class Reload { url: dialing, // A redirect names the relay; a fallback URL pinned for the old one does not follow. websocket: this.#redirect.peek() ? { ...this.websocket, url: undefined } : this.websocket, + protocols: this.protocols, webtransport: this.webtransport, publish: this.publish, consume: this.consume, From 1b1202417ddd605ab938fae86ab5025da054272f Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:35:16 -0700 Subject: [PATCH 3/5] docs(net): describe shared protocol preferences Co-Authored-By: GPT-6 --- doc/lib/js/net.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 1029334ac00..c95a7bf453f 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -38,14 +38,14 @@ broadcast.announce(); - **Rank several CDNs.** Connect to each with its own `consume: origin.withPreference(Origin.Preference(rank))` (higher wins; 0 is neutral and a negative rank demotes), all on one origin. Requests use the preferred link that has a route and fail over to the next; see [multiple CDNs](/concept/moq-lite#multiple-cdns). - **Follow restarts.** `origin.request(path, { announced: true })` follows whichever publisher instance serves the path: its `active` changes to a fresh broadcast on a restart, clears on an end, and resolves again on the next start, so read it reactively and close the request when done. Instances are never stitched, so subscribe again to each new broadcast. A refusal ends the request. Add `epoch` to pin one instance; a plain `request(path)` keeps its accepted instance, can resolve blindly, and ends once that instance stops serving. To list what is live, iterate `announced(scope)`. - **Discovery follows what you watch.** A `consume` origin asks the relay for announcements only under the prefixes its readers watch with `announced(scope)` or `request(path, { announced: true })`, stops asking about a prefix 2s after nothing at or beneath it is watched, and asks again on reconnect. A plain `request(path)` asks nothing and subscribes blind, but once it resolves through a discovered route it watches its path until closed, so it outlives the listing that found it. A relay that refuses an announce request turns that session's announced requests blind too. -- **One connection per URL, unless you opt out.** `new Connection({ url })` pools and reconnects with backoff, which the elements use. Supplying your own transport options or origin selects a private loop. Receive estimates are on demand: observe `connection.probe` reactively or subscribe while you need them; `peek()` alone opens no PROBE stream. +- **Connections share by URL and protocol offer.** `new Connection({ url })` pools and reconnects with backoff, which the elements use. An ordered `protocols` offer applies to both WebTransport and WebSocket; equivalent offers share, while a different order or omitted offer gets its own pool entry. Supplying your own transport options or origin selects a private loop. Receive estimates are on demand: observe `connection.probe` reactively or subscribe while you need them; `peek()` alone opens no PROBE stream. - **GOAWAY moves the session.** The connection dials the replacement at once while the old session keeps serving its groups, up to `goaway.handover` (default 10s, or the relay's deadline when that is sooner). New requests keep opening on the old session until the replacement's route outranks it, which is a restart without an epoch: an unpinned announced request follows the replacement, while a blind or epoch-pinned request stays on its accepted instance. An empty GOAWAY redials the same URL. A redirect stays on the same host unless `goaway.redirect` is `"follow"`. - **One send estimate per connection.** `Bandwidth.Allocator` divides it by track priority, max-min fair within a tier. An idle track claims nothing. Publishers reserve against it so their targets sum to the estimate. - **One track per name.** Concurrent subscriptions share one request and producer. `createTrack` on a name with a request still queued answers it. The application owns sequence numbers: a track that replaces an ended one, created or requested, throws on an append that would reuse a sequence the name already wrote, so it writes its first group with an explicit sequence. Only a new broadcast starts over. - **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Omitting it means untimed, as in Rust. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers. - **Subscriber staleness is `maxDelay`.** It counts from a group's successor, by media time and by wall clock, whichever reaches it first. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`. - **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts). -- **Authorization is in band.** On moq-lite 07 (offered only when `moq-lite-07-wip` is listed in `webtransport.protocols`; the WebSocket fallback cannot offer it) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Auth` it returns (`broadcast.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. An `Auth.Auth` belongs to the session that added it, so after a reconnect, add the token again and re-announce. +- **Authorization is in band.** On moq-lite 07 (opt in on both transports with `protocols: ["moq-lite-07-wip", "moq-lite-06"]`, including every desired fallback) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Auth` it returns (`broadcast.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. An `Auth.Auth` belongs to the session that added it, so after a reconnect, add the token again and re-announce. - **A graceful close waits.** `await connection.close()` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions wait only for transport ACKs. IETF media streams are not drained. `abort()` ends immediately. Examples: From 900c95dc11a855abdf32ba20237801e218e9236b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 18:38:04 -0700 Subject: [PATCH 4/5] fix(net): keep server-chosen SETUP versions accepted A server may still pick a SETUP version the compatibility handshake did not list; only the offer itself follows `versions`. Co-Authored-By: Claude Opus 5.5 --- js/net/src/connection/connect.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index ada4f6696ea..2579f9e9786 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -431,13 +431,15 @@ async function negotiate( Ietf.hiddenIntoSetup(params); Ietf.activeCountIntoSetup(params, setupVersion); - const offered = - setupVersion === Ietf.Version.DRAFT_16 - ? [Ietf.Version.DRAFT_16] - : setupVersion === Ietf.Version.DRAFT_15 - ? [Ietf.Version.DRAFT_15] - : setup; - const client = new Ietf.ClientSetup({ versions: offered, parameters: params }); + const client = new Ietf.ClientSetup({ + versions: + setupVersion === Ietf.Version.DRAFT_16 + ? [Ietf.Version.DRAFT_16] + : setupVersion === Ietf.Version.DRAFT_15 + ? [Ietf.Version.DRAFT_15] + : setup, + parameters: params, + }); await client.encode(stream.writer, setupVersion); const serverCompat = await stream.reader.u53(); @@ -446,9 +448,6 @@ async function negotiate( } const server = await Ietf.ServerSetup.decode(stream.reader, setupVersion); - if (!(offered as number[]).includes(server.version)) { - throw new Error(`server selected an unoffered version: 0x${server.version.toString(16)}`); - } if (Object.values(Lite.Version).includes(server.version as Lite.Version)) { return new Lite.Connection({ From fdde4ec978a8ca58aa9f44de0d62ef8b343d5727 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 18:59:00 -0700 Subject: [PATCH 5/5] docs(net): name both removed protocol fields and the SETUP fallbacks Co-Authored-By: Claude Opus 5.5 --- js/net/MIGRATE.md | 2 +- js/net/src/connection/connect.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 4c8e5065a70..5b81d137fa1 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -8,7 +8,7 @@ How to update your code for each breaking change, newest first. Move the list to the top-level `versions` of `connect` or `Connection`, spelled as version names: `moq-transport-22`, not the ALPN `moqt-22`; lite names are unchanged. It now applies to the WebSocket fallback too, so list every fallback you still want, such as `versions: ["moq-lite-07-wip", "moq-lite-06"]`. -Passing `webtransport.protocols` throws a `TypeError`. +Passing `webtransport.protocols` or `websocket.protocols` throws a `TypeError`; move either to `versions`. ### Track demand reports the wanted group (#5930) diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 2579f9e9786..afb9eca3a8c 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -154,7 +154,9 @@ export interface ConnectProps { /** * Protocol versions to offer on every transport, most preferred first, spelled like * `moq-lite-06`. Defaults to every supported version except the work-in-progress - * `moq-lite-07-wip`; list every fallback you still want. + * `moq-lite-07-wip`; list every fallback you still want. A peer that negotiates no + * subprotocol (such as WebTransport in Firefox before 155) is refused unless the list + * includes `moq-lite-02`, `moq-lite-01` or `moq-transport-14`, which negotiate in SETUP. */ versions?: readonly string[];