diff --git a/bench/browser/page/media.ts b/bench/browser/page/media.ts index 36687d0dd65..6b8da1dce82 100644 --- a/bench/browser/page/media.ts +++ b/bench/browser/page/media.ts @@ -193,7 +193,7 @@ async function watch(config: Media): Promise<{ first_frame_ms: number }> { }, 250); const first = new Promise((resolve) => { let last: number | undefined; - const stop = player?.renderer.out.frame.subscribe((frame) => { + const stop = player?.renderer.painted.subscribe((frame) => { // A resize or catalog change repaints the same frame; count each frame once. if (!frame || frame.timestamp === last) return; last = frame.timestamp; diff --git a/bench/browser/page/stats.ts b/bench/browser/page/stats.ts index cee88d035c7..35890deb2bd 100644 --- a/bench/browser/page/stats.ts +++ b/bench/browser/page/stats.ts @@ -8,7 +8,7 @@ */ import { Milli } from "@moq/clock"; import * as Moq from "@moq/net"; -import Session from "@moq/qmux"; +import { Session } from "@moq/qmux"; import { check, payload } from "../fixture.ts"; import "./observe.ts"; diff --git a/demo/carla/web/src/player.ts b/demo/carla/web/src/player.ts index 0b890b141b7..a0d389658f0 100644 --- a/demo/carla/web/src/player.ts +++ b/demo/carla/web/src/player.ts @@ -1056,11 +1056,11 @@ export function mount(root: ShadowRoot, connection: Moq.Connection, origin: Moq. const visible = effect.get(focused) === this.truck.id || (!effect.get(focused) && effect.get(hovered) === this.truck.id); - const frame = effect.get(this.truck.videoDecoder.out.frame); + const decoded = effect.get(this.truck.videoDecoder.out.timestamp) !== undefined; const stalled = effect.get(this.truck.videoDecoder.out.stalled); - const live = visible && Boolean(frame) && !stalled; - q(".badge").textContent = live ? "Live" : frame ? "Interrupted" : "Connecting"; - q(".video-state").textContent = frame ? "Camera interrupted" : "Waiting for camera"; + const live = visible && decoded && !stalled; + q(".badge").textContent = live ? "Live" : decoded ? "Interrupted" : "Connecting"; + q(".video-state").textContent = decoded ? "Camera interrupted" : "Waiting for camera"; q(".badge").dataset.live = String(live); q(".video-state").hidden = live; }); diff --git a/doc/bin/cli.md b/doc/bin/cli.md index ca1962b4a86..8f7676ccc64 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -383,8 +383,8 @@ The RTMP, SRT, and WHIP ingests mint one per connection instead, `import rtsp` one per RTSP session, and `import ts --program all` one per program. No importer continues its group sequences across a restart, so `moq` has no flag to set a stable instance and refuses one. A publisher that does continue -them sets it on its route in a library, such as `Route::with_instance` in Rust -or `announce({ instance, epoch })` in JavaScript; see +them sets it on its route in a library, such as `RouteOptions::with_instance` in Rust +or `origin.route(path, { instance: { id, epoch } })` in JavaScript; see [identity and recovery](/concept/identity). A restarted process takes the name at once: viewers see the broadcast restart, diff --git a/doc/concept/identity.md b/doc/concept/identity.md index 49df5551284..4eb8e716b14 100644 --- a/doc/concept/identity.md +++ b/doc/concept/identity.md @@ -32,8 +32,8 @@ no credential, and auth decides who may publish the path. keeps its instance on every route, which is what lets a subscription move between them. - **A stable instance is a promise.** A publisher may name its own instance - (`Route::with_instance(instance, epoch)` in Rust, `announce({ instance, epoch })` - in JavaScript) only if it continues the sequence space across restarts and + (`RouteOptions::with_instance(instance, epoch)` in Rust, + `origin.route(path, { instance: { id, epoch } })` in JavaScript) only if it continues the sequence space across restarts and announces a higher **epoch** each time, for example a counter persisted on disk. Breaking the promise splices unrelated groups into every reader's tracks. Two encoders with identical settings fed the same source still diff --git a/doc/lib/js/auth.md b/doc/lib/js/auth.md index ffa503bec0e..9698d45ecf6 100644 --- a/doc/lib/js/auth.md +++ b/doc/lib/js/auth.md @@ -18,8 +18,8 @@ root. Tokens are interchangeable with the Rust side. Grants and claims are [`Pattern`](https://www.npmjs.com/package/@moq/pattern) unions: `foo` is one broadcast, `foo/**` is a subtree, `**` is everything. A claim or grant role may also carry a bitrate cap, parsed into `publish_rate` or `subscribe_rate` beside -the pattern arrays; key scopes bound paths only, and `authorize` checks paths -only. Answer a capped grant with `encodeGrant`, which writes the cap where the +the pattern arrays, which is also how `Key.sign` takes it; key scopes bound +paths only, and `authorize` checks paths only. Answer a capped grant with `encodeGrant`, which writes the cap where the relay reads it. ```bash diff --git a/doc/lib/js/index.md b/doc/lib/js/index.md index 5134987f200..59fee2dc88f 100644 --- a/doc/lib/js/index.md +++ b/doc/lib/js/index.md @@ -25,7 +25,7 @@ small examples. The [API reference](/lib/js/api/) covers every package; source l | [@moq/watch](/lib/js/watch) | Subscribe, decode, and render. `` plus an optional UI overlay. | | [@moq/video](/lib/js/video) | Draw video frames into a canvas through WebGPU or Canvas2D. Used by `@moq/watch` and `@moq/publish`. | | [@moq/publish](/lib/js/publish) | Capture, encode, and publish. `` plus an optional UI overlay. | -| [@moq/ui](/lib/js/api/ui) | Presentation components shared by the watch and publish overlays: control buttons, fullscreen, stats cards and graphs, and the support banner. | +| [@moq/ui](/lib/js/api/ui) | The base of the watch and publish browser support banners. | | [@moq/room](/lib/js/room) | Headless rooms: announce-derived roster, local publish, remote watch, and a chat track. | | [@moq/auth](/lib/js/auth) | Validate a relay's request, build its grant, and mint and verify relay JWTs. | | [@moq/clock](/lib/js/clock) | Logical monotonic time, wall time, and deterministic timers. | diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 03e6b53dd22..ebeb88d6cd4 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -41,7 +41,7 @@ route.announce(); - **The origin holds the routes, not the connection.** `origin.route(path)` creates a route that owns a new broadcast; fill its tracks through `route.broadcast`. Closing a session withdraws its announcements and leaves the routes for the next one. A route is visible at once unless created `{ hidden: true }`, which keeps it invisible, locally and remotely, until `announce()`. `withdraw()` hides it again and `announce()` brings the same stream back. `close()` retracts it and closes its broadcast, as closing the broadcast does. - **Answer claimed paths with a route.** `origin.routePrefix(prefix)` claims a prefix, and `route.requested()` yields each requested path. Answer with `request.route()`, a new exact route under the claim's instance (a fresh one if the claim names its own), so on `moq-lite-07-wip` readers resolved through the claim continue (older wires mint another instance for it, restarting their subscription), or `request.route({ hidden: true })` to fill its broadcast before `announce()` answers the requesters; closing it or its broadcast before then refuses them as unroutable, so `using job = request.route({ hidden: true })` refuses on a throw. Serve a broadcast you already hold with `request.serve(broadcast)`, or refuse with `request.reject(err)`. - **End content by finishing tracks.** A broadcast has no finished state. When the input ends, `finish()` each track, then `close()` it; closing a track without finishing aborts it. The broadcast stays announced and keeps serving them until you `close()` it. How long to keep it up for late readers is yours to decide. -- **Requests can pin an instance.** `consumer.request(path, { instance })` resolves only through a route announcing that [publisher instance](/concept/moq-lite#publisher-instances). The resolved broadcast's `instance` names the stream it came through, pinned or not. `origin.route(path)` mints a random instance when it is created and keeps it across `withdraw()` and `announce()`. Unless one is named, a claim moves to a fresh instance each time a job holding its current one ends, and a job takes the claim's. Pass `origin.route(path, { instance, epoch })` only for a publisher that continues its group sequences across restarts and raises the epoch each time. +- **Requests can pin an instance.** `consumer.request(path, { instance })` resolves only through a route announcing that [publisher instance](/concept/moq-lite#publisher-instances). The resolved broadcast's `instance` names the stream it came through, pinned or not. `origin.route(path)` mints a random instance when it is created and keeps it across `withdraw()` and `announce()`. Unless one is named, a claim moves to a fresh instance each time a job holding its current one ends, and a job takes the claim's. Pass `origin.route(path, { instance: { id, epoch } })` only for a publisher that continues its group sequences across restarts and raises the epoch each time. - **Follow restarts.** `origin.follow(path)` 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 it when done. Instances are never stitched, so subscribe again to each new broadcast. Another route of the same instance taking over (instances travel only on `moq-lite-07-wip`; older wires mint one per announcement) keeps `active` as is, and each track subscription resumes there from the first group it lacks. A refusal ends it. Pass `{ instance }` to pin one stream, which it waits for again after an end. `request(path)` instead keeps its accepted instance and ends once that instance stops serving; while pending, it fails `Unroutable` once discovery of its path has synced without a route for it. To list what is live, iterate `announced(scope)`. See [identity and recovery](/concept/identity) to choose between following and holding one instance. - **Discovery follows what you watch.** A `consume` origin asks the relay for announcements only under the prefixes its readers watch with `announced(scope)`, `request(path)`, or `follow(path)`, stops asking about a prefix 2s after nothing at or beneath it is watched, and asks again on reconnect. Every request and follower watches its path from creation until closed, so it outlives the listing that found it. A relay that refuses an announce request downgrades that session: requests subscribe blind through it, and followers keep waiting for a session that can discover. - **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. @@ -53,7 +53,7 @@ route.announce(); - **Serve history on request.** `track.requests()` opens a handle that serves the groups readers miss: ones their ranges name below the newest group written that the cache no longer holds, or never did. Each run of misses arrives as a `Track.Fill` from `await requests.requestedFill()`, shared by every reader missing it. `accept(sequence)` it into the cache and write the group's frames as first published, `dropGroups(start, end)` what the source lacks (sequences are `bigint`), and serve `wanted()` until it is `undefined`: a reader that leaves or narrows stops wanting its groups. `close()` the fill when done; whatever is still wanted is named dropped. A `fetchGroup` of an uncached group asks the handle at once, even for a group not written yet, which a live write then answers; with no handle open it fails with `NotFound`, as in Rust, and a reader's miss is given up. A reader only asks for what its `delay.max` still wants, so a reader at the live edge never asks. This serves lite FETCH, single-group IETF FETCH, and lite-07 ranged subscriptions from the same track, as Rust's `requests()` does. - **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` producers. - **A subscription's `delay` is a `Track.Delay { min, max }`, as in Rust.** Staleness is `delay.max`: it counts from a group's successor, by media time and by wall clock, whichever reaches it first. `delay.min` is the target latency, carried on moq-lite 07; a peer that cannot carry it leaves it `null` (not stated). Passing the old `maxAge` or `maxDelay` key throws a `TypeError` naming `delay.max`. The publisher's own staleness bound is the separate `Track.Info.maxAge`, which caps `delay.max` and fetches. -- **Authored tracks default to a zero `maxAge`.** `createTrack` and `accept` take a `Track.InfoInit`: an omitted `maxAge` is zero, so readers get only the live edge; `null` sets no publisher limit, and a number is that bound, as `Info::default()` and `with_max_age(None)` do in Rust. A track whose readers reach back for superseded groups (history, a fetch, a range from an absolute start) sets one explicitly; media takes `@moq/hang`'s 30-second `Container.MAX_AGE`. `maxAge` is how long a superseded group stays fresh, never the newest group, so a track that writes one snapshot stays readable to a late joiner. Received metadata keeps absence as no limit; serve a received `Track.Info` onward with `maxAge: info.maxAge ?? null`. +- **Authored tracks default to a zero `maxAge`.** `createTrack` and `accept` take a `Track.InfoInit`: an omitted `maxAge` is zero, so readers get only the live edge; `null` sets no publisher limit, and a number is that bound, as `Info::default()` and `with_max_age(None)` do in Rust. A track whose readers reach back for superseded groups (history, a fetch, a range from an absolute start) sets one explicitly; media takes `@moq/hang`'s 30-second `Container.MAX_AGE`. `maxAge` is how long a superseded group stays fresh, never the newest group, so a track that writes one snapshot stays readable to a late joiner. A received `Track.Info` uses `null` for none (`maxAge`, `timescale`, `refresh`), so it serves onward unchanged. - **The cache keeps an idle group for 30 seconds.** A track evicts a group nothing has written for 30 seconds, matching the Rust cache's default expiry; a live track's newest group is exempt until a newer one arrives, unless it failed. Neither `Track.Info.maxAge` nor `delay.max` changes that window: they only decide which cached groups a reader still receives. - **Subscription defaults.** `Track.Subscription.default()` returns the default subscription, whose zero `delay.max` skips a superseded group at once; calling `subscribe()` with no options means the same. An omitted `delay` field means the same zero; `max: null` sets no subscriber limit. - **A subscription asks for `ranges`.** Each [range](/concept/moq-lite#subscriptions) starts and ends at an `absolute` group, one `relative` to the latest, or the group containing a `time` (a `Timestamp`), and a reader reads only its own ranges. A read whose ranges all end finishes once every group in them is handed out, dropped, or skipped, as in Rust. A time anchor needs a timed track and `moq-lite-07-wip` over a session. Passing the old `groups` key throws a `TypeError` naming `ranges`; `withRange` only narrows reads locally. diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index 078e97afc4f..d7ee4142f88 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -90,8 +90,8 @@ encoder's `muted` instead: it sends nothing, keeps the rendition enabled, and marks where each utterance ends, so listeners stay subscribed and treat the gap as intentional silence. -Pass `onDemand: false` to `Publish.Broadcast` to encode audio continuously too. -`onDemand: true` also gates video, which then loses its unsubscribed past. +Pass `onDemand: "off"` to `Publish.Broadcast` to encode audio continuously too. +`onDemand: "all"` also gates video, which then loses its unsubscribed past. Bandwidth reservations still follow subscriptions; unwatched video uses its configured bitrate. @@ -188,7 +188,7 @@ const broadcast = new Publish.Broadcast({ }); // Video encodes while enabled. Audio buffers raw PCM until someone subscribes. -// Pass `onDemand: false` to the broadcast to encode both continuously. +// Pass `onDemand: "off"` to the broadcast to encode both continuously. new Publish.Video.Encoder("video/hd", { broadcast, capture, enabled: true }); new Publish.Video.Encoder("video/sd", { broadcast, capture, enabled: true, config: { maxScale: 0.25 } }); const audioSource = new Publish.Signals.Computed((effect) => effect.get(microphone.out.source)?.audio); diff --git a/doc/lib/js/room.md b/doc/lib/js/room.md index e3c8c5dbd77..b3bf4c34e65 100644 --- a/doc/lib/js/room.md +++ b/doc/lib/js/room.md @@ -50,8 +50,9 @@ demo at [`demo/web`](https://github.com/moq-dev/moq/tree/main/demo/web) (`meet.h does that under `anon/meet/{room}`. Each remote member owns a `Watch.Player` and starts muted. Display video with -`member.canvas.set(canvas)` and unmute with `member.muted.set(false)`, or use -`member.player` for the full pipeline. `Chat` adds a text chat track with a +`member.canvas.set(canvas)` and unmute with `member.muted.set(false)`; +`member.video` and `member.audio` report the playing timestamp and receive +stats. `Chat` adds a text chat track with a short retained history. Under a CSP that refuses `blob:`, host the worklets and worker as described diff --git a/js/auth/README.md b/js/auth/README.md index a94e9a50214..48a494648ac 100644 --- a/js/auth/README.md +++ b/js/auth/README.md @@ -9,7 +9,8 @@ contract, the relay flags, and worked examples, see the Grants and claims name paths with patterns from `@moq/pattern`: `foo` is one broadcast, `foo/**` is a subtree, `**` is everything. A role may also carry a bitrate cap, written `{"path": [...], "rate": bps}` and parsed into -`publish_rate` or `subscribe_rate` beside the patterns. +`publish_rate` or `subscribe_rate` beside the patterns. `Key.sign` takes that +parsed form. ## Installation diff --git a/js/auth/api/index.api.md b/js/auth/api/index.api.md index da803b40fc4..2600ab9da18 100644 --- a/js/auth/api/index.api.md +++ b/js/auth/api/index.api.md @@ -178,7 +178,7 @@ export const Key: { generate: (algorithm: Algorithm, kid?: string) => Promise; parse: (jwk: string) => Key; public: (key: Key) => PublicKey; - sign: (key: Key, claims: { + sign: (key: Key, claims: Omit<{ exp?: number; get?: string | string[]; iat?: number; @@ -196,8 +196,10 @@ export const Key: { path: string[]; rate: number; }; - } & { + }, "publish" | "subscribe"> & { + publish?: string[]; publish_rate?: number; + subscribe?: string[]; subscribe_rate?: number; }) => Promise; verify: (key: PublicKey | SymmetricKey, token: string) => Promise; @@ -403,7 +405,7 @@ export const KeySet: { find: (set: KeySet, kid: string) => Key | undefined; parse: (jwks: string) => KeySet; public: (set: KeySet) => PublicKeySet; - sign: (set: KeySet, claims: { + sign: (set: KeySet, claims: Omit<{ exp?: number; get?: string | string[]; iat?: number; @@ -421,8 +423,10 @@ export const KeySet: { path: string[]; rate: number; }; - } & { + }, "publish" | "subscribe"> & { + publish?: string[]; publish_rate?: number; + subscribe?: string[]; subscribe_rate?: number; }) => Promise; verify: (set: KeySet, token: string) => Promise; diff --git a/js/auth/src/key.test.ts b/js/auth/src/key.test.ts index 1ae15cd4389..788a2ece0b6 100644 --- a/js/auth/src/key.test.ts +++ b/js/auth/src/key.test.ts @@ -836,8 +836,10 @@ test("caps survive signing, and a key scope bounds only their paths", async () = const key: Key = { ...Key.parse(encodeJwk(testKey)), scope: { root: "room", publish: ["**"], subscribe: ["**"] } }; const token = await Key.sign(key, { root: "room", - publish: { path: ["alice/**"], rate: 8000 }, - subscribe: { path: ["bob/**"], rate: 16000 }, + publish: ["alice/**"], + publish_rate: 8000, + subscribe: ["bob/**"], + subscribe_rate: 16000, }); const claims = await Key.verify(key, token); expect(claims).toMatchObject({ @@ -851,6 +853,9 @@ test("caps survive signing, and a key scope bounds only their paths", async () = const resigned = await Key.verify(key, await Key.sign(key, claims)); expect(resigned).toMatchObject({ publish_rate: 8000, subscribe_rate: 16000 }); await expect(Key.sign(key, { ...claims, publish_rate: 0 })).rejects.toThrow(/rate must be a positive integer/); + // The wire form is not a second spelling. + const wire = { root: "room", publish: { path: ["alice/**"], rate: 8000 } } as unknown as Claims; + await expect(Key.sign(key, wire)).rejects.toThrow(/pattern list/); }); test("key scope treats absolute and rooted grants alike", async () => { diff --git a/js/auth/src/key.ts b/js/auth/src/key.ts index 9771824133d..22a1971db81 100644 --- a/js/auth/src/key.ts +++ b/js/auth/src/key.ts @@ -194,8 +194,8 @@ function parse(jwk: string): Key { * `iat` is written only when the claims carry one, so an unset field stays off the * wire rather than being stamped with the current time. * - * Takes the wire shape or the decoded {@link Claims}, so a cap may be either a capped - * role or a `publish_rate`/`subscribe_rate` beside it. + * Takes each role as its patterns, capped by `publish_rate` or `subscribe_rate` beside + * it, as the decoded {@link Claims} spell them; the `{path, rate}` wire form is refused. * * Throws on a root spelled with an empty segment, a `\` escaping anything but `\` or * `/`, or more than 4096 bytes. Verifying stays lenient on the empty segments, so tokens @@ -203,13 +203,18 @@ function parse(jwk: string): Key { */ async function sign( key: Key, - claims: z.input & { publish_rate?: number; subscribe_rate?: number }, + claims: Omit, "publish" | "subscribe"> & { + publish?: string[]; + publish_rate?: number; + subscribe?: string[]; + subscribe_rate?: number; + }, ): Promise { ensureOperationSupported(key, "sign"); // Scope-check and sign what the schema parsed, never the raw input: an untyped // caller could pass legacy `put`/`get` fields the scope check would not see. The - // caps of decoded claims, as `verify` returns them, nest back into their roles first. + // caps nest back into their roles first, which the schema refuses a role already in. let parsed: Claims; try { parsed = ClaimsSchema.parse(nestRates(claims)); diff --git a/js/auth/src/wire.ts b/js/auth/src/wire.ts index 6fbe6c79134..f59acbeea1e 100644 --- a/js/auth/src/wire.ts +++ b/js/auth/src/wire.ts @@ -127,12 +127,17 @@ export function decodeRoles(claims: T) { const { publish_rate, subscribe_rate, ...rest } = claims; + for (const role of [rest.publish, rest.subscribe]) { + if (role !== undefined && !Array.isArray(role)) { + throw new Error("a role is a pattern list, capped by publish_rate or subscribe_rate"); + } + } return { ...rest, ...(publish_rate !== undefined && { publish: { path: rest.publish ?? [], rate: publish_rate } }), diff --git a/js/crypto/README.md b/js/crypto/README.md index 09b85db380c..6234988d902 100644 --- a/js/crypto/README.md +++ b/js/crypto/README.md @@ -7,7 +7,7 @@ Content signing and end-to-end encryption for [Media over QUIC](https://moq.dev), mirroring the Rust `moq-crypto` crate. `Sign` signs every frame so relays cannot forge it, and `E2ee` encrypts groups, datagrams, and track names so relays cannot read them. Both wrap `@moq/net` and check each -timestamp with `@moq/net`'s `Track.rescale(info, timestamp)` before signing or encrypting, so a frame either accepts is +timestamp with `@moq/net`'s `Datagram.rescale(info, timestamp)` before signing or encrypting, so a frame either accepts is one `@moq/net` accepts. ## Signing @@ -95,7 +95,7 @@ const generation = credential.produce(); // The route's instance names the generation's epoch, so subscribers find the key. const route = origin.route(await credential.path("meeting.hang"), { hidden: true, - instance: Epoch.instance(generation.epoch), + instance: { id: Epoch.instance(generation.epoch), epoch: 0n }, }); const video = await new Broadcast.Producer(route.broadcast, generation).createTrack("video", { timescale: Time.Timescale.MILLI, diff --git a/js/crypto/src/e2ee/group.ts b/js/crypto/src/e2ee/group.ts index f88b4f61150..94528e43cd2 100644 --- a/js/crypto/src/e2ee/group.ts +++ b/js/crypto/src/e2ee/group.ts @@ -3,7 +3,7 @@ * * @module */ -import { type Group as NetGroup, Track as NetTrack, type Time } from "@moq/net"; +import { Datagram as NetDatagram, type Group as NetGroup, type Time } from "@moq/net"; import { Failure } from "./failure.ts"; import type { Reader, Writer } from "./internal.ts"; import { MAX_GROUPED_PAYLOAD, TAG_LEN } from "./limits.ts"; @@ -61,7 +61,7 @@ export class Producer { if (this.#closed || this.#writer.closed) throw this.#aborted ?? this.#writer.aborted ?? new Error("group is closed"); if (plaintext.byteLength + TAG_LEN > MAX_GROUPED_PAYLOAD) throw new Failure("oversize"); - const at = NetTrack.rescale(this.#writer.track, timestamp); + const at = NetDatagram.rescale(this.#writer.track, timestamp); const owned = plaintext.slice(); return this.#writer.pump.run(() => this.#write(at, owned)); } diff --git a/js/crypto/src/e2ee/index.ts b/js/crypto/src/e2ee/index.ts index c7db9f1270a..e043464e5ba 100644 --- a/js/crypto/src/e2ee/index.ts +++ b/js/crypto/src/e2ee/index.ts @@ -18,7 +18,7 @@ * const generation = credential.produce(); * const route = origin.route(await credential.path("meeting.hang"), { * hidden: true, - * instance: Epoch.instance(generation.epoch), + * instance: { id: Epoch.instance(generation.epoch), epoch: 0n }, * }); * const video = await new Broadcast.Producer(route.broadcast, generation).createTrack("video", { timescale }); * route.announce(); diff --git a/js/crypto/src/e2ee/transport.test.ts b/js/crypto/src/e2ee/transport.test.ts index 712f105a042..6cd4d85bbad 100644 --- a/js/crypto/src/e2ee/transport.test.ts +++ b/js/crypto/src/e2ee/transport.test.ts @@ -62,7 +62,10 @@ for (const [name, protocol, routed, timed] of transports) { const cred = credential(); const generation = cred.produce(); const path = await cred.path("meeting.hang"); - const broadcastRoute = publisher.route(path, { hidden: true, instance: Epoch.instance(generation.epoch) }); + const broadcastRoute = publisher.route(path, { + hidden: true, + instance: { id: Epoch.instance(generation.epoch), epoch: 0n }, + }); const broadcast = broadcastRoute.broadcast; const video = await new Protected.Producer(broadcast, generation).createTrack("video", { timescale: Time.Timescale.MILLI, @@ -119,7 +122,10 @@ for (const [name, protocol] of [ const cred = credential(); const generation = cred.produce(); const path = await cred.path("meeting.hang"); - const broadcastRoute = publisher.route(path, { hidden: true, instance: Epoch.instance(generation.epoch) }); + const broadcastRoute = publisher.route(path, { + hidden: true, + instance: { id: Epoch.instance(generation.epoch), epoch: 0n }, + }); const broadcast = broadcastRoute.broadcast; const audio = await new Protected.Producer(broadcast, generation).createDatagrams("audio", { timescale: Time.Timescale.MILLI, diff --git a/js/crypto/src/sign/broadcast.ts b/js/crypto/src/sign/broadcast.ts index 5d7ffebb90a..82be15706b4 100644 --- a/js/crypto/src/sign/broadcast.ts +++ b/js/crypto/src/sign/broadcast.ts @@ -58,7 +58,7 @@ export class Producer { } // Snapshot the timescale, as the net track does, so a caller reusing its options cannot // change the scale frames are signed at. - const timescale = { timescale: info.timescale }; + const timescale = { timescale: info.timescale ?? null }; return Track.Producer.make(inner, this.#key, new Scope(this.#path, this.instance, name), sequences, timescale); } diff --git a/js/crypto/src/sign/group.ts b/js/crypto/src/sign/group.ts index 8f35ea70ab5..adac8df45bf 100644 --- a/js/crypto/src/sign/group.ts +++ b/js/crypto/src/sign/group.ts @@ -3,7 +3,7 @@ * * @module */ -import { type Group as NetGroup, Track as NetTrack, type Time } from "@moq/net"; +import { Datagram as NetDatagram, type Group as NetGroup, type Track as NetTrack, type Time } from "@moq/net"; import type { Pump } from "../pump.ts"; import { Failure } from "./failure.ts"; import type { PublicKey, SecretKey } from "./key.ts"; @@ -71,7 +71,7 @@ export class Producer { if (this.#closed || this.#writer.closed) throw this.#writer.aborted ?? new Error("group is closed"); const owned = payload.slice(); // Signed at the track's timescale, and written that way so a local reader sees the signed value too. - const at = NetTrack.rescale(this.#writer.track, timestamp); + const at = NetDatagram.rescale(this.#writer.track, timestamp); return this.#writer.pump.run(async () => { this.#check(); const body = await this.#writer.scope.sign(this.#writer.key, this.sequence, this.#next, at, owned); diff --git a/js/crypto/src/sign/sign.test.ts b/js/crypto/src/sign/sign.test.ts index 342bb759c24..19edae28b62 100644 --- a/js/crypto/src/sign/sign.test.ts +++ b/js/crypto/src/sign/sign.test.ts @@ -55,7 +55,7 @@ async function publish(key: SecretKey, path: Path.Valid) { track(name: string) { const raw = route.broadcast.createTrack(name); const scope = new Scope(path, signed.instance, name); - return { signed: Track.Producer.make(raw, key, scope, { next: 0n }, {}), raw }; + return { signed: Track.Producer.make(raw, key, scope, { next: 0n }, { timescale: null }), raw }; }, async consume(expected: PublicKey): Promise { return new Signed.Consumer(await active(net.subscriber, path), expected, path); diff --git a/js/hang/src/container/track.test.ts b/js/hang/src/container/track.test.ts index f0523477a4d..4ad411bbf6b 100644 --- a/js/hang/src/container/track.test.ts +++ b/js/hang/src/container/track.test.ts @@ -15,6 +15,6 @@ test("trackInfo declares the media max age, null is unlimited, and a value is ke const committed = async (maxAge?: Milli | null) => (await new Track.Producer("media").accept(trackInfo({ priority: 1, maxAge })).info()).maxAge; expect(await committed()).toBe(Milli(30_000)); - expect(await committed(null)).toBeUndefined(); + expect(await committed(null)).toBeNull(); expect(await committed(Milli(5000))).toBe(Milli(5000)); }); diff --git a/js/moq-boy/README.md b/js/moq-boy/README.md index 07de484e06e..4a8460df402 100644 --- a/js/moq-boy/README.md +++ b/js/moq-boy/README.md @@ -39,20 +39,7 @@ Shows a grid of all active Game Boy sessions. Click a game to expand and play. ## JavaScript API -For programmatic use: - -```typescript -import { GameCard } from "@moq/boy"; - -const card = new GameCard({ - sessionId: "opossum", - connection, - expanded, - root: document.body, -}); - -document.body.appendChild(card.el); -``` +`@moq/boy` exports the element's type, `MoqBoy`, and the zod schemas for the status a game publishes (`GameStatusSchema`, `GameStatsSchema`). ## License diff --git a/js/moq-boy/api/element.api.md b/js/moq-boy/api/element.api.md index c716e351a69..51171d4000e 100644 --- a/js/moq-boy/api/element.api.md +++ b/js/moq-boy/api/element.api.md @@ -1,19 +1,19 @@ # `@moq/boy/element` ```ts -import { Game } from "@moq/boy"; -import { Moq } from "@moq/boy"; +import { Connection } from "@moq/net"; +import { Origin } from "@moq/net"; +import { Signal } from "@moq/signals"; export default class extends HTMLElement { constructor(); attributeChangedCallback(name: "url" | "prefix" | "prefix-game", _oldValue: string | null, newValue: string | null): void; connectedCallback(): void; - readonly connection: Moq.Connection; + readonly connection: Connection; disconnectedCallback(): void; - readonly expanded: Moq.Signals.Signal; - readonly games: Moq.Signals.Signal>; + readonly expanded: Signal; static observedAttributes: readonly ["url", "prefix", "prefix-game"]; - readonly origin: Moq.Origin.Producer; + readonly origin: Origin.Producer; get prefixGame(): string; set prefixGame(value: string); get prefixPath(): string; diff --git a/js/moq-boy/api/index.api.md b/js/moq-boy/api/index.api.md index 922e6207ed0..4b58427ced3 100644 --- a/js/moq-boy/api/index.api.md +++ b/js/moq-boy/api/index.api.md @@ -9,51 +9,6 @@ import { ZodMiniOptional } from "zod/mini"; import { ZodMiniString } from "zod/mini"; import { infer } from "zod/mini"; -export class Game { - constructor(config: GameConfig); - readonly active: Moq.Signals.Signal; - readonly audioDecoder: Watch.Audio.Decoder; - readonly audioEmitter: Watch.Audio.Emitter; - readonly audioSource: Watch.Audio.Source; - readonly broadcast: Watch.Broadcast; - readonly canvas: Moq.Signals.Signal; - close(): void; - readonly delay: Moq.Signals.Signal; - readonly expanded: Moq.Signals.Signal; - readonly heldButtons: Set; - readonly hovered: Moq.Signals.Signal; - readonly latency: Moq.Signals.Signal; - sendButtons(): void; - sendCommand(cmd: Record): void; - readonly sessionId: string; - readonly status: Moq.Signals.Signal<{ - buttons: string[]; - location?: string | undefined; - players: number; - stats?: { - audio_secs: number; - emulation_secs: number; - video_secs: number; - wall_secs: number; - } | undefined; - } | undefined>; - readonly sync: Watch.Sync; - readonly userMuted: Moq.Signals.Signal; - readonly videoDecoder: Watch.Video.Decoder; - readonly videoRenderer: Watch.Video.Renderer; - readonly videoSource: Watch.Video.Source; - readonly volume: Moq.Signals.Signal; -} - -export interface GameConfig { - connection: Moq.Connection; - endpoint: Moq.Socket.Endpoint; - expanded: Moq.Signals.Signal; - gamePrefix: string; - origin: Moq.Origin.Producer; - sessionId: string; -} - export type GameStats = infer; export const GameStatsSchema: ZodMiniObject<{ @@ -77,16 +32,10 @@ export const GameStatusSchema: ZodMiniObject<{ }, $strip>>; }, $strip>; -export const KEY_MAP: Record; - export interface LatencyEntry { label: string; ms: number; } -export * as Moq from "@moq/net"; - export { default as MoqBoy } from "@moq/boy/element"; - -export * as Watch from "@moq/watch"; ``` diff --git a/js/moq-boy/src/element.tsx b/js/moq-boy/src/element.tsx index 6fc891cbc03..6220c3f9530 100644 --- a/js/moq-boy/src/element.tsx +++ b/js/moq-boy/src/element.tsx @@ -78,7 +78,7 @@ export default class MoqBoy extends HTMLElement { /** The session currently expanded, or undefined for the grid. */ readonly expanded = new Moq.Signals.Signal(undefined); - /** Reactive map of active game sessions. Emits on add/remove. */ + /** Reactive map of active game sessions, read by the overlay. Emits on add/remove. @internal */ readonly games = new Moq.Signals.Signal>(new Map()); readonly #signals = new Moq.Signals.Effect(); diff --git a/js/moq-boy/src/index.ts b/js/moq-boy/src/index.ts index 4e6d5760eef..524bcf31375 100644 --- a/js/moq-boy/src/index.ts +++ b/js/moq-boy/src/index.ts @@ -1,14 +1,12 @@ /** * MoQ Boy: a Game Boy streaming viewer built on `@moq/watch`. * + * The `` element lives at `@moq/boy/element`; this entry holds its type and the status + * a game publishes. + * * @module */ -import * as Moq from "@moq/net"; -import * as Watch from "@moq/watch"; export type { default as MoqBoy } from "./element.tsx"; -export type { GameConfig } from "./game.ts"; -export { Game, KEY_MAP } from "./game.ts"; export type { GameStats, GameStatus, LatencyEntry } from "./schemas.ts"; export { GameStatsSchema, GameStatusSchema } from "./schemas.ts"; -export { Moq, Watch }; diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index 40d9626fd6a..5242acce8c0 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -170,24 +170,6 @@ export namespace Connection { readonly timeout?: Milli; readonly uri: string; } - export interface Established { - abort(): void; - announced(scope?: Path.Pattern, options?: Announce.Options): Announce.Consumer; - readonly auth: Auth.Handle; - close(): Promise; - closed: Promise; - readonly draining: Signals.GetPromise; - readonly peerImplementation: Promise; - readonly probe: Signals.Getter; - stats(): Promise; - readonly transport: Connection.Transport; - readonly url: URL; - readonly version: string; - } - export interface Extensions { - auth?: boolean; - solicit?: boolean; - } export interface Goaway { handover?: Milli; redirect?: Connection.Redirect; @@ -213,6 +195,20 @@ export namespace Connection { webtransport?: Connection.WebTransportProps; } export type Redirect = "follow" | "same-host" | "ignore"; + export interface Session { + abort(): void; + announced(scope?: Path.Pattern, options?: Announce.Options): Announce.Consumer; + readonly auth: Auth.Handle; + close(): Promise; + closed: Promise; + readonly draining: Signals.GetPromise; + readonly peerImplementation: Promise; + readonly probe: Signals.Getter; + stats(): Promise; + readonly transport: Connection.Transport; + readonly url: URL; + readonly version: string; + } export interface Stats { bytesLost?: number; bytesReceived?: number; @@ -242,7 +238,7 @@ export namespace Connection { export class Connection { constructor(props?: Connection.Props); - static readonly accept: ({ ... }: Connection.AcceptProps) => Promise; + static readonly accept: ({ ... }: Connection.AcceptProps) => Promise; readonly allocator: Signals.Getter; announced(scope?: Path.Pattern, options?: Announce.Options): Announce.Consumer; readonly auth: Signals.Getter; @@ -250,7 +246,7 @@ export class Connection { static readonly checkVersions: (versions: readonly string[]) => string[]; close(abort?: Error): void; get closed(): Signals.GetPromise; - static readonly connect: ({ ... }: Connection.ConnectProps) => Promise; + static readonly connect: ({ ... }: Connection.ConnectProps) => Promise; enabled: Signals.Signal; readonly error: Signals.Getter; static readonly isWebTransportSupported: () => boolean; @@ -281,7 +277,7 @@ export namespace Datagram { } export interface Info { priority: number; - timescale?: Time.Timescale | null; + timescale: Time.Timescale | null; } export class Producer { constructor(name: string); @@ -395,7 +391,6 @@ export namespace Group { get frameCount(): number; get isClosed(): boolean; nextFrame(): Promise; - readBool(): Promise; readFrame(): Promise; readFrameSequence(options?: Group.ReadOptions): Promise<{ sequence: number; @@ -516,9 +511,11 @@ export namespace Origin { export interface RouteOptions { auth?: Auth.Credential; cost?: Origin.Cost; - epoch?: bigint; hidden?: boolean; - instance?: Instance.Valid; + instance?: { + epoch: bigint; + id: Instance.Valid; + }; } export interface Table extends Origin.Reader { route(path: Path.Valid, options?: Origin.RouteOptions): Origin.Route; @@ -711,7 +708,7 @@ export namespace Track { } export class Fill { [Symbol.dispose](): void; - accept(sequence: bigint, info?: Partial): Group.Producer; + accept(sequence: bigint, info?: Track.Info): Group.Producer; close(): void; dropGroups(start: bigint, end: bigint): void; readonly end: bigint; @@ -730,13 +727,13 @@ export namespace Track { start?: Track.Bound; } export interface Info { - compress?: boolean; - maxAge?: Milli; + compress: boolean; + maxAge: Milli | null; priority: number; - refresh?: number; - timescale?: Time.Timescale | null; + refresh: number | null; + timescale: Time.Timescale | null; } - export interface InfoInit extends Partial> { + export interface InfoInit extends Partial { maxAge?: Milli | null; } export interface InfoOptions { @@ -751,7 +748,6 @@ export namespace Track { largest(): Promise; readonly name: string; nextGroup(): Promise; - readBool(): Promise; readFrame(): Promise<{ frame: number; group: bigint; @@ -832,8 +828,6 @@ export namespace Track { priority?: number; ranges?: Track.Range[]; } - export function infoDefaults(info?: Partial): Track.Info; - export import rescale = Datagram.rescale; } export namespace Varint { diff --git a/js/net/bench/broadcasts.ts b/js/net/bench/broadcasts.ts index ec5b9d44e8d..92ec7ee7468 100644 --- a/js/net/bench/broadcasts.ts +++ b/js/net/bench/broadcasts.ts @@ -134,7 +134,7 @@ for (const pinned of [false, true]) { const reader = origin.consume(); const options = pinned ? { instance } : {}; const publishers = Array.from({ length: routeCount }, (_, index) => - origin.route(Path.from(`room/${index}`), { instance }), + origin.route(Path.from(`room/${index}`), { instance: { id: instance, epoch: 0n } }), ); const background = publishers.map((_, index) => reader.request(Path.from(`room/${index}`), options)); const requests = Array.from({ length: readerCount }, () => reader.request(Path.from("room/0"), options)); @@ -233,7 +233,7 @@ const nextInstance = Instance.parse("01900000-0000-7000-8000-000000000002"); for (const pathCount of routeCounts) { for (const readerCount of observerCounts) { const origin = new Producer(); - const dynamic = origin.routePrefix(Path.from("room"), { instance }); + const dynamic = origin.routePrefix(Path.from("room"), { instance: { id: instance, epoch: 0n } }); const pending = dynamic.requested(); const requests = Array.from({ length: pathCount }, (_, index) => Array.from({ length: readerCount }, () => origin.consume().request(Path.from(`room/${index}`))), diff --git a/js/net/bench/forward.ts b/js/net/bench/forward.ts index 35ee64e7977..073d8ec8a64 100644 --- a/js/net/bench/forward.ts +++ b/js/net/bench/forward.ts @@ -8,8 +8,8 @@ import { announcedView } from "../src/announced.test-util"; import * as Announce from "../src/announced.ts"; import { AuthSession } from "../src/auth_session.ts"; import { Producer as BroadcastProducer } from "../src/broadcast.ts"; -import type { Established } from "../src/connection/established.ts"; import { forwardAnnounced } from "../src/connection/forward.ts"; +import type { Session } from "../src/connection/session.ts"; import { WireRoute } from "../src/hop.ts"; import { Producer } from "../src/origin.ts"; import * as Path from "../src/path.ts"; @@ -65,7 +65,7 @@ for (const touched of ["covering", "single"] as const) { new Path.Patterns(heads.map((head) => Path.Pattern.subtree(head))), ); const session = new FakeSession(); - forwardAnnounced(session as unknown as Established, scoped); + forwardAnnounced(session as unknown as Session, scoped); // Watching everything narrows to one request per head. const table = await announcedView(origin); @@ -117,7 +117,7 @@ for (const watchers of [8, 128, 2048]) { const origin = new Producer(); const attached = Array.from({ length: sessions }, () => { const session = new FakeSession(); - forwardAnnounced(session as unknown as Established, origin); + forwardAnnounced(session as unknown as Session, origin); return session; }); const held = Array.from({ length: watchers }, (_, index) => diff --git a/js/net/bench/qmux-eager.ts b/js/net/bench/qmux-eager.ts index 2edda5262e8..13c1e9d0b66 100644 --- a/js/net/bench/qmux-eager.ts +++ b/js/net/bench/qmux-eager.ts @@ -1,4 +1,4 @@ -import Session from "@moq/qmux"; +import { Session } from "@moq/qmux"; // The entrypoint has already loaded the fallback before a connection is requested. Object.assign(globalThis, { diff --git a/js/net/bench/qmux-lazy.ts b/js/net/bench/qmux-lazy.ts index 374756be502..b7681656582 100644 --- a/js/net/bench/qmux-lazy.ts +++ b/js/net/bench/qmux-lazy.ts @@ -2,7 +2,7 @@ Object.assign(globalThis, { async measureQmux(url: string) { const start = performance.now(); - const { default: Session } = await import("@moq/qmux"); + const { Session } = await import("@moq/qmux"); const session = new Session(url); await session.ready; const elapsed = performance.now() - start; diff --git a/js/net/bench/requests.ts b/js/net/bench/requests.ts index 44e1da5645d..254e8f9bc80 100644 --- a/js/net/bench/requests.ts +++ b/js/net/bench/requests.ts @@ -139,9 +139,9 @@ for (const failovers of [0, 1, 16, 128]) const sources: Broadcast[] = []; for (let i = 0; i < failovers; i++) { const instance = Instance.random(); - const primary = origin.routePrefix(prefix, { instance, cost: 0n }); + const primary = origin.routePrefix(prefix, { instance: { id: instance, epoch: 0n }, cost: 0n }); const ar = primary.requested(); - const br = origin.routePrefix(prefix, { instance, cost: 1n }).requested(); + const br = origin.routePrefix(prefix, { instance: { id: instance, epoch: 0n }, cost: 1n }).requested(); const held = origin.request(path); const source = new Broadcast(); sources.push(source); @@ -151,8 +151,9 @@ for (const failovers of [0, 1, 16, 128]) entries.push({ primary, ar, br, held }); } const instance = Instance.random(); - origin.routePrefix(prefix, { instance }); - for (let i = 0; i < extraRoutes; i++) origin.routePrefix(prefix, { instance, cost: BigInt(i + 1) }); + origin.routePrefix(prefix, { instance: { id: instance, epoch: 0n } }); + for (let i = 0; i < extraRoutes; i++) + origin.routePrefix(prefix, { instance: { id: instance, epoch: 0n }, cost: BigInt(i + 1) }); const pending = []; for (const entry of entries) { entry.primary.close(); diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index f97aecea501..684ad2244ee 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -1,7 +1,7 @@ import { describe, expect, spyOn, test } from "bun:test"; import { Manual, Milli, Nano, yieldNow } from "@moq/clock"; import { Duplicate, type Grant, Handle, type Issued, type Request, type Requests, Unsupported } from "./auth.ts"; -import { accept as acceptSession, connect as connectSession, type Established } from "./connection/index.ts"; +import { accept as acceptSession, connect as connectSession, type Session } from "./connection/index.ts"; import { SessionCode, SessionError, StreamCode, StreamError, toStreamCode } from "./error.ts"; import { AuthMessage as IetfAuthMessage, IetfAuthWire } from "./ietf/auth.ts"; import * as Ietf from "./ietf/index.ts"; @@ -51,8 +51,8 @@ async function settles(done: () => boolean): Promise { } interface Pair { - client: Established; - server: Established; + client: Session; + server: Session; transport: MockTransport; } diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index 12cd2aa9aa3..7f50219d0d4 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -343,7 +343,7 @@ export function ended(handle: Handle): Error | undefined { /** * The tokens this side presents and the grant they earn, across every session built on it. * - * A session made without one creates its own, returned by `Established.auth`, which ends with + * A session made without one creates its own, returned by `Session.auth`, which ends with * that session. Pass one to `connect` as `auth` instead and it outlives its sessions: each * presents every token the handle holds, so a {@link Credential} it returned keeps riding a route's * announcements across a reconnect. It backs one active session at a time, plus the ones diff --git a/js/net/src/connection/accept.ts b/js/net/src/connection/accept.ts index a0b5ad99254..a8fd3236a59 100644 --- a/js/net/src/connection/accept.ts +++ b/js/net/src/connection/accept.ts @@ -8,10 +8,10 @@ import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../ import { Stream } from "../stream.ts"; import { untilAborted } from "../util/abort.ts"; import { after } from "../util/timer.ts"; -import type { Established } from "./established.ts"; import * as Extensions from "./extensions.ts"; import { DISCOVERY_REMOVED, forwardAnnounced } from "./forward.ts"; import { DEFAULT_TIMEOUT, exchangeSetup, handsOff } from "./handshake.ts"; +import type { Session } from "./session.ts"; /** Options for {@link Connection.accept}. */ export interface AcceptProps { @@ -67,7 +67,7 @@ type SessionProps = { * * @returns A promise that resolves to an established session */ -export async function accept({ transport, url, ...props }: AcceptProps): Promise { +export async function accept({ transport, url, ...props }: AcceptProps): Promise { if ("discovery" in props) throw new TypeError(DISCOVERY_REMOVED); props.signal?.throwIfAborted(); const limit = props.timeout ?? DEFAULT_TIMEOUT; @@ -108,7 +108,7 @@ async function acceptInner( transport: WebTransport, url: URL, props: Omit, -): Promise { +): Promise { // The DOM lib has no `protocol` property yet. It is "" when none was negotiated, and // undefined in a browser that predates subprotocols (Firefox before 155). const protocol = (transport as { protocol?: string }).protocol; @@ -154,7 +154,7 @@ async function acceptAlpn( url: URL, version: Ietf.IetfVersion, wiring: SessionProps, -): Promise { +): Promise { // Spread whole, so every negotiated flag reaches the connection. // The IETF session code loads on demand, since most sessions negotiate lite; the ALPN already // committed to IETF, so fetch it alongside SETUP. @@ -179,7 +179,7 @@ async function acceptNegotiated( url: URL, wiring: SessionProps, version?: number, -): Promise { +): Promise { // The SETUP keeps lite-02's framing whichever version it offers. const stream = await Stream.accept(transport, Lite.Version.DRAFT_02); if (!stream) throw new Error("no incoming bidi stream for SETUP"); diff --git a/js/net/src/connection/connect.test.ts b/js/net/src/connection/connect.test.ts index 50c8e320676..a3763f62306 100644 --- a/js/net/src/connection/connect.test.ts +++ b/js/net/src/connection/connect.test.ts @@ -1,7 +1,7 @@ import { expect, spyOn, test } from "bun:test"; import { Manual, Milli, Nano, sleep, yieldNow } from "@moq/clock"; import { hostTime } from "@moq/clock/test"; -import Qmux, { selectSubprotocol, type WebSocketLike } from "@moq/qmux"; +import { Session as Qmux, selectSubprotocol, type WebSocketLike } from "@moq/qmux"; import { ConnectTimeout, SessionCode, SessionError } from "../error.ts"; import * as Ietf from "../ietf/index.ts"; import { ClientSetup, ServerSetup } from "../lite/compat.ts"; diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 3310bcc156f..9d1c68cc8b7 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -1,5 +1,5 @@ import { Elapsed, Milli, Nano, sleep, timeout } from "@moq/clock"; -import Session, { type SessionOptions as QmuxOptions, type Version as QmuxVersion } from "@moq/qmux"; +import { Session as Qmux, type SessionOptions as QmuxOptions, type Version as QmuxVersion } from "@moq/qmux"; import { type Getter, race } from "@moq/signals"; import type * as Auth from "../auth.ts"; import { vacant } from "../auth_session.ts"; @@ -14,10 +14,10 @@ import * as Hex from "../util/hex.ts"; import { after } from "../util/timer.ts"; import { isWebTransportSupported } from "./browser.ts"; import { parse, show } from "./endpoint.ts"; -import type { Established } from "./established.ts"; import * as Extensions from "./extensions.ts"; import { DISCOVERY_REMOVED, forwardAnnounced } from "./forward.ts"; import { DEFAULT_TIMEOUT, exchangeSetup, handsOff } from "./handshake.ts"; +import type { Session } from "./session.ts"; import { inspect } from "./stats.ts"; import { credential, fallback, hold, type Presentation } from "./tokens.ts"; @@ -344,7 +344,7 @@ const NEVER_ABORTED = new AbortController().signal; * * @returns A promise that resolves to an established session */ -export async function connect({ url, ...props }: ConnectProps): Promise { +export async function connect({ url, ...props }: ConnectProps): Promise { if ("discovery" in props) throw new TypeError(DISCOVERY_REMOVED); props.versions = normalizeVersions(props); if (props.transport && props.versions) throw new TypeError("versions cannot reconfigure a supplied transport"); @@ -399,7 +399,7 @@ async function connectInner( tokens: readonly string[], props: Omit, abort: Promise, -): Promise<[Established, Presentation]> { +): Promise<[Session, Presentation]> { const offered = offer(props.versions); const dial = credential(raw, tokens, offered.setupToken); const url = parse(dial.url).url; @@ -461,7 +461,7 @@ async function connectInner( // Race the available transports, using `.any` to ignore if one participant has an error. // `webtransport`/`websocket` are `Promise | undefined`, so test existence explicitly: a // promise is always truthy, so bare truthiness here would be a misused-promise. - const contenders: Promise[] = []; + const contenders: Promise[] = []; if (websocket !== undefined) contenders.push(websocket); if (webtransport !== undefined) contenders.push(webtransport); const session = await Promise.any(contenders).catch((err: unknown) => { @@ -481,7 +481,7 @@ async function connectInner( void abort.then(() => session.close()); // Record who won the race to this server, so QUIC's next head start follows the last result. - if (session instanceof Session) { + if (session instanceof Qmux) { console.debug(show(parse(url)), "connected via WebSocket"); websocketWon.add(server); } else { @@ -499,7 +499,7 @@ async function connectInner( session.close(); throw new Error("server negotiated no protocol, and no offered version negotiates in SETUP"); } - const won = session instanceof Session && fallbackDial ? fallbackDial : dial; + const won = session instanceof Qmux && fallbackDial ? fallbackDial : dial; return [await connectTransport(url, session as WebTransport, wiring(won), offered.setup), won]; } @@ -508,7 +508,7 @@ async function connectTransport( session: WebTransport, wiring: SessionProps, setup: Lite.Version[], -): Promise { +): Promise { const closed = session.closed.then(fromClose, error); const terminal = closed.then((cause) => { throw cause ?? new Error("session closed during SETUP"); @@ -546,7 +546,7 @@ async function negotiate( session: WebTransport, wiring: SessionProps, setup: Lite.Version[], -): Promise { +): Promise { // The DOM lib has no `protocol` property yet. It is "" when none was negotiated, and // undefined in a browser that predates subprotocols (Firefox before 155). const protocol: string | undefined = (session as { protocol?: string }).protocol || undefined; @@ -602,7 +602,7 @@ async function handshakeAlpn( session: WebTransport, version: Ietf.IetfVersion, wiring: SessionProps, -): Promise { +): Promise { // Spread whole, so every negotiated flag reaches the connection. // The IETF session code loads on demand, since most sessions negotiate lite; the ALPN already // committed to IETF, so fetch it alongside SETUP. @@ -671,7 +671,7 @@ async function resolveCertificateHashes(options?: WebTransportProps): Promise[], winner: unknown): void { +function closeLosers(contenders: Promise[], winner: unknown): void { for (const contender of contenders) { void contender.then( (transport) => { @@ -866,14 +866,14 @@ async function connectWebSocket( delay: number, cancel: Promise, options: QmuxOptions, -): Promise { +): Promise { const timer = sleep(Nano.fromMilli(Milli(delay))); const active = await Promise.race([cancel, timer.then(() => true)]); if (!active) return undefined; for (let wait = WEBSOCKET_REDIAL; ; wait = Milli(wait * 2)) { - const quic = new Session(url, options); + const quic = new Qmux(url, options); // Wait for the WebSocket to connect, for the cancel promise to resolve, or for the // redial. `ready` rejects on a refused/failed connection, so a throw here is the diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index 70d9eab1d16..566d07b5efc 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -9,8 +9,8 @@ import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; import { closeUnwanted } from "../util/announce.ts"; import { registerWire, wireOf } from "../wire.ts"; -import type { Established } from "./established.ts"; import { forwardAnnounced } from "./forward.ts"; +import type { Session } from "./session.ts"; const LINGER = 2000; @@ -127,8 +127,8 @@ class FakeSession { } /** What `forwardAnnounced` takes; only the members it touches are implemented. */ - get session(): Established { - return this as unknown as Established; + get session(): Session { + return this as unknown as Session; } } diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index 7f290064a14..bb9f71e978d 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -13,7 +13,7 @@ import type { Producer as OriginProducer, Route } from "../origin.ts"; import * as Path from "../path.ts"; import { after } from "../util/timer.ts"; import { type RequestSlot, wireOf } from "../wire.ts"; -import type { Established } from "./established.ts"; +import type { Session } from "./session.ts"; /** Why a connection refuses the removed `discovery` option rather than ignoring it. */ export const DISCOVERY_REMOVED = @@ -75,7 +75,7 @@ interface Reported { * * @internal */ -export function forwardAnnounced(conn: Established, origin: OriginProducer): void { +export function forwardAnnounced(conn: Session, origin: OriginProducer): void { const originWire = wireOf(origin); const exclusive = wireOf(conn).exclusive; // Reassigned on a downgrade, so the origin stops counting this session as discovering. @@ -290,7 +290,7 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi } /** Answer the requests under a received route by consuming them from the session. */ -async function drive(handle: Route, conn: Established): Promise { +async function drive(handle: Route, conn: Session): Promise { const session = wireOf(conn); try { for await (const request of handle.requested()) { @@ -312,7 +312,7 @@ async function drive(handle: Route, conn: Established): Promise { * A path the table already routes is left alone. A request resolves to the table's route over * any blind answer, so answering one would only park a handle nothing reads. */ -async function serveRequests(conn: Established, origin: OriginProducer): Promise { +async function serveRequests(conn: Session, origin: OriginProducer): Promise { const table = wireOf(origin); const session = wireOf(conn); // The withdraws for the answers this session provided, so a dead session only takes diff --git a/js/net/src/connection/index.ts b/js/net/src/connection/index.ts index 31b6199fdbe..252d82e2663 100644 --- a/js/net/src/connection/index.ts +++ b/js/net/src/connection/index.ts @@ -5,5 +5,5 @@ */ export { type AcceptProps, accept } from "./accept.ts"; export { type ConnectProps, connect } from "./connect.ts"; -export type { Established } from "./established.ts"; export { Connection } from "./pool.ts"; +export type { Session } from "./session.ts"; diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 4ca50528787..cedbf200ace 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -27,11 +27,10 @@ import { type WebSocketProps as WebSocketPropsType, type WebTransportProps as WebTransportPropsType, } from "./connect.ts"; -import type { Established as EstablishedType } from "./established.ts"; -import type { Extensions as ExtensionsType } from "./extensions.ts"; import { DISCOVERY_REMOVED } from "./forward.ts"; import type { Drain as DrainType, GoawayProps, Redirect as RedirectType } from "./goaway.ts"; import { pumpAnnounced, Reload, type ReloadDelay, type ReloadStatus } from "./reload.ts"; +import type { Session as SessionType } from "./session.ts"; import type { Probe as ProbeType, Stats as StatsType } from "./stats.ts"; import type { Transport as TransportType } from "./transport.ts"; @@ -227,7 +226,7 @@ export class Connection { #closed = new Once(); readonly #status = new Signal("disconnected"); - readonly #established = new Signal(undefined); + readonly #established = new Signal(undefined); readonly #probe = new Signal | undefined>(undefined); readonly #origin = new Signal(undefined); readonly #allocator = new Signal(undefined); @@ -440,10 +439,8 @@ export namespace Connection { export type WebTransportProps = WebTransportPropsType; /** A server certificate hash used to pin a self-signed server. @reexport */ export type CertificateHash = CertificateHashType; - /** An established one-shot session. @reexport */ - export type Established = EstablishedType; - /** The moq-transport extensions a one-shot session offers. @reexport */ - export type Extensions = ExtensionsType; + /** An established MoQ session, as a one-shot connect or accept returns it. @reexport */ + export type Session = SessionType; } /** The checked copy of `versions` to dial with, or why it cannot be offered. */ diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index fb144cd4a93..a9eb402b7d6 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -17,10 +17,10 @@ import { Stream } from "../stream.ts"; import { Timescale } from "../time.ts"; import { closeUnwanted } from "../util/announce.ts"; import { wireOf } from "../wire.ts"; -import type { Established } from "./established.ts"; import { type AcceptProps, accept as acceptSession } from "./index.ts"; import { Connection, resetShared } from "./pool.ts"; import { pumpAnnounced, Redirects, Reload, type ReloadDelay, type ReloadProps } from "./reload.ts"; +import type { Session } from "./session.ts"; function accept(transport: WebTransport, url: URL, props: Omit = {}) { return acceptSession({ transport, url, ...props }); @@ -752,7 +752,7 @@ for (const [name, teardown] of Object.entries(teardowns)) { // Only the first session matters: the subscription lives on it. const url = new URL("https://example.com/"); - const dials: { server: Promise; quic: WebTransport }[] = []; + const dials: { server: Promise; quic: WebTransport }[] = []; const fin = { hold: undefined as ReturnType | undefined, closed: false }; const stub = function StubWebTransport() { const pair = createMockTransportPair(Lite.ALPN_06); @@ -848,7 +848,7 @@ for (const [name, drainEnd] of Object.entries(drainEnds)) { Object.assign(globalThis, { window: win, document: Object.assign(new EventTarget(), { hidden: false }) }); const clock = new Manual(); const installed = clock.install(); - const dials: { server: Promise; closed: boolean }[] = []; + const dials: { server: Promise; closed: boolean }[] = []; const stub = function StubWebTransport(dialed: string | URL) { const pair = createMockTransportPair(Lite.ALPN_06); const dial = { server: accept(pair.server, new URL(dialed)), closed: false }; @@ -908,7 +908,7 @@ test("a retired GOAWAY predecessor still closes by its handover cap", async () = const original = globalThis.WebTransport; const clock = new Manual(); const installed = clock.install(); - const dials: { server: Promise; quic: WebTransport; closed: boolean }[] = []; + const dials: { server: Promise; quic: WebTransport; closed: boolean }[] = []; const stub = function StubWebTransport(dialed: string | URL) { const pair = createMockTransportPair(Lite.ALPN_06); const dial = { server: accept(pair.server, new URL(dialed)), quic: pair.server, closed: false }; @@ -974,7 +974,7 @@ test("closing the loop keeps the tokens until the session drains", async () => { const clock = new Manual(); const installed = clock.install(); const url = new URL("https://example.com/"); - const dials: { server: Promise; quic: WebTransport }[] = []; + const dials: { server: Promise; quic: WebTransport }[] = []; const fin = { hold: undefined as ReturnType | undefined, closed: false }; const stub = function StubWebTransport() { const pair = createMockTransportPair(Lite.ALPN_07_WIP); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 4beaad32499..0a6b8fa74dc 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -17,9 +17,9 @@ import { type WebTransportProps, } from "./connect.ts"; import { parse } from "./endpoint.ts"; -import type { Established } from "./established.ts"; import { DISCOVERY_REMOVED } from "./forward.ts"; import { DEFAULT_HANDOVER, type Drain, dialed, type GoawayProps, handover, pinnedTransport, target } from "./goaway.ts"; +import type { Session } from "./session.ts"; import { estimates, type Probe } from "./stats.ts"; import { tick } from "./ticker.ts"; import { heldOf } from "./tokens.ts"; @@ -207,14 +207,14 @@ export class Reload { readonly error: Getter; /** The currently established session, or undefined while disconnected. */ - established = new Signal(undefined); + established = new Signal(undefined); /** * The current connection's PROBE estimates, spanning reconnects. * * Undefined while disconnected: the estimates belong to a single connection. * Observation requests updates; `peek()` alone is passive. - * See {@link Established.probe}. + * See {@link Session.probe}. */ readonly probe: Getter; #probeSource = new Signal | undefined>(undefined); @@ -322,7 +322,7 @@ export class Reload { #draining: Draining | undefined; // Sessions this loop ended that are still draining, each until its close settles. - #ending = new Map>(); + #ending = new Map>(); // The current run of migrations. A redirect back to an endpoint that already sent us away is // a loop, however healthy each session looked. Reset when a session ends without a GOAWAY, @@ -531,7 +531,7 @@ export class Reload { // The session this run serves, ended with the run. A drained one leaves this slot // for #draining, which outlives the run. - let current: Established | undefined; + let current: Session | undefined; effect.cleanup(() => { if (current) { this.#end(current); @@ -557,7 +557,7 @@ export class Reload { this.#setDialing(true); this.#dialedTokens = this.tokens.peek(); - let connection: Established; + let connection: Session; try { connection = await connect({ url: dialing, @@ -668,7 +668,7 @@ export class Reload { * to go next and leave the old session serving until it drains. Returns false when the * redirect is refused, which ends the sequence rather than redialing. */ - #migrate(connection: Established, dialing: URL, drain: Drain): boolean { + #migrate(connection: Session, dialing: URL, drain: Drain): boolean { const hashes = this.webtransport?.serverCertificateHashes?.length ?? 0; const configured = hashes > 0 || this.webtransport?.serverCertificate !== undefined; // The pin is a WebTransport option. A WebSocket that won the race never used it. @@ -711,7 +711,7 @@ export class Reload { * deadline, unless the page is going away: nothing would run to finish them, and an abort * closes the transport now rather than leaving the browser to sever it. */ - #end(connection: Established): void { + #end(connection: Session): void { if (this.#suspended.peek()) { connection.abort(); return; @@ -723,7 +723,7 @@ export class Reload { } /** Serve `connection`, or nothing. */ - #serve(connection: Established | undefined): void { + #serve(connection: Session | undefined): void { this.established.set(connection); this.#report(); } @@ -940,12 +940,12 @@ export function pumpAnnounced(effect: Effect, upstream: Announce.Routes, produce * aborted at the window's end if still open. */ class Draining { - #connection: Established; + #connection: Session; #timer = new AbortController(); #onRetire: () => void; #retired = false; - constructor(connection: Established, handover: Milli, onRetire: () => void) { + constructor(connection: Session, handover: Milli, onRetire: () => void) { this.#connection = connection; this.#onRetire = onRetire; void sleep(Nano.fromMilli(handover), { signal: this.#timer.signal }).then( @@ -962,7 +962,7 @@ class Draining { } /** Stop serving from the old session, returning it the first time for the caller to end. */ - retire(): Established | undefined { + retire(): Session | undefined { if (this.#retired) return undefined; this.#retired = true; this.#onRetire(); diff --git a/js/net/src/connection/established.ts b/js/net/src/connection/session.ts similarity index 99% rename from js/net/src/connection/established.ts rename to js/net/src/connection/session.ts index 9e55e067e25..2e7e31fb7b7 100644 --- a/js/net/src/connection/established.ts +++ b/js/net/src/connection/session.ts @@ -13,7 +13,7 @@ import type { Transport } from "./transport.ts"; * Publishing goes through an origin, not the session: pass an `Origin.Consumer` as the * `publish` connect option and the session announces and serves that origin's broadcasts. */ -export interface Established { +export interface Session { /** URL of the connected server. */ readonly url: URL; diff --git a/js/net/src/connection/stats.test.ts b/js/net/src/connection/stats.test.ts index cd3a56f3ff6..0c319eb4b26 100644 --- a/js/net/src/connection/stats.test.ts +++ b/js/net/src/connection/stats.test.ts @@ -1,6 +1,6 @@ import { expect, spyOn, test } from "bun:test"; import { Manual, Milli, Nano, yieldNow } from "@moq/clock"; -import QmuxSession from "@moq/qmux"; +import { Session as QmuxSession } from "@moq/qmux"; import { Effect } from "@moq/signals"; import * as Ietf from "../ietf/index.ts"; import * as Lite from "../lite/index.ts"; diff --git a/js/net/src/connection/stats.ts b/js/net/src/connection/stats.ts index 764c117b066..80e8982318d 100644 --- a/js/net/src/connection/stats.ts +++ b/js/net/src/connection/stats.ts @@ -1,5 +1,5 @@ import { Milli } from "@moq/clock"; -import type { Established } from "./established.ts"; +import type { Session } from "./session.ts"; import { transportOf } from "./transport.ts"; /** * Connection statistics: what the local transport counts, and what the peer reports. @@ -176,10 +176,10 @@ export function canEstimate(quic: WebTransport): boolean { } // The sessions whose transport can report a send-rate estimate. -const estimating = new WeakSet(); +const estimating = new WeakSet(); /** Record whether `session`'s transport `quic` can report a send-rate estimate. @internal */ -export function detect(session: Established, quic: WebTransport): void { +export function detect(session: Session, quic: WebTransport): void { if (canEstimate(quic)) estimating.add(session); } @@ -188,6 +188,6 @@ export function detect(session: Established, quic: WebTransport): void { * * @internal */ -export function estimates(session: Established): boolean { +export function estimates(session: Session): boolean { return estimating.has(session); } diff --git a/js/net/src/connection/tokens.test.ts b/js/net/src/connection/tokens.test.ts index 62ef0d38b92..e5a5a73988a 100644 --- a/js/net/src/connection/tokens.test.ts +++ b/js/net/src/connection/tokens.test.ts @@ -4,9 +4,9 @@ import type { Pending } from "../auth_session.ts"; import * as Ietf from "../ietf/index.ts"; import { createMockTransportPair } from "../mock.ts"; import { Reader } from "../stream.ts"; -import type { Established } from "./established.ts"; import { offered } from "./extensions.ts"; import { exchangeSetup } from "./handshake.ts"; +import type { Session } from "./session.ts"; import { credential, extra, fallback, Held, hold } from "./tokens.ts"; const URL_ = new URL("https://relay.example.com/room"); @@ -112,8 +112,8 @@ test("a token replaced before its answer is withdrawn at once", async () => { test("sessions sharing an auth handle present each configured token once", async () => { const { fake, closed, presented, answer } = handle(); - const first = { auth: fake } as unknown as Established; - const second = { auth: fake } as unknown as Established; + const first = { auth: fake } as unknown as Session; + const second = { auth: fake } as unknown as Session; hold(first, credential(URL_, ["connection", "a"], true), ["connection", "a"]); await answer("a"); diff --git a/js/net/src/connection/tokens.ts b/js/net/src/connection/tokens.ts index 1123a88d769..2ddb72f9599 100644 --- a/js/net/src/connection/tokens.ts +++ b/js/net/src/connection/tokens.ts @@ -1,6 +1,6 @@ import { type Handle, Unsupported } from "../auth.ts"; import type { Pending } from "../auth_session.ts"; -import type { Established } from "./established.ts"; +import type { Session } from "./session.ts"; /** What one dial presents, decided from its URL, the offered versions, and the tokens. @internal */ export interface Presentation { @@ -149,7 +149,7 @@ export class Held { const held = new WeakMap(); /** Present `tokens` on `connection`, beside the credential its dial carried. @internal */ -export function hold(connection: Established, credential: Presentation, tokens: readonly string[]): Held { +export function hold(connection: Session, credential: Presentation, tokens: readonly string[]): Held { const existing = held.get(connection.auth); if (existing) { existing.dialed(credential, tokens); @@ -162,6 +162,6 @@ export function hold(connection: Established, credential: Presentation, tokens: } /** The tokens `connection` holds, if it was dialed with any. @internal */ -export function heldOf(connection: Established): Held | undefined { +export function heldOf(connection: Session): Held | undefined { return held.get(connection.auth); } diff --git a/js/net/src/connection/transport.ts b/js/net/src/connection/transport.ts index 774e287da04..cab9312702c 100644 --- a/js/net/src/connection/transport.ts +++ b/js/net/src/connection/transport.ts @@ -1,4 +1,4 @@ -import Session from "@moq/qmux"; +import { Session } from "@moq/qmux"; /** The wire transport a session runs over. */ export type Transport = "webtransport" | "websocket" | "webrtc"; diff --git a/js/net/src/datagram.ts b/js/net/src/datagram.ts index f09d72d4297..24b93eed6da 100644 --- a/js/net/src/datagram.ts +++ b/js/net/src/datagram.ts @@ -24,8 +24,8 @@ export interface Datagram { /** Publisher properties fixed for the lifetime of a datagram track. */ export interface Info { - /** Timestamp units per second, or omitted for an untimed track. */ - timescale?: Timescale | null; + /** Timestamp units per second, or `null` for an untimed track. */ + timescale: Timescale | null; /** Publisher priority, higher first, breaking ties between subscriber priorities. */ priority: number; } @@ -116,7 +116,7 @@ export class Producer { const state = this.#state; if (state.closed.peek() !== undefined) throw state.closed.peek() ?? new Error("datagram track is closed"); if (state.info.peek() !== undefined) throw new Error("datagram track is already accepted"); - const timescale = properties.timescale == null ? undefined : Timescale(properties.timescale); + const timescale = properties.timescale == null ? null : Timescale(properties.timescale); state.info.set(Object.freeze({ timescale, priority: priority(properties.priority ?? 0) })); return this; } catch (error) { diff --git a/js/net/src/fill.ts b/js/net/src/fill.ts index c866f1f3520..68e874ae830 100644 --- a/js/net/src/fill.ts +++ b/js/net/src/fill.ts @@ -31,7 +31,7 @@ export interface FillTrack { /** The track's name. */ readonly name: string; /** Insert group `sequence` into the cache, committing `info` first if the track has none. */ - accept(sequence: bigint, info: Partial | undefined): GroupProducer; + accept(sequence: bigint, info: Info | undefined): GroupProducer; /** Record `[start, end)` as never to be delivered. */ drop(start: bigint, end: bigint, error: Error): void; /** Whether the track still caches group `sequence`, not failed. */ @@ -62,7 +62,7 @@ export interface Asker { // The fill operations a handle runs against the state it came from; assigned in its static block. let fillOps: { - accept(state: FillState, pending: Pending, sequence: bigint, info: Partial | undefined): GroupProducer; + accept(state: FillState, pending: Pending, sequence: bigint, info: Info | undefined): GroupProducer; drop(state: FillState, pending: Pending, start: bigint, end: bigint): void; refuse(state: FillState, pending: Pending, start: bigint, end: bigint, error: Error): void; settle(state: FillState, pending: Pending): void; @@ -518,10 +518,10 @@ export class Fill { /** * Insert group `sequence` into the track's cache, handing it to the readers waiting on it, * and return the producer to write its frames into. `info` commits the track's {@link Info} - * if it has none yet, as received metadata: an absent `maxAge` sets no publisher limit. Throws a `RangeError` outside the fill, and an error if the group was + * if it has none yet. Throws a `RangeError` outside the fill, and an error if the group was * already answered or cached, or the track is closed. */ - accept(sequence: bigint, info?: Partial): GroupProducer { + accept(sequence: bigint, info?: Info): GroupProducer { checkSequence(sequence); return fillOps.accept(this.#state, this.#pending, sequence, info); } diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 7196b38418b..993b5769270 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -1447,15 +1447,6 @@ export class Consumer { return frame !== undefined ? JSON.parse(frame) : undefined; } - /** Reads the next frame and decodes its payload as a one-byte boolean. */ - async readBool(): Promise { - const frame = await this.readFrame(); - if (!frame) return undefined; - const payload = frame.payload; - if (payload.byteLength !== 1 || !(payload[0] === 0 || payload[0] === 1)) throw new Error("invalid bool frame"); - return payload[0] === 1; - } - /** * Release this handle, like dropping a Rust consumer. Never changes the group for other * readers or the producer. It drains what it buffered and then ends. Idempotent. diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index 9a5220a2753..643681ed435 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -3,8 +3,8 @@ import { type GetPromise, type Getter, Once, race, Signal } from "@moq/signals"; import * as announce from "../announced.ts"; import type * as Auth from "../auth.ts"; import { AuthSession } from "../auth_session.ts"; -import type { Established } from "../connection/established.ts"; import type { Drain } from "../connection/goaway.ts"; +import type { Session } from "../connection/session.ts"; import { detect, type Probe, type Stats, transportStats } from "../connection/stats.ts"; import { type Transport, transportOf } from "../connection/transport.ts"; import { error, fromClose, ProtocolViolation, SessionCode, StreamCode, StreamError, sessionClosed } from "../error.ts"; @@ -49,7 +49,7 @@ type Phase = { kind: "open" } | { kind: "closing"; done: Promise } | { kin * * @internal */ -export class Connection implements Established { +export class Connection implements Session { #phase: Phase = { kind: "open" }; // The URL of the connection. readonly url: URL; @@ -60,10 +60,10 @@ export class Connection implements Established { // The wire transport this session runs over. readonly transport: Transport; - /** moq-transport has no PROBE, so this stays empty; see {@link Established.probe}. */ + /** moq-transport has no PROBE, so this stays empty; see {@link Session.probe}. */ readonly probe: Getter = new Signal({}); - /** Our tokens and grants, when the peer negotiated MoQ Auth; see {@link Established.auth}. */ + /** Our tokens and grants, when the peer negotiated MoQ Auth; see {@link Session.auth}. */ get auth(): Auth.Handle { return this.#auth.handle; } @@ -98,7 +98,7 @@ export class Connection implements Established { // The peer's GOAWAY, read off its SETUP stream. #goaway = new Once(); - /** The implementation the peer advertised; see {@link Established.peerImplementation}. */ + /** The implementation the peer advertised; see {@link Session.peerImplementation}. */ readonly peerImplementation: Promise; /** @@ -238,7 +238,7 @@ export class Connection implements Established { void this.#run(early); } - /** Snapshot the transport's counters; see {@link Established.stats}. */ + /** Snapshot the transport's counters; see {@link Session.stats}. */ async stats(): Promise { return transportStats(this.#quic); } @@ -320,7 +320,7 @@ export class Connection implements Established { } } - /** The paths announced matching `scope`, each present or absent; see {@link Established.announced}. */ + /** The paths announced matching `scope`, each present or absent; see {@link Session.announced}. */ announced(scope?: Path.Pattern, options?: announce.Options): announce.Consumer { return announce.list(this.#subscriber.routes(scope, options)); } @@ -600,12 +600,12 @@ export class Connection implements Established { return GoAway.decode(controlStream.reader, version); } - /** Settles with the peer's GOAWAY; see {@link Established.draining}. */ + /** Settles with the peer's GOAWAY; see {@link Session.draining}. */ get draining(): GetPromise { return this.#goaway; } - /** Resolves when the session closes, decoding the peer's close code; see {@link Established.closed}. */ + /** Resolves when the session closes, decoding the peer's close code; see {@link Session.closed}. */ get closed(): Promise { return this.#quic.closed.then(fromClose, (err: unknown) => error(err)); } diff --git a/js/net/src/ietf/priority.test.ts b/js/net/src/ietf/priority.test.ts index f35ba559a72..fb78d6ac7a9 100644 --- a/js/net/src/ietf/priority.test.ts +++ b/js/net/src/ietf/priority.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import * as Lite from "../lite/priority.ts"; -import { infoDefaults } from "../track.ts"; +import { authoredInfo } from "../track.ts"; import { DEFAULT_WIRE, fromWire, toWire } from "./priority.ts"; test("IETF priority is lower first", () => { @@ -29,6 +29,6 @@ test("IETF mirrors moq-lite", () => { // An omitted priority is the drafts' 128, one step below the unset 127, so it forwards unchanged. test("an unset track priority goes out as 127 and an omitted one reads as -1", () => { - expect(toWire(infoDefaults().priority)).toBe(127); + expect(toWire(authoredInfo().priority)).toBe(127); expect(fromWire(DEFAULT_WIRE)).toBe(-1); }); diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 5655ac60375..69946a9c67e 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -1457,7 +1457,7 @@ test("a new instance at the same path clears a refusal", async () => { const pair = createMockTransportPair(ALPN.DRAFT_18); const { pub, origin } = publisher(pair.server); - const broadcast = origin.route(Path.from("restarted"), { instance: Instance.random() }); + const broadcast = origin.route(Path.from("restarted"), { instance: { id: Instance.random(), epoch: 0n } }); void pub.runPublishNamespaces(); diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index e9c66faefae..4131a963d74 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -22,7 +22,15 @@ import { } from "../error.ts"; import type * as group from "../group.ts"; import type { Hop, WireRoute } from "../hop.ts"; -import { finalSequence, firstEligible, hiddenBelow, hooks, presented, type ReadGroupFrame } from "../internal.ts"; +import { + datagramTrackInfo, + finalSequence, + firstEligible, + hiddenBelow, + hooks, + presented, + type ReadGroupFrame, +} from "../internal.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; import { sendPayload } from "../payload.ts"; @@ -582,6 +590,7 @@ export class Publisher { track.close(); datagramTrack = new TrackProducer(msg.trackName).accept(selected.info); track = hooks.parked(datagramTrack.subscribe({ priority: fromWire(msg.subscriberPriority) })); + return datagramTrackInfo(selected.info); } return selected.info; }; @@ -3009,7 +3018,7 @@ function trackProperties(info: Info, wanted: boolean): Properties.Properties { priority: toWire(info.priority), // The publisher's default, newest first. groupOrder: Properties.DESCENDING, - maxCacheDuration: info.maxAge === undefined ? undefined : BigInt(info.maxAge), + maxCacheDuration: info.maxAge === null ? undefined : BigInt(info.maxAge), }; } diff --git a/js/net/src/ietf/subscriber.test.ts b/js/net/src/ietf/subscriber.test.ts index 1935502beaa..d4bc658c3e1 100644 --- a/js/net/src/ietf/subscriber.test.ts +++ b/js/net/src/ietf/subscriber.test.ts @@ -799,7 +799,7 @@ function stampedGroup(groupId: bigint): GroupMessage { test("a track without TIMESCALE arrives untimed, even if an object carries a Timestamp", async () => { const { subscriber, track } = await subscribeTrack(); - expect((await track.info()).timescale).toBeUndefined(); + expect((await track.info()).timescale).toBeNull(); // Property 0x10 (Timestamp) = 5, with no units to read it in. await subscriber.handleGroup(stampedGroup(0n), new Reader(undefined, encodeStamped([0x10, 5]), VERSION)); diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 5c37972039c..0cf3d60a093 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -8,7 +8,7 @@ import { Connection, type ConnectProps, connect as connectSession, - type Established, + type Session, } from "./connection/index.ts"; import { SessionCode, SessionError, StreamCode, StreamError, TimestampMismatch, TooFarBehind } from "./error.ts"; import * as Ietf from "./ietf/index.ts"; @@ -39,7 +39,7 @@ function publish(origin: OriginProducer, path: Path.Valid) { const url = new URL("https://localhost:4443/test"); -function connect(url: URL, props: Omit = {}): Promise { +function connect(url: URL, props: Omit = {}): Promise { return connectSession({ url, ...props }); } @@ -47,7 +47,7 @@ function accept( transport: WebTransport, url: URL, props: Omit = {}, -): Promise { +): Promise { return acceptSession({ transport, url, ...props }); } @@ -933,9 +933,9 @@ test("integration: a peer is served the cheaper route it was offered, not the lo const path = Path.from("test"); // One publisher instance, so the two compete on cost alone. const instance = Instance.random(); - const localRoute = origin.route(path, { instance, cost: 5n }); + const localRoute = origin.route(path, { instance: { id: instance, epoch: 0n }, cost: 5n }); const local = localRoute.broadcast; - const dynamic = origin.routePrefix(path, { instance, cost: 1n }); + const dynamic = origin.routePrefix(path, { instance: { id: instance, epoch: 0n }, cost: 1n }); const [client, server] = await Promise.all([ connect(url, { transport: pair.client }), @@ -1236,7 +1236,7 @@ test.each([Lite.ALPN_03, Lite.ALPN_05, Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_22])( explicit: broadcast.createTrack("explicit", { timescale: Timescale.MILLI, maxAge: Milli(5000) }), }; expect((await tracks.omitted.info()).maxAge).toBe(Milli.zero); - expect((await tracks.unlimited.info()).maxAge).toBeUndefined(); + expect((await tracks.unlimited.info()).maxAge).toBeNull(); const remote = wireOf(client).consume(path); const info = async (name: string) => { @@ -1249,9 +1249,9 @@ test.each([Lite.ALPN_03, Lite.ALPN_05, Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_22])( } }; const carried = alpn !== Lite.ALPN_03; - expect((await info("omitted")).maxAge).toBe(carried ? Milli.zero : undefined); - expect((await info("unlimited")).maxAge).toBeUndefined(); - expect((await info("explicit")).maxAge).toBe(carried ? Milli(5000) : undefined); + expect((await info("omitted")).maxAge).toBe(carried ? Milli.zero : null); + expect((await info("unlimited")).maxAge).toBeNull(); + expect((await info("explicit")).maxAge).toBe(carried ? Milli(5000) : null); broadcast.close(); remote.close(); @@ -1330,7 +1330,7 @@ test("integration: ietf fetches a past group of a finished track", async () => { expect(await group.readFrame()).toBeUndefined(); group.close(); // The FETCH-only copy keeps the absent MAX_CACHE_DURATION as no limit, not the authored zero. - expect((await remote.track("video").info()).maxAge).toBeUndefined(); + expect((await remote.track("video").info()).maxAge).toBeNull(); remote.close(); client.abort(); server.abort(); diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index ae144d4f70f..2d854474dc7 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -39,6 +39,11 @@ import type { Advertised, Advertisements } from "./wire.ts"; /** What a null {@link Delay.max} goes out as: the largest value every peer can read, as Rust sends for `None`. */ export const NO_MAX_DELAY = Milli(Varint.MAX_U53); +/** A datagram track's properties as the stream track info a wire answers with: no groups, so no age, refresh, or compression. */ +export function datagramTrackInfo(info: datagram.Info): Info { + return { compress: false, timescale: info.timescale, maxAge: null, priority: info.priority, refresh: null }; +} + /** A subscription's {@link Delay.max} as the wire carries it: null for no limit, omitted for zero. */ export function maxDelayOf(subscription: Pick): Milli { const max = subscription.delay?.max; diff --git a/js/net/src/listing.test.ts b/js/net/src/listing.test.ts index 10438b23079..ceeb289fdc7 100644 --- a/js/net/src/listing.test.ts +++ b/js/net/src/listing.test.ts @@ -3,8 +3,11 @@ import { yieldNow } from "@moq/clock"; import { Producer as BroadcastProducer } from "./broadcast.ts"; import { NotFound, StreamCode, StreamError } from "./error.ts"; import type { Tracks } from "./listing.ts"; +import type { Info } from "./track.ts"; import { wireOf } from "./wire.ts"; +const INFO: Info = { compress: false, timescale: null, maxAge: null, priority: 0, refresh: null }; + /** Lets queued microtasks and the listing's signal notifications run. */ function settle(): Promise { return yieldNow(); @@ -110,8 +113,8 @@ test("a lister finishes or removes a track", async () => { const broadcast = new BroadcastProducer(); const lister = wireOf(broadcast).lister(); const tracks = list(broadcast); - lister.add("up", { priority: 0 }); - lister.add("gone", { priority: 0 }); + lister.add("up", INFO); + lister.add("gone", INFO); lister.synced(); expect(await tracks.drain()).toEqual(["+up", "+gone", "synced"]); lister.finish("up"); @@ -234,7 +237,7 @@ test("a clean end follows the removals before it", async () => { // The producer's finished track is what a handler's answer of its name served. const both = new BroadcastProducer(); const handler = wireOf(both).lister(); - handler.add("a", { priority: 0 }); + handler.add("a", INFO); handler.synced(); const own = both.createTrack("a"); const served = list(both); @@ -260,7 +263,7 @@ test("a clean end follows the removals before it", async () => { const answered = new BroadcastProducer(); const lister = wireOf(answered).lister(); const reader = list(answered); - lister.add("up", { priority: 0 }); + lister.add("up", INFO); lister.synced(); expect(await reader.drain()).toEqual(["+up", "synced"]); lister.finish("up"); @@ -295,8 +298,8 @@ test("readers wait for the lister to sync", async () => { const tracks = list(broadcast); expect(lister.wanted.peek()).toBe(true); expect(await tracks.drain()).toEqual(["+own"]); - lister.add("up", { priority: 0 }); - expect(() => lister.add("up", { priority: 0 })).toThrow("duplicate"); + lister.add("up", INFO); + expect(() => lister.add("up", INFO)).toThrow("duplicate"); lister.synced(); expect(await tracks.drain()).toEqual(["+up", "synced"]); @@ -312,7 +315,7 @@ test("a name listed by both is reported once", async () => { const broadcast = new BroadcastProducer(); broadcast.createTrack("a"); const lister = wireOf(broadcast).lister(); - lister.add("a", { priority: 0 }); + lister.add("a", INFO); lister.synced(); const tracks = list(broadcast); expect(await tracks.drain()).toEqual(["+a", "synced"]); @@ -326,7 +329,7 @@ test("an aborted answer fails its readers and a later reader asks again", async const broadcast = new BroadcastProducer(); const lister = wireOf(broadcast).lister(); const tracks = list(broadcast); - lister.add("up", { priority: 0 }); + lister.add("up", INFO); lister.abort(new NotFound("broadcast")); expect(await tracks.drain()).toEqual([`error: ${StreamCode.NotFound}`]); expect(lister.wanted.peek()).toBe(false); @@ -344,7 +347,7 @@ test("an aborted answer fails its readers and a later reader asks again", async test("a closed lister withdraws its answer and syncs", async () => { const broadcast = new BroadcastProducer(); const lister = wireOf(broadcast).lister(); - lister.add("up", { priority: 0 }); + lister.add("up", INFO); const tracks = list(broadcast); expect(await tracks.drain()).toEqual(["+up"]); lister.close(); @@ -357,11 +360,11 @@ test("a closed lister answers nothing more, even beside its successor", async () stale.close(); const lister = wireOf(broadcast).lister(); const tracks = list(broadcast); - stale.add("stale", { priority: 0 }); + stale.add("stale", INFO); stale.synced(); stale.abort(new Error("stale")); expect(await tracks.drain()).toEqual([]); - lister.add("up", { priority: 0 }); + lister.add("up", INFO); lister.synced(); expect(await tracks.drain()).toEqual(["+up", "synced"]); }); @@ -401,7 +404,7 @@ test("an ended answer ends its readers and a later reader asks again", async () const broadcast = new BroadcastProducer(); const lister = wireOf(broadcast).lister(); const tracks = list(broadcast); - lister.add("up", { priority: 0 }); + lister.add("up", INFO); lister.synced(); expect(await tracks.drain()).toEqual(["+up", "synced"]); lister.end(); diff --git a/js/net/src/lite/connection.test.ts b/js/net/src/lite/connection.test.ts index 6a9b42928f3..f772ed064da 100644 --- a/js/net/src/lite/connection.test.ts +++ b/js/net/src/lite/connection.test.ts @@ -1,6 +1,6 @@ import { expect, spyOn, test } from "bun:test"; import { Manual, Milli, Nano, yieldNow } from "@moq/clock"; -import Qmux from "@moq/qmux"; +import { Session as Qmux } from "@moq/qmux"; import { accept } from "../connection/accept.ts"; import { connect } from "../connection/connect.ts"; import { inspect } from "../connection/stats.ts"; diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index abbb6c66ab8..accbd06065f 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -3,8 +3,8 @@ import { Effect, type GetPromise, type Getter, Once, Signal } from "@moq/signals import * as announce from "../announced.ts"; import type * as Auth from "../auth.ts"; import { AuthSession } from "../auth_session.ts"; -import type { Established } from "../connection/established.ts"; import type { Drain } from "../connection/goaway.ts"; +import type { Session } from "../connection/session.ts"; import { detect, type Probe, type Stats, statsOf, type TransportStats, transportStats } from "../connection/stats.ts"; import { type Transport, transportOf } from "../connection/transport.ts"; import { closeError, error, fromClose, ProtocolViolation, StreamCode, sessionCause } from "../error.ts"; @@ -90,7 +90,7 @@ type Phase = { kind: "open" } | { kind: "closing"; done: Promise } | { kin * * @internal */ -export class Connection implements Established { +export class Connection implements Session { #phase: Phase = { kind: "open" }; // The URL of the connection. readonly url: URL; @@ -119,10 +119,10 @@ export class Connection implements Established { // The received unidirectional streams still being read, which a closing session drains. #unis = new Set>(); - /** The peer's PROBE estimates; see {@link Established.probe}. */ + /** The peer's PROBE estimates; see {@link Session.probe}. */ readonly probe: Getter; - // Our tokens and grants, and the answers to the peer's; see {@link Established.auth}. + // Our tokens and grants, and the answers to the peer's; see {@link Session.auth}. #auth: AuthSession; /** Random per-connection Hop ID. Shared by Publisher (for outbound hop @@ -152,7 +152,7 @@ export class Connection implements Established { #probeTarget?: Getter; #peerImplementation = Promise.withResolvers(); - /** The implementation the peer advertised; see {@link Established.peerImplementation}. */ + /** The implementation the peer advertised; see {@link Session.peerImplementation}. */ readonly peerImplementation = this.#peerImplementation.promise; // The peer's GOAWAY. Lite carries no deadline, so only the URI is set. @@ -265,7 +265,7 @@ export class Connection implements Established { void this.#run(); } - /** Our tokens and grants; see {@link Established.auth}. */ + /** Our tokens and grants; see {@link Session.auth}. */ get auth(): Auth.Handle { return this.#auth.handle; } @@ -374,7 +374,7 @@ export class Connection implements Established { } } - /** The paths announced matching `scope`, each present or absent; see {@link Established.announced}. */ + /** The paths announced matching `scope`, each present or absent; see {@link Session.announced}. */ announced(scope?: Path.Pattern, options?: announce.Options): announce.Consumer { return announce.list(this.#subscriber.routes(scope, options)); } @@ -564,19 +564,19 @@ export class Connection implements Established { } } - /** Snapshot the transport's counters; see {@link Established.stats}. */ + /** Snapshot the transport's counters; see {@link Session.stats}. */ async stats(): Promise { const stats = await transportStats(this.#quic); if (hasDatagrams(this.#version)) stats.datagramsUnrouted = this.#subscriber.datagramsUnrouted; return stats; } - /** Settles with the peer's GOAWAY; see {@link Established.draining}. */ + /** Settles with the peer's GOAWAY; see {@link Session.draining}. */ get draining(): GetPromise { return this.#goaway; } - /** Resolves when the session closes, decoding the peer's close code; see {@link Established.closed}. */ + /** Resolves when the session closes, decoding the peer's close code; see {@link Session.closed}. */ get closed(): Promise { return this.#quic.closed.then(fromClose, (err: unknown) => error(err)); } diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index e76ae79f119..f786f86dc99 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -23,6 +23,7 @@ import type * as group from "../group.ts"; import { Cost, type Hop, routesEqual, type WireRoute } from "../hop.ts"; import { type DropRun, + datagramTrackInfo, finalSequence, type Hole, hiddenBelow, @@ -130,11 +131,11 @@ interface Stamp { * equals. Lite-05/06 cannot encode an untimed track, so an untimed one carries its send time * instead. */ - timed?: Pick; + timed?: { timescale?: Timescale | null }; } /** How `version` stamps a track with `info`, or undefined when it carries no timestamps. */ -function stamping(version: Version, info: Pick): Stamp | undefined { +function stamping(version: Version, info: { timescale?: Timescale | null }): Stamp | undefined { const timescale = info.timescale ?? undefined; const wire = wireTimescale(version, timescale); if (wire === undefined) return undefined; @@ -159,14 +160,14 @@ function acknowledged(writer: Writer): Promise { function trackInfoMessage(info: track.Info, version: Version): TrackInfoMessage { return new TrackInfoMessage({ priority: info.priority, - refresh: info.refresh, + refresh: info.refresh ?? undefined, // Publisher Max Age: the publisher's staleness bound, advertised so relays re-serve // with the same bound. - maxAge: info.maxAge, + maxAge: info.maxAge ?? undefined, // `#serveGroup` emits each frame converted to it. timescale: info.timescale ?? undefined, // An older peer is served the frames inflated. - compress: hasCompression(version) && !!info.compress, + compress: hasCompression(version) && info.compress, }); } @@ -1095,7 +1096,7 @@ export class Publisher { // what TRACK_INFO advertised. It comes from the producer's accept(), so // they always agree. stamp = stamping(this.version, info); - compress = hasCompression(this.version) && !!info.compress; + compress = hasCompression(this.version) && info.compress; } else { // Older drafts acknowledge with SUBSCRIBE_OK and stream frames verbatim. const ok = new SubscribeOk({ @@ -1995,7 +1996,10 @@ export class Publisher { throw err; const asked = front.datagrams(msg.track); try { - info = trackInfoMessage(await untilAborted(asked.info(), hold.signal), this.version); + info = trackInfoMessage( + datagramTrackInfo(await untilAborted(asked.info(), hold.signal)), + this.version, + ); } catch (err) { asked.close(); throw err; diff --git a/js/net/src/lite/refresh.test.ts b/js/net/src/lite/refresh.test.ts index 15378a82123..48b92610c39 100644 --- a/js/net/src/lite/refresh.test.ts +++ b/js/net/src/lite/refresh.test.ts @@ -43,7 +43,7 @@ test.each([ALPN_06, ALPN_07_WIP])("refresh joins reach a browser publisher on %s const active = request.active.peek(); if (!active) throw new Error("broadcast missing"); const remote = active.track("data"); - expect((await remote.info()).refresh).toBe(alpn === ALPN_07_WIP ? 1 : undefined); + expect((await remote.info()).refresh).toBe(alpn === ALPN_07_WIP ? 1 : null); const baseline = remote.subscribe(); const group = producer.appendGroup(); group.writeFrame({ payload: new Uint8Array(3) }); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 4e2be0b4d04..ee0cd2f311a 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -1510,8 +1510,7 @@ export class Subscriber { // accept it again. state.cancel.signal.throwIfAborted(); const model = this.#toModelInfo(answered.info); - // A TRACK_INFO without a max age sets no publisher limit, not the authored default. - const producer = request.accept({ ...model, maxAge: model.maxAge ?? null }); + const producer = request.accept(model); const framing = { compress: answered.info.compress, timescale: answered.info.timescale ?? 0, @@ -1692,12 +1691,12 @@ export class Subscriber { // Map the wire TRACK_INFO onto the model track.Info a producer/consumer holds. #toModelInfo(info: TrackInfo): track.Info { return { - timescale: info.timescale, + timescale: info.timescale ?? null, // Publisher Max Age rides on the wire, so the local media-time budget // matches what the upstream advertises (relays re-serve with the same bound). - maxAge: info.maxAge === undefined ? undefined : Milli(info.maxAge), + maxAge: info.maxAge === undefined ? null : Milli(info.maxAge), priority: info.priority, - refresh: info.refresh, + refresh: info.refresh ?? null, compress: info.compress, // Lite-05/06 timestamps may be send times, so the copy's groups skip the rewind check. ...(hasMediaTime(this.version) ? {} : { unchecked: true }), diff --git a/js/net/src/lite/track.test.ts b/js/net/src/lite/track.test.ts index c35c728589f..b0bb2d7996d 100644 --- a/js/net/src/lite/track.test.ts +++ b/js/net/src/lite/track.test.ts @@ -4,7 +4,7 @@ import { ProtocolViolation } from "../error.ts"; import * as Path from "../path.ts"; import { Reader, Writer } from "../stream.ts"; import { Timescale } from "../time.ts"; -import { authoredInfo, infoDefaults } from "../track.ts"; +import { authoredInfo } from "../track.ts"; import { Track, TrackInfo } from "./track.ts"; import { Version } from "./version.ts"; @@ -52,7 +52,13 @@ test("TrackInfo for Rust's default Info matches cross-language wire bytes", asyn // The default authored track is untimed, which lite-05 declares as milliseconds, and // advertises the default zero max age. const model = authoredInfo(); - const info = new TrackInfo({ ...model, timescale: model.timescale ?? undefined }); + const info = new TrackInfo({ + priority: model.priority, + maxAge: model.maxAge ?? undefined, + timescale: model.timescale ?? undefined, + refresh: model.refresh ?? undefined, + compress: model.compress, + }); expect(await bytes((w) => info.encode(w, Version.DRAFT_05), Version.DRAFT_05)).toEqual( new Uint8Array([0x05, 0x80, 0x00, 0x00, 0x43, 0xe8]), ); @@ -199,7 +205,7 @@ for (const version of [Version.DRAFT_05, Version.DRAFT_06]) { if (version === Version.DRAFT_05) await old.bool(); const maxAge = Milli(await old.u53()); expect(maxAge).toBe(Milli(Number.MAX_SAFE_INTEGER)); - expect(infoDefaults({ maxAge }).maxAge).toBe(maxAge); + expect(authoredInfo({ maxAge }).maxAge).toBe(maxAge); expect(await old.u53()).toBe(1000); }); test(`legacy unlimited range decodes without unsafe numeric conversion on ${version}`, async () => { diff --git a/js/net/src/lite/tracks.test.ts b/js/net/src/lite/tracks.test.ts index 2160123c3e5..f6885af8c54 100644 --- a/js/net/src/lite/tracks.test.ts +++ b/js/net/src/lite/tracks.test.ts @@ -498,7 +498,7 @@ test("an instance-pinned front lists its serving route's tracks", async () => { ]); const broadcastRoute = serverOrigin.route(Path.from("room"), { hidden: true, - instance: Instance.parse("01900000-0000-7000-8000-000000000001"), + instance: { id: Instance.parse("01900000-0000-7000-8000-000000000001"), epoch: 0n }, }); const broadcast = broadcastRoute.broadcast; broadcast.createTrack("a"); @@ -527,7 +527,7 @@ test("an instance-pinned front lists its serving route's tracks", async () => { test("a front pinned to a named instance served through a covering prefix route is NOT_FOUND", async () => { const origin = new OriginProducer(); - const dynamic = origin.routePrefix(Path.from("room"), { instance: Instance.parse("named") }); + const dynamic = origin.routePrefix(Path.from("room"), { instance: { id: Instance.parse("named"), epoch: 0n } }); void (async () => { for await (const request of dynamic.requested()) { const served = new BroadcastProducer(); diff --git a/js/net/src/net-cleanups.test.ts b/js/net/src/net-cleanups.test.ts index af11677ba1b..85a9e3706c1 100644 --- a/js/net/src/net-cleanups.test.ts +++ b/js/net/src/net-cleanups.test.ts @@ -56,21 +56,6 @@ test.each(["group", "track"] as const)("%s JSON rejects an empty frame without t } }); -test("group booleans refuse every shape except one byte of zero or one", async () => { - const group = new GroupProducer(0n); - const reader = group.consume(); - for (const bytes of [[], [2], [1, 0], [0], [1]]) group.writeFrame({ payload: new Uint8Array(bytes) }); - group.finish(); - try { - for (let i = 0; i < 3; i++) await expect(reader.readBool()).rejects.toThrow("invalid bool frame"); - expect(await reader.readBool()).toBe(false); - expect(await reader.readBool()).toBe(true); - expect(await reader.readBool()).toBeUndefined(); - } finally { - reader.close(); - } -}); - for (const [version, alpn] of [ [Version.DRAFT_03, ALPN_03], [Version.DRAFT_04, ALPN_04], diff --git a/js/net/src/open.test.ts b/js/net/src/open.test.ts index b62789991be..514e2fdbb1f 100644 --- a/js/net/src/open.test.ts +++ b/js/net/src/open.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { Manual, Milli, Nano } from "@moq/clock"; -import Qmux from "@moq/qmux"; +import { Session as Qmux } from "@moq/qmux"; import { Version } from "./lite/version.ts"; import { createMockTransportPair } from "./mock.ts"; import { lines } from "./open.ts"; diff --git a/js/net/src/order.test.ts b/js/net/src/order.test.ts index 4ddba1863dc..9817231d046 100644 --- a/js/net/src/order.test.ts +++ b/js/net/src/order.test.ts @@ -7,7 +7,7 @@ import { expect, test } from "bun:test"; import { Milli } from "@moq/clock"; -import { accept, connect, type Established } from "./connection/index.ts"; +import { accept, connect, type Session } from "./connection/index.ts"; import type { Producer as GroupProducer } from "./group.ts"; import { FetchHeader } from "./ietf/fetch.ts"; import * as Ietf from "./ietf/index.ts"; @@ -117,13 +117,13 @@ async function aggregate(track: TrackProducer, predicate: (order: Order | undefi while (!predicate(track.subscription.peek()?.order)) await track.subscription.changed(); } -interface Session { - client: Established; - server: Established; +interface Pair { + client: Session; + server: Session; transport: MockTransport; } -async function session(protocol: string, origin: OriginProducer): Promise { +async function session(protocol: string, origin: OriginProducer): Promise { const pair = createMockTransportPair(protocol); const [client, server] = await Promise.all([ connect({ url, transport: pair.client }), diff --git a/js/net/src/origin-demand.test.ts b/js/net/src/origin-demand.test.ts index a3511747e5d..30c95a81352 100644 --- a/js/net/src/origin-demand.test.ts +++ b/js/net/src/origin-demand.test.ts @@ -314,7 +314,7 @@ test("a pending local request changing routes withdraws the old route's work", a test("cancelling an old instance waiter does not decrement a new instance's demand", async () => { const origin = new Producer(); const instance = Instance.random(); - const dynamic = origin.routePrefix(Path.from("room"), { instance }); + const dynamic = origin.routePrefix(Path.from("room"), { instance: { id: instance, epoch: 0n } }); const requests = dynamic.requested(); const wire = wireOf(origin.consume()); const hold = new AbortController(); @@ -346,8 +346,8 @@ test.each(["answer", "release"] as const)( const origin = new Producer(); const instance = Instance.random(); const prefix = Path.from("room"); - const a = origin.routePrefix(prefix, { instance, cost: 1n }); - const b = origin.routePrefix(prefix, { instance, cost: 5n }); + const a = origin.routePrefix(prefix, { instance: { id: instance, epoch: 0n }, cost: 1n }); + const b = origin.routePrefix(prefix, { instance: { id: instance, epoch: 0n }, cost: 5n }); const ar = a.requested(); const br = b.requested(); const held = origin.request(path); @@ -355,7 +355,7 @@ test.each(["answer", "release"] as const)( (await ar.next()).value.serve(source); await settle(); // Another instance wins new requests. The old handle stays on its own instance. - const c = origin.routePrefix(prefix, { instance: Instance.random(), cost: 0n }); + const c = origin.routePrefix(prefix, { instance: { id: Instance.random(), epoch: 0n }, cost: 0n }); a.close(); await settle(); const pending = (await br.next()).value; @@ -407,8 +407,8 @@ test.each([0, 1])( // Each instance undercuts the last, so it wins the path for the request made now. const instance = Instance.parse(`instance-${i + 1}`); const cost = BigInt(4 - 2 * i); - const primary = origin.routePrefix(prefix, { instance, cost }); - const standby = origin.routePrefix(prefix, { instance, cost: cost + 1n }); + const primary = origin.routePrefix(prefix, { instance: { id: instance, epoch: 0n }, cost }); + const standby = origin.routePrefix(prefix, { instance: { id: instance, epoch: 0n }, cost: cost + 1n }); const ar = primary.requested(); const br = standby.requested(); const held = origin.request(path); @@ -418,7 +418,7 @@ test.each([0, 1])( await settle(); entries.push({ primary, ar, br, held }); } - origin.routePrefix(prefix, { instance: Instance.parse("instance-3"), cost: 0n }); + origin.routePrefix(prefix, { instance: { id: Instance.parse("instance-3"), epoch: 0n }, cost: 0n }); const pending = []; for (const entry of entries) { entry.primary.close(); @@ -642,7 +642,7 @@ test("queued cancellation preserves order, replacement identity, and empty-queue test("reset clears queued work while a handler is suspended and admits the new instance", async () => { const origin = new Producer(); const instance = Instance.random(); - const dynamic = origin.routePrefix(Path.from("room"), { instance }); + const dynamic = origin.routePrefix(Path.from("room"), { instance: { id: instance, epoch: 0n } }); const requests = dynamic.requested(); const wire = wireOf(origin.consume()); const waits = [path, Path.from("room/obsolete")].map((name) => wire.demand(name, instance).catch(() => {})); diff --git a/js/net/src/origin-request.test.ts b/js/net/src/origin-request.test.ts index 01b77ae4888..d74473239ee 100644 --- a/js/net/src/origin-request.test.ts +++ b/js/net/src/origin-request.test.ts @@ -38,10 +38,10 @@ test("an instance-pinned follower refuses another instance", async () => { const origin = new Producer(); const path = Path.from("room/audio"); const instance = Instance.random(); - const firstRoute = origin.route(path, { instance }); + const firstRoute = origin.route(path, { instance: { id: instance, epoch: 0n } }); const first = firstRoute.broadcast; const request = origin.follow(path, { instance }); - const secondRoute = origin.route(path, { instance: Instance.random() }); + const secondRoute = origin.route(path, { instance: { id: Instance.random(), epoch: 0n } }); const second = secondRoute.broadcast; await settle(); expect(request.active.peek()?.instance).not.toBe(secondRoute.instance); @@ -55,7 +55,7 @@ test("a follower changes its handle on an in-place instance restart", async () = const origin = new Producer(); const path = Path.from("room/audio"); const before = Instance.random(); - const broadcastRoute = origin.route(path, { instance: before }); + const broadcastRoute = origin.route(path, { instance: { id: before, epoch: 0n } }); const broadcast = broadcastRoute.broadcast; const request = origin.follow(path); const old = request.active.peek(); @@ -362,7 +362,7 @@ test("a visible job route answers its waiting requesters at once", async () => { const later = origin.request(path); const next = (await requests.next()).value; if (!next) throw new Error("expected another request"); - const again = next.route({ instance: Instance.parse("stable"), epoch: 2n }); + const again = next.route({ instance: { id: Instance.parse("stable"), epoch: 2n } }); await settle(); expect(again.instance).toBe(Instance.parse("stable")); expect(later.active.peek()?.instance).toBe(again.instance); @@ -631,7 +631,7 @@ test("an instance-pinned follower resumes when its instance returns after a gap" const source = new BroadcastProducer(); const keeper = source.consume(); const serve = (instance: Instance.Valid, broadcast: BroadcastProducer) => { - const route = origin.routePrefix(path, { instance: instance }); + const route = origin.routePrefix(path, { instance: { id: instance, epoch: 0n } }); void (async () => { for await (const request of route.requested()) request.serve(broadcast.consume()); })(); @@ -852,8 +852,8 @@ test.each(["request", "follow"] as const)("a %s pinned to a losing instance reso const origin = new Producer(); const path = Path.from("room/audio"); const pinned = Instance.random(); - const losing = origin.routePrefix(path, { instance: pinned, cost: 9n }); - const winning = origin.routePrefix(path, { instance: Instance.random(), cost: 1n }); + const losing = origin.routePrefix(path, { instance: { id: pinned, epoch: 0n }, cost: 9n }); + const winning = origin.routePrefix(path, { instance: { id: Instance.random(), epoch: 0n }, cost: 1n }); const asked = losing.requested(); const requested = origin[method](path, { instance: pinned }); const pending = (await asked.next()).value; @@ -892,8 +892,8 @@ test("a peer's pin is served by a local broadcast that loses the path", async () const wire = wireOf(origin.consume()); const path = Path.from("room/audio"); const pinned = Instance.random(); - const publication = origin.route(path, { instance: pinned, cost: 9n }); - const winning = origin.routePrefix(path, { instance: Instance.random(), cost: 1n }); + const publication = origin.route(path, { instance: { id: pinned, epoch: 0n }, cost: 9n }); + const winning = origin.routePrefix(path, { instance: { id: Instance.random(), epoch: 0n }, cost: 1n }); expect(wire.local(path)).toBeUndefined(); expect(wire.local(path, pinned)).toBeDefined(); expect(await wire.demand(path, pinned)).toBeDefined(); @@ -1124,7 +1124,7 @@ test("one job ending leaves another job's instance alone", async () => { test("a claim naming its instance gives each job a fresh one", async () => { const origin = new Producer(); const stable = Instance.parse("stable"); - const claim = origin.routePrefix(Path.from("room"), { instance: stable, epoch: 1n }); + const claim = origin.routePrefix(Path.from("room"), { instance: { id: stable, epoch: 1n } }); const requests = claim.requested(); const request = origin.request(Path.from("room/alice")); const pending = (await requests.next()).value; diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 77833c9937b..245758a3f03 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -131,7 +131,7 @@ test("a borrowed table exposes dynamic serving and a live scoped broadcast map", const other = publish(origin, Path.from("other")); const localPath = Path.from("room/alice"); - const localRoute = table.route(localPath, { hidden: true, instance: INSTANCE, cost: 4n }); + const localRoute = table.route(localPath, { hidden: true, instance: { id: INSTANCE, epoch: 0n }, cost: 4n }); const local = localRoute.broadcast; await new Promise((resolve) => setImmediate(resolve)); expect(live.peek().has(localPath)).toBe(false); @@ -174,7 +174,7 @@ test("an announced local path competes with a received route on cost", async () expect(live.peek().get(path)).toEqual(WireRoute.normalize({ instance: INSTANCE, hops: [PEER], cost: 5n })); // Unannounced, it competes for nothing. - const localRoute = origin.route(path, { hidden: true, instance: INSTANCE, cost: 9n }); + const localRoute = origin.route(path, { hidden: true, instance: { id: INSTANCE, epoch: 0n }, cost: 9n }); const local = localRoute.broadcast; await new Promise((resolve) => setImmediate(resolve)); expect(live.peek().get(path)).toEqual(WireRoute.normalize({ instance: INSTANCE, hops: [PEER], cost: 5n })); @@ -1179,7 +1179,7 @@ test("a request prefers a cheaper received route over an announced local broadca provider(upstream), WireRoute.normalize({ instance: INSTANCE, hops: [PEER], cost: 1n }), ); - const localRoute = origin.route(path, { instance: INSTANCE, cost: 3n }); + const localRoute = origin.route(path, { instance: { id: INSTANCE, epoch: 0n }, cost: 3n }); const local = localRoute.broadcast; // The cheaper route is served on demand, so it resolves only once its handler answers. @@ -1232,7 +1232,7 @@ test("the cheapest received route competes with the local broadcast, not the new via("pricey"), WireRoute.normalize({ instance: INSTANCE, hops: [PEER], cost: 10n }), ); - const localRoute = origin.route(path, { instance: INSTANCE, cost: 5n }); + const localRoute = origin.route(path, { instance: { id: INSTANCE, epoch: 0n }, cost: 5n }); const local = localRoute.broadcast; await new Promise((resolve) => setImmediate(resolve)); @@ -1387,8 +1387,8 @@ test("a peer is offered and served the cheapest originated route", async () => { const origin = new Producer(); const consumer = origin.consume(); const prefix = Path.from("live"); - const cheap = origin.routePrefix(prefix, { instance: INSTANCE, cost: 1n }); - const pricey = origin.routePrefix(prefix, { instance: INSTANCE, cost: 10n }); + const cheap = origin.routePrefix(prefix, { instance: { id: INSTANCE, epoch: 0n }, cost: 1n }); + const pricey = origin.routePrefix(prefix, { instance: { id: INSTANCE, epoch: 0n }, cost: 10n }); expect(wireOf(consumer).advertised.peek()?.get(prefix)?.[0]?.route).toEqual( WireRoute.normalize({ instance: INSTANCE, cost: 1n }), @@ -1400,7 +1400,7 @@ test("a peer is offered and served the cheapest originated route", async () => { expect(await pending).toBeDefined(); // An exact-path local broadcast competes with them on cost too. - const localRoute = origin.route(prefix, { instance: INSTANCE, cost: 5n }); + const localRoute = origin.route(prefix, { instance: { id: INSTANCE, epoch: 0n }, cost: 5n }); const local = localRoute.broadcast; expect(wireOf(consumer).advertised.peek()?.get(prefix)?.[0]?.route).toEqual( WireRoute.normalize({ instance: INSTANCE, cost: 1n }), @@ -1698,7 +1698,7 @@ test("a request on a replaced instance fails over to a local replica of it", asy const primary = new BroadcastProducer(); const keepPrimary = primary.consume(); const disposePrimary = serve(origin, path, () => keepPrimary.clone(), WireRoute.normalize({ instance: INSTANCE })); - const localRoute = origin.route(path, { instance: INSTANCE, cost: 5n }); + const localRoute = origin.route(path, { instance: { id: INSTANCE, epoch: 0n }, cost: 5n }); const local = localRoute.broadcast; const request = consumer.request(path); await settle(); @@ -1739,7 +1739,7 @@ test("a stale takeover's refusal leaves a request whose source came back", async const path = Path.from("resumed"); const newer = Instance.parse("01900000-0000-7000-8000-000000000002"); - const localRoute = origin.route(path, { instance: INSTANCE, cost: 2n }); + const localRoute = origin.route(path, { instance: { id: INSTANCE, epoch: 0n }, cost: 2n }); const local = localRoute.broadcast; const standby = wireOf(origin).receive(path, WireRoute.normalize({ instance: INSTANCE, cost: 5n })); const asked = standby.requested(); @@ -1850,8 +1850,8 @@ test("a better route's refusal ends a served request", async () => { // The narrower route is asked while the broad one keeps serving, then says no. A // costlier sibling at the same prefix could serve, but is never asked. - const narrow = origin.routePrefix(Path.from("live"), { instance: INSTANCE, cost: 1n }); - const sibling = origin.routePrefix(Path.from("live"), { instance: INSTANCE, cost: 2n }); + const narrow = origin.routePrefix(Path.from("live"), { instance: { id: INSTANCE, epoch: 0n }, cost: 1n }); + const sibling = origin.routePrefix(Path.from("live"), { instance: { id: INSTANCE, epoch: 0n }, cost: 2n }); const siblingRequests = sibling.requested(); const siblingAsked = siblingRequests.next(); const { value: req } = await narrow.requested().next(); @@ -2017,7 +2017,7 @@ test("announced filters before rooting and retains lifecycle, routing and watch expect(request.active.peek()).toBeDefined(); request.close(); - const exact = origin.routePrefix(root, { instance: INSTANCE }); + const exact = origin.routePrefix(root, { instance: { id: INSTANCE, epoch: 0n } }); expect(await events.next()).toMatchObject({ value: { kind: "start", prefix: Path.empty(), captures: expect.any(Array) }, }); @@ -2420,8 +2420,8 @@ test("a scoped reader sees a local broadcast that only loses to a route outside const origin = new Producer(); const chat = origin .scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/*/chat")])) - .routePrefix(Path.from("room/alice"), { instance: INSTANCE, cost: 0n }); - const localRoute = origin.route(Path.from("room/alice"), { instance: INSTANCE, cost: 5n }); + .routePrefix(Path.from("room/alice"), { instance: { id: INSTANCE, epoch: 0n }, cost: 0n }); + const localRoute = origin.route(Path.from("room/alice"), { instance: { id: INSTANCE, epoch: 0n }, cost: 5n }); const local = localRoute.broadcast; const reader = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/*")])); @@ -2475,8 +2475,8 @@ test("a higher epoch of a dynamic route supersedes a cheaper local broadcast of const origin = new Producer(); const path = Path.from("camera"); const instance = Instance.random(); - const localRoute = origin.route(path, { instance, epoch: 1n, cost: 0n }); - const dynamic = origin.routePrefix(path, { instance, epoch: 2n, cost: 10n }); + const localRoute = origin.route(path, { instance: { id: instance, epoch: 1n }, cost: 0n }); + const dynamic = origin.routePrefix(path, { instance: { id: instance, epoch: 2n }, cost: 10n }); const served = new BroadcastProducer(); const requests = dynamic.requested(); const request = origin.consume().request(path); @@ -2497,9 +2497,9 @@ test("a higher epoch that is not the best route still supersedes a local broadca const origin = new Producer(); const path = Path.from("camera"); const stale = Instance.random(); - const localRoute = origin.route(path, { instance: stale, epoch: 1n, cost: 0n }); - const newer = origin.routePrefix(path, { instance: stale, epoch: 2n, cost: 10n }); - const other = origin.routePrefix(path, { instance: Instance.random(), cost: 5n }); + const localRoute = origin.route(path, { instance: { id: stale, epoch: 1n }, cost: 0n }); + const newer = origin.routePrefix(path, { instance: { id: stale, epoch: 2n }, cost: 10n }); + const other = origin.routePrefix(path, { instance: { id: Instance.random(), epoch: 0n }, cost: 5n }); const served = new BroadcastProducer(); const requests = other.requested(); const request = origin.consume().request(path); @@ -2521,7 +2521,7 @@ test("a publisher weighs a local broadcast against the routes it may forward onl const origin = new Producer(); const path = Path.from("camera"); const instance = Instance.random(); - const localRoute = origin.route(path, { instance, epoch: 1n, cost: 0n }); + const localRoute = origin.route(path, { instance: { id: instance, epoch: 1n }, cost: 0n }); const originated = origin.routePrefix(path, { cost: 5n }); // A session delivered the higher epoch, which a publisher never forwards back out. const received = wireOf(origin).receive(path, { instance, epoch: 2n, cost: 10n, hops: [PEER] }); @@ -2535,8 +2535,8 @@ test("a publisher weighs a local broadcast against the routes it may forward onl test("a scope's merged covering prefixes never supersede across prefixes", async () => { const origin = new Producer(); const instance = Instance.random(); - const room = origin.routePrefix(Path.from("room"), { instance, epoch: 2n }); - const video = origin.routePrefix(Path.from("room/video"), { instance, epoch: 1n }); + const room = origin.routePrefix(Path.from("room"), { instance: { id: instance, epoch: 2n } }); + const video = origin.routePrefix(Path.from("room/video"), { instance: { id: instance, epoch: 1n } }); const reader = origin.scope(Path.from("room/video"), new Path.Patterns([Path.Pattern.all()])).consume(); await settle(); const table = wireOf(reader).advertised.peek(); @@ -2553,14 +2553,14 @@ test("supersession only counts routes the reader can see", async () => { const instance = Instance.random(); const video = origin .scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("*/video")])) - .routePrefix(Path.empty(), { instance, epoch: 1n }); + .routePrefix(Path.empty(), { instance: { id: instance, epoch: 1n } }); const live = await announcedView(origin, Path.Pattern.parse("room/video"), { covering: true }); await settle(); expect(live.peek().size).toBe(1); // A higher epoch the reader cannot see never hides the route it can. const chat = origin .scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("*/chat")])) - .routePrefix(Path.empty(), { instance, epoch: 2n }); + .routePrefix(Path.empty(), { instance: { id: instance, epoch: 2n } }); await settle(); expect([...live.peek().values()].map((route) => route.epoch)).toEqual([1n]); chat.close(); @@ -2575,7 +2575,7 @@ test("the newest epoch of an instance is announced in place over a cheaper one", const announced = consumer.routes(); const instance = Instance.random(); - const oldRoute = origin.route(path, { instance, epoch: 1n, cost: 1n }); + const oldRoute = origin.route(path, { instance: { id: instance, epoch: 1n }, cost: 1n }); const old = oldRoute.broadcast; expect(await announced.next()).toMatchObject({ prefix: path, kind: "start" }); @@ -2602,7 +2602,7 @@ test("demand held across an instance change asks again, unless it named the old const origin = new Producer(); const consumer = origin.consume(); const path = Path.from("live/cam"); - const handle = origin.routePrefix(Path.from("live"), { instance: INSTANCE }); + const handle = origin.routePrefix(Path.from("live"), { instance: { id: INSTANCE, epoch: 0n } }); const it = handle.requested(); const pinned = wireOf(consumer).demand(path, INSTANCE); const unpinned = wireOf(consumer).demand(path); @@ -2631,7 +2631,7 @@ test("a demand refused across an instance change does not lose the carried deman const origin = new Producer(); const consumer = origin.consume(); const path = Path.from("live/cam"); - const handle = origin.routePrefix(Path.from("live"), { instance: INSTANCE }); + const handle = origin.routePrefix(Path.from("live"), { instance: { id: INSTANCE, epoch: 0n } }); const it = handle.requested(); const unpinned = wireOf(consumer).demand(path); const pinned = wireOf(consumer).demand(path, INSTANCE); @@ -2651,7 +2651,7 @@ test("a demand refused across an instance change does not lose the carried deman test("demand held across an instance round trip still asks again", async () => { const origin = new Producer(); const path = Path.from("live/cam"); - const handle = origin.routePrefix(Path.from("live"), { instance: INSTANCE }); + const handle = origin.routePrefix(Path.from("live"), { instance: { id: INSTANCE, epoch: 0n } }); const it = handle.requested(); const demanded = wireOf(origin.consume()).demand(path); const { value: req } = await it.next(); @@ -2677,11 +2677,15 @@ test("demand held across an instance round trip still asks again", async () => { test("re-pricing from the current route keeps the instance", () => { const origin = new Producer(); - const handle = origin.routePrefix(Path.from("live"), { instance: INSTANCE, cost: 5n }); + const handle = origin.routePrefix(Path.from("live"), { instance: { id: INSTANCE, epoch: 0n }, cost: 5n }); handle.setCost(1n); expect(wireOf(handle).info).toEqual(WireRoute.normalize({ instance: INSTANCE, cost: 1n })); - const broadcastRoute = origin.route(Path.from("room"), { hidden: true, instance: INSTANCE, cost: 5n }); + const broadcastRoute = origin.route(Path.from("room"), { + hidden: true, + instance: { id: INSTANCE, epoch: 0n }, + cost: 5n, + }); const broadcast = broadcastRoute.broadcast; expect(wireOf(broadcastRoute).info).toBeUndefined(); broadcastRoute.announce(); @@ -2699,7 +2703,7 @@ test("a peer naming another instance is refused rather than served a different i const origin = new Producer(); const consumer = origin.consume(); const path = Path.from("room"); - const localRoute = origin.route(path, { instance: INSTANCE }); + const localRoute = origin.route(path, { instance: { id: INSTANCE, epoch: 0n } }); const local = localRoute.broadcast; expect(wireOf(consumer).local(path, INSTANCE)).toBeDefined(); @@ -2714,14 +2718,14 @@ test("a peer naming another instance is refused rather than served a different i test("a newer instance ends pinned and unpinned requests on the replaced publisher alike", () => { const origin = new Producer(); const path = Path.from("room"); - const first = origin.route(path, { instance: INSTANCE }); + const first = origin.route(path, { instance: { id: INSTANCE, epoch: 0n } }); const reader = origin.consume(); const pinned = reader.request(path, { instance: INSTANCE }); const unpinned = reader.request(path); expect(pinned.active.peek()?.instance).toBe(INSTANCE); expect(unpinned.active.peek()?.instance).toBe(INSTANCE); const replacement = Instance.parse("01900000-0000-7000-8000-000000000002"); - origin.route(path, { instance: replacement }); + origin.route(path, { instance: { id: replacement, epoch: 0n } }); expect(() => first.announce()).toThrow("broadcast is closed"); first.withdraw(); expect(pinned.active.peek()).toBeUndefined(); @@ -2743,7 +2747,7 @@ test("a request held across an instance change asks again and never takes the ol const origin = new Producer(); const prefix = Path.from("room"); const path = Path.from("room/cam"); - const dynamic = origin.routePrefix(prefix, { instance: INSTANCE }); + const dynamic = origin.routePrefix(prefix, { instance: { id: INSTANCE, epoch: 0n } }); const unpinned = origin.consume().request(path); const pinned = origin.consume().request(path, { instance: INSTANCE }); const requests = dynamic.requested(); @@ -2776,7 +2780,7 @@ test.each([ const origin = new Producer(); const prefix = Path.from("live"); const path = Path.from("live/cam"); - const dynamic = origin.routePrefix(prefix, { instance: named }); + const dynamic = origin.routePrefix(prefix, { instance: named && { id: named, epoch: 0n } }); // A claim naming no instance serves one of its own. const from = dynamic.instance; expect(from).toBeDefined(); @@ -2863,7 +2867,7 @@ test.each([ test("an instance update does not retain an answer that already closed", async () => { const origin = new Producer(); const path = Path.from("live/cam"); - const dynamic = origin.routePrefix(Path.from("live"), { instance: INSTANCE }); + const dynamic = origin.routePrefix(Path.from("live"), { instance: { id: INSTANCE, epoch: 0n } }); const requests = dynamic.requested(); const held = origin.consume().request(path); const old = new BroadcastProducer(); @@ -2891,7 +2895,7 @@ test("an instance update does not retain an answer that already closed", async ( test("an unread resolved request retains its broadcast until its last handle closes", async () => { const origin = new Producer(); const path = Path.from("live/cam"); - const dynamic = origin.routePrefix(Path.from("live"), { instance: INSTANCE }); + const dynamic = origin.routePrefix(Path.from("live"), { instance: { id: INSTANCE, epoch: 0n } }); const requests = dynamic.requested(); const held = origin.consume().request(path); const old = new SessionBroadcast(); @@ -2911,7 +2915,7 @@ test("an unread resolved request retains its broadcast until its last handle clo test("a session's re-request after an instance update asks the handler again", async () => { const origin = new Producer(); const path = Path.from("live/cam"); - const dynamic = origin.routePrefix(Path.from("live"), { instance: INSTANCE }); + const dynamic = origin.routePrefix(Path.from("live"), { instance: { id: INSTANCE, epoch: 0n } }); const requests = dynamic.requested(); const wire = wireOf(origin.consume()); @@ -2941,8 +2945,8 @@ test("an update does not override route selection: another claim at the instance const origin = new Producer(); const prefix = Path.from("live"); const path = Path.from("live/cam"); - const first = origin.routePrefix(prefix, { instance: INSTANCE, cost: 1n }); - const second = origin.routePrefix(prefix, { instance: INSTANCE, cost: 5n }); + const first = origin.routePrefix(prefix, { instance: { id: INSTANCE, epoch: 0n }, cost: 1n }); + const second = origin.routePrefix(prefix, { instance: { id: INSTANCE, epoch: 0n }, cost: 5n }); const firstRequests = first.requested(); const secondRequests = second.requested(); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index dfdfba82a1a..d4314b9071e 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -1288,10 +1288,10 @@ export interface RouteOptions { */ auth?: Credential; /** - * Serve this stable instance in place of a freshly minted one. + * Serve this stable instance `id` at `epoch`, in place of a freshly minted one. * * Only for a publisher that continues the instance's sequence space across restarts, - * announcing a higher {@link epoch} each time, such as a counter persisted on disk. Readers + * announcing a higher `epoch` each time, such as a counter persisted on disk. Readers * stitch every route of one instance together, so a publisher that restarts its group * sequences under an old instance splices unrelated groups into their tracks. The highest * epoch wins over every older one of the instance, cheaper or not. Naming an instance grants @@ -1300,9 +1300,7 @@ export interface RouteOptions { * Do not start a named instance with `~`: that marks one a relay minted (see * {@link Instance.isMinted}), which signing refuses to bind and a catalog's instance check skips. */ - instance?: Instance.Valid; - /** Which incarnation of {@link instance} this is; raise it on each restart. Ignored without an instance. */ - epoch?: bigint; + instance?: { id: Instance.Valid; epoch: bigint }; } /** Options for resolving one publisher instance with {@link Consumer.request}. */ @@ -1453,8 +1451,8 @@ export class Producer implements Table { if (!created) throw new Error("origin is closed"); // Normalized first, so a bad cost throws before anything is created. let info = WireRoute.normalize({ - instance: options.instance ?? Instance.random(), - epoch: options.instance === undefined ? 0n : options.epoch, + instance: options.instance?.id ?? Instance.random(), + epoch: options.instance?.epoch ?? 0n, cost: options.cost, auth: options.auth, }); @@ -1610,8 +1608,8 @@ export class Producer implements Table { */ routePrefix(prefix: Path.Valid, options: RouteOptions = {}): Route { const info = WireRoute.normalize({ - instance: options.instance, - epoch: options.epoch, + instance: options.instance?.id, + epoch: options.instance?.epoch, cost: options.cost, auth: options.auth, }); diff --git a/js/net/src/resume.test.ts b/js/net/src/resume.test.ts index 8948611f37b..ee54cef32e1 100644 --- a/js/net/src/resume.test.ts +++ b/js/net/src/resume.test.ts @@ -17,6 +17,8 @@ import type * as track from "./track.ts"; import { unwanted } from "./util/demand.ts"; import { overrideBroadcastWire, wireOf } from "./wire.ts"; +const INFO: track.Info = { compress: false, timescale: null, maxAge: null, priority: 0, refresh: null }; + const INSTANCE = Instance.parse("01900000-0000-7000-8000-000000000001"); const path = Path.from("live"); const enc = new TextEncoder(); @@ -541,7 +543,7 @@ test("a resumed fork shares an on-demand track request", async () => { test("a lagging same-instance replica resumes from one publisher's bytes exactly once", async () => { const { clock, installed } = manual(); const publisher = new Producer(); - const originalRoute = publisher.route(path, { instance: INSTANCE }); + const originalRoute = publisher.route(path, { instance: { id: INSTANCE, epoch: 0n } }); const original = originalRoute.broadcast; const source = original.createTrack("seq", { maxAge: null }); const origin = new Producer(); @@ -3267,7 +3269,7 @@ test.each(["handed out", "buffered"])("an end below a group another copy %s leav test.each([ ["fails", (answer: Lister) => answer.abort(new Error("old route failed"))], ["ends", (answer: Lister) => answer.end()], - ["changes", (answer: Lister) => answer.add("gone", { priority: 0 })], + ["changes", (answer: Lister) => answer.add("gone", INFO)], ])("a listing follows the source that took over as the old one's listing %s", async (_, end) => { const front = new Front(); const a = new BroadcastProducer(); @@ -3309,8 +3311,8 @@ test("a concluded front's listing keeps its closed source's finishes", async () const a = new BroadcastProducer(); const from = a.consume(); const answer = wireOf(from).lister(); - answer.add("seq", { priority: 0 }); - answer.add("live", { priority: 0 }); + answer.add("seq", INFO); + answer.add("live", INFO); answer.synced(); front.serve(from); const tracks = front.consumer.tracks(); diff --git a/js/net/src/resume.ts b/js/net/src/resume.ts index a93a52a81b5..20d401d818f 100644 --- a/js/net/src/resume.ts +++ b/js/net/src/resume.ts @@ -140,8 +140,7 @@ const INFO_FIELDS = Object.keys({ /** Two copies claiming the same content must agree on all of it, like Rust's `Info` equality. */ function compatible(a: track.Info, b: track.Info): boolean { - const [x, y] = [track.infoDefaults(a), track.infoDefaults(b)]; - return INFO_FIELDS.every((field) => x[field] === y[field]); + return INFO_FIELDS.every((field) => a[field] === b[field]); } /** diff --git a/js/net/src/socket.test.ts b/js/net/src/socket.test.ts index 12c4cc4c421..66aba2a3542 100644 --- a/js/net/src/socket.test.ts +++ b/js/net/src/socket.test.ts @@ -153,7 +153,7 @@ test("a peer route replaced between metadata and opening cannot inherit a socket test("track halves start reliable and ascending, preserve old messages and accept independent info", async () => { const { local, remote } = await track(); - expect((await local.producer.info()).maxAge).toBeUndefined(); + expect((await local.producer.info()).maxAge).toBeNull(); expect(remote.consumer.subscription.peek()?.order).toBe("ascending"); expect(remote.consumer.subscription.peek()?.delay?.max).toBe(Milli(Number.MAX_SAFE_INTEGER)); for (let i = 0; i < 3; i++) local.producer.writeFrame(bytes(i)); diff --git a/js/net/src/subscribe_priority.test.ts b/js/net/src/subscribe_priority.test.ts index caa93dff11f..06dfa5569e3 100644 --- a/js/net/src/subscribe_priority.test.ts +++ b/js/net/src/subscribe_priority.test.ts @@ -4,7 +4,7 @@ import { expect, spyOn, test } from "bun:test"; import { Manual, yieldNow } from "@moq/clock"; -import { accept, connect, type Established } from "./connection/index.ts"; +import { accept, connect, type Session } from "./connection/index.ts"; import * as Ietf from "./ietf/index.ts"; import { SubscribeUpdate } from "./ietf/subscribe.ts"; import * as Lite from "./lite/index.ts"; @@ -23,7 +23,7 @@ const LOW = -100; const HIGH = 100; // Dial a session that consumes what `origin` serves. -async function session(protocol: string, origin: OriginProducer): Promise<[Established, Established]> { +async function session(protocol: string, origin: OriginProducer): Promise<[Session, Session]> { const pair = createMockTransportPair(protocol); lateAcks(pair.client); return Promise.all([ diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 42db09bf3ad..32161bfea93 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -7,8 +7,9 @@ import type { Consumer as GroupConsumer, Producer as GroupProducer } from "./gro import { Version as IetfVersion } from "./ietf/version.ts"; import { hooks, skipHole, staleHole } from "./internal.ts"; import { textFrame } from "./mock.ts"; +import { rescale } from "./rescale.ts"; import { Timescale, Timestamp } from "./time.ts"; -import { authoredInfo, infoDefaults, rescale, Subscription, Producer as TrackProducer } from "./track.ts"; +import { authoredInfo, Subscription, Producer as TrackProducer } from "./track.ts"; import { unwanted } from "./util/demand.ts"; const enc = new TextEncoder(); @@ -394,10 +395,10 @@ test("a fixed start is no join", () => { }); test("refresh must be a u32 ratio", () => { - expect(() => infoDefaults({ refresh: -1 })).toThrow(RangeError); - expect(() => infoDefaults({ refresh: 0.5 })).toThrow(RangeError); - expect(() => infoDefaults({ refresh: 2 ** 32 })).toThrow(RangeError); - expect(infoDefaults({ refresh: 0 }).refresh).toBe(0); + expect(() => authoredInfo({ refresh: -1 })).toThrow(RangeError); + expect(() => authoredInfo({ refresh: 0.5 })).toThrow(RangeError); + expect(() => authoredInfo({ refresh: 2 ** 32 })).toThrow(RangeError); + expect(authoredInfo({ refresh: 0 }).refresh).toBe(0); }); test("rescale matches the track's timeline, rounded down as Rust's convert does", () => { @@ -539,23 +540,19 @@ test("priority -128..=127 is accepted and anything outside is refused", () => { expect(() => producer.subscribe({ priority: 128 })).toThrow(RangeError); }); -test("infoDefaults re-validates timescale and the safe maxAge bound", () => { - expect(infoDefaults({ timescale: Timescale.MICRO }).timescale).toBe(Timescale.MICRO); - expect(infoDefaults({ maxAge: Milli(Number.MAX_SAFE_INTEGER) }).maxAge).toBe(Milli(Number.MAX_SAFE_INTEGER)); - expect(() => infoDefaults({ timescale: 0 as Timescale })).toThrow(RangeError); - expect(() => infoDefaults({ timescale: (Number.MAX_SAFE_INTEGER + 1) as Timescale })).toThrow(RangeError); - expect(() => infoDefaults({ maxAge: Milli(Number.MAX_SAFE_INTEGER + 1) })).toThrow(RangeError); +test("authoredInfo re-validates timescale and the safe maxAge bound", () => { + expect(authoredInfo({ timescale: Timescale.MICRO }).timescale).toBe(Timescale.MICRO); + expect(authoredInfo({ maxAge: Milli(Number.MAX_SAFE_INTEGER) }).maxAge).toBe(Milli(Number.MAX_SAFE_INTEGER)); + expect(() => authoredInfo({ timescale: 0 as Timescale })).toThrow(RangeError); + expect(() => authoredInfo({ timescale: (Number.MAX_SAFE_INTEGER + 1) as Timescale })).toThrow(RangeError); + expect(() => authoredInfo({ maxAge: Milli(Number.MAX_SAFE_INTEGER + 1) })).toThrow(RangeError); }); -test("an authored track defaults to a zero max age, null is unlimited, and decoded absence stays unlimited", async () => { +test("an authored track defaults to a zero max age, and null is unlimited", async () => { expect((await new TrackProducer("omitted").accept().info()).maxAge).toBe(Milli.zero); expect((await new TrackProducer("undefined").accept({ maxAge: undefined }).info()).maxAge).toBe(Milli.zero); - expect((await new TrackProducer("unlimited").accept({ maxAge: null }).info()).maxAge).toBeUndefined(); + expect((await new TrackProducer("unlimited").accept({ maxAge: null }).info()).maxAge).toBeNull(); expect((await new TrackProducer("explicit").accept({ maxAge: Milli(5000) }).info()).maxAge).toBe(Milli(5000)); - expect(authoredInfo({ maxAge: null }).maxAge).toBeUndefined(); - // Decoded metadata has no authored default: absence is no publisher limit. - expect(infoDefaults({}).maxAge).toBeUndefined(); - expect(infoDefaults({ maxAge: undefined }).maxAge).toBeUndefined(); }); // The catalog relies on this: a track that publishes one snapshot and never changes it stays @@ -3775,8 +3772,8 @@ for (const [name, extra] of [ ["IETF provenance", { ietf: IetfVersion.DRAFT_22 }], ] as const) { test(`${name} survives normalization and skips the rewind check`, () => { - for (const timescale of [undefined, Timescale.MILLI]) { - const info = infoDefaults({ ...extra, timescale }); + for (const timescale of [null, Timescale.MILLI]) { + const info = authoredInfo({ ...extra, timescale }); expect(info).toMatchObject(extra); expect(info.timescale).toBe(timescale); } diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 805bf6b2d20..ba8bbc867cd 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -45,7 +45,6 @@ import { type TrackRequestOptions, } from "./internal.ts"; import { grace, Ledger } from "./ledger.ts"; -import { rescale } from "./rescale.ts"; import { Timescale, type Timestamp } from "./time.ts"; import { checkEnd, checkSequence, max, min, UNBOUNDED } from "./util/sequence.ts"; import { @@ -127,43 +126,40 @@ export interface Position { * (resolved from the wire TRACK_INFO on lite-05+). They map 1:1 onto TRACK_INFO. */ export interface Info { - /** Compress frame payloads per group on lite-07; other sessions receive plain frames. Defaults to false. */ - compress?: boolean; + /** Whether frame payloads are compressed per group on lite-07; other sessions receive plain frames. */ + compress: boolean; /** - * Units per second for this track's frame timestamps, or omitted (`undefined` or `null`, - * which mean the same) for an untimed track, whose frames carry no timestamp. + * Units per second for this track's frame timestamps, or `null` for an untimed track, whose + * frames carry no timestamp. * - * There is no default: a timed track declares its scale, such as {@link Timescale | Timescale.MILLI}, - * or {@link Timescale | Timescale.MICRO} for media that needs sub-millisecond timing. Reported in + * A timed track declares its scale, such as {@link Timescale | Timescale.MILLI}, or + * {@link Timescale | Timescale.MICRO} for media that needs sub-millisecond timing. Reported in * TRACK_INFO on Lite05+ and as the TIMESCALE property on moq-transport. A track received * without one (moq-lite before Lite05, or moq-transport without TIMESCALE) is untimed, * and stays untimed when served onward. */ - timescale?: Timescale | null; + timescale: Timescale | null; /** - * Publisher Max Age: how long, in milliseconds, a superseded group stays fresh. It is - * stale once either clock reaches the budget: the wall-clock time since its successor - * arrived at this hop, or how far its successor's first timestamp trails the live edge. - * The newest group is never stale, and an untimed track is judged by the wall clock alone. - * Reported in TRACK_INFO (Lite05+) so relays re-serve with the same bound. Absence sets - * no publisher limit, as on a track received without one; zero serves only the live edge. - * This clamps each subscriber budget. An authored track gets zero unless its - * {@link InfoInit.maxAge} asks for more. A freshness bound, not retention: a hop's cache - * bounds the bytes it keeps with its own window. Rounded up to a whole millisecond by - * {@link infoDefaults}, which refuses a negative or non-finite value and a result past - * `Number.MAX_SAFE_INTEGER`. + * Publisher Max Age: how long, in milliseconds, a superseded group stays fresh, or `null` for + * no publisher limit. It is stale once either clock reaches the budget: the wall-clock time + * since its successor arrived at this hop, or how far its successor's first timestamp trails + * the live edge. The newest group is never stale, and an untimed track is judged by the wall + * clock alone. Reported in TRACK_INFO (Lite05+) so relays re-serve with the same bound. A + * track received without one has no limit; zero serves only the live edge. + * This clamps each subscriber budget. A freshness bound, not retention: a hop's cache bounds + * the bytes it keeps with its own window. */ - maxAge?: Milli; - /** Tie-break priority between subscriptions of equal subscriber priority (`-128..=127`, higher first). Defaults to `0`. */ + maxAge: Milli | null; + /** Tie-break priority between subscriptions of equal subscriber priority (`-128..=127`, higher first). */ priority: number; /** * How many times its first frame a group's later stored bytes may grow before a joining - * subscriber asks for a new group, or omitted to never ask. A join against a newest group + * subscriber asks for a new group, or `null` to never ask. A join against a newest group * whose bytes after its first frame exceed this many times the first frame moves * {@link Demand.wanted} past it. The joiner still receives the cached group while the new * one is pending. `0` asks on any join after a delta. An integer in `0..2^32`. */ - refresh?: number; + refresh: number | null; /** The original moq-transport publisher's draft, whose timestamps may rewind. @internal */ ietf?: IetfVersion; /** @@ -175,32 +171,39 @@ export interface Info { } /** - * What an author supplies to create a track: any {@link Info} field, each defaulting as - * {@link infoDefaults} does, except {@link maxAge}. - * - * Received metadata is an {@link Info}, whose absent `maxAge` means no publisher limit. To serve - * one onward unchanged, pass `{ ...info, maxAge: info.maxAge ?? null }`. + * What an author supplies to create a track: any {@link Info} field. `compress` defaults to + * false, `timescale` and `refresh` to `null`, `priority` to `0`, and `maxAge` to zero. A + * received {@link Info} serves onward unchanged. */ -export interface InfoInit extends Partial> { +export interface InfoInit extends Partial { /** - * The publisher max age; see {@link Info.maxAge}. Omitted or `undefined` is zero, so readers - * get only the live edge; `null` sets no publisher limit (the wire omits it), and a number - * is that bound. A track whose readers reach back for superseded groups sets it explicitly. + * The publisher max age; see {@link Info.maxAge}. Omitted is zero, so readers get only the + * live edge; `null` sets no publisher limit (the wire omits it). A track whose readers reach + * back for superseded groups sets it explicitly. Rounded up to a whole millisecond; a + * negative or non-finite value, or one past `Number.MAX_SAFE_INTEGER`, is refused. */ maxAge?: Milli | null; } /** - * Resolve an author's {@link InfoInit} into the {@link Info} a producer commits. + * Resolve an author's {@link InfoInit} into the {@link Info} a producer commits, validating each field. * @internal */ export function authoredInfo(init: InfoInit = {}): Info { - const { maxAge, ...rest } = init; - return infoDefaults({ ...rest, maxAge: maxAge === undefined ? Milli.zero : (maxAge ?? undefined) }); + if (init.compress !== undefined && typeof init.compress !== "boolean") + throw new TypeError("compress must be a boolean"); + const maxAge = init.maxAge === undefined ? Milli.zero : init.maxAge; + return { + compress: init.compress ?? false, + timescale: init.timescale == null ? null : Timescale(init.timescale), + maxAge: maxAge === null ? null : wireMillis("maxAge", maxAge), + priority: priorityValue(init.priority ?? DEFAULT_PRIORITY), + refresh: init.refresh == null ? null : refreshRatio(init.refresh), + ...(init.ietf === undefined ? {} : { ietf: init.ietf }), + ...(init.unchecked ? { unchecked: true } : {}), + }; } -export { rescale }; - // Normalize a duration for the wire, which carries it as an unsigned varint. // // Callers derive it from measurements (a jitter estimate scaled off RTT), so a fractional @@ -225,26 +228,6 @@ function priorityValue(value: number): number { return value; } -/** - * Fill in any unset {@link Info} fields with their defaults, as for received metadata: an absent - * `maxAge` stays absent, meaning no publisher limit. An authored track resolves its - * {@link InfoInit} at {@link Producer.accept} instead, where an omitted `maxAge` is zero. - */ -export function infoDefaults(info: Partial = {}): Info { - if (info.compress !== undefined && typeof info.compress !== "boolean") - throw new TypeError("compress must be a boolean"); - const timescale = info.timescale == null ? undefined : Timescale(info.timescale); - return { - compress: info.compress ?? false, - timescale, - maxAge: info.maxAge === undefined ? undefined : wireMillis("maxAge", info.maxAge), - priority: priorityValue(info.priority ?? DEFAULT_PRIORITY), - refresh: info.refresh == null ? undefined : refreshRatio(info.refresh), - ...(info.ietf === undefined ? {} : { ietf: info.ietf }), - ...(info.unchecked ? { unchecked: true } : {}), - }; -} - function refreshRatio(value: number): number { if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { throw new RangeError(`refresh must be an integer in 0..2^32: ${value}`); @@ -924,7 +907,6 @@ let ordered_: { readFrame(subscriber: Subscriber): Promise<({ group: bigint; frame: number } & Frame) | undefined>; readString(subscriber: Subscriber): Promise; readJson(subscriber: Subscriber): Promise; - readBool(subscriber: Subscriber): Promise; }; // Constructs a Demand from within this module; assigned in the class's static block. @@ -1070,10 +1052,7 @@ export class Producer { this.#fills = new FillState({ name, accept: (sequence, info) => { - // A fill's info is the publisher's metadata, whose absent maxAge means no publisher - // limit, not an authored track's zero. - if (info !== undefined && this.#state.info.peek() === undefined) - this.accept({ ...info, maxAge: info.maxAge ?? null }); + if (info !== undefined && this.#state.info.peek() === undefined) this.accept(info); return this.#createGroup(sequence, 0); }, drop: (start, end, error) => this.#drop(start, end, { error }), @@ -1373,7 +1352,7 @@ export class Producer { try { for (const { group } of this.#cache) { hooks.bindGroupTimed(group, resolved); - hooks.bindGroupCompression(group, resolved.compress ?? false); + hooks.bindGroupCompression(group, resolved.compress); } } catch (err) { this.abort(err instanceof Error ? err : new Error(String(err))); @@ -1429,7 +1408,7 @@ export class Producer { #join(): void { const ratio = this.#state.info.peek()?.refresh; const latest = this.#state.latest; - if (ratio === undefined || latest === undefined) return; + if (ratio == null || latest === undefined) return; const group = this.#cached.get(latest)?.group; if (!group || !hooks.overRefresh(group, ratio)) return; this.#refresh = true; @@ -1793,7 +1772,7 @@ export class Producer { const info = this.#state.info.peek(); if (info) { hooks.bindGroupTimed(group, info); - hooks.bindGroupCompression(group, info.compress ?? false); + hooks.bindGroupCompression(group, info.compress); } } @@ -2024,7 +2003,7 @@ export class Producer { const update = this.#state.update.peek(); const maxDelay = update ? maxDelayOf(update) : Milli.zero; const maxAge = this.#state.info.peek()?.maxAge; - return grace(maxAge === undefined ? maxDelay : Milli(Math.min(maxDelay, maxAge))); + return grace(maxAge == null ? maxDelay : Milli(Math.min(maxDelay, maxAge))); } // Tell every reader whether the pending tail still owes a group below the end. @@ -2377,7 +2356,7 @@ export class Subscriber { const update = this.#state.update.peek(); const requested = this.#fetchBudget ?? (update ? maxDelayOf(update) : 0); - const fresh = info?.maxAge; + const fresh = info?.maxAge ?? undefined; return { budget: fresh === undefined ? requested : Math.min(requested, fresh), bounded: fresh !== undefined && requested >= fresh, @@ -3337,7 +3316,6 @@ export class Subscriber { readFrame: (subscriber) => subscriber.#readFrame(), readString: (subscriber) => subscriber.#readString(), readJson: (subscriber) => subscriber.#readJson(), - readBool: (subscriber) => subscriber.#readBool(), }; } @@ -4097,15 +4075,6 @@ export class Subscriber { return JSON.parse(next); } - /** Reads the next frame and decodes it as a one-byte boolean, throwing on a malformed frame. */ - async #readBool(): Promise { - const next = await this.#readFrame(); - if (!next) return undefined; - const payload = next.payload; - if (payload.byteLength !== 1 || !(payload[0] === 0 || payload[0] === 1)) throw new Error("invalid bool frame"); - return payload[0] === 1; - } - /** * Read this track's groups in sequence order instead of arrival order. * @@ -4269,9 +4238,4 @@ export class Ordered { readJson(): Promise { return ordered_.readJson(this.#subscriber); } - - /** Read the next frame and decode it as a one-byte boolean, throwing on a malformed frame. */ - readBool(): Promise { - return ordered_.readBool(this.#subscriber); - } } diff --git a/js/net/src/untimed.test.ts b/js/net/src/untimed.test.ts index eae4fe355e2..ea6efbb5241 100644 --- a/js/net/src/untimed.test.ts +++ b/js/net/src/untimed.test.ts @@ -140,7 +140,7 @@ test("a forwarded untimed track stays untimed", async () => { const remote = wireOf(subscriber).consume(Path.from("test")); const track = remote.track("video").subscribe(); - expect((await track.info()).timescale).toBeUndefined(); + expect((await track.info()).timescale).toBeNull(); const frame = await (await track.ordered().nextGroup())?.readFrame(); expect(frame?.payload).toEqual(new Uint8Array([7])); expect(frame?.timestamp).toBeUndefined(); @@ -158,7 +158,7 @@ for (const wire of WIRES) { test(`${wire.name}: an untimed track arrives ${wire.sends ? "with its send times" : "untimed"}`, async () => { const { info, frames } = await roundTrip(wire, undefined, [undefined, undefined]); expect(frames.map((frame) => frame.payload[0])).toEqual([0, 1]); - expect(info.timescale).toBe(wire.sends ? Timescale.MILLI : undefined); + expect(info.timescale).toBe(wire.sends ? Timescale.MILLI : null); for (const frame of frames) expect(frame.timestamp !== undefined).toBe(wire.sends); }); @@ -174,7 +174,7 @@ for (const wire of WIRES) { expect(frames.map((frame) => frame.timestamp?.asMicros())).toEqual([1_000, 1_234]); } else { // No trusted units on the wire, and no arrival time made up in their place. - expect(info.timescale).toBeUndefined(); + expect(info.timescale).toBeNull(); for (const frame of frames) expect(frame.timestamp).toBeUndefined(); } }); diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index 5913f25a88e..8da4e386e9e 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -178,7 +178,7 @@ export function wireOf(handle: broadcast.Producer | broadcast.Consumer): Broadca export function wireOf(handle: origin.Producer): OriginProducer; export function wireOf(handle: origin.Consumer): OriginConsumer; export function wireOf(handle: origin.Route): Route; -export function wireOf(handle: import("./connection/established.ts").Established): Established; +export function wireOf(handle: import("./connection/session.ts").Session): Established; /** Return the package-private view for a handle. */ export function wireOf(handle: object): View { const view = views.get(handle); diff --git a/js/p2p/src/transport.ts b/js/p2p/src/transport.ts index 85404c7e9ea..a734faee01c 100644 --- a/js/p2p/src/transport.ts +++ b/js/p2p/src/transport.ts @@ -1,4 +1,4 @@ -import Session, { type SendStreamOptions } from "@moq/qmux"; +import { type SendStreamOptions, Session } from "@moq/qmux"; import { Channel } from "./channel.ts"; /** The peer connection and the MoQ ALPN agreed through signaling. */ diff --git a/js/publish/README.md b/js/publish/README.md index a41af07982d..391e5bbb021 100644 --- a/js/publish/README.md +++ b/js/publish/README.md @@ -125,7 +125,7 @@ const broadcast = new Publish.Broadcast({ // Each encoder registers its rendition on the broadcast and encodes while enabled, // subscribed or not, so a late viewer can reach back into the cache. Pass -// `onDemand: true` to the broadcast to encode only while someone is subscribed. +// `onDemand: "all"` to the broadcast to encode only while someone is subscribed. const hd = new Publish.Video.Encoder("video/hd", { broadcast, capture, enabled: true }); const sd = new Publish.Video.Encoder("video/sd", { broadcast, capture, enabled: true, config: { maxScale: 0.25 } }); diff --git a/js/publish/api/index.api.md b/js/publish/api/index.api.md index e6651a3e727..afab8e9f83d 100644 --- a/js/publish/api/index.api.md +++ b/js/publish/api/index.api.md @@ -110,9 +110,6 @@ export namespace Audio { sampleRate: number; sampleSize?: number; } - export function isSampleSource(source: Audio.Source): source is Audio.SampleSource; - export function normalizeSource(source: Audio.StreamTrack | Audio.SourceConfig): Audio.SourceConfig; - export function sourceKind(source: Audio.Source): Audio.Kind; } export class Broadcast { @@ -122,7 +119,7 @@ export class Broadcast { readonly catalog: CatalogProducer; close(): void; readonly in: Signals.Readonlys; - readonly join: Signals.Signal; + readonly join: Signals.Getter; readonly net: Signals.Getter; text(name: string): Rendition; video(name: string): Rendition; @@ -139,7 +136,7 @@ export type BroadcastInput = { flip: Signals.Getter; maxAge: Signals.Getter; name: Signals.Getter; - onDemand: Signals.Getter; + onDemand: Signals.Getter; origin: Signals.Getter; probe: Signals.Getter; rotation: Signals.Getter; @@ -164,6 +161,8 @@ export type Kind = "video" | "audio" | "text"; export * as Net from "@moq/net"; +export type OnDemand = "off" | "retain" | "all"; + export namespace Preview { export { Backend } from "@moq/video"; export type Mode = "none" | "source" | "encoded"; @@ -190,21 +189,6 @@ export namespace Preview { rotation: Signals.Getter; }; export type RendererProps = Signals.Inputs; - export class Transcode { - constructor(props?: Preview.TranscodeProps); - close(): void; - readonly in: Signals.Readonlys; - readonly out: Signals.Readonlys; - } - export type TranscodeInput = { - config: Signals.Getter; - settings: Signals.Getter; - source: Signals.Getter | undefined>; - }; - export type TranscodeOutput = { - frame: Signals.Signal; - }; - export type TranscodeProps = Signals.Inputs; } export class Rendition { @@ -435,8 +419,6 @@ export namespace Video { resizeMode: "none" | "crop-and-scale"; width: number; } - export function isStreamTrack(source: Video.Source): source is Video.StreamTrack; - export function normalizeSource(source: Video.StreamTrack | Video.SourceConfig): Video.SourceConfig; } export function assets(url: string | URL): void; diff --git a/js/publish/bench/audio-page.ts b/js/publish/bench/audio-page.ts index cbd79f8d83b..c23ab39ba14 100644 --- a/js/publish/bench/audio-page.ts +++ b/js/publish/bench/audio-page.ts @@ -84,7 +84,7 @@ async function measure(tracks: number, seconds: number, onDemand: boolean) { origin: new Moq.Origin.Producer(), name: Moq.Path.from("audio.hang"), maxAge: Milli(seconds * 1000), - onDemand, + onDemand: onDemand ? "retain" : "off", }); const feeds = Array.from({ length: tracks }, () => new Feed()); const encoders = feeds.map((feed, index) => { diff --git a/js/publish/src/audio/capture.test.ts b/js/publish/src/audio/capture.test.ts index d131b693bb7..88fa114b7d9 100644 --- a/js/publish/src/audio/capture.test.ts +++ b/js/publish/src/audio/capture.test.ts @@ -586,7 +586,7 @@ test("a failed encoder stays failed until the capture source changes", async () broadcast: { audio: () => rendition, order: new Order(), - in: { maxAge: new Signal(undefined), onDemand: new Signal(false) }, + in: { maxAge: new Signal(undefined), onDemand: new Signal("off") }, } as never, capture, enabled, diff --git a/js/publish/src/audio/encoder.test.ts b/js/publish/src/audio/encoder.test.ts index 8d42adeb4b5..d8c62218672 100644 --- a/js/publish/src/audio/encoder.test.ts +++ b/js/publish/src/audio/encoder.test.ts @@ -339,7 +339,7 @@ async function setup(order = new Order(), codec?: Codec, join?: Milli) { broadcast: { audio: () => rendition, order, - in: { maxAge: new Signal(undefined), onDemand: new Signal(false) }, + in: { maxAge: new Signal(undefined), onDemand: new Signal("off") }, } as never, capture: capture as never, enabled, @@ -1021,7 +1021,7 @@ test("audio failure stays local through demand changes and capture format re-arm const broadcast = new Broadcast({ origin: new Moq.Origin.Producer(), name: Moq.Path.from("failure.hang"), - onDemand: false, + onDemand: "off", }); const feed = new Feed(); const capture = fakeCapture(feed); @@ -1167,7 +1167,7 @@ test.each(["failure first", "config first"])( using _now = manualAt(0); const broadcast = new Broadcast({ origin: new Moq.Origin.Producer(), - onDemand: false, + onDemand: "off", name: Moq.Path.from("coalesced.hang"), }); const configuring = configured(); @@ -1203,7 +1203,7 @@ test.each(["failure first", "config first"])( ); // Both policies preserve the past; only always-on spends encode work while idle. -test.each([undefined, false, true])( +test.each([undefined, "off", "all"] as const)( "a late subscriber reaches captured audio with either encoding policy (%p)", async (onDemand) => { using _webcodecs = installFakeWebCodecs(); @@ -1222,7 +1222,7 @@ test.each([undefined, false, true])( await settle(); const net = broadcast.net.peek(); if (!net) throw new Error("expected network broadcast"); - expect(encoder.out.active.peek()).toBe(onDemand === false); + expect(encoder.out.active.peek()).toBe(onDemand === "off"); // One 20ms Opus frame per push, each its own group. let index = 0; @@ -1234,7 +1234,7 @@ test.each([undefined, false, true])( }; await push(4); - expect(encoder.out.stats.peek().frames).toBe(onDemand === false ? 4 : 0); + expect(encoder.out.stats.peek().frames).toBe(onDemand === "off" ? 4 : 0); late = net.track("audio").subscribe({ delay: { max: Milli(10_000) } }); await settle(); expect(encoder.out.active.peek()).toBe(true); @@ -1254,7 +1254,7 @@ test.each([undefined, false, true])( late = undefined; await settle(); expect(discontinuity).not.toHaveBeenCalled(); - expect(encoder.out.active.peek()).toBe(onDemand === false); + expect(encoder.out.active.peek()).toBe(onDemand === "off"); } finally { discontinuity.mockRestore(); late?.close(); diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index 15071e1e995..c48804d43b1 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -136,7 +136,7 @@ export type EncoderOutput = { // source that isn't a track. root: Signal; // True while encoding capture: enabled and unmuted with a captured format and a track, which also - // takes a subscriber unless the broadcast's `onDemand` is false. Retained audio encoded for a + // takes a subscriber unless the broadcast's `onDemand` is `"off"`. Retained audio encoded for a // subscriber while paused does not count. active: Signal; // Cumulative output totals (frames, bytes) measured while serving, and the target bitrate. @@ -156,7 +156,7 @@ type Pipeline = { * * Registers itself on the {@link Broadcast} under {@link name} (via `broadcast.audio(name)`), pumps PCM * off the source via a {@link Capture}, and retains raw PCM while enabled. A subscriber triggers encoding of that history followed by - * live audio; leaving resumes buffering without ending the epoch. The broadcast's `onDemand: false` + * live audio; leaving resumes buffering without ending the epoch. The broadcast's `onDemand: "off"` * keeps encoding continuously instead. Rename by constructing a new encoder; the name is not a signal. */ export class Encoder { @@ -483,7 +483,7 @@ export class Encoder { // the current subscriber and every later one get it rather than a track that stays silent. const fatal = effect.get(this.#out.error); - const onDemand = effect.get(broadcast.in.onDemand) ?? true; + const onDemand = effect.get(broadcast.in.onDemand) !== "off"; const demanded = !!track && effect.get(track.demand().wanted) !== undefined; // Demand from fetches alone carries no subscription, so no budget beyond the retention. const max = track && effect.get(track.subscription)?.delay?.max; diff --git a/js/publish/src/audio/index.ts b/js/publish/src/audio/index.ts index 837ad3d250f..57953444632 100644 --- a/js/publish/src/audio/index.ts +++ b/js/publish/src/audio/index.ts @@ -14,7 +14,16 @@ export { type OpusConfig, type Stats, } from "./encoder"; -export * from "./types"; +export type { + CodecMime, + Constraints, + Kind, + SampleSource, + Source, + SourceConfig, + StreamTrack, + TrackSettings, +} from "./types"; /** A registered audio rendition on a Broadcast. See {@link BaseRendition}. */ export type Rendition = BaseRendition; diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index e043c2276e9..df92fc7f699 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -305,7 +305,7 @@ test("on demand hands the producer to the rendition only while a subscriber is a enabled: true, origin: new Origin.Producer(), name: Path.from("test.hang"), - onDemand: true, + onDemand: "all", }); await settle(); @@ -559,7 +559,7 @@ test("changing maxAge publishes a new network broadcast", async () => { expect((await broadcast.net.peek()?.track("video").info())?.maxAge).toBe(Container.MAX_AGE); maxAge.set(null); await settle(); - expect((await broadcast.net.peek()?.track("video").info())?.maxAge).toBeUndefined(); + expect((await broadcast.net.peek()?.track("video").info())?.maxAge).toBeNull(); broadcast.close(); }); @@ -1092,7 +1092,7 @@ test("an on-demand rendition declares the interval before encoding, and a media enabled: true, origin: new Origin.Producer(), name: Path.from("ondemand.hang"), - onDemand: true, + onDemand: "all", }); const video = broadcast.video("video"); video.join.set(Milli(2000)); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index d38b60d8961..41b9c56f558 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -85,15 +85,23 @@ export type BroadcastInput = { */ echo: Getter; - /** - * Override when renditions encode. Omitted: audio retains raw PCM until subscribed, while - * video encodes continuously. False encodes both continuously; true gates video too, losing - * its past. Unless false, audio keeps raw history up to `maxAge`, or 30 seconds when it is - * `null`. Capture stays open. - */ - onDemand: Getter; + /** Which renditions wait for a subscriber before encoding. Defaults to `"retain"`. */ + onDemand: Getter; }; +/** + * Which renditions wait for a subscriber before encoding. Capture stays open in every mode. + * + * - `"off"`: every rendition encodes continuously. + * - `"retain"`: audio retains raw PCM until subscribed, then encodes from it; video and text + * encode continuously. + * - `"all"`: audio retains, and video and text also wait for a subscriber, losing their + * unsubscribed past. + * + * Retained audio keeps raw history up to the broadcast's `maxAge`, or 30 seconds when it is `null`. + */ +export type OnDemand = "off" | "retain" | "all"; + // The longest group among the video and audio renditions, the catalog's `media.join.interval`: the // promise that every one starts a group at least that often. Undefined while none declares one, so a // later listing tries again; it reads every hint until then, since none is fixed yet. @@ -164,12 +172,14 @@ export class Broadcast { // The renditions of the current network broadcast, undefined while there is none. readonly #publication = new Signal(undefined); + readonly #join = new Signal(undefined); + /** * The catalog's `media.join.interval` the current network broadcast declared, in milliseconds: * the longest group any video or audio rendition promised when media was first listed. Fixed * for the network broadcast, so an encoder holds its groups within it. Undefined until declared. */ - readonly join = new Signal(undefined); + readonly join: Getter = this.#join; // Each catalog ID in the latest catalog fold, which seeds a new network broadcast's publication. #listed = new Map(); @@ -189,7 +199,7 @@ export class Broadcast { stats: getter(props?.stats ?? false), probe: getter(props?.probe), echo: getter(props?.echo), - onDemand: getter(props?.onDemand), + onDemand: getter(props?.onDemand ?? "retain"), }; this.#signals.run(this.#runCatalog.bind(this)); @@ -312,7 +322,7 @@ export class Broadcast { publication.join = longestGroup(effect, renditions); } const join = publication?.join; - this.join.set(join === undefined ? undefined : Milli(join)); + this.#join.set(join === undefined ? undefined : Milli(join)); this.catalog.mutate((catalog) => { if (join !== undefined) { @@ -569,7 +579,7 @@ export class Broadcast { } const idle = rendition.kind !== "audio" && - gate.get(this.in.onDemand) && + gate.get(this.in.onDemand) === "all" && gate.get(served.demand().wanted) === undefined; handle.track.set(idle ? undefined : served); }); diff --git a/js/publish/src/element.ts b/js/publish/src/element.ts index 769254f1bbb..4fda6f0d804 100644 --- a/js/publish/src/element.ts +++ b/js/publish/src/element.ts @@ -14,6 +14,7 @@ import * as Preview from "./preview"; import * as Source from "./source"; import { clearSourceState } from "./source-state"; import * as Video from "./video"; +import { normalizeSource } from "./video/types"; function invalidAttribute(name: string, value: unknown, expected: string): Error { return new Error(`moq-publish: invalid ${name}="${value}", expected ${expected}`); @@ -428,7 +429,7 @@ export default class MoqPublish extends HTMLElement { }; effect.event(preview, "loadedmetadata", resize); effect.event(preview, "resize", resize); - preview.srcObject = new MediaStream([Video.normalizeSource(source).track]); + preview.srcObject = new MediaStream([normalizeSource(source).track]); effect.cleanup(() => { preview.srcObject = null; diff --git a/js/publish/src/preview.ts b/js/publish/src/preview.ts index 88bd13509ed..69b3b62fed2 100644 --- a/js/publish/src/preview.ts +++ b/js/publish/src/preview.ts @@ -185,7 +185,7 @@ export class Renderer { } } -/** Signals the transcoder reads. */ +/** Signals the transcoder reads. @internal */ export type TranscodeInput = { // The captured frames to re-encode. We subscribe for our own copies and close them. source: Getter | undefined>; @@ -195,10 +195,10 @@ export type TranscodeInput = { settings: Getter; }; -/** Constructor options for {@link Transcode}. */ +/** Constructor options for {@link Transcode}. @internal */ export type TranscodeProps = Inputs; -/** Signals the transcoder derives. */ +/** Signals the transcoder derives. @internal */ export type TranscodeOutput = { // The decoded output frame. Owned here, closed on each update and on close(). frame: Signal; @@ -207,6 +207,8 @@ export type TranscodeOutput = { /** * Encodes the captured frames with the live rendition settings and decodes the result, so the * output frame is what a viewer would actually see after transmission. + * + * @internal */ export class Transcode { /** The wired inputs, read-only. */ diff --git a/js/publish/src/rendition-internal.ts b/js/publish/src/rendition-internal.ts index 3a546c02459..8ee164144fd 100644 --- a/js/publish/src/rendition-internal.ts +++ b/js/publish/src/rendition-internal.ts @@ -61,7 +61,7 @@ export class Rendition { /** * The open track producer, or `undefined` while the network broadcast is down, after the track - * closes, and, for non-audio tracks with the broadcast's `onDemand`, while no subscriber is attached. + * closes, and, for non-audio tracks with the broadcast's `onDemand: "all"`, while no subscriber is attached. * Producers should * encode only while this is set, and only media matching the current {@link config}: a new * identity swaps in a new track. diff --git a/js/publish/src/video/encoder.test.ts b/js/publish/src/video/encoder.test.ts index 5a3e31d518a..e515a23b1e0 100644 --- a/js/publish/src/video/encoder.test.ts +++ b/js/publish/src/video/encoder.test.ts @@ -1371,14 +1371,15 @@ test("a join that a written group serves forces no keyframe, even one encoded wi // Groups encoded with nobody watching wait in the cache for a late subscriber's maxDelay, while on // demand a subscriber finds only what its arrival started. -test.each([false, true])( +test.each(["off", "all"] as const)( "a late subscriber reaches groups encoded before it arrived unless on demand (%p)", - async (onDemand) => { + async (mode) => { + const onDemand = mode === "all"; using recording = await installRecording(); const broadcast = new Broadcast({ origin: new Moq.Origin.Producer(), name: Moq.Path.from("eager.hang"), - onDemand, + onDemand: mode, }); const encoder = new Encoder("video", { enabled: true, broadcast, capture: recording.capture as never }); let late: Moq.Track.Subscriber | undefined; @@ -1436,7 +1437,7 @@ test("an on-demand rendition declares its join, and a media subscriber starts it const broadcast = new Broadcast({ origin: new Moq.Origin.Producer(), name: Moq.Path.from("join.hang"), - onDemand: true, + onDemand: "all", }); const config = new Signal({ join: Milli(3000) }); const encoder = new Encoder("video", { diff --git a/js/publish/src/video/encoder.ts b/js/publish/src/video/encoder.ts index 741a3ad16e3..0e47f94da54 100644 --- a/js/publish/src/video/encoder.ts +++ b/js/publish/src/video/encoder.ts @@ -115,7 +115,7 @@ export type EncoderOutput = { // Exposed so a local preview can re-encode with identical settings to mirror the wire output. resolved: Signal; // True while encoding: enabled and the rendition has a track, which with the broadcast's - // `onDemand` also takes a subscriber. + // `onDemand: "all"` also takes a subscriber. active: Signal; // Cumulative output totals (frames, bytes, keyframes, skipped) measured while serving, and the // target bitrate. @@ -127,7 +127,7 @@ export type EncoderOutput = { * * Registers itself on the {@link Broadcast} under {@link name} (via `broadcast.video(name)`), resolves * the best codec/bitrate/dimensions for the capture, and encodes frames while enabled, so a late - * subscriber or fetch finds the recent groups in the track's cache. The broadcast's `onDemand` encodes + * subscriber or fetch finds the recent groups in the track's cache. The broadcast's `onDemand: "all"` encodes * only while a subscriber is attached instead. A joining subscriber that wants a newer group gets a * fresh keyframe either way. Rename by constructing a new encoder; the name is not a signal. * @@ -356,7 +356,7 @@ export class Encoder { effect.proxy(rendition.config, this.out.catalog); effect.proxy(rendition.stats, this.out.stats); - // Encode while enabled and the broadcast hands over the track (with `onDemand`, a subscriber is attached). + // Encode while enabled and the broadcast hands over the track (with `onDemand: "all"`, a subscriber is attached). effect.run((effect) => { const enabled = effect.get(this.in.enabled); const track = effect.get(rendition.track); @@ -619,7 +619,7 @@ export class Encoder { } } finally { if (encoder.state !== "closed") encoder.close(); - // The broadcast owns this static track across gaps (a disable, or with `onDemand` a demand + // The broadcast owns this static track across gaps (a disable, or with `onDemand: "all"` a demand // gap). Close the current group, marking the break so a later subscriber resumes on the same // track without the pre-gap group reading as live. A fatal encoder error aborts the track instead. if (track.closed.peek() === undefined) producer.discontinuity(); diff --git a/js/publish/src/video/index.ts b/js/publish/src/video/index.ts index 36edb3d0e8c..bbd62a6f196 100644 --- a/js/publish/src/video/index.ts +++ b/js/publish/src/video/index.ts @@ -3,7 +3,7 @@ import type { Rendition as BaseRendition } from "../rendition"; export * from "./capture"; export * from "./encoder"; -export * from "./types"; +export type { Constraints, FrameSource, Source, SourceConfig, StreamTrack, TrackSettings } from "./types"; /** A registered video rendition on a Broadcast. See {@link BaseRendition}. */ export type Rendition = BaseRendition; diff --git a/js/publish/src/video/types.test.ts b/js/publish/src/video/types.test.ts index c1723fa7bd3..fe247367f25 100644 --- a/js/publish/src/video/types.test.ts +++ b/js/publish/src/video/types.test.ts @@ -1,30 +1,12 @@ -import { describe, expect, test } from "bun:test"; -import { type FrameSource, isStreamTrack, normalizeSource, type StreamTrack } from "./types"; +import { expect, test } from "bun:test"; +import { normalizeSource, type StreamTrack } from "./types"; // A MediaStreamTrack stand-in: the check is structural, never a prototype check, so it stays // correct for a track that came from another realm (an iframe, a worker). const track = { kind: "video", getSettings: () => ({}) } as unknown as StreamTrack; -const frames: FrameSource = { - frames: new ReadableStream(), - frameRate: 24, -}; - -describe("isStreamTrack", () => { - test("separates a frame stream from a capture track", () => { - expect(isStreamTrack(track)).toBe(true); - expect(isStreamTrack(frames)).toBe(false); - }); - - // frameRate is optional, so a source without one must still be recognized. - test("recognizes a frame stream with no declared rate", () => { - expect(isStreamTrack({ frames: new ReadableStream() })).toBe(false); - }); -}); - -test("capture options preserve the track and scale without masquerading as a bare track", () => { +test("capture options preserve the track and scale", () => { const source = { track, scale: 2 }; - expect(isStreamTrack(source)).toBe(false); expect(normalizeSource(source)).toBe(source); expect(normalizeSource(track).track).toBe(track); }); diff --git a/js/publish/src/video/types.ts b/js/publish/src/video/types.ts index a1d560d77cc..4043ee9c42a 100644 --- a/js/publish/src/video/types.ts +++ b/js/publish/src/video/types.ts @@ -32,12 +32,6 @@ export interface FrameSource { frameRate?: number; } -/** Whether this source is a bare live capture track. */ -export function isStreamTrack(source: Source): source is StreamTrack { - // Structural check rather than `instanceof MediaStreamTrack` so this stays correct across realms. - return !("frames" in source) && !("track" in source); -} - /** Stronger typing for the MediaStreamTrack interface. */ export interface StreamTrack extends MediaStreamTrack { /** Always video. */ diff --git a/js/qmux/README.md b/js/qmux/README.md index f05d05656cf..d43b0630f85 100644 --- a/js/qmux/README.md +++ b/js/qmux/README.md @@ -15,7 +15,7 @@ npm install @moq/qmux Use as a drop-in `WebTransport` replacement: ```ts -import Session from "@moq/qmux" +import { Session } from "@moq/qmux" const transport = new Session("https://example.com/endpoint") await transport.ready @@ -46,7 +46,7 @@ the value to accept from what the client offered, and the session reads the wire off the socket. ```ts -import Session, { selectSubprotocol } from "@moq/qmux" +import { Session, selectSubprotocol } from "@moq/qmux" Deno.serve((req) => { const protocol = selectSubprotocol(req.headers.get("sec-websocket-protocol"), { diff --git a/js/qmux/api/index.api.md b/js/qmux/api/index.api.md index d206a8c5826..aa12a4a8e6e 100644 --- a/js/qmux/api/index.api.md +++ b/js/qmux/api/index.api.md @@ -40,6 +40,29 @@ export interface SendStreamStats { bytesWritten: number; } +export class Session implements WebTransport { + constructor(source: string | URL | WebSocketLike | WebSocketStreamLike, options?: SessionOptions); + static accept(socket: WebSocketLike | WebSocketStreamLike, options?: AcceptOptions): Session; + close(info?: { + closeCode?: number; + reason?: string; + }): void; + readonly closed: Promise; + get congestionControl(): WebTransportCongestionControl; + createBidirectionalStream(options?: SendStreamOptions): Promise; + createUnidirectionalStream(options?: SendStreamOptions): Promise; + get datagrams(): WebTransportDatagramDuplexStream; + getStats(): Promise<{ + datagrams: {}; + }>; + readonly incomingBidirectionalStreams: ReadableStream; + readonly incomingUnidirectionalStreams: ReadableStream>; + get protocol(): string; + readonly ready: Promise; + get reliability(): "reliable-only"; + setSendBufferSize(bytes: number): void; +} + export class SessionError extends Error implements Pick { constructor(message: string, options?: { cause?: unknown; @@ -111,29 +134,6 @@ export interface WebSocketStreamOpenEvent { writable: WritableStream; } -export default class implements WebTransport { - constructor(source: string | URL | WebSocketLike | WebSocketStreamLike, options?: SessionOptions); - static accept(socket: WebSocketLike | WebSocketStreamLike, options?: AcceptOptions): import("@moq/qmux").default; - close(info?: { - closeCode?: number; - reason?: string; - }): void; - readonly closed: Promise; - get congestionControl(): WebTransportCongestionControl; - createBidirectionalStream(options?: SendStreamOptions): Promise; - createUnidirectionalStream(options?: SendStreamOptions): Promise; - get datagrams(): WebTransportDatagramDuplexStream; - getStats(): Promise<{ - datagrams: {}; - }>; - readonly incomingBidirectionalStreams: ReadableStream; - readonly incomingUnidirectionalStreams: ReadableStream>; - get protocol(): string; - readonly ready: Promise; - get reliability(): "reliable-only"; - setSendBufferSize(bytes: number): void; -} - export function install(options?: InstallOptions): boolean; export function selectSubprotocol(offered: string | readonly string[] | null | undefined, options?: Pick): string | undefined; diff --git a/js/qmux/bench/datagrams.ts b/js/qmux/bench/datagrams.ts index 527736ade55..2fdbce2e195 100644 --- a/js/qmux/bench/datagrams.ts +++ b/js/qmux/bench/datagrams.ts @@ -1,6 +1,6 @@ /** Sweep receive-queue draining over backlog size through the public session API. */ import * as Frame from "../src/frame.ts"; -import Session from "../src/session.ts"; +import { Session } from "../src/session.ts"; import type { WebSocketStreamCloseEvent } from "../src/web-socket-stream.ts"; const packet = Frame.encode({ type: "datagram", data: new Uint8Array(1200) }, "qmux-01"); diff --git a/js/qmux/src/index.ts b/js/qmux/src/index.ts index dddadb09e37..0ead167da07 100644 --- a/js/qmux/src/index.ts +++ b/js/qmux/src/index.ts @@ -1,6 +1,5 @@ /** WebTransport over WebSockets with QMux framing: a browser polyfill and a server for an accepted socket. @module */ -import type { Version } from "./session.ts"; -import Session from "./session.ts"; +import { Session, type Version } from "./session.ts"; export { SessionError, StreamError } from "./error.ts"; export type { @@ -13,7 +12,7 @@ export type { SessionOptions, Version, } from "./session.ts"; -export { selectSubprotocol } from "./session.ts"; +export { Session, selectSubprotocol } from "./session.ts"; export type { WebSocketLike, WebSocketStreamCloseEvent, @@ -52,5 +51,3 @@ export function install(options?: InstallOptions): boolean { }; return true; } - -export default Session; diff --git a/js/qmux/src/session.test.ts b/js/qmux/src/session.test.ts index 4cd81df4589..94745573b8d 100644 --- a/js/qmux/src/session.test.ts +++ b/js/qmux/src/session.test.ts @@ -4,7 +4,7 @@ import { SessionError, StreamError } from "./error.ts"; import * as Frame from "./frame.ts"; import { DEFAULT_MAX_RECORD_SIZE, type TransportParams } from "./frame.ts"; import * as QMux from "./index.ts"; -import Session, { type Config } from "./session.ts"; +import { type Config, Session } from "./session.ts"; import * as Stream from "./stream.ts"; import { VarInt } from "./varint.ts"; import type { WebSocketLike, WebSocketStreamLike } from "./web-socket-stream.ts"; diff --git a/js/qmux/src/session.ts b/js/qmux/src/session.ts index 55dd773bb07..b858bb1b35d 100644 --- a/js/qmux/src/session.ts +++ b/js/qmux/src/session.ts @@ -634,7 +634,7 @@ interface StreamState { } /** A `WebTransport` session carried over a WebSocket, for hosts without native WebTransport. */ -export default class Session implements WebTransport { +export class Session implements WebTransport { // The transport: a native `WebSocketStream` when the platform has one, // otherwise the `WebSocketStream` ponyfill over a plain `WebSocket`. Either // way, one API. diff --git a/js/room/README.md b/js/room/README.md index 67bbcd968a6..11300722348 100644 --- a/js/room/README.md +++ b/js/room/README.md @@ -61,8 +61,8 @@ const room = new Room({ connection, identity }); // room.remotes is a Map. Each Remote has camera/screen // Members; call member.canvas.set(canvas) to display video and -// member.muted.set(false) to play audio. -// member.player exposes the complete Watch.Player pipeline. +// member.muted.set(false) to play audio. member.video and member.audio +// report the playing timestamp and receive stats. ``` `Local` publishes through `Publish.Broadcast`, which encodes while enabled: while `cameraEnabled` is on, the camera's hd and sd renditions encode even when nobody in the room watches, so a member who arrives late can fetch the last few seconds. While `microphoneEnabled` is on, the microphone keeps raw audio and encodes it once someone listens. Turn either off to stop that capture. diff --git a/js/room/api/index.api.md b/js/room/api/index.api.md index 5141ac50ab0..208b7b1d076 100644 --- a/js/room/api/index.api.md +++ b/js/room/api/index.api.md @@ -7,7 +7,6 @@ import { Effect } from "@moq/signals"; import { Getter } from "@moq/signals"; import { GetterInit } from "@moq/signals"; import { Path } from "@moq/net"; -import { Player } from "@moq/watch"; import { Readonlys } from "@moq/signals"; import { Root } from "@moq/hang/catalog"; import { Signal } from "@moq/signals"; @@ -111,19 +110,21 @@ export interface LocalProps { export class Member { constructor(kind: Kind, path: Path.Valid, connection: Connection); - readonly audio: Audio.Decoder; - readonly broadcast: import("@moq/watch").Broadcast; + readonly audio: Readonlys<{ + stats: Getter; + timestamp: Getter; + }>; readonly canvas: Signal; close(): void; - readonly emitter: Audio.Emitter; readonly kind: Kind; readonly muted: Signal; readonly path: Path.Valid; - readonly player: Player; readonly preview: Getter; - readonly renderer: import("@moq/watch").Video.Renderer; readonly user: Readonlys; - readonly video: import("@moq/watch").Video.Decoder; + readonly video: Readonlys<{ + stats: Getter; + timestamp: Getter; + }>; readonly volume: Signal; } diff --git a/js/room/src/remote.ts b/js/room/src/remote.ts index 00b28e059ae..04669e7bece 100644 --- a/js/room/src/remote.ts +++ b/js/room/src/remote.ts @@ -4,6 +4,7 @@ * @module */ +import type { Milli } from "@moq/clock"; import type * as Moq from "@moq/net"; import { Effect, type Getter, type Readonlys, readonlys, Signal } from "@moq/signals"; import * as Watch from "@moq/watch"; @@ -24,18 +25,16 @@ export class Member { /** Playback volume, 0..1. */ readonly volume = new Signal(0.5); - /** Playback pipeline for this member. */ - readonly player: Watch.Player; - /** Watched broadcast and catalog. */ - readonly broadcast: Watch.Broadcast; - /** Video decoding pipeline. */ - readonly video: Watch.Video.Decoder; - /** Audio decoding pipeline. */ - readonly audio: Watch.Audio.Decoder; - /** Canvas video renderer. */ - readonly renderer: Watch.Video.Renderer; - /** Speaker audio output. */ - readonly emitter: Watch.Audio.Emitter; + /** The painted video's media timestamp and the video received so far. */ + readonly video: Readonlys<{ + timestamp: Getter; + stats: Getter; + }>; + /** The playing audio's media timestamp and the audio received so far. */ + readonly audio: Readonlys<{ + timestamp: Getter; + stats: Getter; + }>; /** Published participant identity and display fields. */ readonly user: Readonlys; @@ -49,21 +48,18 @@ export class Member { this.kind = kind; this.path = path; - this.player = new Watch.Player({ + const player = new Watch.Player({ origin: connection.origin, name: path, video: { canvas: this.canvas }, audio: { muted: this.muted, volume: this.volume }, viewer: { viewers: connection.viewers }, }); - this.#signals.cleanup(() => this.player.close()); - this.broadcast = this.player.broadcast; - this.video = this.player.video; - this.audio = this.player.audio; - this.renderer = this.player.renderer; - this.emitter = this.player.emitter; - - this.#metadata = consume(this.broadcast); + this.#signals.cleanup(() => player.close()); + this.video = { timestamp: player.renderer.out.timestamp, stats: player.video.out.stats }; + this.audio = { timestamp: player.audio.out.timestamp, stats: player.audio.out.stats }; + + this.#metadata = consume(player.broadcast); this.user = this.#metadata.user; this.preview = this.#metadata.preview; this.#signals.cleanup(() => this.#metadata.close()); diff --git a/js/ui/README.md b/js/ui/README.md index 6fff1eddaea..799eee3cf1f 100644 --- a/js/ui/README.md +++ b/js/ui/README.md @@ -7,15 +7,13 @@ [![npm](https://img.shields.io/npm/v/@moq/ui)](https://www.npmjs.com/package/@moq/ui) [![TypeScript](https://img.shields.io/badge/TypeScript-ready-blue.svg)](https://www.typescriptlang.org/) -Presentation components shared by the [@moq/watch](../watch) and [@moq/publish](../publish) overlays, built on [@moq/signals](../signals). -You only need it directly to build a custom overlay in the same style. +The base of the `` and `` banners in [@moq/watch](../watch) and [@moq/publish](../publish), built on [@moq/signals](../signals). +You only need it directly to build a support banner for your own capability checks. -- `controlButton`, `settingsButton`, `fullscreenButton`, `icon`: overlay control buttons and SVG glyphs. `fullscreenButton` toggles cross-browser fullscreen, falling back to an iPhone pseudo-fullscreen. -- `statCard`, `statLine`, `graph`: stats panel cards, key/value rows, and a rolling sparkline. -- `formatBitrate`, `formatMillis`, `formatHz`, `formatFps`: human-readable stats values. -- `Support`: the base of `` and ``, a banner and details table for a browser support report. +`Support` is an abstract `HTMLElement` that renders a banner and details table for the `Report` its `detect()` returns. Its `show` attribute (`Show`) picks when the banner appears, by the report's `Level`. +The overlay helpers the two packages share (control buttons, stats cards, sparkline, formatters) live here too, but are internal and may change in any release. -It holds no capability detection and publishes no styles: each overlay styles these elements, including the `player--pseudo-fullscreen` class, and detects its own support. +It holds no capability detection and publishes no styles: each overlay styles these elements and detects its own support. Inside this repository, `@moq/watch` and `@moq/publish` inline the shared stats card and sparkline styles from `src/stats.css` at build time; that file is not part of the npm package. ```bash @@ -25,13 +23,15 @@ npm add @moq/ui ``` ```ts -import { Effect } from "@moq/signals"; -import { graph } from "@moq/ui"; - -const effect = new Effect(); -const bitrate = graph(effect, "Bitrate", { color: "#a855f7" }); -document.body.append(bitrate.el); -bitrate.push(2_500_000); +import { type Report, Support } from "@moq/ui"; + +class MySupport extends Support { + protected async detect(): Promise { + const webcodecs = typeof VideoDecoder !== "undefined"; + return { summary: webcodecs ? "full" : "none", rows: [["Video", "WebCodecs", webcodecs]] }; + } +} +customElements.define("my-support", MySupport); ``` ## License diff --git a/js/ui/api/index.api.md b/js/ui/api/index.api.md index 99b096a59bf..72c2af3f932 100644 --- a/js/ui/api/index.api.md +++ b/js/ui/api/index.api.md @@ -1,19 +1,6 @@ # `@moq/ui` ```ts -import { Effect } from "@moq/signals"; -import { Signal } from "@moq/signals"; - -export interface Graph { - el: HTMLElement; - push(value: number | undefined): void; -} - -export interface GraphOptions { - color?: string; - format?: (v: number) => string; -} - export type Level = "full" | "partial" | "none"; export interface Report { @@ -23,14 +10,6 @@ export interface Report { export type Show = "always" | "warning" | "error" | "never"; -export interface StatCard { - el: HTMLElement; - grid: HTMLElement; - status: HTMLElement; -} - -export type StatKind = "network" | "video" | "audio"; - export type Status = boolean | Level | { hardware?: boolean; software: boolean; @@ -48,26 +27,4 @@ export abstract class Support extends HTMLElement { get show(): Show; set show(show: Show); } - -export function controlButton(svg: string, label: string): HTMLButtonElement; - -export function formatBitrate(bps: number): string; - -export function formatFps(fps: number): string; - -export function formatHz(hz: number): string; - -export function formatMillis(ms: number): string; - -export function fullscreenButton(parent: Effect, player: HTMLElement): HTMLButtonElement; - -export function graph(parent: Effect, title: string, opts?: GraphOptions): Graph; - -export function icon(svg: string): HTMLElement; - -export function settingsButton(parent: Effect, panel: Signal): HTMLButtonElement; - -export function statCard(kind: StatKind, label: string, svg: string): StatCard; - -export function statLine(grid: HTMLElement, label: string): HTMLSpanElement; ``` diff --git a/js/ui/src/graph.ts b/js/ui/src/graph.ts index 4799749a690..414662c22a4 100644 --- a/js/ui/src/graph.ts +++ b/js/ui/src/graph.ts @@ -1,6 +1,6 @@ import type { Effect } from "@moq/signals"; -/** Options for {@link graph}. */ +/** Options for {@link graph}. @internal */ export interface GraphOptions { /** Stroke/fill color for the line (any CSS color). */ color?: string; @@ -8,7 +8,7 @@ export interface GraphOptions { format?: (v: number) => string; } -/** A sparkline created by {@link graph}. */ +/** A sparkline created by {@link graph}. @internal */ export interface Graph { /** The root element to mount. */ el: HTMLElement; diff --git a/js/ui/src/index.ts b/js/ui/src/index.ts index 8f94bba6108..b49b2aa0b1d 100644 --- a/js/ui/src/index.ts +++ b/js/ui/src/index.ts @@ -1,15 +1,18 @@ /** - * Presentation components shared by the `@moq/watch` and `@moq/publish` overlays: control - * buttons, stats cards and formatters, a sparkline graph, and the base of their browser support - * banners. + * The base of the `@moq/watch` and `@moq/publish` browser support banners: {@link Support} and + * the report it renders. * * Browser-only: {@link Support} extends `HTMLElement` when this module loads. * * @module */ +/** Overlay control buttons and SVG glyphs. @internal */ export { controlButton, fullscreenButton, icon, settingsButton } from "./button"; +/** Human-readable stats values. @internal */ export { formatBitrate, formatFps, formatHz, formatMillis } from "./format"; +/** A rolling sparkline. @internal */ export { type Graph, type GraphOptions, graph } from "./graph"; +/** Stats panel cards and key/value rows. @internal */ export { type StatCard, type StatKind, statCard, statLine } from "./stats"; export { type Level, type Report, type Show, type Status, Support } from "./support"; diff --git a/js/ui/src/stats.ts b/js/ui/src/stats.ts index 0bd5a288941..0ab8a4a0a1f 100644 --- a/js/ui/src/stats.ts +++ b/js/ui/src/stats.ts @@ -1,10 +1,10 @@ import * as DOM from "@moq/signals/dom"; import { icon } from "./button"; -/** Which accent a {@link statCard} takes. */ +/** Which accent a {@link statCard} takes. @internal */ export type StatKind = "network" | "video" | "audio"; -/** A stats card created by {@link statCard}. */ +/** A stats card created by {@link statCard}. @internal */ export interface StatCard { /** The root element to mount. */ el: HTMLElement; diff --git a/js/video/api/index.api.md b/js/video/api/index.api.md index 586324c3d07..8a4af9cc1c8 100644 --- a/js/video/api/index.api.md +++ b/js/video/api/index.api.md @@ -10,17 +10,6 @@ export type Backend = "auto" | "webgpu" | "2d"; export type Canvas = HTMLCanvasElement | OffscreenCanvas; -export type Dimensions = { - height: number; - width: number; -}; - -export type Presentation = { - flip?: boolean; - interlaced?: boolean; - rotation?: number; -}; - export class Renderer { constructor(props?: RendererProps); close(): void; @@ -31,9 +20,16 @@ export class Renderer { export type RendererInput = { backend: Getter; canvas: Getter; - display: Getter; + display: Getter<{ + height: number; + width: number; + } | undefined>; frame: Getter; - presentation: Getter; + presentation: Getter<{ + flip?: boolean; + interlaced?: boolean; + rotation?: number; + } | undefined>; }; export type RendererOutput = { @@ -42,6 +38,4 @@ export type RendererOutput = { }; export type RendererProps = Inputs; - -export function rotateVideoDimensions(dimensions: Dimensions, rotation?: number): Dimensions; ``` diff --git a/js/video/src/index.ts b/js/video/src/index.ts index aa9d2ed9f9e..cb75aaeb2ee 100644 --- a/js/video/src/index.ts +++ b/js/video/src/index.ts @@ -23,5 +23,6 @@ * * @module */ -export { type Dimensions, type Presentation, rotateVideoDimensions } from "./presentation"; +/** Swap a width and height for a quarter-turn rotation. @internal */ +export { rotateVideoDimensions } from "./presentation"; export * from "./renderer"; diff --git a/js/video/src/presentation.ts b/js/video/src/presentation.ts index 33ce57c9ffd..0d9b0af231d 100644 --- a/js/video/src/presentation.ts +++ b/js/video/src/presentation.ts @@ -1,4 +1,7 @@ -/** How a frame is shown: deinterlaced if needed, then rotated clockwise, then optionally mirrored. */ +/** + * How a frame is shown: deinterlaced if needed, then rotated clockwise, then optionally mirrored. + * `RendererInput.presentation` spells out the same shape, keeping this name off the public API. + */ export type Presentation = { /** Clockwise rotation in degrees, rounded to the nearest quarter-turn. Defaults to 0. */ rotation?: number; diff --git a/js/video/src/renderer.ts b/js/video/src/renderer.ts index 5e40336d715..c4ccba9cfea 100644 --- a/js/video/src/renderer.ts +++ b/js/video/src/renderer.ts @@ -1,6 +1,5 @@ import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; import { Canvas2D } from "./canvas2d"; -import type { Dimensions, Presentation } from "./presentation"; import { Gpu, probe } from "./webgpu"; /** @@ -22,9 +21,19 @@ export type RendererInput = { /** The frame to draw. Borrowed: the renderer clones what it keeps. */ frame: Getter; /** The canvas size in pixels. Undefined leaves the canvas size alone. */ - display: Getter; - /** The rotation and flip to draw the frame with. */ - presentation: Getter; + display: Getter<{ width: number; height: number } | undefined>; + /** How to draw the frame: deinterlaced if needed, then rotated clockwise, then optionally mirrored. */ + presentation: Getter< + | { + /** Clockwise rotation in degrees, rounded to the nearest quarter-turn. Defaults to 0. */ + rotation?: number; + /** Mirror the rotated picture horizontally. Defaults to false. */ + flip?: boolean; + /** The frame weaves two fields, so blend adjacent lines to hide the combing. Defaults to false. */ + interlaced?: boolean; + } + | undefined + >; /** Which graphics API to draw with. Defaults to `"auto"`. */ backend: Getter; }; diff --git a/js/watch/api/element.api.md b/js/watch/api/element.api.md index 6aa2b0f5559..0e4d033baa7 100644 --- a/js/watch/api/element.api.md +++ b/js/watch/api/element.api.md @@ -8,7 +8,6 @@ import { Clock } from "@moq/watch"; import { Delay } from "@moq/watch"; import { Hang } from "@moq/watch"; import { Net } from "@moq/watch"; -import { Player } from "@moq/watch"; import { Signals } from "@moq/watch"; import { Sync } from "@moq/watch"; import { Text } from "@moq/watch"; @@ -67,7 +66,6 @@ export default class extends HTMLElement { static observedAttributes: readonly ["url", "versions", "name", "paused", "volume", "muted", "visible", "backend", "follow", "announced", "buffer", "delay", "lookahead", "catalog-format", "captions", "echo"]; get paused(): boolean; set paused(value: boolean); - readonly player: Player; readonly renderer: Video.Renderer; reset(): void; readonly signals: Signals.Effect; diff --git a/js/watch/api/index.api.md b/js/watch/api/index.api.md index 690197eb262..b3a8b386a72 100644 --- a/js/watch/api/index.api.md +++ b/js/watch/api/index.api.md @@ -81,7 +81,6 @@ export namespace Audio { catalog: Signals.Signal; config: Signals.Signal; error: Signals.Signal; - jitter: Signals.Signal; track: Signals.Signal; }; export interface Stats { @@ -272,9 +271,6 @@ export namespace Video { width: number; } | undefined>; error: Signals.Signal; - frame: Signals.Signal; - interlaced: Signals.Signal; - jitter: Signals.Signal; stalled: Signals.Signal; stats: Signals.Signal; timestamp: Signals.Signal; @@ -288,18 +284,18 @@ export namespace Video { close(): void; readonly decoder: Video.Decoder; readonly in: Signals.Readonlys; - readonly out: Signals.Readonlys<{ - error: Signals.Getter; - frame: Signals.Getter; - timestamp: Signals.Getter; - visible: Signals.Getter; - }>; + readonly out: Signals.Readonlys; } export type RendererInput = { backend: Signals.Getter; canvas: Signals.Getter; visible: Signals.Getter; }; + export type RendererOutput = { + error: Signals.Getter; + timestamp: Signals.Getter; + visible: Signals.Getter; + }; export type RendererProps = Signals.Inputs & { decoder: Video.Decoder; }; @@ -318,7 +314,6 @@ export namespace Video { export type SourceOutput = { available: Signals.Signal>; catalog: Signals.Signal; - ceiling: Signals.Signal; config: Signals.Signal; error: Signals.Signal; track: Signals.Signal; diff --git a/js/watch/src/audio/source.ts b/js/watch/src/audio/source.ts index 3530af3f4fe..0a28d501119 100644 --- a/js/watch/src/audio/source.ts +++ b/js/watch/src/audio/source.ts @@ -42,7 +42,11 @@ export type SourceOutput = { track: Signal; config: Signal; - // The jitter the selected rendition advertises (ms): a floor on the decoder's playout target. + /** + * The jitter the selected rendition advertises: a floor on the decoder's playout target. + * + * @internal Feeds the decoder. + */ jitter: Signal; }; diff --git a/js/watch/src/broadcast.test.ts b/js/watch/src/broadcast.test.ts index 11d27d4d592..66702d2829b 100644 --- a/js/watch/src/broadcast.test.ts +++ b/js/watch/src/broadcast.test.ts @@ -337,12 +337,12 @@ describe("relativeBroadcast", () => { Moq.Instance.random(), Moq.Instance.random(), ]; - const catalogRoute = owner.route(Path.from("a/b"), { instance: catalogInstance }); + const catalogRoute = owner.route(Path.from("a/b"), { instance: { id: catalogInstance, epoch: 0n } }); const catalog = catalogRoute.broadcast; - const siblingRoute = owner.route(Path.from("a/source"), { instance: siblingInstance }); + const siblingRoute = owner.route(Path.from("a/source"), { instance: { id: siblingInstance, epoch: 0n } }); const sibling = siblingRoute.broadcast; // A route over a wire that carries no instance has one its session minted. - const plainRoute = owner.route(Path.from("a/plain"), { instance: Moq.Instance.minted() }); + const plainRoute = owner.route(Path.from("a/plain"), { instance: { id: Moq.Instance.minted(), epoch: 0n } }); const plain = plainRoute.broadcast; const source = new Broadcast({ origin: owner, @@ -381,7 +381,7 @@ describe("relativeBroadcast", () => { const owner = new Origin.Producer(); const main = publish(owner, Path.from("a/b")); const [oldInstance, newInstance] = [Moq.Instance.random(), Moq.Instance.random()]; - const oldRoute = owner.route(Path.from("a/source"), { instance: oldInstance }); + const oldRoute = owner.route(Path.from("a/source"), { instance: { id: oldInstance, epoch: 0n } }); const old = oldRoute.broadcast; const source = new Broadcast({ origin: owner, @@ -406,7 +406,7 @@ describe("relativeBroadcast", () => { // The announcement is the online signal: the plain reference follows the restart, // while one naming the old instance never reads the new one. - const replacementRoute = owner.route(Path.from("a/source"), { instance: newInstance }); + const replacementRoute = owner.route(Path.from("a/source"), { instance: { id: newInstance, epoch: 0n } }); const replacement = replacementRoute.broadcast; await settle(); expect(following?.instance).toBe(newInstance); @@ -425,7 +425,7 @@ describe("relativeBroadcast", () => { const owner = new Origin.Producer(); const main = publish(owner, Path.from("a/b")); const [oldInstance, newInstance] = [Moq.Instance.random(), Moq.Instance.random()]; - const oldRoute = owner.route(Path.from("a/source"), { instance: oldInstance }); + const oldRoute = owner.route(Path.from("a/source"), { instance: { id: oldInstance, epoch: 0n } }); const old = oldRoute.broadcast; const named = { ...video("avc1.64001f", "./source"), instance: oldInstance } as Catalog.VideoConfig; const source = new Broadcast({ @@ -439,7 +439,7 @@ describe("relativeBroadcast", () => { await settle(); expect(videoRenditions(source)).toEqual(["named", "plain"]); - const replacementRoute = owner.route(Path.from("a/source"), { instance: newInstance }); + const replacementRoute = owner.route(Path.from("a/source"), { instance: { id: newInstance, epoch: 0n } }); const replacement = replacementRoute.broadcast; await withoutWarnings(settle); expect(videoRenditions(source)).toEqual(["plain"]); diff --git a/js/watch/src/element.ts b/js/watch/src/element.ts index ee5e5b345f6..b300d237663 100644 --- a/js/watch/src/element.ts +++ b/js/watch/src/element.ts @@ -172,8 +172,8 @@ export default class MoqWatch extends HTMLElement { */ readonly connection: Moq.Connection; - /** Headless playback pipeline behind this element. */ - readonly player: Player; + readonly #player: Player; + /** The broadcast being watched. */ readonly broadcast: Broadcast; @@ -272,7 +272,7 @@ export default class MoqWatch extends HTMLElement { }); this.signals.cleanup(() => this.connection.close()); - this.player = new Player({ + this.#player = new Player({ origin: this.connection.origin, enabled: this.#enabled, name: this.#name, @@ -297,26 +297,26 @@ export default class MoqWatch extends HTMLElement { viewer: { ...this.controls.viewer, viewers: this.connection.viewers }, text: { ...this.controls.text, container: this.#captionsOverlay }, }); - this.signals.cleanup(() => this.player.close()); + this.signals.cleanup(() => this.#player.close()); // Owned here rather than by the player, which only reads its origin: feedback publishes into it. const echo = new Echo({ - broadcast: this.player.broadcast, + broadcast: this.#player.broadcast, origin: this.connection.origin, enabled: this.#enabled, name: this.#echo, probe: this.connection.probe, - sources: [this.player.video, this.player.audio], + sources: [this.#player.video, this.#player.audio], }); this.signals.cleanup(() => echo.close()); - this.broadcast = this.player.broadcast; - this.video = this.player.video; - this.audio = this.player.audio; - this.renderer = this.player.renderer; - this.emitter = this.player.emitter; - this.text = this.player.text; - this.textRenderer = this.player.textRenderer; - this.sync = this.player.sync; + this.broadcast = this.#player.broadcast; + this.video = this.#player.video; + this.audio = this.#player.audio; + this.renderer = this.#player.renderer; + this.emitter = this.#player.emitter; + this.text = this.#player.text; + this.textRenderer = this.#player.textRenderer; + this.sync = this.#player.sync; // Watch to see if the canvas element is added or removed. const setCanvas = () => { @@ -624,7 +624,7 @@ export default class MoqWatch extends HTMLElement { * and flush the audio buffer so the next utterance plays from its own first frame. */ reset(): void { - this.player.reset(); + this.#player.reset(); } /** The catalog format to read, mirrored by the `catalog-format` attribute; undefined detects it. */ diff --git a/js/watch/src/include.test.ts b/js/watch/src/include.test.ts index 67f4f2fb416..bafaddbebf0 100644 --- a/js/watch/src/include.test.ts +++ b/js/watch/src/include.test.ts @@ -8,7 +8,7 @@ import { Broadcast } from "./broadcast"; import { Source } from "./video/source"; function publish(origin: Origin.Producer, path: string, instance?: Instance.Valid) { - const publication = origin.route(Path.from(path), { instance }); + const publication = origin.route(Path.from(path), { instance: instance && { id: instance, epoch: 0n } }); return publication.broadcast; } diff --git a/js/watch/src/player.ts b/js/watch/src/player.ts index 330861e5cae..901cd4a45da 100644 --- a/js/watch/src/player.ts +++ b/js/watch/src/player.ts @@ -241,7 +241,7 @@ export class Player { this.#videoEnabled.set(visible); } else { // Keep downloading a paused poster until a frame is painted, then stop. - this.#videoEnabled.set(visible && !effect.get(this.renderer.out.frame)); + this.#videoEnabled.set(visible && !effect.get(this.renderer.painted)); } }); } diff --git a/js/watch/src/video/decoder.ts b/js/watch/src/video/decoder.ts index a45759b6a82..e6d02f46013 100644 --- a/js/watch/src/video/decoder.ts +++ b/js/watch/src/video/decoder.ts @@ -90,10 +90,18 @@ const EMPTY: Stats = { frameCount: 0, bytesReceived: 0, late: 0, errors: 0, stal export type DecoderOutput = { /** Fatal decode failure, cleared when another source is selected. */ error: Signal; - // The current frame to render. + /** + * The current frame to render. + * + * @internal Feeds the renderer; a `VideoFrame` cannot leave the decoding thread. + */ frame: Signal; - // Whether the current frame weaves two fields, per the rendition that decoded it. + /** + * Whether the current frame weaves two fields, per the rendition that decoded it. + * + * @internal Feeds the renderer. + */ interlaced: Signal; // The timestamp of the current frame. @@ -105,7 +113,11 @@ export type DecoderOutput = { stalled: Signal; stats: Signal; - // The rendition delay Sync must cover while switching tracks. + /** + * The rendition delay Sync must cover while switching tracks. + * + * @internal Feeds the decoder's playout target. + */ jitter: Signal; // Combined buffered ranges (network jitter + decode buffer) diff --git a/js/watch/src/video/renderer.ts b/js/watch/src/video/renderer.ts index 6b81524b092..ea0c1eeebc7 100644 --- a/js/watch/src/video/renderer.ts +++ b/js/watch/src/video/renderer.ts @@ -29,6 +29,16 @@ export type RendererInput = { backend: Getter; }; +/** Signals the renderer derives. */ +export type RendererOutput = { + /** The media timestamp of the most recently painted frame. */ + timestamp: Getter; + /** Whether the canvas should currently download per the configured distance and tab focus. */ + visible: Getter; + /** Why drawing stopped, or undefined while healthy. See {@link Video.RendererOutput.error}. */ + error: Getter; +}; + /** Constructor properties for {@link Renderer}. */ export type RendererProps = Inputs & { /** Decoder supplying video frames. */ @@ -50,16 +60,15 @@ export class Renderer { #video: Video.Renderer; /** The derived outputs, read-only. */ - readonly out: Readonlys<{ - // The most recently rendered frame, updated after each rAF paint. - frame: Getter; - // The media timestamp of the most recently rendered frame. - timestamp: Getter; - // Whether the canvas should currently download per the configured distance and tab focus. - visible: Getter; - // Why drawing stopped, or undefined while healthy. See {@link Video.RendererOutput.error}. - error: Getter; - }>; + readonly out: Readonlys; + + /** + * The most recently painted frame, updated after each rAF paint. A paused poster stops + * downloading once one is painted. + * + * @internal A `VideoFrame` cannot leave the thread that paints it. + */ + readonly painted: Getter; #signals = new Effect(); @@ -84,8 +93,8 @@ export class Renderer { this.#signals.cleanup(() => this.#video.close()); const frame = this.#video.out.frame; + this.painted = frame; this.out = readonlys({ - frame, timestamp: new Derived([frame], (frame) => (frame ? Milli.fromMicro(frame.timestamp as Micro) : undefined)), visible: this.#visible, error: this.#video.out.error, diff --git a/js/watch/src/video/source.ts b/js/watch/src/video/source.ts index dca941489c3..936f910cff3 100644 --- a/js/watch/src/video/source.ts +++ b/js/watch/src/video/source.ts @@ -59,9 +59,13 @@ export type SourceOutput = { track: Signal; config: Signal; - // The receive rate, in bits per second, at which selection settles on the best rendition - // the target allows, or undefined when no candidate has a catalog bitrate. A player claims it - // as its viewer ceiling, so a grant that reaches it makes that rendition selectable. + /** + * The receive rate, in bits per second, at which selection settles on the best rendition + * the target allows, or undefined when no candidate has a catalog bitrate. A player claims it + * as its viewer ceiling, so a grant that reaches it makes that rendition selectable. + * + * @internal Feeds the player's viewer claim. + */ ceiling: Signal; }; diff --git a/quest/m2/api-shape/README.md b/quest/m2/api-shape/README.md index 0d12fbe06e0..50a339883d7 100644 --- a/quest/m2/api-shape/README.md +++ b/quest/m2/api-shape/README.md @@ -58,5 +58,4 @@ Rejected in planning: - [Transports take shared types](/quest/m2/api-shape/transport.md) - moq-uring takes a congestion factory and rustls configs, and the tokio server request stops exposing its QUIC internals - [Crates re-export no whole crate](/quest/m2/api-shape/reexports.md) - no moq crate re-exports a whole crate, moq's own included, so a dependency bump is no moq break, and moq-mux's container `Guard` gets a name - [Media crates' small surface fixes](/quest/m2/api-shape/media.md) - exporters, the container consumer, catalog color range, rate grant, moq-stats, JSON, and MSF each have one path and one name -- [JS packages' small surface fixes](/quest/m2/api-shape/js.md) - track info, connection, qmux, watch, publish, ui, video, boy, and auth exports each have one path and one name - [Bindings' small surface fixes](/quest/m2/api-shape/bindings.md) - Go's read cancel, JSON producer construction, and the Python wheel's test module diff --git a/quest/m2/api-shape/js.md b/quest/m2/api-shape/js.md deleted file mode 100644 index b4f94747d5f..00000000000 --- a/quest/m2/api-shape/js.md +++ /dev/null @@ -1,42 +0,0 @@ -# [M] JS packages' small surface fixes - -## Goal - -- `@moq/net`: - - A resolved `Track.Info` uses `null` for "none" (`maxAge`, `timescale`); - `Track.infoDefaults` and the `Track.rescale` alias go. - - `Connection.Established` becomes `Session`, and the orphan - `Connection.Extensions` type goes. - - `readBool` goes; it has no caller and no Rust mirror. - - `RouteOptions.instance` takes `{ id, epoch }`, like Rust's - `with_instance(instance, epoch)`. -- `@moq/qmux` exports `Session` by name instead of an anonymous default. -- `@moq/watch`: the decoder and source plumbing outputs are `@internal`, the - renderer's output is a named `Video.RendererOutput` with no `frame` (a - `VideoFrame` signal cannot cross a worker, so - [the watch worker](/quest/m3/watch-worker.md) would break it; the paused - poster reads an internal painted flag), and ``'s `player` - property goes private, so each component has one path. -- `@moq/room` exposes timestamps and stats instead of `Video.Renderer` and - `Video.Decoder`, so the worker's break lands in one package. -- `@moq/publish`: `Rendition.onDemand` becomes an enum, `Broadcast.join` - becomes a `Getter`, and the five internal source helpers are unexported. -- `@moq/ui` and `@moq/video` mark their helpers `@internal`; only `Support`, - `Report`, `Show`, `Level`, and `Status` stay public in `@moq/ui`, and - `rotateVideoDimensions`, `Dimensions`, `Presentation`, and - `Preview.Transcode` leave `@moq/video`'s public API. -- `@moq/boy` stops exporting `Game`, `GameConfig`, `KEY_MAP`, and its - `Moq` and `Watch` namespace re-exports, keeping the element and status - schemas. -- `@moq/auth`'s `Key.sign` takes one spelling for caps. - -## Plan - -Added 2026-11-07 (maintainer, /quest-plan from the 2026-11-07 API sweep of open PRs and remaining quests): the worker-safe renderer output and room's narrower surface. - -Found by the 2026-11-07 API shape audit (JS core and browser media); see -the [epic](/quest/m2/api-shape/README.md). Durations are -[js-time-types](/quest/m2/js-time-types.md); hosted assets and the -`supported` default are m1 quests. Each change updates its package's -`api/*.api.md`, and moq.dev's npm consumer is checked for each removed -export. diff --git a/quest/m3/goaway-migration-followups.md b/quest/m3/goaway-migration-followups.md index 384f4a40aa7..cbbd79b9c30 100644 --- a/quest/m3/goaway-migration-followups.md +++ b/quest/m3/goaway-migration-followups.md @@ -11,8 +11,8 @@ Follow-ups of [moq-dev/moq#7462](https://github.com/moq-dev/moq/pull/7462) (merg Decided 2026-11-01 (maintainer, follow-up planning after the 2026-11-01 /quest-afk run): m2, both parts in one quest, since the JS half should copy the Rust half's final rule rather than #7462's. - **Hold for the replacement's announce sync.** Today the idle predecessor can close as soon as the replacement connects, before the replacement's announcements reach either side, so an unsubscribed broadcast briefly reads as ended then started. Gate the close on both idle and the replacement's announce sync. That needs a signal moq-net doesn't expose yet: a per-session "announcements synced" readiness (lite-07's announce sync, and the IETF equivalent or a bound where the version can't say). Keep the handover cap as the upper bound. -- **JS early close.** `js/net/src/connection/reload.ts` holds the predecessor to `goaway.handover` (`handover(...)`, about line 670). Add an idle signal on `Established` mirroring `Session::idle` (subscriptions and fetches in either direction; announcements, track info, PROBE, and AUTH don't count), the same announce-sync gate, and a retire in `Reload` that closes the predecessor when both hold. +- **JS early close.** `js/net/src/connection/reload.ts` holds the predecessor to `goaway.handover` (`handover(...)`, about line 670). Add an idle signal on `Connection.Session` mirroring `Session::idle` (subscriptions and fetches in either direction; announcements, track info, PROBE, and AUTH don't count), the same announce-sync gate, and a retire in `Reload` that closes the predecessor when both hold. - Test both on simulated time over the mock transport, as #7462's `rs/moq-net/tests/integration/session_idle.rs` does: a broadcast announced on both sessions and subscribed by nobody never shows an end across the migration, and a busy predecessor still waits for its readers. -Public API: a moq-net readiness for the announce sync, `Established`'s idle signal in `@moq/net`, and their mirrors. Wire: none. +Public API: a moq-net readiness for the announce sync, `Connection.Session`'s idle signal in `@moq/net`, and their mirrors. Wire: none. diff --git a/quest/m3/watch-worker.md b/quest/m3/watch-worker.md index e18e1983bf8..97db1ca2710 100644 --- a/quest/m3/watch-worker.md +++ b/quest/m3/watch-worker.md @@ -28,7 +28,7 @@ Wire: none. Gate on the plan's jank harness and N-player sweep, both nightly. -Changed 2026-11-07 (maintainer, /quest-plan from the API sweep of open PRs and remaining quests): [api-shape/js](/quest/m2/api-shape/js.md) ships `Video.RendererOutput` without `frame` and narrows `@moq/room` before the cut, so those two breaks are gone from this quest. +Changed 2026-11-07 (maintainer, /quest-plan from the API sweep of open PRs and remaining quests): [moq-dev/moq#8296](https://github.com/moq-dev/moq/pull/8296) ships `Video.RendererOutput` without `frame` and narrows `@moq/room` before the cut, so those two breaks are gone from this quest. ## Required diff --git a/test/audio-quality/clients/js/src/probe.test.ts b/test/audio-quality/clients/js/src/probe.test.ts index 0b642f88e63..5eb684f2777 100644 --- a/test/audio-quality/clients/js/src/probe.test.ts +++ b/test/audio-quality/clients/js/src/probe.test.ts @@ -18,14 +18,12 @@ test("audio-only sampling holds probe demand until its final RTT sample", async const empty = new Signal(undefined); const watch = { connection: { probe: network }, - player: { - audio: { - out: { stalled: new Signal(false), context: empty, root: empty, timestamp: empty, stats: empty }, - source: { out: { config: empty } }, - }, - sync: { out: { delay: empty, jitter: empty }, now: () => undefined }, - broadcast: { out: { catalog: empty } }, + audio: { + out: { stalled: new Signal(false), context: empty, root: empty, timestamp: empty, stats: empty }, + source: { out: { config: empty } }, }, + sync: { out: { delay: empty, jitter: empty }, now: () => undefined }, + broadcast: { out: { catalog: empty } }, } as unknown as MoqWatch; const measurement = probe(watch); try { diff --git a/test/audio-quality/clients/js/src/probe.ts b/test/audio-quality/clients/js/src/probe.ts index 412fde6ecc7..500c909381d 100644 --- a/test/audio-quality/clients/js/src/probe.ts +++ b/test/audio-quality/clients/js/src/probe.ts @@ -50,7 +50,7 @@ export function probe(watch: MoqWatch, capture = false): Probe { if (notes.length < 200) notes.push(line.slice(0, 200)); }; - const { audio, sync, broadcast } = watch.player; + const { audio, sync, broadcast } = watch; const recording = capture ? new Capture({ broadcast: audio.source.in.broadcast, diff --git a/test/audio-quality/clients/js/src/ptt.ts b/test/audio-quality/clients/js/src/ptt.ts index a664c7ff499..ba0ab94c536 100644 --- a/test/audio-quality/clients/js/src/ptt.ts +++ b/test/audio-quality/clients/js/src/ptt.ts @@ -135,15 +135,15 @@ watch.muted = false; watch.paused = false; document.body.append(watch); -const audio = watch.player.audio; +const audio = watch.audio; const capture = new Capture({ broadcast: audio.source.in.broadcast, track: audio.source.out.track, config: audio.source.out.config, - delay: watch.player.sync.in.delay, + delay: watch.sync.in.delay, // Decoder subscriptions include codec warmup beyond Sync's budget. The observer must // request the same age so its own subscription cannot discard an otherwise visible frame. - maxDelay: new Derived([watch.player.sync.out.maxDelay, audio.source.out.config], (max, config) => + maxDelay: new Derived([watch.sync.out.maxDelay, audio.source.out.config], (max, config) => Milli(max + (config?.warmup ?? 0)), ), }); @@ -210,7 +210,7 @@ publish.audio.out.catalog.subscribe((config) => { if (now !== selectable) records.catalog.push({ at: performance.now(), selectable: now }); selectable = now; }); -watch.player.sync.out.delay.subscribe((delay) => records.delays.push({ at: performance.now(), delay })); +watch.sync.out.delay.subscribe((delay) => records.delays.push({ at: performance.now(), delay })); // Playback counts from the measurement's start, so the warmup's tune-in is not charged to it. let baseline = { underruns: 0, stalls: 0 }; const playback = () => { @@ -238,13 +238,13 @@ export const measurement = { context: audio.out.context.peek()?.state, stalled: audio.out.stalled.peek(), timestamp: audio.out.timestamp.peek(), - delay: watch.player.sync.out.delay.peek(), + delay: watch.sync.out.delay.peek(), error: String(publish.audio.out.error.peek() ?? audio.out.error.peek() ?? ""), }; }, reset() { for (const list of Object.values(records)) list.length = 0; - records.delays.push({ at: performance.now(), delay: watch.player.sync.out.delay.peek() }); + records.delays.push({ at: performance.now(), delay: watch.sync.out.delay.peek() }); capture.drain(); arrivals.length = 0; const stats = audio.out.stats.peek(); diff --git a/test/datagram-tracks/client.ts b/test/datagram-tracks/client.ts index 6ef4da50a2f..a39c707abad 100644 --- a/test/datagram-tracks/client.ts +++ b/test/datagram-tracks/client.ts @@ -14,7 +14,7 @@ * @module */ import assert from "node:assert/strict"; -import Session from "@moq/qmux"; +import { Session } from "@moq/qmux"; import * as Moq from "../../js/net/src/index.ts"; // stdout carries the handshake to the Rust test, so library debug logs go to stderr. @@ -125,7 +125,7 @@ try { assert.equal(audioInfo.timescale, Moq.Time.Timescale.MICRO, "audio timescale"); assert.equal(audioInfo.priority, -7, "audio publisher priority"); const eventsInfo = await events.info(); - assert.equal(eventsInfo.timescale ?? undefined, undefined, "events are untimed"); + assert.equal(eventsInfo.timescale, null, "events are untimed"); assert.equal(eventsInfo.priority, 0, "events publisher priority"); assert.equal((await track.info()).timescale, Moq.Time.Timescale.MILLI, "stream track timescale"); // Answered, so what the publisher writes from now on reaches this session. diff --git a/test/interop/clients/js-native/control.ts b/test/interop/clients/js-native/control.ts index 7f966e6c5b4..96338699e96 100644 --- a/test/interop/clients/js-native/control.ts +++ b/test/interop/clients/js-native/control.ts @@ -6,7 +6,7 @@ import { exchangeSetup } from "../../../../js/net/src/connection/handshake.ts"; import type * as Ietf from "../../../../js/net/src/ietf/index.ts"; import { NativeSession } from "../../../../js/net/src/ietf/session.ts"; import { TrackStatusOk, TrackStatusRequest } from "../../../../js/net/src/ietf/track.ts"; -import Qmux from "../../../../js/qmux/src/index.ts"; +import { Session as Qmux } from "../../../../js/qmux/src/index.ts"; console.debug = console.error; console.info = console.error; diff --git a/test/interop/clients/js-native/server.ts b/test/interop/clients/js-native/server.ts index 36b56e236b7..c01971f6033 100644 --- a/test/interop/clients/js-native/server.ts +++ b/test/interop/clients/js-native/server.ts @@ -7,7 +7,7 @@ import { exchangeSetup } from "../../../../js/net/src/connection/handshake.ts"; import { Connection } from "../../../../js/net/src/ietf/connection.ts"; import { GoAway } from "../../../../js/net/src/ietf/goaway.ts"; import * as Ietf from "../../../../js/net/src/ietf/index.ts"; -import Qmux, { selectSubprotocol } from "../../../../js/qmux/src/index.ts"; +import { Session as Qmux, selectSubprotocol } from "../../../../js/qmux/src/index.ts"; console.debug = console.error; console.info = console.error; diff --git a/test/interop/clients/js/portrait.ts b/test/interop/clients/js/portrait.ts index 705885e5cda..5fef997eaab 100644 --- a/test/interop/clients/js/portrait.ts +++ b/test/interop/clients/js/portrait.ts @@ -27,7 +27,7 @@ try { await page.waitForFunction( () => { const watch = document.querySelector("moq-watch") as MoqWatch; - const frame = watch.renderer.out.frame.peek(); + const frame = watch.renderer.painted.peek(); return ( watch.video.source.out.track.peek() === "video/360p" && frame?.displayWidth === 360 && diff --git a/test/interop/clients/js/qmux-datagrams.browser.ts b/test/interop/clients/js/qmux-datagrams.browser.ts index 1ebca6814c7..2c70887bdc6 100644 --- a/test/interop/clients/js/qmux-datagrams.browser.ts +++ b/test/interop/clients/js/qmux-datagrams.browser.ts @@ -1,7 +1,7 @@ /** A QMux session over Chromium's native `WebSocketStream` sends every datagram on an idle socket. */ import { expect, test } from "bun:test"; import { createServer } from "vite"; -import Session, { selectSubprotocol } from "../../../../js/qmux/src/index.ts"; +import { Session, selectSubprotocol } from "../../../../js/qmux/src/index.ts"; import { BunSocketStream } from "../../../qmux/bun-socket.ts"; import { launch } from "./harness"; diff --git a/test/interop/clients/js/qmux-datagrams/main.ts b/test/interop/clients/js/qmux-datagrams/main.ts index 4aeb963c25f..0c9e2905feb 100644 --- a/test/interop/clients/js/qmux-datagrams/main.ts +++ b/test/interop/clients/js/qmux-datagrams/main.ts @@ -1,5 +1,5 @@ /** Datagrams from a QMux session over the browser's native `WebSocketStream`. @module */ -import Session from "../../../../../js/qmux/src/index.ts"; +import { Session } from "../../../../../js/qmux/src/index.ts"; /** Dial `url`, send `count` one-byte datagrams back to back, then a stream that marks the end. */ export async function run(url: string, protocol: string, count: number, burst: boolean): Promise { diff --git a/test/interop/clients/js/src/finish.ts b/test/interop/clients/js/src/finish.ts index e20e7d6f5b5..4e8b60e79fe 100644 --- a/test/interop/clients/js/src/finish.ts +++ b/test/interop/clients/js/src/finish.ts @@ -44,7 +44,7 @@ export function finish( releaseAck = resolve; }); const origin = new Moq.Origin.Producer(); - let connection: Moq.Connection.Established | undefined; + let connection: Moq.Connection.Session | undefined; const state: State = { ready: false, ended: false, diff --git a/test/interop/compat/transport.ts b/test/interop/compat/transport.ts index abad0cb3e3b..08ae57b5a8f 100644 --- a/test/interop/compat/transport.ts +++ b/test/interop/compat/transport.ts @@ -1,7 +1,7 @@ import * as Moq from "@moq/net"; /** Pin the transport to one wire version, including drafts omitted from defaults. */ -export async function connect(props: Moq.Connection.ConnectProps): Promise { +export async function connect(props: Moq.Connection.ConnectProps): Promise { const version = process.env.INTEROP_VERSION; if (!version) return Moq.Connection.connect(props); const url = new URL(props.url); diff --git a/test/max-age/client.ts b/test/max-age/client.ts index be02aa65069..cf244302e84 100644 --- a/test/max-age/client.ts +++ b/test/max-age/client.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { Milli } from "@moq/clock"; -import Session from "@moq/qmux"; +import { Session } from "@moq/qmux"; import { connect } from "../../js/net/src/connection/connect.ts"; import { Producer } from "../../js/net/src/origin.ts"; import * as Path from "../../js/net/src/path.ts"; @@ -48,8 +48,8 @@ try { assert(broadcast); for (const [i, age] of ages.entries()) { const track = broadcast.track(String(i)).subscribe(); - // A track received without a max age has no publisher limit: absent, not null. - assert.equal((await track.info()).maxAge, age ?? undefined, `${protocol} track ${i}`); + // A track received without a max age has no publisher limit. + assert.equal((await track.info()).maxAge, age, `${protocol} track ${i}`); track.close(); } request.close(); diff --git a/test/qmux/interop.test.ts b/test/qmux/interop.test.ts index 4175d7fa61d..3bd615f0a84 100644 --- a/test/qmux/interop.test.ts +++ b/test/qmux/interop.test.ts @@ -2,7 +2,7 @@ // The Rust side is the in-tree qmux crate's interop examples, built by `just test qmux`. import { expect, test } from "bun:test"; import { join } from "node:path"; -import Session, { selectSubprotocol, type Version } from "@moq/qmux"; +import { Session, selectSubprotocol, type Version } from "@moq/qmux"; import { BunSocketStream } from "./bun-socket.ts"; const BIN = process.env.QMUX_INTEROP_BIN; diff --git a/test/relay-fill/relay.ts b/test/relay-fill/relay.ts index 81cdaad706c..718d37ada2b 100644 --- a/test/relay-fill/relay.ts +++ b/test/relay-fill/relay.ts @@ -8,7 +8,7 @@ import assert from "node:assert/strict"; import { createInterface } from "node:readline"; import { Manual, Milli, Nano } from "@moq/clock"; -import Session from "@moq/qmux"; +import { Session } from "@moq/qmux"; import { DEFAULT_EXPIRY } from "../../js/net/src/cache.ts"; import { connect } from "../../js/net/src/connection/connect.ts"; import { Producer } from "../../js/net/src/origin.ts"; diff --git a/test/video/src/poster.ts b/test/video/src/poster.ts index 95986059a46..d47e0351734 100644 --- a/test/video/src/poster.ts +++ b/test/video/src/poster.ts @@ -121,7 +121,7 @@ async function poster(audio: boolean, buffered: boolean) { if (!context) throw new Error("no renderer canvas context"); const rgba = context.getImageData(32, 16, 1, 1).data; return { - timestamp: player.renderer.out.frame.peek()?.timestamp, + timestamp: player.renderer.painted.peek()?.timestamp, decoded: !!player.video.out.frame.peek(), downloading: player.video.in.enabled.peek(), playing: player.sync.playing.peek(),