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
4 changes: 2 additions & 2 deletions doc/lib/js/net.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. Another route of the same epoch taking over (epochs travel only on `moq-lite-07-wip`) keeps `active` as is, and each track subscription resumes there from the first group it lacks. 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 versions.** `new Connection({ url })` pools and reconnects with backoff, which the elements use. An ordered `versions` list, spelled like `moq-lite-06` as in Rust, applies to both WebTransport and WebSocket; equivalent lists share, while a different order or an omitted list 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. With an epoch, which travels only when `moq-lite-07-wip` is offered, the replacement serves the same broadcast, so open subscriptions move across without missing a group. 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` or `websocket.protocols`) 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 with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) 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:
Expand Down
6 changes: 6 additions & 0 deletions js/net/MIGRATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first.

## Unreleased

### `webtransport.protocols` is `versions` (#5979)

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` or `websocket.protocols` throws a `TypeError`; move either to `versions`.

### Track demand reports the wanted group (#5930)

`Track.Demand.used` and `unused()` are gone.
Expand Down
102 changes: 84 additions & 18 deletions js/net/src/connection/connect.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { expect, jest, test } from "bun:test";
import { SessionCode, SessionError } from "../error.ts";
import { ALPN_05, ALPN_06, ALPN_07_WIP } from "../lite/version.ts";
import * as Ietf from "../ietf/index.ts";
import { ALPN, 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");
Expand Down Expand Up @@ -84,7 +86,7 @@ test("WebTransport offers lite-06 first by default", async () => {
});

test("WebTransport negotiates lite-07-wip only when explicitly offered", async () => {
const { protocols, version } = await offered(ALPN_07_WIP, { webtransport: { protocols: [ALPN_07_WIP] } });
const { protocols, version } = await offered(ALPN_07_WIP, { versions: ["moq-lite-07-wip"] });
expect(protocols).toEqual(["moq-lite-07-wip"]);
expect(version).toBe("moq-lite-07-wip");
});
Expand Down Expand Up @@ -293,6 +295,84 @@ test("SETUP preserves a delayed session close code", async () => {
}
});

test("versions map to WebTransport ALPNs in order", async () => {
const versions = ["moq-lite-07-wip", "moq-lite-06", "moq-transport-22", "moq-lite-02", "moq-transport-14"];
expect((await offered(ALPN_07_WIP, { versions })).protocols).toEqual([
ALPN_07_WIP,
ALPN_06,
Ietf.ALPN.DRAFT_22,
ALPN,
]);
});

test("versions reject empty, duplicate, unsupported and ALPN spellings", async () => {
for (const versions of [[], ["moq-lite-06", "moq-lite-06"], ["moq-lite-99"], ["toString"], [Ietf.ALPN.DRAFT_22]]) {
expect(await connect(url, { versions }).catch((err: unknown) => err)).toBeInstanceOf(TypeError);
}
});

test("the removed per-transport protocols fail loudly", async () => {
for (const props of [{ webtransport: { protocols: [ALPN_06] } }, { websocket: { protocols: [ALPN_06] } }]) {
await expect(connect(url, props as Omit<ConnectProps, "url">)).rejects.toThrow("protocols was removed");
}
});

test("a peer cannot select outside the explicit versions", async () => {
const pair = createMockTransportPair(ALPN_05);
const restore = stubWebTransport(pair.client);
try {
await expect(connect(url, { versions: ["moq-lite-06"], websocket: { enabled: false } })).rejects.toThrow(
`server selected an unoffered protocol: ${ALPN_05}`,
);
} finally {
restore();
}
});

test("an IETF version name negotiates its ALPN", async () => {
const pair = createMockTransportPair(Ietf.ALPN.DRAFT_22);
const restore = stubWebTransport(pair.client);
try {
const [client, server] = await Promise.all([
connect(url, { versions: ["moq-transport-22"], websocket: { enabled: false } }),
accept({ url, transport: pair.server }),
]);
expect(client.version).toBe("moq-transport-22");
client.abort();
server.abort();
} finally {
restore();
}
});

test("SETUP offers only the named versions to a peer without an ALPN", async () => {
const pair = createMockTransportPair("");
const restore = stubWebTransport(pair.client);
try {
const [client, server] = await Promise.all([
connect(url, { versions: ["moq-lite-06", "moq-transport-14"], websocket: { enabled: false } }),
accept({ url, transport: pair.server }),
]);
expect(client.version).toBe("moq-transport-14");
client.abort();
server.abort();
} finally {
restore();
}
});

test("a peer without an ALPN is refused when no named version negotiates in SETUP", async () => {
const pair = createMockTransportPair("");
const restore = stubWebTransport(pair.client);
try {
await expect(connect(url, { versions: ["moq-lite-06"], websocket: { enabled: false } })).rejects.toThrow(
"server negotiated no protocol",
);
} finally {
restore();
}
});

// The WebSocket subprotocols one connect() attempt asks a server for.
async function websocketOffer(props: Omit<ConnectProps, "url">): Promise<string[]> {
const original = globalThis.WebTransport;
Expand Down Expand Up @@ -322,26 +402,12 @@ async function websocketOffer(props: Omit<ConnectProps, "url">): Promise<string[
}
}

test("the WebSocket fallback offers lite-07 only when asked", async () => {
test("the WebSocket fallback offers the same versions", async () => {
const defaults = await websocketOffer({});
expect(defaults.some((protocol) => protocol.endsWith(ALPN_06))).toBe(true);
expect(defaults.some((protocol) => protocol.endsWith(ALPN_07_WIP))).toBe(false);

const asked = await websocketOffer({ websocket: { protocols: [ALPN_07_WIP, ALPN_06] } });
const asked = await websocketOffer({ versions: ["moq-lite-07-wip", "moq-lite-06"] });
expect(asked[0]?.endsWith(ALPN_07_WIP)).toBe(true);
expect(asked.some((protocol) => protocol.endsWith(ALPN_05))).toBe(false);
});

test("the WebSocket fallback refuses an unsupported protocol", async () => {
const original = globalThis.WebTransport;
globalThis.WebTransport = undefined as unknown as typeof WebTransport;
try {
for (const protocol of ["moq-lite-99", "toString"]) {
await expect(connect(url, { websocket: { protocols: [protocol] } })).rejects.toThrow(
"unsupported WebSocket protocol",
);
}
} finally {
globalThis.WebTransport = original;
}
});
Loading
Loading