diff --git a/js/net/src/connection/accept.ts b/js/net/src/connection/accept.ts index 26eb23ba1..29288dc47 100644 --- a/js/net/src/connection/accept.ts +++ b/js/net/src/connection/accept.ts @@ -134,7 +134,17 @@ async function acceptNegotiated(transport: WebTransport, url: URL, props?: Accep await server.encode(stream.writer, setupVersion); if (Object.values(Lite.Version).includes(selectedVersion as Lite.Version)) { - return new Lite.Connection(url, transport, selectedVersion as Lite.Version, stream); + const version = selectedVersion as Lite.Version; + // Lite03+ has no SessionInfo protocol on the control stream. When it's + // negotiated via this Draft14 SETUP fallback (e.g. Firefox WebTransport, + // which can't select an ALPN), close the bootstrap stream after the + // exchange and run the session as if it had been ALPN-negotiated directly. + const isLegacy = version === Lite.Version.DRAFT_01 || version === Lite.Version.DRAFT_02; + if (isLegacy) { + return new Lite.Connection(url, transport, version, stream); + } + stream.writer.close(); + return new Lite.Connection(url, transport, version, undefined); } else if (Object.values(Ietf.Version).includes(selectedVersion as Ietf.Version)) { const maxRequestId = client.parameters.getVarint(Ietf.SetupOption.MaxRequestId) ?? 0n; return new Ietf.Connection({ diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index c624c3c99..284830c15 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -9,6 +9,40 @@ import { exchangeSetup } from "./handshake.ts"; // Default head start for WebTransport before attempting the WebSocket fallback. const DEFAULT_WEBSOCKET_DELAY_MS = 500; +// Versions advertised in the Draft14 SETUP fallback (bare `moql` ALPN or no ALPN +// at all, e.g. Firefox WebTransport, which can't select an ALPN). Listing every +// shipped moq-lite version lets the server pick Lite03+ even without ALPN +// selection. Lite05Wip is intentionally omitted: it's work-in-progress and not +// advertised by default. +const LITE_FALLBACK_VERSIONS = [ + Lite.Version.DRAFT_04, + Lite.Version.DRAFT_03, + Lite.Version.DRAFT_02, + Lite.Version.DRAFT_01, + Ietf.Version.DRAFT_14, +]; + +// Lite01/Lite02 still run the SessionInfo protocol on the SETUP control stream. +function isLiteLegacy(v: Lite.Version): boolean { + return v === Lite.Version.DRAFT_01 || v === Lite.Version.DRAFT_02; +} + +// Build a Lite connection negotiated via the Draft14 SETUP fallback. Lite03+ has +// no SessionInfo protocol, so close the bootstrap stream we borrowed for the +// exchange and run the session as if it had been ALPN-negotiated directly. +function liteFallbackConnection( + url: URL, + session: WebTransport, + version: Lite.Version, + stream: Stream, +): Lite.Connection { + if (isLiteLegacy(version)) { + return new Lite.Connection(url, session, version, stream); + } + stream.writer.close(); + return new Lite.Connection(url, session, version, undefined); +} + /** Tuning for the WebSocket fallback used when WebTransport is unavailable or loses the connect race. */ export interface WebSocketOptions { // If true (default), enable the WebSocket fallback. @@ -162,12 +196,14 @@ export async function connect(url: URL, props?: ConnectProps): Promise { await runPublishSubscribeFlow(Lite.ALPN_03); }); +test("integration: lite draft-04", async () => { + await runPublishSubscribeFlow(Lite.ALPN_04); +}); + +// No-ALPN fallback (e.g. Firefox WebTransport, which can't select an ALPN). +// The SETUP exchange advertises every shipped moq-lite version, so the server +// can pick Lite03+ even without ALPN selection. +test("integration: lite draft-03 via SETUP fallback (no ALPN)", async () => { + await runPublishSubscribeFlow("", Lite.Version.DRAFT_03); +}); + +test("integration: lite draft-04 via SETUP fallback (no ALPN)", async () => { + await runPublishSubscribeFlow("", Lite.Version.DRAFT_04); +}); + test("integration: ietf draft-14", async () => { await runPublishSubscribeFlow("", Ietf.Version.DRAFT_14); }); diff --git a/js/net/src/lite/version.ts b/js/net/src/lite/version.ts index a3beda5f5..e92110cac 100644 --- a/js/net/src/lite/version.ts +++ b/js/net/src/lite/version.ts @@ -11,7 +11,10 @@ export const Version = { export type Version = (typeof Version)[keyof typeof Version]; /// The WebTransport subprotocol identifier for moq-lite. -/// Version negotiation still happens via SETUP when this is used. +/// Version negotiation still happens via SETUP when this is used, or when no +/// ALPN selection is available at all (e.g. Firefox WebTransport). In both +/// cases we advertise every shipped moq-lite version in the legacy SETUP +/// versions list, so Lite03+ can still be negotiated without a dedicated ALPN. export const ALPN = "moql"; /// The ALPN string for Draft03, which uses ALPN-based version negotiation. diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 204289c51..48103b781 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -205,10 +205,21 @@ impl Client { let recv_bw = match version { Version::Lite(v) => { - let stream = stream.with_version(v); + // Lite03+ has no SessionInfo protocol on the control stream. When it's + // negotiated via this legacy SETUP fallback (because ALPN selection + // wasn't available, e.g. Firefox WebTransport), close the bootstrap + // stream after the exchange and run the session as if it had been + // ALPN-negotiated directly. + let setup_stream = match v { + lite::Version::Lite01 | lite::Version::Lite02 => Some(stream.with_version(v)), + _ => { + let _ = stream.writer.finish(); + None + } + }; lite::start( session.clone(), - Some(stream), + setup_stream, self.publish.clone(), self.consume.clone(), self.stats.clone(), @@ -454,6 +465,7 @@ mod tests { assert_eq!( advertised, vec![ + Version::Lite(lite::Version::Lite03), Version::Lite(lite::Version::Lite02), Version::Lite(lite::Version::Lite01), Version::Ietf(ietf::Version::Draft14), @@ -466,6 +478,49 @@ mod tests { assert_eq!(code, Error::Cancel.to_code()); } + async fn run_alpn_lite_fallback_lite03_case(protocol: Option<&'static str>) { + // Server negotiates Lite03 via the legacy SETUP versions list. + // No SessionInfo frame is appended: Lite03+ has no SETUP stream protocol. + let mut server_bytes = Vec::new(); + let server = setup::Server { + version: Version::Lite(lite::Version::Lite03).into(), + parameters: Bytes::new(), + }; + server + .encode(&mut server_bytes, Version::Ietf(ietf::Version::Draft14)) + .unwrap(); + + let fake = FakeSession::new(protocol, server_bytes); + let client = Client::new().with_versions( + [ + Version::Lite(lite::Version::Lite04), + Version::Lite(lite::Version::Lite03), + Version::Lite(lite::Version::Lite02), + Version::Lite(lite::Version::Lite01), + Version::Ietf(ietf::Version::Draft14), + ] + .into(), + ); + + let session = client.connect(fake.clone()).await.unwrap(); + assert_eq!(session.version(), Version::Lite(lite::Version::Lite03)); + + // The client advertises every shipped moq-lite version in the SETUP list. + let mut setup_bytes = Bytes::from(fake.control_writes()); + let setup = setup::Client::decode(&mut setup_bytes, Version::Ietf(ietf::Version::Draft14)).unwrap(); + let advertised: Vec = setup.versions.iter().map(|v| Version::try_from(*v).unwrap()).collect(); + assert_eq!( + advertised, + vec![ + Version::Lite(lite::Version::Lite04), + Version::Lite(lite::Version::Lite03), + Version::Lite(lite::Version::Lite02), + Version::Lite(lite::Version::Lite01), + Version::Ietf(ietf::Version::Draft14), + ] + ); + } + #[tokio::test(start_paused = true)] async fn alpn_lite_falls_back_to_draft14_and_switches_version_post_setup() { run_alpn_lite_fallback_case(Some(ALPN_LITE)).await; @@ -475,4 +530,14 @@ mod tests { async fn no_alpn_falls_back_to_draft14_and_switches_version_post_setup() { run_alpn_lite_fallback_case(None).await; } + + #[tokio::test(start_paused = true)] + async fn alpn_lite_fallback_negotiates_lite03() { + run_alpn_lite_fallback_lite03_case(Some(ALPN_LITE)).await; + } + + #[tokio::test(start_paused = true)] + async fn no_alpn_fallback_negotiates_lite03() { + run_alpn_lite_fallback_lite03_case(None).await; + } } diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 2ae855daa..9f5dca091 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -207,10 +207,21 @@ impl Server { let recv_bw = match version { Version::Lite(v) => { - let stream = stream.with_version(v); + // Lite03+ has no SessionInfo protocol on the control stream. When it's + // negotiated via this legacy SETUP fallback (because ALPN selection + // wasn't available, e.g. Firefox WebTransport), close the bootstrap + // stream after the exchange and run the session as if it had been + // ALPN-negotiated directly. + let setup_stream = match v { + lite::Version::Lite01 | lite::Version::Lite02 => Some(stream.with_version(v)), + _ => { + let _ = stream.writer.finish(); + None + } + }; lite::start( session.clone(), - Some(stream), + setup_stream, self.publish.clone(), self.consume.clone(), self.stats.clone(), diff --git a/rs/moq-net/src/version.rs b/rs/moq-net/src/version.rs index becd3c019..e63bc584f 100644 --- a/rs/moq-net/src/version.rs +++ b/rs/moq-net/src/version.rs @@ -6,9 +6,22 @@ use crate::{coding, ietf, lite}; /// The versions of MoQ that are negotiated via SETUP. /// /// Ordered by preference, with the client's preference taking priority. -/// This intentionally includes only SETUP-negotiated versions (Lite02, Lite01, Draft14); -/// Lite03 and newer IETF drafts negotiate via dedicated ALPNs instead. -pub(crate) const NEGOTIATED: [Version; 3] = [ +/// +/// This path is used when ALPN-based selection is unavailable: a bare `"moql"` +/// ALPN, or no ALPN at all (e.g. Firefox's WebTransport, which doesn't expose +/// an ALPN selection API). To avoid stranding such peers on the oldest drafts, +/// we advertise every shipped moq-lite version in the legacy SETUP versions +/// list so Lite03+ can still be negotiated without a dedicated ALPN. +/// +/// Lite03+ borrows the draft-14 SETUP framing *only* for this bootstrap +/// exchange. Once negotiated, the SETUP stream is closed and the rest of the +/// session follows the dedicated-ALPN semantics (no SessionInfo messages). +/// +/// Lite05Wip is intentionally excluded: it is work-in-progress and must not be +/// advertised by default, matching [`ALPNS`]. +pub(crate) const NEGOTIATED: [Version; 5] = [ + Version::Lite(lite::Version::Lite04), + Version::Lite(lite::Version::Lite03), Version::Lite(lite::Version::Lite02), Version::Lite(lite::Version::Lite01), Version::Ietf(ietf::Version::Draft14),