diff --git a/demo/web/src/publish.ts b/demo/web/src/publish.ts index d76f2cf11b..6b45369f61 100644 --- a/demo/web/src/publish.ts +++ b/demo/web/src/publish.ts @@ -404,7 +404,7 @@ meta.run((effect) => { if (!net) return; // A day-long cache so a viewer joining long after the last edit still replays the value. - const track = net.createTrack(META_TRACK, { maxAge: 86_400_000 }); + const track = net.createTrack(META_TRACK, { maxAge: Net.Time.Milli(86_400_000) }); effect.cleanup(() => track.close()); const producer = new Json.Snapshot.Producer({ track }); diff --git a/demo/web/src/stats.ts b/demo/web/src/stats.ts index e2c9658404..08867ecc9b 100644 --- a/demo/web/src/stats.ts +++ b/demo/web/src/stats.ts @@ -167,7 +167,7 @@ function subscribeNode(effect: Signals.Effect, origin: Net.Origin.Table, path: N if (!consumer) return; const sub = (trackName: string, key: K) => { - const track = consumer.subscribe(trackName).ordered(); + const track = consumer.track(trackName).subscribe().ordered(); effect.cleanup(() => track.close()); effect.spawn(async () => { for (;;) { diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 4679beaff6..18ad4460ed 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -19,7 +19,11 @@ const url = new URL("https://cdn.moq.dev/anon?jwt=..."); // Publish. The origin is the routing table the connection announces and serves, // so a broadcast survives a reconnect. const origin = new Moq.Origin.Producer(); -const connection = await Moq.Connection.connect(url, { publish: origin.consume() }); +const connection = await Moq.Connection.connect({ + url, + publish: origin.consume(), + consume: origin, +}); const broadcast = origin.createBroadcast(Moq.Path.from("chat.room")); const track = broadcast.createTrack("messages"); @@ -29,7 +33,13 @@ group.close(); broadcast.announce(); // Subscribe -const consumer = connection.consume(Moq.Path.from("chat.room")).track("messages").subscribe({ priority: 0 }); +const request = origin.request(Moq.Path.from("chat.room")); +let active = request.active.peek(); +while (!active) { + await request.active.changed(); + active = request.active.peek(); +} +const consumer = active.track("messages").subscribe({ priority: 0 }); for (;;) { const group = await consumer.recvGroup(); if (!group) break; @@ -41,9 +51,9 @@ for (;;) { - **Connections** race WebTransport against WebSocket. `new Connection({ url })` pools one connection per relay URL and reconnects with backoff, which the elements use. `closed` settles when the handle is released (`null` on a clean close); the failure that stopped retrying the current URL is `error`, and a new URL recovers the same handle. A connection owns one send-rate sampler and one `Bandwidth.Allocator`; publishers reserve against it so their encoder targets sum to the estimate instead of each matching it. - **Bandwidth** (`Bandwidth.Allocator`) divides the connection's send-rate estimate by track priority, max-min fair within a tier. An idle track claims nothing. The receive side is untouched. - **Discovery** by any pattern scope (`origin.announced(scope)`, such as `room/*/chat`; default everything). Each event's `path` is the covered prefix relative to the origin, `captures` reports what the scope's wildcards matched when the prefix pins them, and `kind` says whether it was announced, updated, or retracted. The consumer is an async iterable. `origin.dynamic(prefix, route)` advertises a prefix. -- **Subscriptions** carry a priority and max age; groups arrive out of order and are read frame by frame, with `Lagged` when a reader asks for a frame the group never held and `GroupTooLarge` when a write exceeds the cache budget and aborts the group. +- **Subscriptions** carry a priority, a `Time.Milli` max age, and optional `groups` bounds. Groups arrive out of order and are read frame by frame, with `Error.TooFarBehind` when a reader asks for a frame the group never held and `Error.GroupTooLarge` when a write exceeds the cache budget and aborts the group. - **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. -- **Errors** split by scope: a stream reset throws `StreamError` with a `StreamCode`, a session close gives `SessionError` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Same on either transport. Named conditions like `Lagged` subclass `StreamError`, so one `code` check catches a gap whether it happened here or at the peer, and resetting a moq-lite stream with one sends that code rather than a bare internal error. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. +- **Errors** live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. - **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate. The [path pattern](/concept/moq-lite#path-patterns) grammar lives on the @@ -83,7 +93,7 @@ Three operations, on an origin: - `origin.dynamic(prefix, route)` claims `prefix` and every path beneath it (`""` claims everything). Hold the returned `Origin.Dynamic` while the claim should stay advertised; `close()` retracts it. A request beneath it - with no local broadcast is a `BroadcastRequest` to `accept` or `reject`; + with no local broadcast is an `Origin.Request` to `accept` or `reject`; reject what you will not serve rather than narrowing the claim, since a route is always a prefix on every wire. diff --git a/js/binary/src/snapshot/consumer.ts b/js/binary/src/snapshot/consumer.ts index e1520d9717..c6b0263613 100644 --- a/js/binary/src/snapshot/consumer.ts +++ b/js/binary/src/snapshot/consumer.ts @@ -51,7 +51,7 @@ export class Consumer { // Anything else is the track's terminal error, which every later read would throw // again; swallowing it would spin here instead of telling the caller the // subscription died. - if (!(err instanceof Moq.Group.Lagged)) throw err; + if (!(err instanceof Moq.Error.TooFarBehind)) throw err; continue; } diff --git a/js/binary/src/snapshot/producer.ts b/js/binary/src/snapshot/producer.ts index 1281e7f072..8b4e1e66f5 100644 --- a/js/binary/src/snapshot/producer.ts +++ b/js/binary/src/snapshot/producer.ts @@ -53,7 +53,7 @@ export class Producer { // Check before opening a group. `appendGroup` publishes immediately, so letting `writeFrame` // reject the frame would leave an empty newest group behind: a snapshot consumer jumps to the // newest, so the previous value would be lost even though this update threw. - if (encoded.byteLength > Moq.Group.MAX_GROUP_CACHE_BYTES) throw new Moq.Group.FrameTooLarge(); + if (encoded.byteLength > Moq.Group.MAX_GROUP_CACHE_BYTES) throw new Moq.Error.FrameTooLarge(); const group = this.#track.appendGroup(); try { diff --git a/js/binary/src/snapshot/snapshot.test.ts b/js/binary/src/snapshot/snapshot.test.ts index b91e073b9a..bee50c6925 100644 --- a/js/binary/src/snapshot/snapshot.test.ts +++ b/js/binary/src/snapshot/snapshot.test.ts @@ -6,7 +6,7 @@ const bytes = (...values: number[]) => new Uint8Array(values); // Walking a finished track's groups inspects a complete timeline, so request a replay window // instead of the transport's live-edge default, which skips every superseded group. -const REPLAY_LATENCY = 30_000; +const REPLAY_LATENCY = Time.Milli(30_000); // Drain every value currently available from a fresh consumer over the (finished) track. async function drain(track: Track.Subscriber, compression: boolean): Promise { diff --git a/js/binary/src/stream/stream.test.ts b/js/binary/src/stream/stream.test.ts index 6df02fdad4..b1f547c3a3 100644 --- a/js/binary/src/stream/stream.test.ts +++ b/js/binary/src/stream/stream.test.ts @@ -5,7 +5,7 @@ import { Consumer, Producer, Rolled } from "./index.ts"; // Ask for a replay window, so the superseded first group is delivered rather than skipped by the // subscriber's default max-age budget. A rolled log is exactly the case where both groups matter. -const REPLAY_LATENCY = 30_000; +const REPLAY_LATENCY = Time.Milli(30_000); const payloads = (count: number) => Array.from({ length: count }, (_, n) => new Uint8Array(8).fill(n)); diff --git a/js/clock/src/main.ts b/js/clock/src/main.ts index 9d7aee5ad5..27a04506a1 100755 --- a/js/clock/src/main.ts +++ b/js/clock/src/main.ts @@ -74,7 +74,7 @@ ENVIRONMENT VARIABLES: async function publish(config: Config) { // The origin holds what we publish; the connection announces and serves it. const origin = new Moq.Origin.Producer(); - await Moq.Connection.connect(new URL(config.url), { publish: origin.consume() }); + const connection = await Moq.Connection.connect({ url: new URL(config.url), publish: origin.consume() }); console.log("✅ Connected to relay:", config.url); // Create a new "broadcast", which is a collection of tracks. @@ -83,19 +83,8 @@ async function publish(config: Config) { console.log("✅ Published broadcast:", config.broadcast); - // Wait until we get a subscription for the track - for (;;) { - const request = await broadcast.requested(); - if (!request) break; - - if (request.name === config.track) { - // Accept to commit the track's immutable properties (so a lite-05 TRACK - // request resolves) and obtain the Track to produce into. - void publishTrack(request.accept()); - } else { - request.reject(new Error("not found")); - } - } + void publishTrack(broadcast.createTrack(config.track)); + await connection.closed; } async function publishTrack(track: Moq.Track.Producer) { @@ -141,10 +130,16 @@ async function publishTrack(track: Moq.Track.Producer) { } async function subscribe(config: Config) { - const connection = await Moq.Connection.connect(new URL(config.url)); + const origin = new Moq.Origin.Producer(); + const connection = await Moq.Connection.connect({ url: new URL(config.url), consume: origin }); console.log("✅ Connected to relay:", config.url); - const broadcast = connection.consume(Moq.Path.from(config.broadcast)); + const request = origin.request(Moq.Path.from(config.broadcast)); + let broadcast = request.active.peek(); + while (!broadcast) { + await request.active.changed(); + broadcast = request.active.peek(); + } const track = broadcast.track(config.track).subscribe({ priority: 0 }); console.log("✅ Subscribed to track:", config.track); @@ -185,6 +180,9 @@ async function subscribe(config: Config) { console.log(clockEmoji, base + seconds); } } + + connection.close(); + origin.close(); } // Wait for the WebTransport polyfill to be ready diff --git a/js/hang/src/container/consumer.outoforder.test.ts b/js/hang/src/container/consumer.outoforder.test.ts index 7b2ae336a3..c9b3f6452b 100644 --- a/js/hang/src/container/consumer.outoforder.test.ts +++ b/js/hang/src/container/consumer.outoforder.test.ts @@ -47,8 +47,8 @@ async function drain(consumer: Consumer): Promise<[number, number | undefined][] // audio writes into a timestamp-indexed ring and video drops a late frame at render, and the // subscription's own max age already bounds how far back one can be. test("out-of-order groups are delivered rather than dropped", async () => { - const track = new Track.Producer("test").accept({ maxAge: 30_000 }); - const consumer = new Consumer(track.subscribe({ maxAge: 5000 }), { + const track = new Track.Producer("test").accept({ maxAge: Time.Milli(30_000) }); + const consumer = new Consumer(track.subscribe({ maxAge: Time.Milli(5000) }), { format: new LegacyFormat("data"), maxAge: 5000 as Time.Milli, }); @@ -77,8 +77,8 @@ test("out-of-order groups are delivered rather than dropped", async () => { // drained (the decode loop consumes faster than the network delivers). Removing it at that // instant silently truncates its tail, so removal must wait for the group to finish. test("a below-cursor group still downloading is not truncated", async () => { - const track = new Track.Producer("test").accept({ maxAge: 30_000 }); - const consumer = new Consumer(track.subscribe({ maxAge: 5000 }), { + const track = new Track.Producer("test").accept({ maxAge: Time.Milli(30_000) }); + const consumer = new Consumer(track.subscribe({ maxAge: Time.Milli(5000) }), { format: new LegacyFormat("data"), maxAge: 5000 as Time.Milli, }); diff --git a/js/hang/src/container/consumer.test.ts b/js/hang/src/container/consumer.test.ts index b18c71ed1c..bb2110cf94 100644 --- a/js/hang/src/container/consumer.test.ts +++ b/js/hang/src/container/consumer.test.ts @@ -1,6 +1,6 @@ import { expect, spyOn, test } from "bun:test"; import { Format as LocFormat, Producer as LocProducer } from "@moq/loc"; -import { Group, SessionCode, SessionError, StreamCode, StreamError, Time, Track, Varint } from "@moq/net"; +import { Group, Error as NetError, SessionCode, StreamCode, Time, Track, Varint } from "@moq/net"; import { AudioConfigSchema } from "../catalog/audio.ts"; import { decodeInitSegment, type InitSegment } from "./cmaf/decode.ts"; import { createAudioInitSegment, encodeDataSegment } from "./cmaf/encode.ts"; @@ -50,7 +50,7 @@ function settle(ms = 20): Promise { // These tests write every group up front and only then read, so they ask for history // rather than the live edge. function replay(track: Track.Producer): Track.Subscriber { - return track.subscribe({ maxAge: 30_000 }); + return track.subscribe({ maxAge: Time.Milli(30_000) }); } // --- LegacyFormat --- @@ -130,7 +130,7 @@ test("Legacy Producer accepts open-GOP leading pictures above the previous group test("Legacy Producer writes a duration marker at the next keyframe", async () => { const track = new Track.Producer("test"); - const subscriber = track.subscribe({ maxAge: 30_000 }); + const subscriber = track.subscribe({ maxAge: Time.Milli(30_000) }); const producer = new LegacyProducer(track, new LegacyFormat("video")); producer.encode(new Uint8Array([0xde, 0xad]), 0 as Time.Micro, true); producer.encode(new Uint8Array([0xbe, 0xef]), 10_000 as Time.Micro, false); @@ -155,7 +155,7 @@ test("Legacy Producer writes a duration marker at the next keyframe", async () = test("Legacy Producer omits a reordered group's presentation endpoint marker", async () => { const track = new Track.Producer("test"); - const subscriber = track.subscribe({ maxAge: 30_000 }); + const subscriber = track.subscribe({ maxAge: Time.Milli(30_000) }); const producer = new LegacyProducer(track, new LegacyFormat("video")); for (const [index, timestamp] of [0, 120_000, 40_000, 80_000].entries()) { producer.encode(new Uint8Array([1]), timestamp as Time.Micro, index === 0); @@ -180,7 +180,7 @@ test("Legacy Producer omits a reordered group's presentation endpoint marker", a test("Legacy Producer estimates the tail from the current cadence", async () => { const track = new Track.Producer("test"); - const subscriber = track.subscribe({ maxAge: 30_000 }); + const subscriber = track.subscribe({ maxAge: Time.Milli(30_000) }); const producer = new LegacyProducer(track, new LegacyFormat("video")); for (const [index, timestamp] of [0, 16_000, 32_000, 65_000, 98_000].entries()) { producer.encode(new Uint8Array([1]), timestamp as Time.Micro, index === 0); @@ -200,7 +200,7 @@ test("Legacy Producer estimates the tail from the current cadence", async () => test("Legacy Producer rejects a backwards cut without closing the group", async () => { const track = new Track.Producer("test"); - const subscriber = track.subscribe({ maxAge: 30_000 }); + const subscriber = track.subscribe({ maxAge: Time.Milli(30_000) }); const producer = new LegacyProducer(track, new LegacyFormat("video")); producer.encode(new Uint8Array([1]), 20_000 as Time.Micro, true); expect(() => producer.cut(10_000 as Time.Micro)).toThrow(); @@ -1445,7 +1445,7 @@ for (const code of [StreamCode.Cancel, StreamCode.Internal, StreamCode.Old, Stre timestamp: Time.Timestamp.now(), }); await settle(); - group.close(new StreamError(code)); + group.close(new NetError.Stream(code)); await settle(); expect((await consumer.next())?.frame?.payload).toEqual(new Uint8Array([1])); expect((await consumer.next())?.frame).toBeUndefined(); @@ -1470,9 +1470,9 @@ for (const code of [StreamCode.Cancel, StreamCode.Internal, StreamCode.Old, Stre for (const end of [ null, - new StreamError(StreamCode.Cancel), - new StreamError(StreamCode.Internal), - new SessionError(SessionCode.ProtocolViolation), + new NetError.Stream(StreamCode.Cancel), + new NetError.Stream(StreamCode.Internal), + new NetError.Session(SessionCode.ProtocolViolation), ]) { test(`Consumer settles a pending read when the track ends: ${end}`, async () => { const track = new Track.Producer("test"); @@ -1495,7 +1495,7 @@ test("a group reset does not hide a later container decode failure", async () => try { const reset = track.appendGroup(); await settle(); - reset.close(new StreamError(StreamCode.Cancel)); + reset.close(new NetError.Stream(StreamCode.Cancel)); await settle(); const malformed = track.appendGroup(); malformed.writeFrame({ payload: new Uint8Array(), timestamp: Time.Timestamp.now() }); @@ -1509,7 +1509,11 @@ test("a group reset does not hide a later container decode failure", async () => } }); -for (const end of [null, new StreamError(StreamCode.Internal), new SessionError(SessionCode.ProtocolViolation)]) { +for (const end of [ + null, + new NetError.Stream(StreamCode.Internal), + new NetError.Session(SessionCode.ProtocolViolation), +]) { test(`Consumer drains a permanent buffered gap after track termination: ${end}`, async () => { const track = new Track.Producer("test"); const consumer = new Consumer(replay(track), { diff --git a/js/hang/src/container/consumer.ts b/js/hang/src/container/consumer.ts index 47b37c98a4..6540d02445 100644 --- a/js/hang/src/container/consumer.ts +++ b/js/hang/src/container/consumer.ts @@ -249,7 +249,7 @@ export class Consumer { // frames decoded before the error are still valid and playable. // The tail is gone though, so the next group does not continue this one. group.truncated = true; - if (!(err instanceof Moq.StreamError)) throw err; + if (!(err instanceof Moq.Error.Stream)) throw err; } finally { group.done = true; diff --git a/js/hang/src/container/track.ts b/js/hang/src/container/track.ts index 2013ff98be..56ed3eee57 100644 --- a/js/hang/src/container/track.ts +++ b/js/hang/src/container/track.ts @@ -11,7 +11,7 @@ import { Time, type Track } from "@moq/net"; // How long a media track asks its publisher (and, through TRACK_INFO, every relay) to keep a // non-latest group fetchable. Must match `hang::container::MAX_AGE` in // rs/hang/src/container/frame.rs. -const MAX_AGE_MS = 30_000; +const MAX_AGE_MS = Time.Milli(30_000); /** * Track properties for a track carrying media frames, for `Request.accept`. @@ -53,5 +53,5 @@ export type TrackInfoOptions = { * A RETENTION budget, not a delivery one, so it never makes anyone play further behind live * and lowering it does not reduce latency: it only shortens how far back a fetch can reach. */ - maxAge?: number; + maxAge?: Time.Milli; }; diff --git a/js/json/src/snapshot/compression.test.ts b/js/json/src/snapshot/compression.test.ts index 80935a07f5..aa792080a5 100644 --- a/js/json/src/snapshot/compression.test.ts +++ b/js/json/src/snapshot/compression.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { Decoder } from "@moq/flate"; -import { Track } from "@moq/net"; +import { Time, Track } from "@moq/net"; import { Consumer } from "./consumer.ts"; import { Producer } from "./producer.ts"; @@ -8,7 +8,7 @@ type Value = Record; const enc = new TextEncoder(); const dec = new TextDecoder(); -const REPLAY_LATENCY = 30_000; +const REPLAY_LATENCY = Time.Milli(30_000); // Reconstruct every value a compressed consumer yields, in order. async function drainCompressed(track: Track.Subscriber): Promise { diff --git a/js/json/src/snapshot/producer.ts b/js/json/src/snapshot/producer.ts index b53590e877..ca9d252be3 100644 --- a/js/json/src/snapshot/producer.ts +++ b/js/json/src/snapshot/producer.ts @@ -67,7 +67,7 @@ export class Producer { // replacement before the frame is written, so discovering the limit inside `writeFrame` would // leave an empty newest group behind: a snapshot consumer jumps to the newest, so the last // good value would vanish even though this update reported an error. - if (encoded.payload.byteLength > Moq.Group.MAX_GROUP_CACHE_BYTES) throw new Moq.Group.FrameTooLarge(); + if (encoded.payload.byteLength > Moq.Group.MAX_GROUP_CACHE_BYTES) throw new Moq.Error.FrameTooLarge(); if (encoded.keyframe) { // The previous group is complete; no more frames will be appended to it. Drop the handle diff --git a/js/json/src/snapshot/snapshot.test.ts b/js/json/src/snapshot/snapshot.test.ts index 3e2d64836f..f428efabee 100644 --- a/js/json/src/snapshot/snapshot.test.ts +++ b/js/json/src/snapshot/snapshot.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { Group, Track } from "@moq/net"; +import { Group, Error as NetError, Time, Track } from "@moq/net"; import { Consumer } from "./consumer.ts"; import { Producer } from "./producer.ts"; @@ -7,7 +7,7 @@ type Value = Record; // These tests inspect complete finished timelines, so request a replay window // instead of the transport's live-edge default. -const REPLAY_LATENCY = 30_000; +const REPLAY_LATENCY = Time.Milli(30_000); // Reconstruct every value a consumer yields, in order. async function drain(track: Track.Subscriber): Promise { @@ -323,7 +323,7 @@ test("a rejected update leaves the previous value readable", async () => { // Serializes past the group cache limit, so the frame cannot be published. const oversized = { big: "x".repeat(Group.MAX_GROUP_CACHE_BYTES + 1) }; - expect(() => producer.update(oversized)).toThrow(Group.FrameTooLarge); + expect(() => producer.update(oversized)).toThrow(NetError.FrameTooLarge); producer.finish(); // A reader arriving now still finds the last good value, not an empty superseding group. diff --git a/js/json/src/stream/codec.test.ts b/js/json/src/stream/codec.test.ts index f3539e0d3e..17fd6da25e 100644 --- a/js/json/src/stream/codec.test.ts +++ b/js/json/src/stream/codec.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { Group, Track } from "@moq/net"; +import { Group, Error as NetError, Track } from "@moq/net"; import { Decoder } from "./decoder.ts"; import { Encoder } from "./encoder.ts"; import { Producer } from "./producer.ts"; @@ -175,11 +175,11 @@ test("a failed write on the very first record still ends the track", async () => // Serializes past the group cache limit, so `appendGroup` succeeds and `writeFrame` rejects it. const oversized = "x".repeat(Group.MAX_GROUP_CACHE_BYTES + 1); - expect(() => producer.append(oversized)).toThrow(Group.FrameTooLarge); + expect(() => producer.append(oversized)).toThrow(NetError.FrameTooLarge); // The consumer is handed the group that was already published, and reading it surfaces the // abort. Without ending the track that group stays open and empty, so this read hangs instead. const group = await subscriber.nextGroup(); expect(group).toBeDefined(); - await expect(group?.readFrame()).rejects.toThrow(Group.FrameTooLarge); + await expect(group?.readFrame()).rejects.toThrow(NetError.FrameTooLarge); }); diff --git a/js/json/src/stream/stream.test.ts b/js/json/src/stream/stream.test.ts index c48bf5820d..5e93b54724 100644 --- a/js/json/src/stream/stream.test.ts +++ b/js/json/src/stream/stream.test.ts @@ -83,7 +83,7 @@ test("a second group is reported while the first is still open", async () => { // Ask for a replay window, so the first group is delivered rather than skipped by the // subscriber's default max-age budget once a newer group exists. - const consumer = new Consumer({ track: track.subscribe({ maxAge: 30_000 }) }); + const consumer = new Consumer({ track: track.subscribe({ maxAge: Time.Milli(30_000) }) }); expect(await consumer.next()).toEqual({ n: 0 }); await expect(consumer.next()).rejects.toThrow(Rolled); diff --git a/js/json/src/window/consumer.ts b/js/json/src/window/consumer.ts index c17fe8e89e..7b949aac4e 100644 --- a/js/json/src/window/consumer.ts +++ b/js/json/src/window/consumer.ts @@ -72,7 +72,7 @@ export class Consumer { frame = await this.#group.readFrame(); } catch (err) { // A locally raised gap carries its code too, so this catches both sides. - if (!(err instanceof Moq.StreamError && GAPS.includes(err.code))) throw err; + if (!(err instanceof Moq.Error.Stream && GAPS.includes(err.code))) throw err; // The next group starts with a checkpoint that accounts for everything missed. this.#group = undefined; diff --git a/js/json/src/window/window.test.ts b/js/json/src/window/window.test.ts index cc9e945adf..95d2f0bedd 100644 --- a/js/json/src/window/window.test.ts +++ b/js/json/src/window/window.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { Group, StreamCode, StreamError, Time, Track } from "@moq/net"; +import { Group, Error as NetError, StreamCode, Time, Track } from "@moq/net"; import { Consumer } from "./consumer.ts"; import { Decoder, Encoder, type Event, Producer, type Span } from "./index.ts"; @@ -256,12 +256,12 @@ test("a lagging consumer is told what it missed", async () => { test("consumer resumes at a checkpoint after losing a group", async () => { for (const error of [ - new Group.Lagged(), - new Group.GroupTooLarge(), - new StreamError(StreamCode.TooFarBehind), - new StreamError(StreamCode.GroupTooLarge), - new StreamError(StreamCode.Old), - new StreamError(StreamCode.Evicted), + new NetError.TooFarBehind(), + new NetError.GroupTooLarge(), + new NetError.Stream(StreamCode.TooFarBehind), + new NetError.Stream(StreamCode.GroupTooLarge), + new NetError.Stream(StreamCode.Old), + new NetError.Stream(StreamCode.Evicted), ]) { const track = new Track.Producer("test"); const consumer = new Consumer({ track: track.subscribe() }); diff --git a/js/moq-boy/src/game.ts b/js/moq-boy/src/game.ts index 42b4322bdf..6f4e37a72d 100644 --- a/js/moq-boy/src/game.ts +++ b/js/moq-boy/src/game.ts @@ -256,7 +256,7 @@ export class Game { const active = effect.get(this.broadcast.out.active); if (!active) return; - const statusTrack = active.subscribe("status", { priority: 10 }); + const statusTrack = active.track("status").subscribe({ priority: 10 }); effect.cleanup(() => statusTrack.close()); // Reconstruct each status from snapshots and deltas, validated against the schema. @@ -308,20 +308,10 @@ export class Game { this.viewerId.set(undefined); }); - effect.spawn(async () => { - for (;;) { - const req = await Promise.race([effect.cancel, viewerBroadcast.requested()]); - if (!req) break; - - if (req.name === "command") { - // accept() commits the track's immutable properties and returns the producer. - const track = req.accept(); - const producer = new Json.Snapshot.Producer>({ track }); - effect.cleanup(() => producer.finish()); - effect.run(this.#runCommandTrack.bind(this, track, producer)); - } - } - }); + const track = viewerBroadcast.createTrack("command"); + const producer = new Json.Snapshot.Producer>({ track }); + effect.cleanup(() => producer.finish()); + effect.run(this.#runCommandTrack.bind(this, track, producer)); } #runCommandTrack( diff --git a/js/net/examples/connection.ts b/js/net/examples/connection.ts index 1e14763b27..3ae055c5be 100644 --- a/js/net/examples/connection.ts +++ b/js/net/examples/connection.ts @@ -2,7 +2,7 @@ import * as Moq from "@moq/net"; async function main() { const url = new URL("https://cdn.moq.dev/anon"); - const connection = await Moq.Connection.connect(url); + const connection = await Moq.Connection.connect({ url }); console.log("Connected to MoQ relay!"); diff --git a/js/net/examples/discovery.ts b/js/net/examples/discovery.ts index 1bfbfef095..b532a26196 100644 --- a/js/net/examples/discovery.ts +++ b/js/net/examples/discovery.ts @@ -2,7 +2,8 @@ import * as Moq from "@moq/net"; async function main() { const url = new URL("https://cdn.moq.dev/anon"); - const connection = await Moq.Connection.connect(url); + const origin = new Moq.Origin.Producer(); + const connection = await Moq.Connection.connect({ url, consume: origin }); // Get the announced stream iterator const announced = connection.announced(); @@ -13,12 +14,13 @@ async function main() { console.log("New stream available:", announcement.path); // Subscribe to new streams - const _broadcast = connection.consume(announcement.path); + const _broadcast = origin.request(announcement.path, { announced: true }); // Do something with the broadcast } connection.close(); + origin.close(); } main().catch(console.error); diff --git a/js/net/examples/publish.ts b/js/net/examples/publish.ts index 63e2741b02..1fe6a6d879 100644 --- a/js/net/examples/publish.ts +++ b/js/net/examples/publish.ts @@ -5,7 +5,7 @@ async function main() { // The origin holds what we publish; the connection announces and serves it. const origin = new Moq.Origin.Producer(); - await Moq.Connection.connect(url, { publish: origin.consume() }); + const connection = await Moq.Connection.connect({ url, publish: origin.consume() }); // Create a broadcast (a collection of tracks) at a path on the origin const broadcast = origin.createBroadcast(Moq.Path.from("my-broadcast")); @@ -16,15 +16,7 @@ async function main() { void publishTrack(broadcast.createTrack("chat")); console.log("Published broadcast: my-broadcast"); - // Tracks created on demand (instead of up front) are still supported: handle any - // subscribe for a track that wasn't statically inserted. - for (;;) { - const request = await broadcast.requested(); - if (!request) break; - - // Reject anything we didn't insert above. - request.reject(new Error("track not found")); - } + await connection.closed; } async function publishTrack(track: Moq.Track.Producer) { diff --git a/js/net/examples/subscribe.ts b/js/net/examples/subscribe.ts index b0b5d08f2c..3516316768 100644 --- a/js/net/examples/subscribe.ts +++ b/js/net/examples/subscribe.ts @@ -2,13 +2,19 @@ import * as Moq from "@moq/net"; async function main() { const url = new URL("https://cdn.moq.dev/anon"); - const connection = await Moq.Connection.connect(url); + const origin = new Moq.Origin.Producer(); + const connection = await Moq.Connection.connect({ url, consume: origin }); // Subscribe to a broadcast - const broadcast = connection.consume(Moq.Path.from("my-broadcast")); + const broadcast = origin.request(Moq.Path.from("my-broadcast")); // Subscribe to a specific track (with priority 0) - const track = broadcast.track("chat").subscribe({ priority: 0 }); + let active = broadcast.active.peek(); + while (!active) { + await broadcast.active.changed(); + active = broadcast.active.peek(); + } + const track = active.track("chat").subscribe({ priority: 0 }); // Read data as it arrives for (;;) { @@ -21,7 +27,7 @@ async function main() { frame = await group.readString(); } catch (err) { // The publisher reset the group. The code reads the same way on every transport. - if (err instanceof Moq.StreamError) { + if (err instanceof Moq.Error.Stream) { console.warn("group reset with code", err.code); break; } @@ -34,6 +40,7 @@ async function main() { } connection.close(); + origin.close(); } main().catch(console.error); diff --git a/js/net/examples/wait.ts b/js/net/examples/wait.ts index e3b8080e2f..5bf4d8170a 100644 --- a/js/net/examples/wait.ts +++ b/js/net/examples/wait.ts @@ -5,11 +5,12 @@ const { Effect } = Moq.Signals; async function main() { const url = new URL("https://cdn.moq.dev/anon"); - const connection = new Moq.Connection({ url }); + const origin = new Moq.Origin.Producer(); + const connection = new Moq.Connection({ url, consume: origin }); // Wait for a broadcast that may not exist yet. `consume` would subscribe blind and get reset // if nobody is publishing the path; this waits for the announcement instead. - const broadcast = connection.announcedBroadcast(Moq.Path.from("my-broadcast")); + const broadcast = origin.request(Moq.Path.from("my-broadcast"), { announced: true }); const effect = new Effect(); effect.run((effect) => { @@ -50,6 +51,7 @@ async function main() { effect.close(); broadcast.close(); connection.close(); + origin.close(); } } diff --git a/js/net/src/announce.ts b/js/net/src/announce.ts new file mode 100644 index 0000000000..91d3cee37c --- /dev/null +++ b/js/net/src/announce.ts @@ -0,0 +1,6 @@ +/** + * Broadcast announcement streams. + * + * @module + */ +export { Consumer, isActive, type Kind, type Update } from "./announced.ts"; diff --git a/js/net/src/announced.test.ts b/js/net/src/announced.test.ts index b181ce5e09..2844152586 100644 --- a/js/net/src/announced.test.ts +++ b/js/net/src/announced.test.ts @@ -4,6 +4,7 @@ import type { Producer as BroadcastProducer } from "./broadcast.ts"; import { Route } from "./hop.ts"; import { Producer as OriginProducer } from "./origin.ts"; import * as Path from "./path.ts"; +import { wireOf } from "./wire.ts"; function publish(origin: OriginProducer, path: Path.Valid): BroadcastProducer { const broadcast = origin.createBroadcast(path); @@ -80,7 +81,7 @@ test("an origin handle resolves a local publish with no session attached", async const origin = new OriginProducer(); const path = p("loopback"); - const watch = new Announce.Broadcast({ origin, path }); + const watch = origin.request(path, { announced: true }); await settle(); expect(watch.active.peek()).toBeUndefined(); @@ -114,10 +115,10 @@ test("the local route wins over a blind request on a no-discovery origin", async // A session without discovery is attached, so the handle stands a request; but the // local publish must still resolve through the table, not wait on an answer. - const detach = origin.attach(false); + const detach = wireOf(origin).attach(false); const broadcast = publish(origin, path); - const watch = new Announce.Broadcast({ origin, path }); + const watch = origin.request(path, { announced: true }); await settle(); expect(watch.active.peek()).toBeDefined(); @@ -126,9 +127,3 @@ test("the local route wins over a blind request on a no-discovery origin", async broadcast.close(); origin.close(); }); - -test("BroadcastProps requires a source", () => { - // @ts-expect-error neither source is a compile error; the union demands exactly one. - const neither: Announce.BroadcastProps = { path: p("x") }; - expect(neither.path).toBeDefined(); -}); diff --git a/js/net/src/announced.ts b/js/net/src/announced.ts index 0dcd6880d7..a735645342 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -3,12 +3,9 @@ * * @module */ -import { Effect, type GetPromise, type Getter, type GetterInit, getter, Once, Signal } from "@moq/signals"; -import type * as broadcast from "./broadcast.js"; -import type { Established } from "./connection/established.js"; +import { type GetPromise, Once, Signal } from "@moq/signals"; import type { Route } from "./hop.js"; -import type { Table as OriginTable } from "./origin.js"; -import * as Path from "./path.js"; +import type * as Path from "./path.js"; /** * What an {@link Update} reports about its path. @@ -151,271 +148,3 @@ export class Consumer { closeState(this.#state, abort); } } - -// Connections already warned about missing broadcast discovery, so the fallback logs at most -// once per connection instead of once per watched path. -const warnedNoDiscovery = new WeakSet(); - -/** - * What to watch, for {@link Broadcast}: a path on exactly one source, enforced by the - * union so a call with neither or both does not compile. - * - * @public - */ -export type BroadcastProps = { - /** The broadcast path to watch. */ - path: Path.Valid; -} & ( - | { - /** - * The connection to watch on. Accepts a live {@link Established} session from - * `Connection.connect`, or a reactive one, which is how the handle survives - * reconnects. Prefer an origin-backed handle on a reconnecting `Connection`. - */ - connection: GetterInit; - origin?: undefined; - } - | { - /** - * The origin to watch instead of a session. - * - * The handle then follows the origin's table: it resolves whenever anything - * routes the path (a local publish, or any session feeding the origin), which is - * how it spans reconnects without watching the connection itself. While every - * attached session lacks discovery it falls back to a standing request, so - * `active` means assumed present. - */ - origin: GetterInit; - connection?: undefined; - } -); - -/** - * A reactive handle to a single broadcast: {@link Broadcast.active} holds a live - * {@link broadcast.Consumer} while the path is announced and `undefined` while nobody - * publishes it. - * - * Use this instead of {@link Established.consume} whenever the broadcast may not exist yet. - * Subscribing to a path nobody publishes gets the stream reset, so a consumer that races the - * publisher stays silent forever unless it retries; this waits for the announcement instead. - * - * A same-name republish re-consumes, so the handle attaches to the new instance rather than - * clinging to the dead one. A relay failover that keeps the same publisher does *not*: the - * subscription resumes across the new route, so `active` holds the same consumer throughout and - * never goes offline. Only a change of publisher produces an offline/online transition. - * - * Built from a reconnecting `Connection`'s origin, the handle also spans reconnects: the - * broadcast drops to `undefined` while disconnected and resolves again once the new connection - * announces it. - * - * Falls back to consuming blind (and warns once) on a relay without - * {@link Established.discovery}, where there is no announcement to wait for. `active` then - * means *assumed present* rather than known live: nothing reports whether the path exists, so - * a subscribe to a missing broadcast is how a caller finds out. The handle stays usable either - * way, and because it is scoped to the path rather than to one publisher, a subscribe made - * after a publisher finally appears succeeds. - * - * If discovery fails on a live session (the announcement stream is reset, or the relay - * refuses it) a connection-backed handle goes offline and stays there: nothing reopens the - * stream on that connection. Build it from a reconnecting `Connection`'s origin if you need - * it to recover, since a new session starts a new stream. An origin-backed handle recovers - * on its own: the session stops counting as discovering, so the handle falls back to a - * standing request. - * - * Close it to release the announcement stream and the current broadcast. - * - * @public - */ -export class Broadcast { - /** The broadcast path this handle watches. */ - readonly path: Path.Valid; - - /** - * The live broadcast, or `undefined` while it is offline. - * - * Borrowed, not yours to close: this handle owns the consumer and swaps it when the path is - * republished. `active` keeps pointing at whatever you closed, so once you drop the last - * reference the shared broadcast is gone and reads fail until the next announcement replaces - * it. Take a {@link broadcast.Consumer.clone} for a lifetime of your own, or close this whole - * handle to release everything. - */ - readonly active: Getter; - - #active = new Signal(undefined); - #signals = new Effect(); - - /** - * Watch a path on a connection or an origin. - * - * Prefer `announcedBroadcast(path)` on the connection itself. Reach for this when the - * source you want to follow isn't either connection type, e.g. your own - * `Getter` or an origin fed by a `consume` option. - */ - constructor({ connection, path, origin }: BroadcastProps) { - this.path = path; - this.active = this.#active; - - if (origin) { - const source = getter(origin); - this.#signals.run((effect) => this.#runOrigin(effect, source)); - return; - } - - const source = getter(connection); - this.#signals.run((effect) => { - const conn = effect.get(source); - if (!conn) return; - - // Without discovery no announcement ever arrives, so waiting would hang forever. - if (!conn.discovery) { - if (!warnedNoDiscovery.has(conn)) { - warnedNoDiscovery.add(conn); - console.warn("relay does not support broadcast discovery; consuming without waiting."); - } - - const blind = conn.consume(path); - effect.cleanup(() => blind.close()); - effect.set(this.#active, blind, undefined); - - // The announcement-gated path below goes offline when the stream ends with the - // session; without discovery there is no stream, so watch the session itself. - // A consumed broadcast is a path-scoped handle, not a subscription, so its own - // `closed` says nothing about whether the path exists or the session is alive. - // Raced against the run's teardown so a closed handle isn't retained until the - // session ends; the cleanup above has already cleared `active` in that case. - effect.spawn(async () => { - await Promise.race([effect.cancel, conn.closed]); - if (this.#active.peek() === blind) this.#active.set(undefined); - }); - return; - } - - const scope = Path.Pattern.subtree(path); - const announced = conn.announced(scope); - effect.cleanup(() => announced.close()); - - let current: broadcast.Consumer | undefined; - const offline = () => { - const mine = current; - current?.close(); - current = undefined; - // Only clear what this run put there. A spawn task that resumes after its run was - // torn down would otherwise wipe the consumer a newer run already installed. - if (this.#active.peek() === mine) this.#active.set(undefined); - }; - effect.cleanup(offline); - - effect.spawn(async () => { - try { - for (;;) { - const event = await Promise.race([effect.cancel, announced.next()]); - if (!event) break; - - // Routes covering this path clamp to it; one beneath it is a different - // broadcast and is skipped. - if (event.path !== path) continue; - - if (isActive(event.kind)) { - // A live subscription survives a redundant (re-)announce; only replace a dead one. - if (current && current.closed.peek() === undefined) continue; - current?.close(); - current = conn.consume(path); - this.#active.set(current); - } else { - offline(); - } - } - } catch (err) { - // Discovery failed: the session died under the stream, or the relay refused - // to answer. Nothing reopens it on this connection, so say so out loud. - console.warn("broadcast discovery failed", err); - } - - // The stream ended, or this run was torn down (its cleanup already ran). Either - // way there is nothing left announcing the path, so don't hold a dead broadcast. - offline(); - }); - }); - } - - // Follow the origin's table instead of a session's announce stream. The table already - // merges every source (local publishes, every feeding session), so this is simpler than - // the session path: no hop bookkeeping, and the table's identity-diffed announcements - // retract before a republish, which is what lets a plain re-consume suffice. - #runOrigin(effect: Effect, source: Getter): void { - const origin = effect.get(source); - if (!origin) return; - - // The two ways the broadcast can resolve. The table wins: it is knowledge (a local - // publish or an announcement) while a request's answer is only assumed present. - const table = new Signal(undefined); - const requested = new Signal(undefined); - effect.run((nested) => { - nested.set(this.#active, nested.get(table) ?? nested.get(requested), undefined); - }); - - // Follow the table regardless of sessions: a local publish resolves with no - // connection at all (and keeps resolving while one reconnects), and the - // identity-diffed announcements swap the handle on a republish. - // The scope is the path's subtree: the exact path plus everything beneath it. - const scope = Path.Pattern.subtree(this.path); - const announced = origin.announced(scope); - effect.cleanup(() => announced.close()); - - // Held open while the path is announced. A request resolves to the table's route when - // there is one, and a session skips answering a path the table routes, so within the - // announced window this can only ever produce the announced broadcast. Follow - // `active` rather than peeking once: a dynamic accept lands after the announcement. - const live = new Signal(false); - effect.run((nested) => { - if (!nested.get(live)) { - nested.set(table, undefined); - return; - } - const request = origin.request(this.path); - nested.cleanup(() => request.close()); - nested.run((inner) => { - inner.set(table, inner.get(request.active), undefined); - }); - }); - - effect.spawn(async () => { - for (;;) { - const event = await Promise.race([effect.cancel, announced.next()]); - if (!event) break; - - // Routes covering this path clamp to it; one beneath it is a different - // broadcast and is skipped. - if (event.path !== this.path) continue; - live.set(isActive(event.kind)); - } - - // The origin closed, or this run was torn down. Either way nothing routes the path. - live.set(false); - }); - - // Blind fallback: while any attached session cannot announce, the table is an - // incomplete picture of what is reachable, so stand a request for whichever session - // answers. Gated on exactly `false`: with no session there is nobody to ask, and with - // every session announcing the gate is the point, so a blind subscribe would defeat it. - effect.run((nested) => { - if (nested.get(origin.discovery) !== false) return; - - const request = origin.request(this.path); - nested.cleanup(() => request.close()); - nested.run((inner) => { - inner.set(requested, inner.get(request.active), undefined); - }); - }); - } - - /** Resolves once the handle is closed, so an owner can drop its reference. */ - get closed(): Promise { - return this.#signals.closed; - } - - /** Closes the handle and the broadcast it currently holds. Idempotent. */ - close() { - this.#signals.close(); - } -} diff --git a/js/net/src/bandwidth_api.ts b/js/net/src/bandwidth_api.ts new file mode 100644 index 0000000000..a3adac50cd --- /dev/null +++ b/js/net/src/bandwidth_api.ts @@ -0,0 +1,6 @@ +/** + * Public bandwidth allocation handles. + * + * @module + */ +export { Allocator, type Demand, type Handle, Reservation } from "./bandwidth.ts"; diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index 920fec648b..1a0c019c42 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -1,9 +1,11 @@ import { expect, setSystemTime, test } from "bun:test"; import { Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "./broadcast.ts"; -import { Producer as GroupProducer, GroupTooLarge, MAX_GROUP_FRAMES } from "./group.ts"; -import { Timestamp } from "./time.ts"; +import { GroupTooLarge } from "./error.ts"; +import { Producer as GroupProducer, MAX_GROUP_FRAMES } from "./group.ts"; +import { Milli, Timestamp } from "./time.ts"; import type { Request as TrackRequest } from "./track.ts"; import { Producer as TrackProducer } from "./track.ts"; +import { wireOf } from "./wire.ts"; // The public API mints consumers internally (Producer.consume, the wire layers); tests act // as a wire layer by subclassing, the same way lite's ConsumeBroadcast does. @@ -16,9 +18,9 @@ class TestConsumer extends BroadcastConsumer { // Observe whether an on-demand track request is pending without blocking: returns the // next request if one has already been emitted, or undefined if none is (yet) waiting. -async function pendingRequest(broadcast: Pick): Promise { +async function pendingRequest(broadcast: BroadcastProducer | BroadcastConsumer): Promise { const none = Symbol("none"); - const result = await Promise.race([broadcast.requested(), Promise.resolve(none)]); + const result = await Promise.race([wireOf(broadcast).requested(), Promise.resolve(none)]); return result === none ? undefined : (result as TrackRequest | undefined); } @@ -27,7 +29,7 @@ test("consumer dedupes repeat subscriptions onto one upstream request", async () // Two subscriptions to the same track share one upstream subscription... const a = consumer.track("video").subscribe().ordered(); - const b = consumer.subscribe("video").ordered(); + const b = consumer.track("video").subscribe().ordered(); const request = await pendingRequest(consumer); expect(request?.name).toBe("video"); @@ -42,12 +44,12 @@ test("consumer dedupes repeat subscriptions onto one upstream request", async () expect(await b.readString()).toBe("hello"); // A different track still opens its own request. - consumer.subscribe("audio"); + consumer.track("audio").subscribe(); expect((await pendingRequest(consumer))?.name).toBe("audio"); // Once the shared track closes, a later subscribe re-opens it. producer.close(); - consumer.subscribe("video"); + consumer.track("video").subscribe(); expect((await pendingRequest(consumer))?.name).toBe("video"); }); @@ -56,8 +58,8 @@ test("consumer dedupes repeat subscriptions onto one upstream request", async () test("dynamic track sequences continue across producer replacements", async () => { const broadcast = new BroadcastProducer(); - const firstSubscriber = broadcast.subscribe("media"); - const firstRequest = await broadcast.requested(); + const firstSubscriber = broadcast.track("media").subscribe(); + const firstRequest = await wireOf(broadcast).requested(); if (!firstRequest) throw new Error("expected first request"); const firstProducer = firstRequest.accept(); expect(firstProducer.appendGroup().sequence).toBe(0); @@ -67,15 +69,15 @@ test("dynamic track sequences continue across producer replacements", async () = firstSubscriber.close(); firstProducer.close(); - const secondSubscriber = broadcast.subscribe("media"); - const secondRequest = await broadcast.requested(); + const secondSubscriber = broadcast.track("media").subscribe(); + const secondRequest = await wireOf(broadcast).requested(); if (!secondRequest) throw new Error("expected second request"); const secondProducer = secondRequest.accept(); expect(secondProducer.appendGroup().sequence).toBe(13); const nextGeneration = new BroadcastProducer(); - const nextSubscriber = nextGeneration.subscribe("media"); - const nextRequest = await nextGeneration.requested(); + const nextSubscriber = nextGeneration.track("media").subscribe(); + const nextRequest = await wireOf(nextGeneration).requested(); if (!nextRequest) throw new Error("expected next-generation request"); expect(nextRequest.accept().appendGroup().sequence).toBe(0); @@ -88,10 +90,10 @@ test("dynamic track sequences continue across producer replacements", async () = test("concurrent dynamic producers share a sequence namespace", async () => { const broadcast = new BroadcastProducer(); - const firstSubscriber = broadcast.subscribe("media"); - const secondSubscriber = broadcast.subscribe("media"); - const firstRequest = await broadcast.requested(); - const secondRequest = await broadcast.requested(); + const firstSubscriber = broadcast.track("media").subscribe(); + const secondSubscriber = broadcast.track("media").subscribe(); + const firstRequest = await wireOf(broadcast).requested(); + const secondRequest = await wireOf(broadcast).requested(); if (!firstRequest || !secondRequest) throw new Error("expected requests"); const firstProducer = firstRequest.accept(); const secondProducer = secondRequest.accept(); @@ -109,8 +111,8 @@ test("concurrent dynamic producers share a sequence namespace", async () => { test("closing a broadcast rejects a dequeued request", async () => { const broadcast = new BroadcastProducer(); - const subscriber = broadcast.subscribe("media"); - const request = await broadcast.requested(); + const subscriber = broadcast.track("media").subscribe(); + const request = await wireOf(broadcast).requested(); if (!request) throw new Error("expected request"); broadcast.close(); await expect(subscriber.info()).rejects.toThrow("track closed before info was known"); @@ -125,15 +127,22 @@ test("closing a broadcast rejects a dequeued request", async () => { test("a request exposes the aggregate subscription options", async () => { const consumer = new TestConsumer(); - consumer.subscribe("video", { priority: 3, maxAge: 100, startGroup: 10, endGroup: 20 }); - consumer.subscribe("video", { priority: 7, maxAge: 250, startGroup: 0, endGroup: 30 }); + consumer.track("video").subscribe({ + priority: 3, + maxAge: Milli(100), + groups: { start: { included: 10 }, end: { excluded: 20 } }, + }); + consumer.track("video").subscribe({ + priority: 7, + maxAge: Milli(250), + groups: { start: { included: 0 }, end: { excluded: 30 } }, + }); const request = await pendingRequest(consumer); expect(request?.subscription).toEqual({ priority: 7, - maxAge: 250, - startGroup: 0, - endGroup: 30, + maxAge: Milli(250), + groups: { start: { included: 0 }, end: { excluded: 30 } }, }); expect(request?.priority).toBe(7); }); @@ -141,14 +150,14 @@ test("a request exposes the aggregate subscription options", async () => { test("requested selects the highest current priority", async () => { const consumer = new TestConsumer(); - const first = consumer.subscribe("first", { priority: 1 }); - consumer.subscribe("second", { priority: 5 }); + const first = consumer.track("first").subscribe({ priority: 1 }); + consumer.track("second").subscribe({ priority: 5 }); const updated = first.subscription.changed(); first.update({ priority: 9 }); await updated; - expect((await consumer.requested())?.name).toBe("first"); - expect((await consumer.requested())?.name).toBe("second"); + expect((await wireOf(consumer).requested())?.name).toBe("first"); + expect((await wireOf(consumer).requested())?.name).toBe("second"); }); test("a consumer clone shares the broadcast until every handle closes", () => { @@ -184,8 +193,8 @@ test("consumer track subscriptions fan out and close independently", async () => const consumer = new TestConsumer(); // Two subscriptions to one track dedupe onto a single upstream request... - const a = consumer.subscribe("video").ordered(); - const b = consumer.subscribe("video").ordered(); + const a = consumer.track("video").subscribe().ordered(); + const b = consumer.track("video").subscribe().ordered(); const request = await pendingRequest(consumer); if (!request) throw new Error("expected request"); @@ -229,8 +238,14 @@ test("two subscribers to one inserted track each get a full copy", async () => { const broadcast = new BroadcastProducer(); const producer = broadcast.createTrack("video"); - const a = broadcast.track("video").subscribe({ maxAge: 5000 }).ordered(); - const b = broadcast.track("video").subscribe({ maxAge: 5000 }).ordered(); + const a = broadcast + .track("video") + .subscribe({ maxAge: Milli(5000) }) + .ordered(); + const b = broadcast + .track("video") + .subscribe({ maxAge: Milli(5000) }) + .ordered(); producer.writeString("hello"); producer.writeString("world"); @@ -259,7 +274,10 @@ test("a late subscriber replays the cached window", async () => { test("a read throws GroupTooLarge on an overflow, then resyncs to the next group", async () => { const broadcast = new BroadcastProducer(); const producer = broadcast.createTrack("video"); - const sub = broadcast.track("video").subscribe({ maxAge: 5000 }).ordered(); + const sub = broadcast + .track("video") + .subscribe({ maxAge: Milli(5000) }) + .ordered(); // Group 0 overflows its frame cap: the group is aborted. const g0 = producer.appendGroup(); @@ -283,7 +301,7 @@ test("a stalled consumer does not pin evicted groups", async () => { setSystemTime(new Date(10_000)); const broadcast = new BroadcastProducer(); - const producer = broadcast.createTrack("video", { maxAge: 1000 }); + const producer = broadcast.createTrack("video", { maxAge: Milli(1000) }); // A subscriber that never reads. Its sink must not grow without bound. const stalled = broadcast.track("video").subscribe(); @@ -312,11 +330,11 @@ test("a stalled consumer does not pin evicted groups", async () => { test("createTrack commits info up front", async () => { const broadcast = new BroadcastProducer(); - const producer = broadcast.createTrack("video", { maxAge: 2000, priority: 3 }); + const producer = broadcast.createTrack("video", { maxAge: Milli(2000), priority: 3 }); expect(producer.name).toBe("video"); const info = await broadcast.track("video").info(); - expect(info.maxAge).toBe(2000); + expect(info.maxAge).toBe(Milli(2000)); expect(info.priority).toBe(3); }); @@ -337,7 +355,7 @@ test("a closed track is evicted and re-subscribing falls through to a request", expect(pending).toBeDefined(); // That on-demand request is now waiting to be answered. - const request = await broadcast.requested(); + const request = await wireOf(broadcast).requested(); expect(request?.name).toBe("track1"); }); @@ -379,7 +397,7 @@ test("a fetch waits for a group still to come", async () => { const broadcast = new BroadcastProducer(); const track = broadcast.createTrack("video"); - const pending = broadcast.fetchGroup("video", 1); + const pending = wireOf(broadcast).fetchGroup("video", 1); const first = track.appendGroup(); first.writeFrame({ payload: new TextEncoder().encode("0"), timestamp: Timestamp.now() }); diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index 589198d759..c22f1ef9ea 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -8,6 +8,7 @@ import type { Consumer as GroupConsumer } from "./group.ts"; import { Route } from "./hop.ts"; import { hooks, type TrackSequence } from "./internal.ts"; import * as track from "./track.ts"; +import { registerWire, trackOf, type Broadcast as Wire } from "./wire.ts"; /** The origin callback a created broadcast uses to advertise its exact path. @internal */ export interface Announcer { @@ -153,10 +154,14 @@ async function fetchGroup( * * @public */ -export class Producer implements track.Broadcast { +export class Producer { #state = new BroadcastState(); #announcer?: Announcer; + constructor() { + registerWire(this, this.#wire(false)); + } + static { attachAnnouncer = (producer, announcer) => { producer.#announcer = announcer; @@ -177,8 +182,7 @@ export class Producer implements track.Broadcast { return makeConsumer(this.#state); } - /** Return the next track requested by a peer. */ - async requested(): Promise { + async #requested(): Promise { for (;;) { const request = dequeueRequest(this.#state); if (request) return request; @@ -223,24 +227,18 @@ export class Producer implements track.Broadcast { this.#state.tracks.delete(name); } - /** Open a live subscription to a track. Used by the publishing wire layer. */ - subscribe(name: string, options?: track.Subscription): track.Subscriber { - return subscribe(this.#state, name, options); - } - - /** Resolve a track's immutable info. Used by the publishing wire layer. */ - resolveTrackInfo(name: string): Promise { - return resolveTrackInfo(this.#state, name); - } - - /** Fetch a single group from the local retained window. Used by track handles. */ - fetchGroup(name: string, sequence: number, options?: track.FetchGroupOptions): Promise { - return fetchGroup(this.#state, name, sequence, options); - } - /** A lazy read handle for a track on this broadcast. */ track(name: string): track.Consumer { - return new track.Consumer(name, this); + return trackOf(name, this); + } + + #wire(register: boolean): Wire { + return { + subscribe: (name, options) => subscribe(this.#state, name, options, register), + resolveTrackInfo: (name) => resolveTrackInfo(this.#state, name), + fetchGroup: (name, sequence, options) => fetchGroup(this.#state, name, sequence, options), + requested: () => this.#requested(), + }; } /** @@ -279,12 +277,12 @@ let makeConsumer: (state: BroadcastState) => Consumer; /** * The read side of a broadcast. * - * Created internally: obtain one from {@link Producer.consume} or the connection's - * `consume(path)`. The wire layers subclass it to resolve tracks over the network. + * Created internally: obtain one from {@link Producer.consume} or an origin request. + * The wire layers subclass it to resolve tracks over the network. * * @public */ -export class Consumer implements track.Broadcast { +export class Consumer { #state: BroadcastState; // Guards against a double close() on this handle over-decrementing the consumer count. @@ -294,6 +292,12 @@ export class Consumer implements track.Broadcast { protected constructor(state?: BroadcastState) { this.#state = state ?? new BroadcastState(); this.#state.consumers++; + registerWire(this, { + subscribe: (name, options) => subscribe(this.#state, name, options, true), + resolveTrackInfo: (name) => resolveTrackInfo(this.#state, name), + fetchGroup: (name, sequence, options) => fetchGroup(this.#state, name, sequence, options), + requested: () => this.#requested(), + }); } static { @@ -331,16 +335,10 @@ export class Consumer implements track.Broadcast { /** Get a lazy handle for a track on this broadcast. Repeat subscriptions dedupe onto one upstream subscription. */ track(name: string): track.Consumer { - return new track.Consumer(name, this); - } - - /** Open a live subscription to a track. Used by the subscribing wire layer. Repeat subscriptions to the same track share one upstream subscription. */ - subscribe(name: string, options?: track.Subscription): track.Subscriber { - return subscribe(this.#state, name, options, true); + return trackOf(name, this); } - /** Return the next track requested by the local consumer. Used by the subscribing wire layer. */ - async requested(): Promise { + async #requested(): Promise { for (;;) { const request = dequeueRequest(this.#state); if (request) return request; @@ -353,23 +351,6 @@ export class Consumer implements track.Broadcast { } } - /** - * Resolve a track's immutable info. Used by track handles. This base resolves it from - * the local producers; the consuming wire layer overrides it to fetch over the wire. - */ - resolveTrackInfo(name: string): Promise { - return resolveTrackInfo(this.#state, name); - } - - /** - * Fetch a single group by sequence. Used by track handles. This base serves from the - * local retained window; the consuming wire layer overrides it to fetch over the wire - * (or to reject when the transport has no FETCH). - */ - fetchGroup(name: string, sequence: number, options?: track.FetchGroupOptions): Promise { - return fetchGroup(this.#state, name, sequence, options); - } - /** * Release this handle. The broadcast is closed (optionally with an error to abort waiters) * once this was the last live handle; while other {@link clone}s remain open it stays live. diff --git a/js/net/src/connection/accept.ts b/js/net/src/connection/accept.ts index 9afd2a6672..d7d5236e71 100644 --- a/js/net/src/connection/accept.ts +++ b/js/net/src/connection/accept.ts @@ -8,6 +8,11 @@ import { exchangeSetup } from "./handshake.ts"; /** Options for {@link accept}. */ export interface AcceptProps { + /** The accepted transport. */ + transport: WebTransport; + /** The request URL associated with the transport. */ + url: URL; + /** Version to select during SETUP negotiation (for non-ALPN paths). */ version?: number; @@ -44,19 +49,23 @@ type SessionProps = { * @param props - Optional configuration * @returns A promise that resolves to a Connection instance */ -export async function accept(transport: WebTransport, url: URL, props?: AcceptProps): Promise { +export async function accept({ transport, url, ...props }: AcceptProps): Promise { const connection = await acceptInner(transport, url, props); - if (props?.consume) forwardAnnounced(connection, props.consume); + if (props.consume) forwardAnnounced(connection, props.consume); return connection; } -async function acceptInner(transport: WebTransport, url: URL, props?: AcceptProps): Promise { +async function acceptInner( + transport: WebTransport, + url: URL, + props: Omit, +): Promise { // @ts-expect-error - TODO: add protocol to WebTransport const protocol: string | undefined = transport.protocol; const wiring: SessionProps = { - discovery: props?.discovery ?? true, - publish: props?.publish, + discovery: props.discovery ?? true, + publish: props.publish, }; if (protocol === Ietf.ALPN.DRAFT_21) { @@ -84,7 +93,7 @@ async function acceptInner(transport: WebTransport, url: URL, props?: AcceptProp } else if (protocol === Lite.ALPN_03) { return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_03, ...wiring }); } else if (protocol === Lite.ALPN || protocol === "" || protocol === undefined) { - return acceptNegotiated(transport, url, wiring, props?.version); + return acceptNegotiated(transport, url, wiring, props.version); } else { throw new Error(`unsupported WebTransport protocol: ${protocol}`); } diff --git a/js/net/src/connection/bandwidth.test.ts b/js/net/src/connection/bandwidth.test.ts index ba11e984ab..1c58da6d19 100644 --- a/js/net/src/connection/bandwidth.test.ts +++ b/js/net/src/connection/bandwidth.test.ts @@ -3,11 +3,15 @@ import * as Lite from "../lite/index.ts"; import { createMockTransportPair } from "../mock.ts"; import * as Time from "../time.ts"; import { Producer as TrackProducer } from "../track.ts"; -import { accept } from "./index.ts"; +import { type AcceptProps, accept as acceptSession } from "./index.ts"; import { Connection, resetShared } from "./pool.ts"; const url = new URL("https://example.com/bandwidth"); +function accept(transport: WebTransport, url: URL, props: Omit = {}) { + return acceptSession({ transport, url, ...props }); +} + async function settle() { await new Promise((resolve) => setTimeout(resolve, 0)); } diff --git a/js/net/src/connection/connect.test.ts b/js/net/src/connection/connect.test.ts index f9ff1b4608..0f7a07148d 100644 --- a/js/net/src/connection/connect.test.ts +++ b/js/net/src/connection/connect.test.ts @@ -1,10 +1,14 @@ import { expect, test } from "bun:test"; import { ALPN_05 } from "../lite/version.ts"; import { createMockTransportPair } from "../mock.ts"; -import { connect } from "./connect.ts"; +import { type ConnectProps, connect as connectSession } from "./connect.ts"; const url = new URL("https://example.com/test"); +function connect(url: URL, props: Omit = {}) { + return connectSession({ url, ...props }); +} + // A relay URL as the token flow hands it to us. const SECRET = "super-secret-jwt"; const authUrl = new URL(`https://example.com/test?jwt=${SECRET}#frag`); diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index d961e2136a..4ab12f9c94 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -4,6 +4,7 @@ import * as Ietf from "../ietf/index.ts"; import * as Lite from "../lite/index.ts"; import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts"; import { Stream } from "../stream.ts"; +import * as Time from "../time.ts"; import * as Hex from "../util/hex.ts"; import { dev, redact } from "../util/log.ts"; import { isWebTransportSupported } from "./browser.ts"; @@ -12,14 +13,14 @@ import { forwardAnnounced } from "./forward.ts"; import { exchangeSetup } from "./handshake.ts"; // Default head start for WebTransport before attempting the WebSocket fallback. -const DEFAULT_WEBSOCKET_DELAY_MS = 500; +const DEFAULT_WEBSOCKET_DELAY_MS = Time.Milli(500); // RESET_STREAM and CONNECTION_CLOSE are separate transport notifications. Give the latter // time to surface its authoritative code without letting a reset-only peer stall SETUP forever. const SETUP_CLOSE_GRACE_MS = 100; /** Tuning for the WebSocket fallback used when WebTransport is unavailable or loses the connect race. */ -export interface WebSocketOptions { +export interface WebSocketProps { /** Enable the WebSocket fallback. Defaults to `true`. */ enabled?: boolean; @@ -30,7 +31,7 @@ export interface WebSocketOptions { * The delay in milliseconds before attempting the WebSocket fallback (default: 500). * If WebSocket won the previous race for a given URL, this is 0. */ - delay?: DOMHighResTimeStamp; + delay?: Time.Milli; } /** @@ -63,11 +64,14 @@ export interface WebTransportProps extends Omit url.hostname.endsWith(host)); -} - // Save if WebSocket won the last race, so we won't give QUIC a head start next time. const websocketWon = new Set(); @@ -143,8 +137,8 @@ const NEVER_ABORTED = new AbortController().signal; * @param props - Connection options * @returns A promise that resolves to an established session */ -export async function connect(url: URL, props?: ConnectProps): Promise { - const signal = props?.signal ?? NEVER_ABORTED; +export async function connect({ url, ...props }: ConnectProps): Promise { + const signal = props.signal ?? NEVER_ABORTED; signal.throwIfAborted(); // Resolves on abort so every in-flight transport tears itself down. @@ -157,7 +151,7 @@ export async function connect(url: URL, props?: ConnectProps): Promise undefined)]); if (connection && !signal.aborted) { - if (props?.consume) forwardAnnounced(connection, props.consume); + if (props.consume) forwardAnnounced(connection, props.consume); return connection; } @@ -169,13 +163,13 @@ export async function connect(url: URL, props?: ConnectProps): Promise): Promise { +async function connectInner(url: URL, props: Omit, abort: Promise): Promise { const wiring: SessionProps = { - discovery: props?.discovery ?? defaultDiscovery(url), - publish: props?.publish, + discovery: props.discovery ?? true, + publish: props.publish, }; - if (props?.transport) { + if (props.transport) { const transport = props.transport; void abort.then(() => transport.close()); return connectTransport(url, transport, wiring); @@ -185,15 +179,15 @@ async function connectInner(url: URL, props: ConnectProps | undefined, abort: Pr const { promise: raced, resolve: done } = Promise.withResolvers(); const cancel = Promise.race([raced, abort]); - const webtransport = isWebTransportSupported() ? connectWebTransport(url, cancel, props?.webtransport) : undefined; + const webtransport = isWebTransportSupported() ? connectWebTransport(url, cancel, props.webtransport) : undefined; // Give QUIC a head start to connect before trying WebSocket, unless WebSocket has won in the past. // NOTE that QUIC should be faster because it involves 1/2 fewer RTTs. const headstart = - !webtransport || websocketWon.has(url.toString()) ? 0 : (props?.websocket?.delay ?? DEFAULT_WEBSOCKET_DELAY_MS); + !webtransport || websocketWon.has(url.toString()) ? 0 : (props.websocket?.delay ?? DEFAULT_WEBSOCKET_DELAY_MS); const websocket = - props?.websocket?.enabled !== false - ? connectWebSocket(props?.websocket?.url ?? url, headstart, cancel) + props.websocket?.enabled !== false + ? connectWebSocket(props.websocket?.url ?? url, headstart, cancel) : undefined; if (!websocket && !webtransport) { diff --git a/js/net/src/connection/established.ts b/js/net/src/connection/established.ts index c480d30f58..c8d7278217 100644 --- a/js/net/src/connection/established.ts +++ b/js/net/src/connection/established.ts @@ -1,6 +1,5 @@ import type { Getter } from "@moq/signals"; import type * as announce from "../announced.ts"; -import type * as broadcast from "../broadcast.ts"; import type * as Path from "../path.ts"; import type { Probe, Stats } from "./stats.ts"; import type { Transport } from "./transport.ts"; @@ -41,23 +40,6 @@ export interface Established { */ announced(scope?: Path.Pattern): announce.Consumer; - /** - * Consume the broadcast at the given path, immediately. - * - * The subscription is reset if nobody publishes the path, so use - * {@link announcedBroadcast} instead when the broadcast may not be online yet. - */ - consume(path: Path.Valid): broadcast.Consumer; - - /** - * A reactive handle to the broadcast at the given path, live only while it is announced. - * - * The announcement-gated counterpart to {@link consume}: it waits for the broadcast to come - * online instead of resetting, and drops back to `undefined` when it goes away. See - * {@link announce.Broadcast}. Close the handle when done. - */ - announcedBroadcast(path: Path.Valid): announce.Broadcast; - /** * Snapshot the transport's counters, querying it fresh on each call. * @@ -70,7 +52,7 @@ export interface Established { close(): void; /** - * Resolves when the session closes: `null` for a clean close, a `SessionError` when the + * Resolves when the session closes: `null` for a clean close, an `Error.Session` when the * peer closed with a code (e.g. `SessionCode.Unauthorized` for an auth rejection), or the * transport's own failure. Never rejects. */ diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index cfe8b64b4d..96dda996b9 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -4,6 +4,7 @@ import { type Consumer as BroadcastConsumer, Producer as BroadcastProducer } fro import { Route } from "../hop.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; +import { registerWire, wireOf } from "../wire.ts"; import type { Established } from "./established.ts"; import { forwardAnnounced } from "./forward.ts"; @@ -32,6 +33,7 @@ class FakeSession { constructor(discovery = true) { this.discovery = discovery; + registerWire(this, { consume: (path) => this.consume(path) }); this.closed = new Promise((resolve) => { this.#die = resolve; }); @@ -76,10 +78,10 @@ test("a discovery failure under a live session downgrades the origin", async () session.announces.append({ path, captures: undefined, kind: "announced", route: Route.default }); await settle(); expect(origin.discovery.peek()).toBe(true); - expect(origin.routes(path)).toBe(true); + expect(wireOf(origin).routes(path)).toBe(true); // A watcher gated on the announcement is live on that route. - const watched = new Announce.Broadcast({ origin, path }); + const watched = origin.request(path, { announced: true }); await settle(); await settle(); expect(watched.active.peek()).toBeDefined(); @@ -92,7 +94,7 @@ test("a discovery failure under a live session downgrades the origin", async () // Everything the stream fed is retracted, and the origin stops claiming a discovery that // no longer works. Leaving it true is what used to strand every gated watcher offline. - expect(origin.routes(path)).toBe(false); + expect(wireOf(origin).routes(path)).toBe(false); expect(origin.discovery.peek()).toBe(false); // So the watcher falls back to a standing request, which this same session answers. diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index bea4482d4d..4a2004a87a 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -7,6 +7,7 @@ import type { Dispose } from "@moq/signals"; import { isActive } from "../announced.ts"; import type { Dynamic, Producer as OriginProducer, RequestSlot } from "../origin.ts"; import type * as Path from "../path.ts"; +import { wireOf } from "../wire.ts"; import type { Established } from "./established.ts"; /** @@ -27,10 +28,11 @@ import type { Established } from "./established.ts"; * @internal */ export function forwardAnnounced(conn: Established, origin: OriginProducer): void { + const originWire = wireOf(origin); // Reassigned if discovery dies under a live session, so the origin stops counting this // one as a discovering session. Called through a closure so the session's death always // detaches whichever attachment is current. - let detach = origin.attach(conn.discovery); + let detach = originWire.attach(conn.discovery); let dead = false; void conn.closed.then(() => { @@ -64,7 +66,7 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi if (existing) { existing.update(event.route); } else { - const handle = origin.receive(event.path, event.route); + const handle = originWire.receive(event.path, event.route); inserted.set(event.path, handle); void drive(handle, conn); } @@ -91,7 +93,7 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi if (!dead) { console.warn("broadcast discovery failed; broadcasts resolve on request only.", failure); detach(); - detach = origin.attach(false); + detach = originWire.attach(false); } } })(); @@ -109,9 +111,10 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi * any blind answer, so answering one would only park a handle nothing reads. */ async function drive(handle: Dynamic, conn: Established): Promise { + const session = wireOf(conn); try { for await (const request of handle.requested()) { - request.accept(conn.consume(request.path)); + request.accept(session.consume(request.path)); } } catch { handle.close(); @@ -119,6 +122,8 @@ async function drive(handle: Dynamic, conn: Established): Promise { } async function serveRequests(conn: Established, 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 // back its own. Keyed by path but remembering the slot, because a path outlives its // slot: the last handle closing tears the slot down and a new request installs a fresh @@ -132,13 +137,14 @@ async function serveRequests(conn: Established, origin: OriginProducer): Promise }); for (;;) { - const map = origin.requests.peek(); + const map = table.requests.peek(); if (!map || dead) break; for (const [path, slot] of map) { + if (slot.blind === 0) continue; if (answered.get(path)?.slot === slot || slot.answer !== undefined) continue; - if (origin.routes(path)) continue; - const withdraw = origin.answer(path, conn.consume(path)); + if (table.routes(path)) continue; + const withdraw = table.answer(path, session.consume(path)); if (withdraw) answered.set(path, { slot, withdraw }); } @@ -146,14 +152,14 @@ async function serveRequests(conn: Established, origin: OriginProducer): Promise // A replaced slot counts as withdrawn: the answer we hold belongs to the slot that // went away, not to whatever now occupies the path. for (const [path, entry] of [...answered]) { - if (map.get(path) === entry.slot) continue; + if (map.get(path) === entry.slot && entry.slot.blind > 0) continue; answered.delete(path); entry.withdraw(); } // Woken by the table too, not just the requests: a path that stops being routed needs // the blind answer this loop skipped while it was. - await Promise.race([origin.changed(), closed]); + await Promise.race([table.changed(), closed]); } // Session gone: withdraw our answers, waking a standby session to provide fresh ones. diff --git a/js/net/src/connection/index.ts b/js/net/src/connection/index.ts index e92c8db80a..9989292eb6 100644 --- a/js/net/src/connection/index.ts +++ b/js/net/src/connection/index.ts @@ -10,9 +10,10 @@ export { type ConnectProps, certificateHash, connect, - type WebSocketOptions, + type WebSocketProps, type WebTransportProps, } from "./connect.ts"; +export type { Established } from "./established.ts"; export { Connection, type ConnectionProps } from "./pool.ts"; export type { Probe, Stats } from "./stats.ts"; export type { Transport } from "./transport.ts"; diff --git a/js/net/src/connection/pool.test.ts b/js/net/src/connection/pool.test.ts index 53adea1ad5..9ee4e69eef 100644 --- a/js/net/src/connection/pool.test.ts +++ b/js/net/src/connection/pool.test.ts @@ -6,7 +6,7 @@ import { createMockTransportPair } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; import * as Time from "../time.ts"; -import { accept } from "./index.ts"; +import { type AcceptProps, accept as acceptSession } from "./index.ts"; import { Connection, resetShared } from "./pool.ts"; function publish(origin: { createBroadcast(path: Path.Valid): BroadcastProducer }, path: Path.Valid) { @@ -17,6 +17,10 @@ function publish(origin: { createBroadcast(path: Path.Valid): BroadcastProducer const url = new URL("https://example.com/pool"); +function accept(transport: WebTransport, url: URL, props: Omit = {}) { + return acceptSession({ transport, url, ...props }); +} + async function settle() { await new Promise((resolve) => setTimeout(resolve, 0)); } @@ -115,6 +119,21 @@ test("a handle taken within the linger window reuses the warm connection", async second.close(); }); +test("the longest requested linger keeps the shared connection warm", async () => { + stubTransports(); + + const first = new Connection({ url, linger: Time.Milli(1) }); + const second = new Connection({ url, linger: Time.Milli(500) }); + await waitUntil(() => second.status.peek() === "connected"); + const origin = first.origin.peek(); + + first.close(); + second.close(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(origin?.closed.peek()).toBeUndefined(); +}); + test("disabling a handle releases its share", async () => { stubTransports(); @@ -340,6 +359,34 @@ test("exhausted retries then a new URL recovers a private handle", async () => { } }); +test("a private partial delay keeps the unlimited retry timeout", async () => { + let attempts = 0; + const stub = function StubWebTransport() { + attempts++; + throw new Error("relay is down"); + }; + globalThis.WebTransport = stub as unknown as typeof WebTransport; + + const real = performance.now.bind(performance); + const start = real(); + performance.now = () => start + (real() - start) * 1_000_000; + + const handle = new Connection({ + url, + share: false, + websocket: { enabled: false }, + delay: { initial: Time.Milli(1), multiplier: 1, max: Time.Milli(1) }, + }); + try { + await waitUntil(() => attempts >= 3 || handle.error.peek() !== undefined); + expect(attempts).toBeGreaterThanOrEqual(3); + expect(handle.error.peek()).toBeUndefined(); + } finally { + handle.close(); + performance.now = real; + } +}); + test("a closed handle cannot reconnect", async () => { const dials = stubTransports(); diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index b2baf374cf..7b5473138e 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -16,10 +16,10 @@ import { type ConnectProps as ConnectPropsType, certificateHash, connect, - type WebSocketOptions as WebSocketOptionsType, + type WebSocketProps as WebSocketPropsType, type WebTransportProps as WebTransportPropsType, } from "./connect.ts"; -import type { Established } from "./established.ts"; +import type { Established as EstablishedType } from "./established.ts"; import { Reload, type ReloadDelay, type ReloadStatus } from "./reload.ts"; import type { Probe as ProbeType, Stats as StatsType } from "./stats.ts"; import type { Transport as TransportType } from "./transport.ts"; @@ -39,8 +39,8 @@ export interface ConnectionProps { * How long the underlying connection outlives its last handle (default: 2000ms). * * The window is what makes moving an element around the DOM free: the connection and - * everything it discovered are still warm when the new owner asks for them. Applied by - * whoever dials first, so a later handle sharing the connection inherits it. + * everything it discovered are still warm when the new owner asks for them. The longest + * linger requested by any handle sharing the connection wins. */ linger?: Time.Milli; @@ -57,7 +57,7 @@ export interface ConnectionProps { webtransport?: WebTransportPropsType; /** WebSocket fallback options applied to each connection attempt (not reactive). */ - websocket?: WebSocketOptionsType; + websocket?: WebSocketPropsType; /** Whether the relay supports broadcast discovery. */ discovery?: boolean; @@ -174,7 +174,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); readonly #origin = new Signal(undefined); readonly #bandwidth = new Signal(undefined); @@ -246,7 +246,7 @@ export class Connection { discovery: props.discovery, // A handle nobody watches wants unlimited retries; an auth rejection still // stops this URL, and a new one starts another sequence. - delay: props.delay ?? { timeout: Time.Milli(0) }, + delay: { timeout: Time.Milli(0), ...props.delay }, }); this.#signals.cleanup(() => loop.close()); @@ -315,16 +315,6 @@ export class Connection { return consumer; } - /** - * A reactive handle to one broadcast on the connection's origin; see `Announce.Broadcast`. - * Close the handle when done. - */ - announcedBroadcast(path: Path.Valid): Announce.Broadcast { - // The signal is handed out directly: Producer implements the non-owning Table, so - // the handle can read the origin but never close it. - return new Announce.Broadcast({ origin: this.#origin, path }); - } - /** Snapshot the live connection's transport counters, or undefined while disconnected. */ async stats(): Promise { return this.#established.peek()?.stats(); @@ -349,7 +339,7 @@ export namespace Connection { /** Options for {@link Connection.accept}. */ export type AcceptProps = AcceptPropsType; /** Backoff settings for a private reconnect loop. */ - export type Delay = ReloadDelay; + export type Backoff = ReloadDelay; /** Current state of a {@link Connection}. */ export type Status = ReloadStatus; /** The current connection's PROBE estimates. */ @@ -359,11 +349,13 @@ export namespace Connection { /** The wire transport a session runs over. */ export type Transport = TransportType; /** Tuning for the WebSocket fallback. */ - export type WebSocketOptions = WebSocketOptionsType; + export type WebSocketProps = WebSocketPropsType; /** WebTransport options, including friendlier certificate pinning. */ export type WebTransportProps = WebTransportPropsType; /** A server certificate hash used to pin a self-signed server. */ export type CertificateHash = CertificateHashType; + /** An established one-shot session. */ + export type Established = EstablishedType; } /** Throw if `props` cannot be honored, rather than silently dropping them. */ @@ -443,6 +435,7 @@ function acquire(key: string, linger?: Time.Milli): Entry & { release: () => voi } const taken = entry; + taken.linger = Time.Milli(Math.max(taken.linger, linger ?? LINGER_MS)); taken.refs += 1; if (taken.timer !== undefined) { clearTimeout(taken.timer); diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 835348c142..946648cca3 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -8,9 +8,14 @@ import { createMockTransportPair } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; import * as Time from "../time.ts"; -import { accept } from "./index.ts"; +import { wireOf } from "../wire.ts"; +import { type AcceptProps, accept as acceptSession } from "./index.ts"; import { Reload, type ReloadProps } from "./reload.ts"; +function accept(transport: WebTransport, url: URL, props: Omit = {}) { + return acceptSession({ transport, url, ...props }); +} + function publish(origin: OriginProducer, path: Path.Valid) { const broadcast = origin.createBroadcast(path); broadcast.announce(); @@ -208,7 +213,7 @@ async function waitUntil(pred: () => boolean): Promise { throw new Error("timed out waiting for condition"); } -test("announcedBroadcast follows the reconnect loop", async () => { +test("an announced request follows the reconnect loop", async () => { const original = globalThis.WebTransport; const url = new URL("https://example.com/"); @@ -216,6 +221,7 @@ test("announcedBroadcast follows the reconnect loop", async () => { // handshake finishes. The client therefore always asks before the broadcast exists. const sessions: { close: () => void }[] = []; const published: BroadcastProducer[] = []; + const clientOrigin = new OriginProducer(); const stub = function StubWebTransport() { const pair = createMockTransportPair(Lite.ALPN_06_WIP); const origin = new OriginProducer(); @@ -232,8 +238,9 @@ test("announcedBroadcast follows the reconnect loop", async () => { url, websocket: { enabled: false }, delay: { initial: Time.Milli(10), multiplier: 1, max: Time.Milli(10) }, + consume: clientOrigin, }); - const watched = reload.announcedBroadcast(Path.from("late")); + const watched = clientOrigin.request(Path.from("late"), { announced: true }); try { await waitUntil(() => watched.active.peek() !== undefined); @@ -249,6 +256,7 @@ test("announcedBroadcast follows the reconnect loop", async () => { } finally { watched.close(); reload.close(); + clientOrigin.close(); for (const broadcast of published) broadcast.close(); for (const session of sessions) session.close(); globalThis.WebTransport = original; @@ -527,17 +535,17 @@ test("origins span reconnects: local re-announces, remote re-populates", async ( try { // First session: the server's broadcast lands in the client origin, and the client's // publish lands in the server's. - await waitUntil(() => reader.routes(Path.from("remote"))); - await waitUntil(() => servers[0]?.saw.routes(Path.from("mine"))); + await waitUntil(() => wireOf(reader).routes(Path.from("remote"))); + await waitUntil(() => (servers[0] ? wireOf(servers[0].saw).routes(Path.from("mine")) : false)); // Kill the session: the remote entry retracts, the local publish stays put. servers[0]?.session.close(); - await waitUntil(() => !reader.routes(Path.from("remote"))); + await waitUntil(() => !wireOf(reader).routes(Path.from("remote"))); // The reconnect re-announces the (untouched) publish and re-populates the table. await waitUntil(() => servers.length > 1); - await waitUntil(() => reader.routes(Path.from("remote"))); - await waitUntil(() => servers[1]?.saw.routes(Path.from("mine"))); + await waitUntil(() => wireOf(reader).routes(Path.from("remote"))); + await waitUntil(() => (servers[1] ? wireOf(servers[1].saw).routes(Path.from("mine")) : false)); } finally { reload.close(); publishOrigin.close(); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 1ff259873f..e71aedb66d 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -5,7 +5,8 @@ import { error, SessionCode, SessionError } from "../error.ts"; import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; import * as Time from "../time.ts"; -import { type ConnectProps, connect, type WebSocketOptions, type WebTransportProps } from "./connect.ts"; +import { wireOf } from "../wire.ts"; +import { type ConnectProps, connect, type WebSocketProps, type WebTransportProps } from "./connect.ts"; import type { Established } from "./established.ts"; import type { Probe, Stats } from "./stats.ts"; @@ -43,7 +44,7 @@ export type ReloadDelay = { * * @internal */ -export type ReloadProps = Omit & { +export type ReloadProps = Omit & { /** A reload owns the abort signal for each connection attempt. */ signal?: never; @@ -131,7 +132,7 @@ export class Reload { webtransport?: WebTransportProps; /** WebSocket fallback options applied to each connection attempt (not reactive). */ - websocket: WebSocketOptions | undefined; + websocket: WebSocketProps | undefined; /** * Whether the relay supports broadcast discovery, applied to each connection attempt (not @@ -218,7 +219,7 @@ export class Reload { // is recoverable (a new URL or a disable/re-enable starts another sequence), so a // request must keep waiting rather than go unroutable in the gap. if (this.consume) { - this.#signals.cleanup(this.consume.expect()); + this.#signals.cleanup(wireOf(this.consume).expect()); } this.error = this.#error; @@ -318,7 +319,8 @@ export class Reload { let connected: DOMHighResTimeStamp | undefined; try { - const connection = await connect(url, { + const connection = await connect({ + url, websocket: this.websocket, webtransport: this.webtransport, discovery: this.discovery, @@ -494,23 +496,6 @@ export class Reload { return consumer; } - /** - * A reactive handle to one broadcast, spanning reconnects. - * - * The same {@link Announce.Broadcast} as {@link Established.announcedBroadcast}, but it - * follows the reconnect loop: the broadcast drops to `undefined` when the connection dies - * and resolves again once the new connection announces the path. Use it instead of - * consuming off {@link Reload.established} whenever the broadcast may come online after you - * do, which is exactly the case a blind `consume` loses. - * - * Close the handle when done; {@link Reload.close} only drops it to `undefined`. - */ - announcedBroadcast(path: Path.Valid): Announce.Broadcast { - // Same delegation as announced(): the origin's table is the reconnect-spanning view. - if (this.consume) return new Announce.Broadcast({ origin: this.consume, path }); - return new Announce.Broadcast({ connection: this.established, path }); - } - /** * Snapshot the live connection's transport counters, or undefined while disconnected. * See {@link Established.stats}. diff --git a/js/net/src/connection/stats.test.ts b/js/net/src/connection/stats.test.ts index 45d2fe8cae..c4a0773ef3 100644 --- a/js/net/src/connection/stats.test.ts +++ b/js/net/src/connection/stats.test.ts @@ -3,10 +3,18 @@ import * as Ietf from "../ietf/index.ts"; import * as Lite from "../lite/index.ts"; import { createMockTransportPair } from "../mock.ts"; import * as Time from "../time.ts"; -import { accept, connect } from "./index.ts"; +import { type AcceptProps, accept as acceptSession, type ConnectProps, connect as connectSession } from "./index.ts"; import { Reload } from "./reload.ts"; import { type TransportStats, transportStats } from "./stats.ts"; +function connect(url: URL, props: Omit = {}) { + return connectSession({ url, ...props }); +} + +function accept(transport: WebTransport, url: URL, props: Omit = {}) { + return acceptSession({ transport, url, ...props }); +} + function fakeQuic(stats: TransportStats): WebTransport { return { getStats: () => Promise.resolve(stats) } as unknown as WebTransport; } diff --git a/js/net/src/consume.ts b/js/net/src/consume.ts index 9c070aa878..11bf6acec2 100644 --- a/js/net/src/consume.ts +++ b/js/net/src/consume.ts @@ -5,7 +5,7 @@ import type * as Path from "./path.ts"; * Per-path dedup cache for consumed broadcasts, shared by the moq-lite and moq-ietf * subscribers. * - * `Connection.consume(path)` must not mint a fresh subscription per call: repeat requests + * Resolving the same path must not mint a fresh subscription per call: repeat requests * for the same path (e.g. several renditions referencing one `broadcast: "./source"`) should * share a single upstream subscription. This mirrors the Rust `origin::Consumer` weak-cache: * a still-live path resolves to a shared {@link broadcast.Consumer.clone}, a closed one is diff --git a/js/net/src/error.ts b/js/net/src/error.ts index 04fe38a674..0d64828e5a 100644 --- a/js/net/src/error.ts +++ b/js/net/src/error.ts @@ -15,7 +15,7 @@ declare const SESSION_CODE: unique symbol; export type SessionCode = number & { readonly [SESSION_CODE]: true }; /** - * Codes a peer sends when terminating the session, mirroring the Rust `SessionError`. + * Codes a peer sends when terminating the session, mirroring the Rust `Session`. * * Specified by moq-lite, which reuses moq-transport's codes unchanged. Call `SessionCode(code)` * to construct an application code in the 64+ range. {@link StreamCode} is the other registry, @@ -54,7 +54,7 @@ declare const STREAM_CODE: unique symbol; export type StreamCode = number & { readonly [STREAM_CODE]: true }; /** - * Codes a peer sends when resetting a stream, mirroring the Rust `StreamError`. + * Codes a peer sends when resetting a stream, mirroring the Rust `Stream`. * * The counterpart to {@link SessionCode}, and a disjoint space: a stream reset of 0 is * {@link StreamCode.Internal}, not a cancellation ({@link StreamCode.Cancel} is 1). Call @@ -113,7 +113,7 @@ function applicationCode(code: number): number { * * ```ts * connection.error.subscribe((err) => { - * if (err instanceof SessionError && err.code === SessionCode.Unauthorized) { + * if (err instanceof Session && err.code === SessionCode.Unauthorized) { * console.warn("server rejected the session"); * } * }); @@ -121,19 +121,19 @@ function applicationCode(code: number): number { * * @public */ -export class SessionError extends Error { +export class Session extends Error { /** The session code the peer sent, verbatim. */ readonly code: SessionCode; constructor(code: SessionCode, options?: { cause?: unknown; reason?: string }) { super(options?.reason ? `remote error: ${code} (${options.reason})` : `remote error: ${code}`, options); - this.name = "SessionError"; + this.name = "Session"; this.code = code; } } -/** Options for a {@link StreamError}. */ -export interface StreamErrorOptions { +/** Options for a {@link Stream}. */ +export interface StreamOptions { /** The failure this one wraps. */ cause?: unknown; /** The peer's human-readable reason, appended to the default message. */ @@ -148,7 +148,7 @@ export interface StreamErrorOptions { * * This surfaces on every transport, so catch this type rather than feature-detecting * `WebTransportError`, which a non-browser runtime never defines and the WebSocket fallback - * never throws. Local conditions with a code of their own subclass it ({@link Lagged}, + * never throws. Local conditions with a code of their own subclass it ({@link TooFarBehind}, * {@link FrameTooLarge}, {@link GroupTooLarge}, {@link NotFound}), so the same `code` check catches a condition * whether it was raised here or reported by the peer. * @@ -156,24 +156,24 @@ export interface StreamErrorOptions { * try { * frame = await group.readFrame(); * } catch (err) { - * if (err instanceof StreamError && err.code === StreamCode.Cancel) return; + * if (err instanceof Stream && err.code === StreamCode.Cancel) return; * throw err; * } * ``` * * @public */ -export class StreamError extends Error { +export class Stream extends Error { /** The stream code; moq-lite resets forward it verbatim. */ readonly code: StreamCode; - constructor(code: StreamCode, options?: StreamErrorOptions) { + constructor(code: StreamCode, options?: StreamOptions) { super( options?.message ?? (options?.reason ? `remote error: ${code} (${options.reason})` : `remote error: ${code}`), options, ); - this.name = "StreamError"; + this.name = "Stream"; this.code = code; } } @@ -186,13 +186,13 @@ export class StreamError extends Error { * * @public */ -export class Lagged extends StreamError { +export class TooFarBehind extends Stream { constructor(options?: { cause?: unknown }) { super(StreamCode.TooFarBehind, { ...options, message: "lagged: frames were evicted before being read", }); - this.name = "Lagged"; + this.name = "TooFarBehind"; } } @@ -204,7 +204,7 @@ export class Lagged extends StreamError { * * @public */ -export class FrameTooLarge extends StreamError { +export class FrameTooLarge extends Stream { constructor(options?: { cause?: unknown }) { super(StreamCode.FrameTooLarge, { ...options, @@ -222,7 +222,7 @@ export class FrameTooLarge extends StreamError { * * @public */ -export class GroupTooLarge extends StreamError { +export class GroupTooLarge extends Stream { constructor(options?: { cause?: unknown }) { super(StreamCode.GroupTooLarge, { ...options, @@ -237,7 +237,7 @@ export class GroupTooLarge extends StreamError { * * @public */ -export class NotFound extends StreamError { +export class NotFound extends Stream { constructor(what: string, options?: { cause?: unknown }) { super(StreamCode.NotFound, { ...options, message: `not found: ${what}` }); this.name = "NotFound"; @@ -251,7 +251,7 @@ export class NotFound extends StreamError { * to close. The dispatch that owns the session watches for it and closes, so a nonconforming * peer cannot repeat the violation on the next stream. * - * @internal + * @public */ export class ProtocolViolation extends Error { constructor(message: string, options?: { cause?: unknown }) { @@ -260,6 +260,11 @@ export class ProtocolViolation extends Error { } } +/** Package-internal compatibility names used by the wire implementation. */ +export { Session as SessionError, Stream as StreamError, TooFarBehind as Lagged }; +/** Package-internal compatibility name used by the wire implementation. */ +export type StreamErrorOptions = StreamOptions; + /** The WebTransport-shaped fields a stream reset code arrives in. */ type StreamErrorLike = { source?: unknown; streamErrorCode?: unknown }; @@ -283,11 +288,11 @@ interface TransportErrorOptions { * * The counterpart to {@link fromTransport}, and the pair has to agree: a code we send for a * condition is the code we read that condition back from, or two peers disagree about what it - * means. Mirrors the Rust `From<&Error> for StreamError`. + * means. Mirrors the Rust `From<&Error> for Stream`. * * Lossy on purpose. An error describes what went wrong here, while the registry is what the peer * can act on, so anything without a code of its own degrades to {@link StreamCode.Internal} - * rather than inventing one. A {@link SessionError} lands there too: the two registries are + * rather than inventing one. A {@link Session} lands there too: the two registries are * disjoint, so forwarding its code onto a stream would mistranslate it. * * On an IETF stream the code has to be one the negotiated draft assigns the same meaning to, @@ -304,7 +309,7 @@ export function toStreamCode(err: unknown, options?: TransportErrorOptions): Str /** The moq-lite code for a local failure, before any draft has a say. */ function localStreamCode(err: unknown): StreamCode { - if (err instanceof StreamError) return err.code; + if (err instanceof Stream) return err.code; if (err instanceof TimeoutError) return StreamCode.DeliveryTimeout; // Session-scoped: the peer learns which rule it broke from the session close, not from here. if (err instanceof ProtocolViolation) return StreamCode.SessionClosed; @@ -312,7 +317,7 @@ function localStreamCode(err: unknown): StreamCode { } /** - * Decode a transport failure into a {@link StreamError} when it carries a stream reset code, + * Decode a transport failure into a {@link Stream} when it carries a stream reset code, * otherwise pass it through. * * Native WebTransport rejects with a `WebTransportError`; the WebSocket fallback mints an error @@ -335,12 +340,12 @@ export function fromTransport(err: unknown, options?: TransportErrorOptions): Er const code = streamCode(err); if (code === undefined) return error(err); if (options?.version !== undefined && !sharedStreamCode(code, options.version) && claimedLocally(code)) { - return new StreamError(StreamCode.Internal, { cause: err, message: `remote error: ${code}` }); + return new Stream(StreamCode.Internal, { cause: err, message: `remote error: ${code}` }); } - if (code === StreamCode.TooFarBehind) return new Lagged({ cause: err }); + if (code === StreamCode.TooFarBehind) return new TooFarBehind({ cause: err }); if (code === StreamCode.FrameTooLarge) return new FrameTooLarge({ cause: err }); if (code === StreamCode.GroupTooLarge) return new GroupTooLarge({ cause: err }); - return new StreamError(code, { cause: err }); + return new Stream(code, { cause: err }); } /** The codes moq-lite names, which a foreign one must not borrow. */ @@ -393,14 +398,14 @@ export function toTransport(code: StreamCode, message: string): Error { /** * Decode a session close into its terminal error: `null` for a clean close - * ({@link SessionCode.Cancel}), otherwise a {@link SessionError} carrying the peer's code. + * ({@link SessionCode.Cancel}), otherwise a {@link Session} carrying the peer's code. * * @internal Applied to the transport's `closed` info so the code survives to the application. */ -export function fromClose(info: WebTransportCloseInfo): SessionError | null { +export function fromClose(info: WebTransportCloseInfo): Session | null { const code = (info.closeCode ?? SessionCode.Cancel) as SessionCode; if (code === SessionCode.Cancel) return null; - return new SessionError(code, { reason: info.reason }); + return new Session(code, { reason: info.reason }); } /** diff --git a/js/net/src/errors.ts b/js/net/src/errors.ts new file mode 100644 index 0000000000..09cc4dd606 --- /dev/null +++ b/js/net/src/errors.ts @@ -0,0 +1,15 @@ +/** + * Session and stream error types. + * + * @module + */ +export { + FrameTooLarge, + GroupTooLarge, + NotFound, + ProtocolViolation, + Session, + Stream, + type StreamOptions, + TooFarBehind, +} from "./error.ts"; diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index 541c0d5079..5bb5187199 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; -import { FrameTooLarge, GroupTooLarge, MAX_GROUP_CACHE_BYTES, MAX_GROUP_FRAMES, Producer } from "./group.ts"; +import { FrameTooLarge, GroupTooLarge } from "./error.ts"; +import { MAX_GROUP_CACHE_BYTES, MAX_GROUP_FRAMES, Producer } from "./group.ts"; import { Timestamp } from "./time.ts"; const dec = new TextDecoder(); diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 66c4f3538c..575f304755 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -4,7 +4,7 @@ * @module */ import { type Dispose, type GetPromise, type Getter, Once, Signal } from "@moq/signals"; -import { FrameTooLarge, GroupTooLarge, Lagged } from "./error.ts"; +import { FrameTooLarge, GroupTooLarge, TooFarBehind } from "./error.ts"; import { hooks, type ReadGroupFrame } from "./internal.ts"; import { Timestamp } from "./time.ts"; @@ -47,15 +47,6 @@ export interface Info { sequence: number; } -/** - * Thrown by a frame read when the reader asked for a frame the group never held, and by a - * frame write when the frame or the group exceeds its cache budget. - * - * All three carry a moq-lite stream code, and a peer's reset with one decodes back into the - * same class. - */ -export { FrameTooLarge, GroupTooLarge, Lagged } from "./error.ts"; - /** Reactive backing state shared by the group producer and one consumer. */ class GroupState { readonly sequence: number; @@ -467,7 +458,7 @@ export class Consumer { // the reader simply lagged behind the cache. this.#expire(true); if (!this.#terminal && !this.#ended) { - this.#terminal = new Lagged(); + this.#terminal = new TooFarBehind(); if (this.#verdict.peek() === undefined) this.#verdict.set(this.#terminal); } } diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index 4b5cdb6dc9..d4d24eb416 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -1,6 +1,5 @@ import { type Getter, Signal } from "@moq/signals"; -import * as announce from "../announced.ts"; -import type * as broadcast from "../broadcast.ts"; +import type * as announce from "../announced.ts"; import type { Established } from "../connection/established.ts"; import { type Probe, type Stats, transportStats } from "../connection/stats.ts"; import { type Transport, transportOf } from "../connection/transport.ts"; @@ -8,6 +7,7 @@ import { error, fromClose, ProtocolViolation, StreamCode, StreamError } from ".. import type { Consumer as OriginConsumer } from "../origin.ts"; import type * as Path from "../path.ts"; import { type Reader, Readers, type Stream } from "../stream.ts"; +import { registerWire } from "../wire.ts"; import { ControlStreamAdapter, NativeSession, type Session } from "./adapter.ts"; import * as Cluster from "./cluster.ts"; import { GoAway } from "./goaway.ts"; @@ -139,6 +139,7 @@ export class Connection implements Established { this.#solicit = solicit; this.#cluster = cluster; this.#subscriber = new Subscriber({ session: this.#session, cluster }); + registerWire(this, { consume: (path) => this.#subscriber.consume(path) }); void this.#run(); } @@ -182,29 +183,6 @@ export class Connection implements Established { return this.#subscriber.announced(scope); } - /** - * Consumes a broadcast from the connection. - * - * @remarks - * If the broadcast is not found, a "not found" error will be thrown when requesting any tracks. - * - * @param broadcast - The path of the broadcast to consume - * @returns A Broadcast instance - */ - consume(path: Path.Valid): broadcast.Consumer { - return this.#subscriber.consume(path); - } - - /** - * Watches a broadcast, live only while it is announced. - * - * @param path - The path of the broadcast to watch - * @returns A reactive handle to the broadcast - */ - announcedBroadcast(path: Path.Valid): announce.Broadcast { - return new announce.Broadcast({ connection: this, path }); - } - /** * Accepts bidi streams (virtual for v14-v16, real for v17) and dispatches. */ diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 9f36c6d24c..bac300d202 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -10,6 +10,7 @@ import * as Path from "../path.ts"; import { Reader, Stream } from "../stream.ts"; import { Timestamp } from "../time.ts"; import type { Producer as TrackProducer } from "../track.ts"; +import { wireOf } from "../wire.ts"; import { NativeSession, type Session } from "./adapter.ts"; import type * as Cluster from "./cluster.ts"; import { FetchHeader } from "./fetch.ts"; @@ -930,7 +931,9 @@ test("draft-20: an absolute filter trims the range it serves", async () => { try { // The request forwarded upstream carries the model's exclusive end, one past the // filter's inclusive last group. - expect(track.subscription.peek()).toMatchObject({ startGroup: 1, endGroup: 3 }); + expect(track.subscription.peek()).toMatchObject({ + groups: { start: { included: 1 }, end: { excluded: 3 } }, + }); const first = await nextUni(fx.uni); if (!first) throw new Error("the filter's start group was never served"); @@ -1363,7 +1366,7 @@ test("draft-20: a fill works on a dynamically requested track", async () => { // Answer the request the subscription raises, the way an application serving on demand // does, rather than inserting the track up front. const serving = (async () => { - const request = await fx.broadcast.requested(); + const request = await wireOf(fx.broadcast).requested(); if (!request) throw new Error("no track was requested"); const track = request.accept(); const group = track.appendGroup(); diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index fe60e8ea0b..e9de56ef8f 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -4,13 +4,14 @@ import { error, reason, StreamCode, StreamError } from "../error.ts"; import type * as group from "../group.ts"; import { type Route, routesEqual } from "../hop.ts"; import { hooks } from "../internal.ts"; -import type { Advertised, Consumer as OriginConsumer } from "../origin.ts"; +import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; import { type Stream, Writer } from "../stream.ts"; -import type { Timescale } from "../time.ts"; +import { Milli, type Timescale } from "../time.ts"; import type { Subscriber as TrackSubscriber } from "../track.ts"; import { withTimeout } from "../util/timeout.ts"; import * as Varint from "../varint.ts"; +import { type Advertised, wireOf } from "../wire.ts"; import type { Session } from "./adapter.ts"; import * as Cluster from "./cluster.ts"; import { requestReason, toRequestCode } from "./error.ts"; @@ -192,8 +193,9 @@ export class Publisher { }) { this.#quic = quic; this.#session = session; - this.#broadcasts = publish?.broadcasts ?? new Signal(new Map()); - this.#advertised = publish?.advertised ?? new Signal(new Map()); + const origin = publish && wireOf(publish); + this.#broadcasts = origin?.broadcasts ?? new Signal(new Map()); + this.#advertised = origin?.advertised ?? new Signal(new Map()); this.#publish = publish; this.#requiresSolicitation = requiresSolicitation; this.#advert = Cluster.advertise(cluster); @@ -211,7 +213,8 @@ export class Publisher { let broadcast: broadcast.Consumer | undefined; let refusal: { errorCode: number; reasonPhrase: string } | undefined; try { - broadcast = this.#broadcasts.peek()?.get(name) ?? (await this.#publish?.demand(name)); + broadcast = + this.#broadcasts.peek()?.get(name) ?? (this.#publish && (await wireOf(this.#publish).demand(name))); if (!broadcast) { refusal = { errorCode: toRequestCode("does_not_exist", "subscribe", version), @@ -249,13 +252,13 @@ export class Publisher { if (!broadcast) return; const priority = fromWire(msg.subscriberPriority); - const track = broadcast.subscribe(msg.trackName, { + const track = wireOf(broadcast).subscribe(msg.trackName, { priority, // moq-transport has no subscriber latency parameter. Keep everything the // producer retained and let the receiving subscriber enforce its own budget. // Keep the sentinel encodable if this demand crosses a Lite hop before the // producer's retention bound is known. - maxAge: Varint.MAX_U53, + maxAge: Milli(Varint.MAX_U53), }); let cache: TrackSubscriber | undefined; @@ -285,9 +288,11 @@ export class Publisher { // group; the model's `endGroup` is exclusive. track.update({ priority, - maxAge: Varint.MAX_U53, - startGroup: range.start && Number(range.start.group), - endGroup: range.end && Number(range.end.group) + 1, + maxAge: Milli(Varint.MAX_U53), + groups: { + start: range.start ? { included: Number(range.start.group) } : undefined, + end: range.end ? { included: Number(range.end.group) } : undefined, + }, }); const startGroup = range.start ? Number(range.start.group) : track.latest(); if (startGroup !== undefined) track.setGroups({ start: { included: startGroup } }); @@ -298,7 +303,7 @@ export class Publisher { // asking the broadcast would mint a second producer nobody has accepted. const fill = msg.fill && Filter.isDraft20(version) ? fillRange(msg.fill, msg.filter, edge.largest) : undefined; - cache = fill && fill.kind !== "empty" ? track.fork({ priority, maxAge: Varint.MAX_U53 }) : undefined; + cache = fill && fill.kind !== "empty" ? track.fork({ priority, maxAge: Milli(Varint.MAX_U53) }) : undefined; // Send SUBSCRIBE_OK await stream.writer.u53(SubscribeOk.id); diff --git a/js/net/src/ietf/subscriber.test.ts b/js/net/src/ietf/subscriber.test.ts index 475925ee26..adb819178d 100644 --- a/js/net/src/ietf/subscriber.test.ts +++ b/js/net/src/ietf/subscriber.test.ts @@ -707,7 +707,7 @@ test("a legacy cancel reaches the control stream", async () => { // Ask for a track, which writes SUBSCRIBE, then drop the only consumer. const broadcast = subscriber.consume(Path.from("room")); - const track = broadcast.subscribe("video"); + const track = broadcast.track("video").subscribe(); // Let the SUBSCRIBE reach the control stream before walking away. await new Promise((resolve) => setTimeout(resolve, 50)); @@ -756,7 +756,7 @@ test("a rejected subscribe is not unsubscribed", async () => { const wire = peer!; const broadcast = subscriber.consume(Path.from("room")); - const track = broadcast.subscribe("video"); + const track = broadcast.track("video").subscribe(); // Read the SUBSCRIBE, then reject it the way a publisher that cannot serve it would. const subscribeType = await wire.reader.u53(); @@ -827,7 +827,7 @@ async function subscribeTrack(): Promise<{ subscriber: Subscriber; track: track. const session = new NativeSession(pair.server, VERSION, true); const subscriber = new Subscriber({ session }); - const track = subscriber.consume(Path.from("room")).subscribe("video"); + const track = subscriber.consume(Path.from("room")).track("video").subscribe(); const peer = await nextStream(pair.client); if (!peer) throw new Error("the subscriber never opened a subscribe stream"); @@ -924,7 +924,7 @@ test("returning demand survives a blocked unsubscribe", async () => { }); const broadcast = subscriber.consume(Path.from("room")); - const first = broadcast.subscribe("video"); + const first = broadcast.track("video").subscribe(); expect(await peer.reader.u53()).toBe(Subscribe.id); const request = await Subscribe.decode(peer.reader, version); await peer.writer.u53(SubscribeOk.id); @@ -946,7 +946,7 @@ test("returning demand survives a blocked unsubscribe", async () => { ordered.close(); first.close(); await cancelStarted.promise; - const returned = broadcast.subscribe("video"); + const returned = broadcast.track("video").subscribe(); releaseCancel.resolve(); await oldClosed.promise; expect(returned.closed.peek()).toBeUndefined(); diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index f65601a2f5..97b4a4103f 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -10,6 +10,7 @@ import type { Reader, Stream } from "../stream.ts"; import { type Timescale, Timestamp } from "../time.ts"; import type * as track from "../track.ts"; import { withTimeout } from "../util/timeout.ts"; +import { overrideBroadcastWire, wireOf } from "../wire.ts"; import type { Session } from "./adapter.ts"; import { DuplicateTrackAlias, RetiredTrackAlias, TrackAliases } from "./aliases.ts"; import * as Cluster from "./cluster.ts"; @@ -428,7 +429,7 @@ export class Subscriber { void (async () => { for (;;) { - const request = await consumer.requested(); + const request = await wireOf(consumer).requested(); if (!request) break; void this.#runSubscribe(path, request); } @@ -958,17 +959,15 @@ export class Subscriber { * group fetch, so `track.Consumer.fetchGroup()` is rejected. */ class ConsumeBroadcast extends broadcast.Consumer { - // biome-ignore lint/complexity/noUselessConstructor: widens the protected base constructor to public constructor(state?: never) { super(state); + overrideBroadcastWire(this, { + fetchGroup: () => Promise.reject(new Error("fetch group is not supported for moq-transport")), + }); } // Preserve the subclass when the consume cache shares this broadcast across callers. override clone(): ConsumeBroadcast { return new ConsumeBroadcast(this.shareState()); } - - override fetchGroup(): Promise { - return Promise.reject(new Error("fetch group is not supported for moq-transport")); - } } diff --git a/js/net/src/index.ts b/js/net/src/index.ts index 830801f98b..932f7adc23 100644 --- a/js/net/src/index.ts +++ b/js/net/src/index.ts @@ -8,22 +8,16 @@ /** Re-export of {@link https://jsr.io/@moq/signals | @moq/signals}, the reactive primitives used throughout this package. */ export * as Signals from "@moq/signals"; /** Broadcast announcement streams. */ -export * as Announce from "./announced.ts"; +export * as Announce from "./announce.ts"; /** Send-side bandwidth estimates split among the tracks sharing a connection. */ -export * as Bandwidth from "./bandwidth.ts"; +export * as Bandwidth from "./bandwidth_api.ts"; /** Broadcast role handles. */ export * as Broadcast from "./broadcast.ts"; /** A reconnecting, shareable handle on a MoQ session. */ export { Connection } from "./connection/index.ts"; +export { SessionCode, StreamCode } from "./error.ts"; /** Session and stream errors, each carrying a code from its own registry. */ -export { - NotFound, - SessionCode, - SessionError, - StreamCode, - StreamError, - type StreamErrorOptions, -} from "./error.ts"; +export * as Error from "./errors.ts"; /** Group role handles and frame helpers. */ export * as Group from "./group.ts"; /** Broadcast routing tables, independent of any connection. */ diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 8cba3f1ae1..1026115ba7 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -1,10 +1,15 @@ import { expect, test } from "bun:test"; import type { Getter } from "@moq/signals"; -import * as Announce from "./announced.ts"; import { type Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "./broadcast.ts"; -import { accept, Connection, connect } from "./connection/index.ts"; -import { StreamCode, StreamError } from "./error.ts"; -import * as Group from "./group.ts"; +import { + type AcceptProps, + accept as acceptSession, + Connection, + type ConnectProps, + connect as connectSession, + type Established, +} from "./connection/index.ts"; +import { StreamCode, StreamError, TooFarBehind } from "./error.ts"; import * as Ietf from "./ietf/index.ts"; import * as Lite from "./lite/index.ts"; import { createMockTransportPair } from "./mock.ts"; @@ -14,6 +19,7 @@ import * as Path from "./path.ts"; import { Milli, Timescale, Timestamp } from "./time.ts"; import type { Producer as TrackProducer } from "./track.ts"; import { withTimeout } from "./util/timeout.ts"; +import { wireOf } from "./wire.ts"; function publish(origin: OriginProducer, path: Path.Valid) { const broadcast = origin.createBroadcast(path); @@ -23,6 +29,18 @@ function publish(origin: OriginProducer, path: Path.Valid) { const url = new URL("https://localhost:4443/test"); +function connect(url: URL, props: Omit = {}): Promise { + return connectSession({ url, ...props }); +} + +function accept( + transport: WebTransport, + url: URL, + props: Omit = {}, +): Promise { + return acceptSession({ transport, url, ...props }); +} + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** @@ -58,7 +76,7 @@ async function runPublishSubscribeFlow(protocol: string, version?: number) { let served = 0; const serving = (async () => { for (;;) { - const req = await broadcast.requested(); + const req = await wireOf(broadcast).requested(); if (!req) break; if (req.name !== "video") { req.reject(new Error(`unexpected track: ${req.name}`)); @@ -84,7 +102,7 @@ async function runPublishSubscribeFlow(protocol: string, version?: number) { expect(prefixedEntry.kind).toBe("announced"); // Client consumes the broadcast and subscribes to a track - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const track = remote.track("video").subscribe().ordered(); // Client reads data @@ -137,37 +155,38 @@ test("integration: lite subscription options and updates reach the publisher", a }); const serving = (async () => { for (;;) { - const request = await broadcast.requested(); + const request = await wireOf(broadcast).requested(); if (!request) return; const producer = request.accept(); - if (request.subscription.startGroup === 1) resolveProducer?.(producer); + if (request.subscription.groups?.start?.included === 1) resolveProducer?.(producer); } })(); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); // A floor of 1, not 0: a pre-06 wire folds a vacuous floor of 0 back to absent, since // its encoding of group 0 means "replay from the beginning" instead. const subscriber = remote.track("video").subscribe({ priority: 3, - maxAge: 250, - startGroup: 1, - endGroup: 9, + maxAge: Milli(250), + groups: { start: { included: 1 }, end: { excluded: 9 } }, }); const producer = await accepted; expect(producer.subscription.peek()).toEqual({ priority: 3, - maxAge: 250, - startGroup: 1, - endGroup: 9, + maxAge: Milli(250), + groups: { start: { included: 1 }, end: { excluded: 9 } }, }); const updated = producer.subscription.changed(); - subscriber.update({ priority: 8, maxAge: 500, startGroup: 2, endGroup: 12 }); + subscriber.update({ + priority: 8, + maxAge: Milli(500), + groups: { start: { included: 2 }, end: { excluded: 12 } }, + }); expect(await updated).toEqual({ priority: 8, - maxAge: 500, - startGroup: 2, - endGroup: 12, + maxAge: Milli(500), + groups: { start: { included: 2 }, end: { excluded: 12 } }, }); subscriber.close(); @@ -194,18 +213,18 @@ test("integration: lite carries a fractional maxAge as a whole millisecond", asy }); const serving = (async () => { for (;;) { - const request = await broadcast.requested(); + const request = await wireOf(broadcast).requested(); if (!request) return; const producer = request.accept(); - if (request.subscription.startGroup === 1) resolveProducer?.(producer); + if (request.subscription.groups?.start?.included === 1) resolveProducer?.(producer); } })(); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); // A varint cannot encode 38.75, so an unrounded value fails the SUBSCRIBE outright and // nothing resubscribes. The publisher must see the budget rounded up instead. A floor // of 1, not 0: a pre-06 wire folds a vacuous floor of 0 back to absent. - const subscriber = remote.track("video").subscribe({ maxAge: 38.75, startGroup: 1 }); + const subscriber = remote.track("video").subscribe({ maxAge: Milli(38.75), groups: { start: { included: 1 } } }); // A failed subscribe never reaches the publisher, so race its closure to report the // encode error rather than block until the suite times out. @@ -214,11 +233,11 @@ test("integration: lite carries a fractional maxAge as a whole millisecond", asy }); const producer = await Promise.race([accepted, failed]); - expect(producer.subscription.peek()?.maxAge).toBe(39); + expect(producer.subscription.peek()?.maxAge).toBe(Milli(39)); const updated = producer.subscription.changed(); - subscriber.update({ maxAge: 500.25, startGroup: 1 }); - expect((await Promise.race([updated, failed]))?.maxAge).toBe(501); + subscriber.update({ maxAge: Milli(500.25), groups: { start: { included: 1 } } }); + expect((await Promise.race([updated, failed]))?.maxAge).toBe(Milli(501)); subscriber.close(); remote.close(); @@ -250,13 +269,12 @@ test("integration: lite applies initial and updated group bounds", async () => { const producer = broadcast.createTrack("video"); for (let sequence = 0; sequence < GROUP_COUNT; sequence++) producer.appendGroup().close(); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const subscriber = remote .track("video") .subscribe({ - maxAge: REPLAY_LATENCY_MS, - startGroup: INITIAL_START_GROUP, - endGroup: INITIAL_END_GROUP, + maxAge: Milli(REPLAY_LATENCY_MS), + groups: { start: { included: INITIAL_START_GROUP }, end: { excluded: INITIAL_END_GROUP } }, }) .ordered(); try { @@ -267,9 +285,8 @@ test("integration: lite applies initial and updated group bounds", async () => { expect(await Promise.race([pending, sleep(PENDING_ASSERT_MS).then(() => "pending")])).toBe("pending"); subscriber.update({ - maxAge: REPLAY_LATENCY_MS, - startGroup: UPDATED_GROUP, - endGroup: UPDATED_END_GROUP, + maxAge: Milli(REPLAY_LATENCY_MS), + groups: { start: { included: UPDATED_GROUP }, end: { excluded: UPDATED_END_GROUP } }, }); expect((await withTimeout(pending, UPDATE_TIMEOUT_MS, "updated group bound timed out"))?.sequence).toBe( UPDATED_GROUP, @@ -302,20 +319,31 @@ test("integration: lite refuses an empty requested range on open and on update", const producer = broadcast.createTrack("video"); for (let sequence = 0; sequence < GROUP_COUNT; sequence++) producer.appendGroup().close(); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const video = remote.track("video"); try { // Bounds that meet cannot go on the wire: the nearest encoding inverts the range. - const empty = video.subscribe({ maxAge: REPLAY_LATENCY_MS, startGroup: 2, endGroup: 2 }); + const empty = video.subscribe({ + maxAge: Milli(REPLAY_LATENCY_MS), + groups: { start: { included: 2 }, end: { excluded: 2 } }, + }); await expect(withTimeout(empty.recvGroup(), TIMEOUT_MS, "empty open never settled")).rejects.toThrow( "empty subscription range cannot be encoded", ); empty.close(); // A live subscription whose demand later collapses to nothing fails the same way. - const live = video.subscribe({ maxAge: REPLAY_LATENCY_MS, startGroup: 1, endGroup: 2 }).ordered(); + const live = video + .subscribe({ + maxAge: Milli(REPLAY_LATENCY_MS), + groups: { start: { included: 1 }, end: { excluded: 2 } }, + }) + .ordered(); expect((await live.nextGroup())?.sequence).toBe(1); - live.update({ maxAge: REPLAY_LATENCY_MS, startGroup: 3, endGroup: 3 }); + live.update({ + maxAge: Milli(REPLAY_LATENCY_MS), + groups: { start: { included: 3 }, end: { excluded: 3 } }, + }); await expect(withTimeout(live.nextGroup(), TIMEOUT_MS, "empty update never settled")).rejects.toThrow( "empty subscription range cannot be encoded", ); @@ -394,7 +422,7 @@ test("integration: lite draft-05 datagram delivery", async () => { const broadcast = publish(origin, Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const track = remote.track("video").subscribe(); // Datagrams aren't cached, so the first few may race the subscription setup. Pump until @@ -436,7 +464,7 @@ test("integration: lite draft-05 datagrams not sent on a non-datagram transport" const broadcast = publish(origin, Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const track = remote.track("video").subscribe().ordered(); const datagrams = remote.track("video").subscribe(); @@ -484,7 +512,7 @@ test("integration: lite draft-05 datagrams sent with standards-track createWrita const broadcast = publish(origin, Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const track = remote.track("video").subscribe(); const received = track.recvDatagram(); @@ -522,7 +550,7 @@ test("integration: lite draft-05 missing datagram writer does not close streams" const broadcast = publish(origin, Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const track = remote.track("video").subscribe().ordered(); producer.appendDatagram(Timestamp.fromMillis(0), enc.encode("dgram")); @@ -549,7 +577,7 @@ test("integration: ietf does not deliver datagrams", async () => { const broadcast = publish(origin, Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const track = remote.track("video").subscribe().ordered(); const datagrams = remote.track("video").subscribe(); @@ -582,7 +610,7 @@ test("integration: lite draft-05 missing datagram reader does not close streams" const broadcast = publish(origin, Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const track = remote.track("video").subscribe().ordered(); producer.appendDatagram(Timestamp.fromMillis(0), enc.encode("dgram")); @@ -619,7 +647,7 @@ test("integration: a group reset carries the peer's code to the subscriber", asy const broadcast = publish(origin, Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const track = remote.track("video").subscribe().ordered(); const group = producer.appendGroup(); @@ -659,7 +687,7 @@ test("integration: a locally raised group error reaches the peer as its own code const broadcast = publish(origin, Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const track = remote.track("video").subscribe().ordered(); const group = producer.appendGroup(); @@ -671,7 +699,7 @@ test("integration: a locally raised group error reaches the peer as its own code // The publisher's own cache dropped the rest of the group. Nothing hand-builds a transport // error here, which is the point: the condition is raised the way the library raises it. - group.close(new Group.Lagged()); + group.close(new TooFarBehind()); const err = await consumer.readFrame().then( () => undefined, @@ -682,7 +710,7 @@ test("integration: a locally raised group error reaches the peer as its own code expect(err).toBeInstanceOf(StreamError); expect((err as StreamError).code).toBe(StreamCode.TooFarBehind); // And the reverse direction agrees, so a gap is one class whichever side it happened on. - expect(err).toBeInstanceOf(Group.Lagged); + expect(err).toBeInstanceOf(TooFarBehind); broadcast.close(); remote.close(); @@ -702,7 +730,7 @@ test("integration: subscribing to an unserved broadcast is refused as NotFound", // Announced, so the subscribe is attempted, but the publisher drops it before the subscribe // arrives and answers with a reset instead of a track. const broadcast = publish(origin, Path.from("test")); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); broadcast.close(); const track = remote.track("video").subscribe(); @@ -739,7 +767,7 @@ test("integration: lite draft-05 fetches a cached group", async () => { group1.close(); // Fetch group 0 without holding a live subscription; the timestamps round-trip. - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const fetched = await remote.track("video").fetchGroup(0); const first = await fetched.readFrame(); @@ -777,7 +805,7 @@ test("integration: lite draft-05 coalesces concurrent fetches of one group", asy group0.writeFrame({ payload: enc.encode("beta"), timestamp: Timestamp.fromMillis(15) }); group0.close(); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const trackConsumer = remote.track("video"); // Two concurrent fetches of the same group coalesce onto one FETCH stream; each reads an @@ -820,7 +848,7 @@ test("integration: lite draft-05 fetches an in-progress group", async () => { const group0 = producer.appendGroup(); group0.writeFrame({ payload: enc.encode("alpha"), timestamp: Timestamp.fromMillis(10) }); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const fetched = await remote.track("video").fetchGroup(0); const first = await fetched.readFrame(); @@ -855,23 +883,23 @@ test("integration: lite draft-05 track info follows a republished broadcast", as const path = Path.from("test"); const first = publish(origin, path); - first.createTrack("video", { priority: 1, timescale: Timescale.MILLI, maxAge: 1000 }); + first.createTrack("video", { priority: 1, timescale: Timescale.MILLI, maxAge: Milli(1000) }); - const remote = client.consume(path); + const remote = wireOf(client).consume(path); const before = await remote.track("video").info(); expect(before.priority).toBe(1); expect(before.timescale).toBe(Timescale.MILLI); - expect(before.maxAge).toBe(1000); + expect(before.maxAge).toBe(Milli(1000)); // Replace the broadcast on the same path with one whose track declares different // immutable properties. const second = publish(origin, path); - second.createTrack("video", { priority: 7, timescale: Timescale.MICRO, maxAge: 5000 }); + second.createTrack("video", { priority: 7, timescale: Timescale.MICRO, maxAge: Milli(5000) }); const after = await remote.track("video").info(); expect(after.priority).toBe(7); expect(after.timescale).toBe(Timescale.MICRO); - expect(after.maxAge).toBe(5000); + expect(after.maxAge).toBe(Milli(5000)); first.close(); second.close(); @@ -900,7 +928,7 @@ test("integration: lite draft-05 fetch uses the republished track's timescale", firstGroup.close(); // Prime the publisher's TRACK_INFO cache against the predecessor. - const remote = client.consume(path); + const remote = wireOf(client).consume(path); expect((await remote.track("video").info()).timescale).toBe(Timescale.MILLI); const second = publish(origin, path); @@ -930,7 +958,7 @@ test("integration: ietf fetch group is unsupported", async () => { accept(pair.server, url, { publish: origin.consume() }), ]); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); await expect(remote.track("video").fetchGroup(0)).rejects.toThrow("fetch group is not supported for moq-transport"); remote.close(); @@ -973,8 +1001,8 @@ async function runConsumeDedup(protocol: string, version?: number) { ]); // Two handles to the same path share one broadcast: closing the first leaves it live... - const first = client.consume(Path.from("shared")); - const second = client.consume(Path.from("shared")); + const first = wireOf(client).consume(Path.from("shared")); + const second = wireOf(client).consume(Path.from("shared")); first.close(); expect(first.closed.peek()).toBeUndefined(); expect(second.closed.peek()).toBeUndefined(); @@ -985,12 +1013,12 @@ async function runConsumeDedup(protocol: string, version?: number) { expect(second.closed.peek()).toBeDefined(); // A different path is independent: a lone handle closes the broadcast immediately. - const other = client.consume(Path.from("other")); + const other = wireOf(client).consume(Path.from("other")); other.close(); expect(other.closed.peek()).toBeDefined(); // Once closed, the path re-consumes fresh (a new live handle). - const third = client.consume(Path.from("shared")); + const third = wireOf(client).consume(Path.from("shared")); expect(third.closed.peek()).toBeUndefined(); third.close(); @@ -1020,7 +1048,7 @@ async function runSubscriberTeardown(protocol: string, version?: number) { const video = broadcast.createTrack("video"); video.writeString("hello"); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const sub = remote.track("video").subscribe().ordered(); expect(await sub.readString()).toBe("hello"); @@ -1062,7 +1090,7 @@ test("integration: ietf draft-14 subscriber teardown on last unsubscribe", async let served: TrackProducer | undefined; const serving = (async () => { for (;;) { - const req = await broadcast.requested(); + const req = await wireOf(broadcast).requested(); if (!req) break; served = req.accept(); served.writeString("hello"); @@ -1073,7 +1101,7 @@ test("integration: ietf draft-14 subscriber teardown on last unsubscribe", async const announced = client.announced(); await announced.next(); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const sub = remote.track("video").subscribe().ordered(); expect(await sub.readString()).toBe("hello"); await waitUntil(() => served?.used.peek() === true); @@ -1105,7 +1133,7 @@ test("integration: lite fetch teardown when the reader abandons an open group", const group = video.appendGroup(); // deliberately left open: an indefinite group. group.writeString("hello"); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const fetched = await remote.track("video").fetchGroup(group.sequence); expect(await fetched.readString()).toBe("hello"); @@ -1144,7 +1172,7 @@ test("integration: lite fan-out keeps the upstream until the last subscriber lea const video = broadcast.createTrack("video"); video.writeString("hello"); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const a = remote.track("video").subscribe().ordered(); const b = remote.track("video").subscribe().ordered(); expect(await a.readString()).toBe("hello"); @@ -1180,7 +1208,7 @@ test("integration: lite re-subscribe re-opens the upstream after each teardown", const broadcast = publish(origin, Path.from("test")); const video = broadcast.createTrack("video"); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); for (let i = 0; i < 8; i++) { video.writeString(`hello-${i}`); @@ -1213,7 +1241,7 @@ test("integration: lite coalesced fetch stays until every reader abandons the op const group = video.appendGroup(); // open group.writeString("hello"); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const f1 = await remote.track("video").fetchGroup(group.sequence); const f2 = await remote.track("video").fetchGroup(group.sequence); expect(await f1.readString()).toBe("hello"); @@ -1254,7 +1282,7 @@ test("integration: lite fetch delivers every frame of a finite multi-frame group for (let i = 0; i < count; i++) group.writeString(`f${i}`); group.close(); // finite - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const fetched = await remote.track("video").fetchGroup(group.sequence); for (let i = 0; i < count; i++) { expect(await fetched.readString()).toBe(`f${i}`); @@ -1288,11 +1316,11 @@ async function runSubscribeWithoutWarmup(version: number) { const broadcast = publish(origin, Path.from("test")); const serving = (async () => { - const req = await broadcast.requested(); + const req = await wireOf(broadcast).requested(); if (req) req.accept().writeString("hello"); })(); - const remote = client.consume(Path.from("test")); + const remote = wireOf(client).consume(Path.from("test")); const track = remote.track("video").subscribe().ordered(); const data = await Promise.race([ track.readString(), @@ -1331,8 +1359,8 @@ test("integration: subscribe to non-existent broadcast", async () => { ]); // Client tries to consume a broadcast that nobody is publishing - const remote = client.consume(Path.from("nonexistent")); - const track = remote.subscribe("video").ordered(); + const remote = wireOf(client).consume(Path.from("nonexistent")); + const track = remote.track("video").subscribe().ordered(); // Reading should eventually error since the broadcast doesn't exist await expect( @@ -1354,18 +1382,19 @@ async function waitFor(signal: Getter, pred: (value: T) => boolean): Promi } } -test("integration: announcedBroadcast waits for a late publisher", async () => { +test("integration: an announced request waits for a late publisher", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); const origin = new OriginProducer(); + const clientOrigin = new OriginProducer(); const [client, server] = await Promise.all([ - connect(url, { transport: pair.client }), + connect(url, { transport: pair.client, consume: clientOrigin }), accept(pair.server, url, { publish: origin.consume() }), ]); // Serves every requested track with `payload`, until the broadcast closes. const serve = async (broadcast: BroadcastProducer, payload: string) => { for (;;) { - const req = await broadcast.requested(); + const req = await wireOf(broadcast).requested(); if (!req) break; req.accept().writeString(payload); } @@ -1373,7 +1402,7 @@ test("integration: announcedBroadcast waits for a late publisher", async () => { // Nobody publishes this path yet. A blind consume would be reset (see the // "subscribe to non-existent broadcast" test); the handle just stays offline. - const watched = client.announcedBroadcast(Path.from("late")); + const watched = clientOrigin.request(Path.from("late"), { announced: true }); await sleep(50); expect(watched.active.peek()).toBeUndefined(); @@ -1383,7 +1412,7 @@ test("integration: announcedBroadcast waits for a late publisher", async () => { const active = await waitFor(watched.active, (b) => b !== undefined); if (!active) throw new Error("expected an active broadcast"); - expect(await active.subscribe("video").ordered().readString()).toBe("hello"); + expect(await active.track("video").subscribe().ordered().readString()).toBe("hello"); // It goes away. first.close(); @@ -1397,60 +1426,63 @@ test("integration: announcedBroadcast waits for a late publisher", async () => { const republished = await waitFor(watched.active, (b) => b !== undefined); if (!republished) throw new Error("expected a republished broadcast"); expect(republished).not.toBe(active); - expect(await republished.subscribe("video").ordered().readString()).toBe("world"); + expect(await republished.track("video").subscribe().ordered().readString()).toBe("world"); // Closing the handle releases the broadcast it held. watched.close(); expect(watched.active.peek()).toBeUndefined(); - expect(republished.closed.peek()).not.toBeUndefined(); second.close(); await servingSecond; client.close(); server.close(); + clientOrigin.close(); }); -test("integration: announcedBroadcast consumes blind without discovery", async () => { +test("integration: an announced request consumes blind without discovery", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); const origin = new OriginProducer(); + const clientOrigin = new OriginProducer(); const [client, server] = await Promise.all([ - connect(url, { transport: pair.client, discovery: false }), + connect(url, { transport: pair.client, discovery: false, consume: clientOrigin }), accept(pair.server, url, { publish: origin.consume() }), ]); const broadcast = publish(origin, Path.from("test")); const serving = (async () => { for (;;) { - const req = await broadcast.requested(); + const req = await wireOf(broadcast).requested(); if (!req) break; req.accept().writeString("blind"); } })(); // No announcement ever arrives, so waiting for one would hang. Subscribe anyway. - const watched = client.announcedBroadcast(Path.from("test")); + const watched = clientOrigin.request(Path.from("test"), { announced: true }); const active = await waitFor(watched.active, (b) => b !== undefined); if (!active) throw new Error("expected an active broadcast"); - expect(await active.subscribe("video").ordered().readString()).toBe("blind"); + expect(await active.track("video").subscribe().ordered().readString()).toBe("blind"); watched.close(); broadcast.close(); await serving; client.close(); server.close(); + clientOrigin.close(); }); test("integration: a republish is not served from the previous generation's cache", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); const origin = new OriginProducer(); + const clientOrigin = new OriginProducer(); const [client, server] = await Promise.all([ - connect(url, { transport: pair.client }), + connect(url, { transport: pair.client, consume: clientOrigin }), accept(pair.server, url, { publish: origin.consume() }), ]); const serve = async (broadcast: BroadcastProducer, payload: string) => { for (;;) { - const req = await broadcast.requested(); + const req = await wireOf(broadcast).requested(); if (!req) break; req.accept().writeString(payload); } @@ -1459,15 +1491,15 @@ test("integration: a republish is not served from the previous generation's cach const first = publish(origin, Path.from("shared")); const servingFirst = serve(first, "old"); - const watched = client.announcedBroadcast(Path.from("shared")); + const watched = clientOrigin.request(Path.from("shared"), { announced: true }); const active = await waitFor(watched.active, (b) => b !== undefined); if (!active) throw new Error("expected an active broadcast"); - expect(await active.subscribe("video").ordered().readString()).toBe("old"); + expect(await active.track("video").subscribe().ordered().readString()).toBe("old"); // A second holder of the same path, which is what makes the cache reachable: consumed // broadcasts are reference-counted, so the handle closing its own copy below does not // release the shared one. - const bystander = client.consume(Path.from("shared")); + const bystander = wireOf(client).consume(Path.from("shared")); first.close(); await servingFirst; @@ -1480,7 +1512,7 @@ test("integration: a republish is not served from the previous generation's cach const republished = await waitFor(watched.active, (b) => b !== undefined); if (!republished) throw new Error("expected a republished broadcast"); - expect(await republished.subscribe("video").ordered().readString()).toBe("new"); + expect(await republished.track("video").subscribe().ordered().readString()).toBe("new"); bystander.close(); watched.close(); @@ -1488,6 +1520,7 @@ test("integration: a republish is not served from the previous generation's cach await servingSecond; client.close(); server.close(); + clientOrigin.close(); }); // #3363: an `invisible muted` unpublishes and republishes the same path on one @@ -1497,8 +1530,9 @@ test("integration: a republish is not served from the previous generation's cach async function runRepublishCycle(protocol: string, version?: number) { const pair = createMockTransportPair(protocol); const origin = new OriginProducer(); + const clientOrigin = new OriginProducer(); const [client, server] = await Promise.all([ - connect(url, { transport: pair.client }), + connect(url, { transport: pair.client, consume: clientOrigin }), accept( pair.server, url, @@ -1508,13 +1542,13 @@ async function runRepublishCycle(protocol: string, version?: number) { const serve = async (broadcast: BroadcastProducer, payload: string) => { for (;;) { - const req = await broadcast.requested(); + const req = await wireOf(broadcast).requested(); if (!req) break; req.accept().writeString(payload); } }; - const watched = client.announcedBroadcast(Path.from("toggle")); + const watched = clientOrigin.request(Path.from("toggle"), { announced: true }); let previous: BroadcastConsumer | undefined; for (let generation = 0; generation < 3; generation++) { @@ -1530,7 +1564,7 @@ async function runRepublishCycle(protocol: string, version?: number) { ); if (!active) throw new Error("expected an active broadcast"); const frame = withTimeout( - active.subscribe("audio").ordered().readString(), + active.track("audio").subscribe().ordered().readString(), 1000, `generation ${generation} never served a frame`, ); @@ -1544,6 +1578,7 @@ async function runRepublishCycle(protocol: string, version?: number) { watched.close(); origin.close(); + clientOrigin.close(); client.close(); server.close(); } @@ -1559,49 +1594,52 @@ test("integration: ietf republish on one session swaps the handle every generati test("integration: a blind handle picks up a publisher that arrives late", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); const origin = new OriginProducer(); + const clientOrigin = new OriginProducer(); const [client, server] = await Promise.all([ - connect(url, { transport: pair.client, discovery: false }), + connect(url, { transport: pair.client, discovery: false, consume: clientOrigin }), accept(pair.server, url, { publish: origin.consume() }), ]); // Without discovery there is no announcement to wait for, so the handle consumes blind. - const watched = client.announcedBroadcast(Path.from("later")); + const watched = clientOrigin.request(Path.from("later"), { announced: true }); const blind = await waitFor(watched.active, (b) => b !== undefined); if (!blind) throw new Error("expected a blind consumer"); // Nobody publishes the path yet, so a subscribe is how the caller finds out. That kills the // track, not the handle: a consumed broadcast is scoped to the path, not to one publisher. - await expect(blind.subscribe("video").ordered().readString()).rejects.toThrow(); + await expect(blind.track("video").subscribe().ordered().readString()).rejects.toThrow(); expect(watched.active.peek()).toBe(blind); // So a subscribe made after the publisher finally shows up still works, on the same handle. const producer = publish(origin, Path.from("later")); const serving = (async () => { for (;;) { - const req = await producer.requested(); + const req = await wireOf(producer).requested(); if (!req) break; req.accept().writeString("late"); } })(); - expect(await blind.subscribe("video").ordered().readString()).toBe("late"); + expect(await blind.track("video").subscribe().ordered().readString()).toBe("late"); watched.close(); producer.close(); await serving; client.close(); server.close(); + clientOrigin.close(); }); test("integration: a blind handle goes offline when the session dies", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); const origin = new OriginProducer(); + const clientOrigin = new OriginProducer(); const [client, server] = await Promise.all([ - connect(url, { transport: pair.client, discovery: false }), + connect(url, { transport: pair.client, discovery: false, consume: clientOrigin }), accept(pair.server, url, { publish: origin.consume() }), ]); - const watched = client.announcedBroadcast(Path.from("whatever")); + const watched = clientOrigin.request(Path.from("whatever"), { announced: true }); await waitFor(watched.active, (b) => b !== undefined); // The gated path goes offline when the announcement stream ends with the session. There is @@ -1611,6 +1649,7 @@ test("integration: a blind handle goes offline when the session dies", async () await waitFor(watched.active, (b) => b === undefined); watched.close(); + clientOrigin.close(); }); // The handle and the consume-cache eviction are protocol-agnostic, but their implementations @@ -1618,35 +1657,37 @@ test("integration: a blind handle goes offline when the session dies", async () test("integration: ietf blind handle picks up a publisher that arrives late", async () => { const pair = createMockTransportPair(""); const origin = new OriginProducer(); + const clientOrigin = new OriginProducer(); const [client, server] = await Promise.all([ - connect(url, { transport: pair.client, discovery: false }), + connect(url, { transport: pair.client, discovery: false, consume: clientOrigin }), accept(pair.server, url, { version: Ietf.Version.DRAFT_14, publish: origin.consume() }), ]); - const watched = client.announcedBroadcast(Path.from("later")); + const watched = clientOrigin.request(Path.from("later"), { announced: true }); const blind = await waitFor(watched.active, (b) => b !== undefined); if (!blind) throw new Error("expected a blind consumer"); // Rejects with 404 rather than resetting the whole handle. - await expect(blind.subscribe("video").ordered().readString()).rejects.toThrow(); + await expect(blind.track("video").subscribe().ordered().readString()).rejects.toThrow(); expect(watched.active.peek()).toBe(blind); const producer = publish(origin, Path.from("later")); const serving = (async () => { for (;;) { - const req = await producer.requested(); + const req = await wireOf(producer).requested(); if (!req) break; req.accept().writeString("ietf-late"); } })(); - expect(await blind.subscribe("video").ordered().readString()).toBe("ietf-late"); + expect(await blind.track("video").subscribe().ordered().readString()).toBe("ietf-late"); watched.close(); producer.close(); await serving; client.close(); server.close(); + clientOrigin.close(); }); // --------------------------------------------------------------------------- @@ -1676,7 +1717,7 @@ async function runOriginFlow(protocol: string, version?: number) { const broadcast = publish(serverOrigin, Path.from("test")); const serving = (async () => { for (;;) { - const req = await broadcast.requested(); + const req = await wireOf(broadcast).requested(); if (!req) break; if (req.name !== "video") { req.reject(new Error(`unexpected track: ${req.name}`)); @@ -1700,7 +1741,7 @@ async function runOriginFlow(protocol: string, version?: number) { // Unpublishing retracts the entry over the wire and out of the origin. broadcast.close(); expect(await announced.next()).toMatchObject({ path: Path.from("test"), kind: "retracted" }); - await until(() => !reader.routes(Path.from("test"))); + await until(() => !wireOf(reader).routes(Path.from("test"))); await serving; track.close(); @@ -1734,13 +1775,13 @@ test("origin: remote entries retract when the session dies, local ones survive", const mine = publish(clientOrigin, Path.from("mine")); const reader = clientOrigin.consume(); - await until(() => reader.routes(Path.from("remote"))); + await until(() => wireOf(reader).routes(Path.from("remote"))); client.close(); server.close(); // The session that fed the entry is gone, so the entry goes with it. - await until(() => !reader.routes(Path.from("remote"))); + await until(() => !wireOf(reader).routes(Path.from("remote"))); // The local publish is not the session's to take. const local = await routed(reader, Path.from("mine")); @@ -1769,7 +1810,7 @@ test("origin: one origin on both directions consumes locally and never echoes", { const remote = publish(serverSees, Path.from("from-server")); const reader = shared.consume(); - await until(() => reader.routes(Path.from("from-server"))); + await until(() => wireOf(reader).routes(Path.from("from-server"))); remote.close(); } @@ -1779,20 +1820,20 @@ test("origin: one origin on both directions consumes locally and never echoes", const reader = shared.consume(); const loopback = await routed(reader, Path.from("from-client")); if (!loopback) throw new Error("expected a local route"); - const track = loopback.subscribe("chat"); + const track = loopback.track("chat").subscribe(); expect(track).toBeDefined(); track.close(); loopback.close(); // The server sees the client's broadcast once, as its own remote entry. const serverReader = serverSees.consume(); - await until(() => serverReader.routes(Path.from("from-client"))); + await until(() => wireOf(serverReader).routes(Path.from("from-client"))); // The critical part: the client must NOT re-announce "from-server" back. If it did, the // server's forwarder would insert it as a remote entry in serverSees. Give the wire a // moment, then check the only remote entry the server has is the client's own broadcast. await sleep(50); - expect(serverReader.routes(Path.from("from-server"))).toBe(false); + expect(wireOf(serverReader).routes(Path.from("from-server"))).toBe(false); mine.close(); client.close(); @@ -1815,7 +1856,7 @@ test("origin: a request resolves blind on a relay without discovery", async () = const broadcast = publish(serverOrigin, Path.from("blind")); const serving = (async () => { for (;;) { - const req = await broadcast.requested(); + const req = await wireOf(broadcast).requested(); if (!req) break; req.accept().writeString("found you"); } @@ -1825,7 +1866,7 @@ test("origin: a request resolves blind on a relay without discovery", async () = expect(reader.discovery.peek()).toBe(false); // Nothing announced, so the table stays empty; a request is the only way through. - expect(reader.routes(Path.from("blind"))).toBe(false); + expect(wireOf(reader).routes(Path.from("blind"))).toBe(false); const request = reader.request(Path.from("blind")); await until(() => request.active.peek() !== undefined); @@ -1901,7 +1942,7 @@ test("origin: a reactive handle follows announcements, republishes, and reconnec accept(pair.server, url, { publish: serverOrigin.consume() }), ]); - const watch = new Announce.Broadcast({ origin: clientOrigin, path: Path.from("show") }); + const watch = clientOrigin.request(Path.from("show"), { announced: true }); // Nothing published yet: the handle waits instead of subscribing blind. for (let i = 0; i < 5; i++) await sleep(1); @@ -1943,7 +1984,7 @@ test("origin: overlapping sessions carrying one path fail over", async () => { const broadcast = publish(serverOrigin, Path.from("redundant")); const serving = (async () => { for (;;) { - const req = await broadcast.requested(); + const req = await wireOf(broadcast).requested(); if (!req) break; req.accept().writeString("still here"); } @@ -1955,7 +1996,7 @@ test("origin: overlapping sessions carrying one path fail over", async () => { const second = await setup(); const reader = clientOrigin.consume(); - await until(() => reader.routes(Path.from("redundant"))); + await until(() => wireOf(reader).routes(Path.from("redundant"))); // The newer session dies; the older one still carries the path and must keep serving. second.client.close(); @@ -1993,7 +2034,7 @@ test("origin: a standby session re-answers a request when the answerer dies", as const broadcast = publish(serverOrigin, Path.from("blind")); const serving = (async () => { for (;;) { - const req = await broadcast.requested(); + const req = await wireOf(broadcast).requested(); if (!req) break; req.accept().writeString(payload); } @@ -2062,7 +2103,7 @@ test("create then announce is discoverable on the wire", async () => { test("a handle serves a request under live/** over the wire", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); const origin = new OriginProducer(); - const handle = origin.dynamic("live"); + const handle = origin.dynamic(Path.from("live")); const serving = (async () => { for await (const request of handle.requested()) { @@ -2082,7 +2123,7 @@ test("a handle serves a request under live/** over the wire", async () => { expect(entry?.path).toBe("live" as Path.Valid); expect(entry?.kind).toBe("announced"); - const remote = client.consume(Path.from("live/cam")); + const remote = wireOf(client).consume(Path.from("live/cam")); const track = remote.track("chat").subscribe().ordered(); expect(await track.readString()).toBe("hello"); diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index ff406892ae..77316e00c8 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -13,6 +13,22 @@ import * as Path from "./path.ts"; import type { Timestamp } from "./time.ts"; import type { Groups, Producer, Request, Subscriber } from "./track.ts"; +/** Normalize public group bounds into an inclusive start and exclusive end. */ +export function groupBounds(groups: Groups = {}): { start: number; end?: number } { + const bound = (value: Groups["start"] | undefined, start: boolean): number | undefined => { + if (value === undefined) return undefined; + if ((value.included === undefined) === (value.excluded === undefined)) { + throw new Error("a group bound must be either included or excluded"); + } + const sequence = value.included ?? value.excluded; + if (sequence === undefined || !Number.isSafeInteger(sequence) || sequence < 0) { + throw new Error("a group bound must be a non-negative safe integer"); + } + return sequence + (start ? Number(value.excluded !== undefined) : Number(value.included !== undefined)); + }; + return { start: bound(groups.start, true) ?? 0, end: bound(groups.end, false) }; +} + /** * The announce-interest prefix a scope needs on a prefix-shaped wire: its literal head. * The peer echoes every suffix beneath it, and the caller filters what arrives. diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index e3be681ea9..3aca065ab6 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -1,6 +1,5 @@ import { type Getter, Signal } from "@moq/signals"; -import * as announce from "../announced.ts"; -import type * as broadcast from "../broadcast.ts"; +import type * as announce from "../announced.ts"; import type { Established } from "../connection/established.ts"; import { type Probe, type Stats, transportStats } from "../connection/stats.ts"; import { type Transport, transportOf } from "../connection/transport.ts"; @@ -9,6 +8,7 @@ import { type Hop, randomHop } from "../hop.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; import type * as Path from "../path.ts"; import { type Reader, Readers, Stream, Writer } from "../stream.ts"; +import { registerWire } from "../wire.ts"; import { AnnounceRequest } from "./announce.ts"; import { Fetch } from "./fetch.ts"; import { Goaway } from "./goaway.ts"; @@ -126,6 +126,7 @@ export class Connection implements Established { this.hop = randomHop(); this.#publisher = new Publisher(this.#quic, this.#version, this.hop, publish); this.#subscriber = new Subscriber(this.#quic, this.#version, this.hop, this.#probe, this.#peerSetup); + registerWire(this, { consume: (path) => this.#subscriber.consume(path) }); void this.#run(); } @@ -173,20 +174,6 @@ export class Connection implements Established { return this.#subscriber.announced(scope); } - consume(path: Path.Valid): broadcast.Consumer { - return this.#subscriber.consume(path); - } - - /** - * Watches a broadcast, live only while it is announced. - * - * @param path - The path of the broadcast to watch - * @returns A reactive handle to the broadcast - */ - announcedBroadcast(path: Path.Valid): announce.Broadcast { - return new announce.Broadcast({ connection: this, path }); - } - async #runSession() { if (!this.#session) { return; diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index 093b0bb203..fedfcb061c 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -7,7 +7,7 @@ import { createMockTransportPair } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; import { Reader, Stream, Writer } from "../stream.ts"; -import { Timestamp } from "../time.ts"; +import { Milli, Timestamp } from "../time.ts"; import { DEFAULT_MAX_AGE_MS } from "../track.ts"; import { AnnounceRequest } from "./announce.ts"; import { Fetch } from "./fetch.ts"; @@ -25,7 +25,7 @@ function publish(origin: OriginProducer, path: Path.Valid) { } // Scheduling tests intentionally stall groups, so keep latency enforcement out of their scope. -const TEST_MAX_AGE_MS = 30_000; +const TEST_MAX_AGE_MS = Milli(30_000); function replaySubscribe(props: ConstructorParameters[0]) { return new Subscribe({ ...props, maxAge: TEST_MAX_AGE_MS }); @@ -492,7 +492,7 @@ async function servedSubscription( endGroup?: number; endFrame?: number; gated?: boolean; - maxAge?: number; + maxAge?: Milli; // Frame payloads written into every served group. Frame bounds need draft-06. frames?: string[]; version?: Version; @@ -888,8 +888,7 @@ test("lite draft-06: scheduling updates apply while SUBSCRIBE_START is blocked", expect(sub.track.subscription.peek()).toEqual({ priority: 9, maxAge: DEFAULT_MAX_AGE_MS, - startGroup: undefined, - endGroup: 6, + groups: { end: { excluded: 6 } }, }); expect(ranges).not.toHaveBeenCalled(); @@ -1369,11 +1368,11 @@ test("runProbe rounds a fractional smoothedRtt instead of killing the stream", a test("a version without the latency field serves a non-dropping budget", async () => { for (const version of [Version.DRAFT_01, Version.DRAFT_02]) { - const sub = await servedSubscription({ version, maxAge: Number.MAX_SAFE_INTEGER }); + const sub = await servedSubscription({ version, maxAge: Milli(Number.MAX_SAFE_INTEGER) }); try { // These drafts decode the absent field as zero. The publisher must not turn // that into a live-edge request the peer never made. - expect(sub.track.subscription.peek()?.maxAge).toBe(Number.MAX_SAFE_INTEGER); + expect(sub.track.subscription.peek()?.maxAge).toBe(Milli(Number.MAX_SAFE_INTEGER)); } finally { await sub.close(); } diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 7f3bb45fd1..157eef4382 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -4,11 +4,12 @@ import { error, NotFound, reason, StreamCode, StreamError } from "../error.ts"; import type * as group from "../group.ts"; import { type Hop, type Route, routesEqual } from "../hop.ts"; import { hooks } from "../internal.ts"; -import type { Advertised, Consumer as OriginConsumer } from "../origin.ts"; +import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; import { type Reader, type Stream, Writer } from "../stream.ts"; -import { Timescale } from "../time.ts"; +import { Milli, Timescale } from "../time.ts"; import type * as track from "../track.ts"; +import { type Advertised, wireOf } from "../wire.ts"; import { AnnounceInit, AnnounceOk, type AnnounceRequest, encodeAnnounceBroadcast } from "./announce.ts"; import { Datagram as DatagramMessage } from "./datagram.ts"; import * as DatagramStream from "./datagram_stream.ts"; @@ -372,8 +373,9 @@ export class Publisher { this.#quic = quic; this.version = version; this.hop = hop; - this.#broadcasts = publish?.broadcasts ?? new Signal(new Map()); - this.#advertised = publish?.advertised ?? new Signal(new Map()); + const origin = publish && wireOf(publish); + this.#broadcasts = origin?.broadcasts ?? new Signal(new Map()); + this.#advertised = origin?.advertised ?? new Signal(new Map()); this.#publish = publish; // Grab the datagram writer up front when the transport carries datagrams (no group @@ -544,7 +546,9 @@ export class Publisher { async runSubscribe(msg: Subscribe, stream: Stream) { let front: broadcast.Consumer | undefined; try { - front = this.#broadcasts.peek()?.get(msg.broadcast) ?? (await this.#publish?.demand(msg.broadcast)); + front = + this.#broadcasts.peek()?.get(msg.broadcast) ?? + (this.#publish && (await wireOf(this.#publish).demand(msg.broadcast))); } catch (err: unknown) { stream.writer.reset(error(err)); return; @@ -556,11 +560,13 @@ export class Publisher { } const endGroup = exclusiveGroupEnd(msg.endGroup); - const track = front.subscribe(msg.track, { + const track = wireOf(front).subscribe(msg.track, { priority: msg.priority, - maxAge: servingMaxAge(this.version, msg.maxAge), - startGroup: msg.startGroup, - endGroup, + maxAge: Milli(servingMaxAge(this.version, msg.maxAge)), + groups: { + start: msg.startGroup === undefined ? undefined : { included: msg.startGroup }, + end: endGroup === undefined ? undefined : { excluded: endGroup }, + }, }); positionCursor(track, this.version, msg.startGroup); hooks.replaceGroups(track, { end: endGroup === undefined ? undefined : { excluded: endGroup } }); @@ -610,11 +616,14 @@ export class Publisher { writer: stream.writer, version: this.version, apply: (update) => { + const end = exclusiveGroupEnd(update.endGroup); track.update({ priority: update.priority, - maxAge: servingMaxAge(this.version, update.maxAge), - startGroup: update.startGroup, - endGroup: exclusiveGroupEnd(update.endGroup), + maxAge: Milli(servingMaxAge(this.version, update.maxAge)), + groups: { + start: update.startGroup === undefined ? undefined : { included: update.startGroup }, + end: end === undefined ? undefined : { excluded: end }, + }, }); }, }); @@ -657,7 +666,9 @@ export class Publisher { let front: broadcast.Consumer | undefined; try { - front = this.#broadcasts.peek()?.get(msg.broadcast) ?? (await this.#publish?.demand(msg.broadcast)); + front = + this.#broadcasts.peek()?.get(msg.broadcast) ?? + (this.#publish && (await wireOf(this.#publish).demand(msg.broadcast))); } catch (err: unknown) { stream.writer.reset(error(err)); return; @@ -677,7 +688,7 @@ export class Publisher { // The timescale is immutable, so serve exactly what TRACK_INFO advertised. Both // come off the same front, so the metadata and the frames are one generation. const info = await this.#resolveTrackInfo(front, msg.track); - group = await front.fetchGroup(msg.track, msg.group, { priority: msg.priority }); + group = await wireOf(front).fetchGroup(msg.track, msg.group, { priority: msg.priority }); await this.#runFetchGroup(group, stream.writer, { timescale: Timescale(info.timescale), start: msg.startFrame, @@ -855,7 +866,9 @@ export class Publisher { */ async runTrackInfo(msg: TrackMessage, stream: Stream) { try { - const front = this.#broadcasts.peek()?.get(msg.broadcast) ?? (await this.#publish?.demand(msg.broadcast)); + const front = + this.#broadcasts.peek()?.get(msg.broadcast) ?? + (this.#publish && (await wireOf(this.#publish).demand(msg.broadcast))); if (!front) throw new NotFound(`broadcast ${msg.broadcast}`); const info = await this.#resolveTrackInfo(front, msg.track); @@ -883,7 +896,7 @@ export class Publisher { if (cached) return cached; const pending = (async () => { - const info = await front.resolveTrackInfo(track); + const info = await wireOf(front).resolveTrackInfo(track); return new TrackInfoMessage({ priority: info.priority, // Publisher Max Age: the publisher's retention bound, advertised so diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index f70f6ff223..9b3428a2cb 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -6,12 +6,13 @@ import { BroadcastCache } from "../consume.ts"; import { error, ProtocolViolation, reason, StreamCode, StreamError } from "../error.ts"; import * as netGroup from "../group.ts"; import { Cost, type Hop, MAX_HOPS, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; -import { scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; +import { groupBounds, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; import * as Path from "../path.ts"; import { type Reader, Stream } from "../stream.ts"; import * as Time from "../time.ts"; import type * as track from "../track.ts"; import { withTimeout } from "../util/timeout.ts"; +import { overrideBroadcastWire, wireOf } from "../wire.ts"; import { AnnounceInit, AnnounceOk, AnnounceRequest, decodeAnnounceBroadcastMaybe } from "./announce.ts"; import { Datagram as DatagramMessage } from "./datagram.ts"; import * as DatagramStream from "./datagram_stream.ts"; @@ -475,7 +476,7 @@ export class Subscriber { void (async () => { for (;;) { - const request = await consumer.requested(); + const request = await wireOf(consumer).requested(); if (!request) break; void this.#runSubscribe(path, request); } @@ -487,7 +488,8 @@ export class Subscriber { async #runSubscribe(broadcast: Path.Valid, request: track.Request) { const id = this.#subscribeNext++; const subscription = request.subscription; - if (emptyRange(subscription)) { + const initialBounds = groupBounds(subscription.groups); + if (emptyRange({ startGroup: initialBounds.start, endGroup: initialBounds.end })) { request.reject(new Error(EMPTY_RANGE)); return; } @@ -497,6 +499,7 @@ export class Subscriber { const timescale = new Signal(undefined); console.debug(`subscribe start: id=${id} broadcast=${broadcast} track=${request.name}`); + const bounds = groupBounds(subscription.groups); const msg = new Subscribe({ id, @@ -504,8 +507,8 @@ export class Subscriber { track: request.name, priority: subscription.priority ?? 0, maxAge: subscription.maxAge, - startGroup: subscription.startGroup, - endGroup: inclusiveGroupEnd(subscription.endGroup), + startGroup: subscription.groups?.start === undefined ? undefined : bounds.start, + endGroup: inclusiveGroupEnd(bounds.end), }); // Open the stream under a timeout. The stream handle flows back via `state` @@ -651,7 +654,7 @@ export class Subscriber { timescale: Time.Timescale(info.timescale), // Publisher Max Age rides on the wire, so the local retention window // matches what the upstream advertises (relays re-serve with the same bound). - maxAge: info.maxAge, + maxAge: Time.Milli(info.maxAge), priority: info.priority, }; } @@ -809,9 +812,11 @@ export class Subscriber { const stopped: Promise = Promise.race([track.closed, stream.reader.closed]).then(() => null); let lastSent: track.Subscription = { priority: msg.priority, - maxAge: msg.maxAge, - startGroup: msg.startGroup, - endGroup: exclusiveGroupEnd(msg.endGroup), + maxAge: Time.Milli(msg.maxAge), + groups: { + start: msg.startGroup === undefined ? undefined : { included: msg.startGroup }, + end: msg.endGroup === undefined ? undefined : { excluded: exclusiveGroupEnd(msg.endGroup) ?? 0 }, + }, }; for (;;) { @@ -825,15 +830,16 @@ export class Subscriber { // Demand collapsing to nothing is refused the same way an initial empty // request is: the error closes the track, so every local subscriber sees it. - if (emptyRange(current)) throw new Error(EMPTY_RANGE); + const bounds = groupBounds(current.groups); + if (emptyRange({ startGroup: bounds.start, endGroup: bounds.end })) throw new Error(EMPTY_RANGE); // Round-trip the other Subscribe parameters so the publisher doesn't // interpret SUBSCRIBE_UPDATE as a reset of ordered/maxAge/etc. const update = new SubscribeUpdate({ priority: current.priority ?? 0, maxAge: current.maxAge, - startGroup: current.startGroup, - endGroup: inclusiveGroupEnd(current.endGroup), + startGroup: current.groups?.start === undefined ? undefined : bounds.start, + endGroup: inclusiveGroupEnd(bounds.end), }); await update.encode(stream.writer, this.version); lastSent = { ...current }; @@ -842,11 +848,13 @@ export class Subscriber { } #sameSubscription(a: track.Subscription, b: track.Subscription): boolean { + const ag = groupBounds(a.groups); + const bg = groupBounds(b.groups); return ( (a.priority ?? 0) === (b.priority ?? 0) && (a.maxAge ?? 0) === (b.maxAge ?? 0) && - a.startGroup === b.startGroup && - a.endGroup === b.endGroup + ag.start === bg.start && + ag.end === bg.end ); } @@ -1069,6 +1077,10 @@ class ConsumeBroadcast extends broadcast.Consumer { constructor(subscriber: Subscriber, path: Path.Valid, state?: never) { super(state); + overrideBroadcastWire(this, { + resolveTrackInfo: (name) => subscriber.resolveTrackInfo(path, name), + fetchGroup: (name, sequence, options) => subscriber.fetchGroup(path, name, sequence, options), + }); this.#subscriber = subscriber; this.#path = path; } @@ -1078,12 +1090,4 @@ class ConsumeBroadcast extends broadcast.Consumer { override clone(): ConsumeBroadcast { return new ConsumeBroadcast(this.#subscriber, this.#path, this.shareState()); } - - override resolveTrackInfo(name: string): Promise { - return this.#subscriber.resolveTrackInfo(this.#path, name); - } - - override fetchGroup(name: string, sequence: number, options?: track.FetchGroupOptions): Promise { - return this.#subscriber.fetchGroup(this.#path, name, sequence, options); - } } diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 0fcf98449a..d457a1e70d 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -6,6 +6,7 @@ import { Route } from "./hop.ts"; import type { Consumer } from "./origin.ts"; import { Producer } from "./origin.ts"; import * as Path from "./path.ts"; +import { wireOf } from "./wire.ts"; function publish(origin: Producer, path: Path.Valid) { const broadcast = origin.createBroadcast(path); @@ -15,7 +16,7 @@ function publish(origin: Producer, path: Path.Valid) { /** Land a received prefix, served from `consume`, the way a session does. */ function serve(origin: Producer, prefix: Path.Valid, consume: () => BroadcastConsumer, route: Route = Route.default) { - const handle = origin.receive(prefix, route); + const handle = wireOf(origin).receive(prefix, route); void (async () => { try { for await (const request of handle.requested()) { @@ -58,7 +59,7 @@ test("a published broadcast resolves by path", async () => { const consumer = origin.consume(); const path = Path.from("room"); - expect(consumer.routes(path)).toBe(false); + expect(wireOf(consumer).routes(path)).toBe(false); const broadcast = publish(origin, path); broadcast.createTrack("video"); @@ -67,7 +68,7 @@ test("a published broadcast resolves by path", async () => { expect(handle).toBeDefined(); // The handle reaches the published tracks. - const track = handle?.subscribe("video"); + const track = handle?.track("video").subscribe(); expect(track).toBeDefined(); track?.close(); @@ -82,11 +83,11 @@ test("closing the producer unpublishes the path", async () => { const path = Path.from("room"); const broadcast = publish(origin, path); - expect(consumer.routes(path)).toBe(true); + expect(wireOf(consumer).routes(path)).toBe(true); broadcast.close(); await settle(); - expect(consumer.routes(path)).toBe(false); + expect(wireOf(consumer).routes(path)).toBe(false); origin.close(); }); @@ -109,7 +110,7 @@ test("a stale broadcast closing does not unpublish a republished path", async () second.close(); await settle(); - expect(consumer.routes(path)).toBe(false); + expect(wireOf(consumer).routes(path)).toBe(false); origin.close(); }); @@ -165,7 +166,7 @@ test("closing the origin closes every routed broadcast", async () => { expect(a.closed.peek()).toBe(abort); expect(b.closed.peek()).toBe(abort); - expect(consumer.routes(Path.from("a"))).toBe(false); + expect(wireOf(consumer).routes(Path.from("a"))).toBe(false); expect(() => publish(origin, Path.from("late"))).toThrow(); // Idempotent: the first close wins. @@ -178,7 +179,7 @@ test("the table is reactive", async () => { const consumer = origin.consume(); const path = Path.from("room"); - const changed = consumer.broadcasts.changed(); + const changed = wireOf(consumer).broadcasts.changed(); const broadcast = publish(origin, path); const table = await changed; @@ -234,7 +235,7 @@ test("a remote entry resolves by path and retracts on dispose", async () => { dispose(); expect(await announced.next()).toMatchObject({ path: path, kind: "retracted" }); - expect(consumer.routes(path)).toBe(false); + expect(wireOf(consumer).routes(path)).toBe(false); announced.close(); upstream.close(); @@ -275,7 +276,7 @@ test("a local publish shadows a remote entry", async () => { // Local wins: the handle reaches the local track, not the remote one. const handle = await routed(consumer, path); - const track = handle?.subscribe("local-track"); + const track = handle?.track("local-track").subscribe(); expect(track).toBeDefined(); track?.close(); handle?.close(); @@ -305,7 +306,7 @@ test("the publisher-facing table excludes remote entries", async () => { serve(origin, Path.from("theirs"), provider(upstream)); // What a session announces to a peer: local only, so a shared origin cannot echo. - const table = consumer.broadcasts.peek(); + const table = wireOf(consumer).broadcasts.peek(); expect(table?.has(Path.from("mine"))).toBe(true); expect(table?.has(Path.from("theirs"))).toBe(false); @@ -337,9 +338,9 @@ test("a request resolves once a front answers, and survives its withdrawal", asy // A session answers (simulated): the slot's front resolves the request. const upstream = new BroadcastProducer(); - const slot = origin.requests.peek()?.get(path); + const slot = wireOf(origin).requests.peek()?.get(path); expect(slot).toBeDefined(); - expect(origin.answer(path, upstream.consume())).toBeDefined(); + expect(wireOf(origin).answer(path, upstream.consume())).toBeDefined(); expect(request.active.peek()).toBeDefined(); // A second request for the same path shares the answer, each through a handle of its @@ -354,7 +355,7 @@ test("a request resolves once a front answers, and survives its withdrawal", asy // effect rerun can re-acquire the slot without dropping the answer. request.close(); await settle(); - expect(origin.requests.peek()?.has(path)).toBe(false); + expect(wireOf(origin).requests.peek()?.has(path)).toBe(false); expect(upstream.closed.peek()).not.toBeUndefined(); origin.close(); @@ -367,7 +368,7 @@ test("a request closed and retaken in the same tick keeps its answer", async () const first = consumer.request(path); const upstream = new BroadcastProducer(); - const withdraw = origin.answer(path, upstream.consume()); + const withdraw = wireOf(origin).answer(path, upstream.consume()); expect(withdraw).toBeDefined(); const front = first.active.peek(); expect(front).toBeDefined(); @@ -414,7 +415,7 @@ test("disposing the newest remote route promotes the fallback", async () => { expect(await announced.next()).toMatchObject({ path: path, kind: "announced" }); const handle = await routed(consumer, path); - const track = handle?.subscribe("chat"); + const track = handle?.track("chat").subscribe(); expect(track).toBeDefined(); track?.close(); handle?.close(); @@ -424,7 +425,7 @@ test("disposing the newest remote route promotes the fallback", async () => { disposeOlder(); await settle(); - expect(consumer.routes(path)).toBe(false); + expect(wireOf(consumer).routes(path)).toBe(false); keepOlder.close(); keepNewer.close(); @@ -440,22 +441,22 @@ test("withdrawing an answer wakes the requests table", async () => { const request = consumer.request(path); const first = new BroadcastProducer(); - const withdraw = origin.answer(path, first.consume()); + const withdraw = wireOf(origin).answer(path, first.consume()); expect(withdraw).toBeDefined(); // A second answer while one stands must lose and stay eligible. const second = new BroadcastProducer(); - expect(origin.answer(path, second.consume())).toBeUndefined(); + expect(wireOf(origin).answer(path, second.consume())).toBeUndefined(); // Withdrawing pokes the requests map, which is what a standby serving loop sleeps on. - const woken = origin.requests.changed(); + const woken = wireOf(origin).requests.changed(); withdraw?.(); await woken; expect(request.active.peek()).toBeUndefined(); // The slot is vacant again, so a standby answers. const third = new BroadcastProducer(); - expect(origin.answer(path, third.consume())).toBeDefined(); + expect(wireOf(origin).answer(path, third.consume())).toBeDefined(); expect(request.active.peek()).toBeDefined(); request.close(); @@ -469,13 +470,13 @@ test("requests never appear in announced or the table", async () => { const request = consumer.request(path); const upstream = new BroadcastProducer(); - origin.answer(path, upstream.consume()); + wireOf(origin).answer(path, upstream.consume()); // An answered request is assumed present, not known live, so it is not availability: it // stays out of the table, and out of the announcements the table drives. expect(request.active.peek()).toBeDefined(); - expect(origin.routes(path)).toBe(false); - expect(consumer.broadcasts.peek()?.has(path)).toBe(false); + expect(wireOf(origin).routes(path)).toBe(false); + expect(wireOf(consumer).broadcasts.peek()?.has(path)).toBe(false); const announced = consumer.announced(); publish(origin, Path.from("real")); @@ -529,15 +530,15 @@ test("a routed path needs no blind answer", async () => { const request = consumer.request(path); await settle(); - expect(origin.routes(path)).toBe(true); + expect(wireOf(origin).routes(path)).toBe(true); expect(request.active.peek()).toBeDefined(); // The route going away is what makes the request need an answer, so the serving loop has // to wake on the table, not just on the requests map. - const woken = origin.changed(); + const woken = wireOf(origin).changed(); dispose(); await woken; - expect(origin.routes(path)).toBe(false); + expect(wireOf(origin).routes(path)).toBe(false); expect(request.active.peek()).toBeUndefined(); request.close(); @@ -551,12 +552,12 @@ test("discovery reflects the attached sessions", async () => { expect(consumer.discovery.peek()).toBeUndefined(); - const blind = origin.attach(false); + const blind = wireOf(origin).attach(false); expect(consumer.discovery.peek()).toBe(false); // Mixed: the table cannot be complete while one session announces nothing, so a consumer // gated on this has to keep asking rather than trusting the announcements. - const seeing = origin.attach(true); + const seeing = wireOf(origin).attach(true); expect(consumer.discovery.peek()).toBe(false); blind(); @@ -629,7 +630,7 @@ test("closing a request releases the handle it was holding", async () => { // handle belongs to the table, carries on. expect(request.active.peek()).toBeUndefined(); expect(producer.closed.peek()).toBeUndefined(); - expect(origin.consume().routes(path)).toBe(true); + expect(wireOf(origin.consume()).routes(path)).toBe(true); producer.close(); origin.close(); @@ -717,7 +718,7 @@ test("a request is unroutable only when nothing can answer it", async () => { expect(request.unroutable.peek()).toBe(true); // A session attaches: now the path is merely unanswered. - const detach = origin.attach(false); + const detach = wireOf(origin).attach(false); await settle(); expect(request.unroutable.peek()).toBe(false); @@ -737,13 +738,13 @@ test("a reconnecting connection keeps requests pending across the gap", async () // What a reconnecting connection holds: no session yet, but one is coming. This is the // page-load window, and it must not read as a missing broadcast. - const release = origin.expect(); + const release = wireOf(origin).expect(); const request = consumer.request(path); expect(request.unroutable.peek()).toBe(false); // A session comes and goes; the expectation still covers the gap. - const detach = origin.attach(true); + const detach = wireOf(origin).attach(true); await settle(); detach(); await settle(); @@ -826,7 +827,7 @@ test("closing the origin makes an existing request unroutable", async () => { const origin = new Producer(); const path = Path.from("doomed"); - const detach = origin.attach(true); + const detach = wireOf(origin).attach(true); const request = origin.consume().request(path); expect(request.unroutable.peek()).toBe(false); @@ -850,8 +851,8 @@ test("createBroadcast is unadvertised until announce", async () => { const path = Path.from("room"); const broadcast = origin.createBroadcast(path); - expect(consumer.routes(path)).toBe(true); - expect(consumer.advertised.peek()?.has(path)).toBe(false); + expect(wireOf(consumer).routes(path)).toBe(true); + expect(wireOf(consumer).advertised.peek()?.has(path)).toBe(false); const announced = consumer.announced(); const pending = announced.next(); @@ -867,7 +868,7 @@ test("createBroadcast is unadvertised until announce", async () => { broadcast.unannounce(); expect(await announced.next()).toMatchObject({ path: path, kind: "retracted" }); - expect(consumer.routes(path)).toBe(true); + expect(wireOf(consumer).routes(path)).toBe(true); announced.close(); broadcast.close(); @@ -877,7 +878,7 @@ test("createBroadcast is unadvertised until announce", async () => { test("a handle serves a request under live", async () => { const origin = new Producer(); const consumer = origin.consume(); - const handle = origin.dynamic("live"); + const handle = origin.dynamic(Path.from("live")); expect(await consumer.announced().next()).toMatchObject({ path: Path.from("live"), kind: "announced" }); @@ -892,7 +893,7 @@ test("a handle serves a request under live", async () => { await settle(); expect(request.active.peek()).toBeDefined(); - const track = request.active.peek()?.subscribe("video"); + const track = request.active.peek()?.track("video").subscribe(); expect(track).toBeDefined(); track?.close(); @@ -904,7 +905,7 @@ test("a handle serves a request under live", async () => { test("a re-priced route is delivered as an update", async () => { const origin = new Producer(); - const handle = origin.dynamic("live"); + const handle = origin.dynamic(Path.from("live")); const announced = origin.consume().announced(); expect(await announced.next()).toMatchObject({ path: Path.from("live"), kind: "announced" }); handle.update({ cost: 5n }); @@ -917,7 +918,7 @@ test("a re-priced route is delivered as an update", async () => { test("an accepted dynamic broadcast is retired when it closes", async () => { const origin = new Producer(); - const handle = origin.dynamic("live"); + const handle = origin.dynamic(Path.from("live")); const path = Path.from("live/cam"); const request = origin.request(path); const it = handle.requested(); @@ -927,7 +928,7 @@ test("an accepted dynamic broadcast is retired when it closes", async () => { produced.createTrack("video"); first.value?.accept(produced); await settle(); - expect(request.active.peek()?.subscribe("video")).toBeDefined(); + expect(request.active.peek()?.track("video").subscribe()).toBeDefined(); produced.close(); await settle(); @@ -939,7 +940,7 @@ test("an accepted dynamic broadcast is retired when it closes", async () => { replacement.createTrack("video"); second.value?.accept(replacement); await settle(); - expect(request.active.peek()?.subscribe("video")).toBeDefined(); + expect(request.active.peek()?.track("video").subscribe()).toBeDefined(); request.close(); handle.close(); @@ -949,10 +950,10 @@ test("an accepted dynamic broadcast is retired when it closes", async () => { test("reject surfaces the error from demand", async () => { const origin = new Producer(); - const handle = origin.dynamic("live"); + const handle = origin.dynamic(Path.from("live")); const consumer = origin.consume(); const it = handle.requested(); - const pending = consumer.demand(Path.from("live/cam")); + const pending = wireOf(consumer).demand(Path.from("live/cam")); const { value: req } = await it.next(); const err = new StreamError(StreamCode.NoCapacity, { message: "full" }); req?.reject(err); @@ -964,12 +965,12 @@ test("reject surfaces the error from demand", async () => { test("a shared reject still surfaces from demand", async () => { const origin = new Producer(); - const handle = origin.dynamic("live"); + const handle = origin.dynamic(Path.from("live")); const consumer = origin.consume(); const it = handle.requested(); const request = origin.request(Path.from("live/cam")); - const pending = consumer.demand(Path.from("live/cam")); + const pending = wireOf(consumer).demand(Path.from("live/cam")); const { value: req } = await it.next(); const err = new Error("unserved"); req?.reject(err); @@ -986,7 +987,7 @@ test("a shared reject still surfaces from demand", async () => { test("a request refusal does not keep demand from asking again", async () => { const origin = new Producer(); - const handle = origin.dynamic("live"); + const handle = origin.dynamic(Path.from("live")); const consumer = origin.consume(); const it = handle.requested(); @@ -996,7 +997,7 @@ test("a request refusal does not keep demand from asking again", async () => { request.close(); await settle(); - const pending = consumer.demand(Path.from("live/cam")); + const pending = wireOf(consumer).demand(Path.from("live/cam")); const { value: second } = await it.next(); expect(second?.path).toBe(Path.from("live/cam")); const produced = new BroadcastProducer(); @@ -1010,15 +1011,15 @@ test("a request refusal does not keep demand from asking again", async () => { test("advancing requested without settling rejects the previous request", async () => { const origin = new Producer(); - const handle = origin.dynamic("live"); + const handle = origin.dynamic(Path.from("live")); const consumer = origin.consume(); const it = handle.requested(); - const firstDemand = consumer.demand(Path.from("live/cam")); + const firstDemand = wireOf(consumer).demand(Path.from("live/cam")); const first = await it.next(); expect(first.value?.path).toBe(Path.from("live/cam")); - const secondDemand = consumer.demand(Path.from("live/other")); + const secondDemand = wireOf(consumer).demand(Path.from("live/other")); const second = await it.next(); expect(second.value?.path).toBe(Path.from("live/other")); @@ -1034,7 +1035,7 @@ test("advancing requested without settling rejects the previous request", async test("a rejected request is not asked of the same route again", async () => { const origin = new Producer(); - const handle = origin.dynamic("live"); + const handle = origin.dynamic(Path.from("live")); const it = handle.requested(); const request = origin.request(Path.from("live/cam")); @@ -1064,7 +1065,7 @@ test("a rejected request is not asked of the same route again", async () => { test("a rejected request falls through to the next-best route", async () => { const origin = new Producer(); const consumer = origin.consume(); - const narrow = origin.dynamic("live"); + const narrow = origin.dynamic(Path.from("live")); const served = new BroadcastProducer(); served.createTrack("video"); const disposeWide = serve(origin, Path.from(""), provider(served)); @@ -1075,7 +1076,7 @@ test("a rejected request falls through to the next-best route", async () => { await settle(); // The narrow route refused, so the broader one answers instead of the path black-holing. - const track = request.active.peek()?.subscribe("video"); + const track = request.active.peek()?.track("video").subscribe(); expect(track).toBeDefined(); track?.close(); @@ -1088,7 +1089,7 @@ test("a rejected request falls through to the next-best route", async () => { test("close rejects queued requests with NoCapacity", async () => { const origin = new Producer(); - const handle = origin.dynamic("live"); + const handle = origin.dynamic(Path.from("live")); const waiting = handle.requested().next(); const request = origin.request(Path.from("live/cam")); const { value: req } = await waiting; diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index ccb6f84e1e..d831cba725 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -17,6 +17,7 @@ import { StreamCode, StreamError } from "./error.ts"; import { Route, routesEqual } from "./hop.ts"; import { hooks, scopeCaptures, scopeOverlaps } from "./internal.ts"; import * as Path from "./path.ts"; +import { type Advertised, registerWire, wireOf } from "./wire.ts"; export type { Cost, Hop, Route } from "./hop.ts"; export { isAnonymous } from "./hop.ts"; @@ -43,6 +44,7 @@ export { isAnonymous } from "./hop.ts"; */ export interface RequestSlot { count: number; + blind: number; answer?: broadcast.Consumer; readonly refused: Set; readonly route: Signal; @@ -71,8 +73,8 @@ function noCapacity(): StreamError { /** A served route from {@link Producer.dynamic}: the queue a handler drains. */ class ServeState { - queue = new Signal([]); - pending = new Map(); + queue = new Signal([]); + pending = new Map(); served = new Map(); rejected = new Map(); // demand() is the only reader of `rejected`. A Consumer.request refusal never @@ -90,14 +92,14 @@ class ServeState { if (this.pending.has(path)) return; const live = this.served.get(path); if (live && live.closed.peek() === undefined) return; - const request = makeBroadcastRequest(path, this); + const request = makeRequest(path, this); this.pending.set(path, request); this.queue.mutate((queue) => { queue.push(request); }); } - accept(request: BroadcastRequest, front: broadcast.Consumer): void { + accept(request: Request, front: broadcast.Consumer): void { if (this.closed.peek() !== undefined || this.pending.get(request.path) !== request) { front.close(); return; @@ -120,7 +122,7 @@ class ServeState { this.settled.update((n) => n + 1); } - reject(request: BroadcastRequest, err: Error): void { + reject(request: Request, err: Error): void { if (this.pending.get(request.path) !== request) return; this.pending.delete(request.path); if (this.demanding.has(request.path)) this.rejected.set(request.path, err); @@ -138,7 +140,7 @@ class ServeState { queue.length = 0; }); for (const request of queued) { - finishBroadcastRequest(request, err); + finishRequest(request, err); } for (const [path, front] of this.served) { front.close(abort); @@ -151,13 +153,6 @@ class ServeState { } } -/** Publisher-facing advertisement: object identity plus the current route. */ -export interface Advertised { - /** A republish is a different object; a re-price is the same object with a new route. */ - readonly identity: object; - readonly route: Route; -} - interface Presented extends Advertised { readonly captures: Path.Pattern[] | undefined; } @@ -347,13 +342,19 @@ export interface Table { /** Create an unadvertised broadcast at `path`; see {@link Producer.createBroadcast}. */ createBroadcast(path: Path.Valid): broadcast.Producer; - /** Resolve `path`, without waiting for an announcement; see {@link Consumer.request}. */ - request(path: Path.Valid): Request; + /** Resolve `path`, optionally waiting for an announcement; see {@link Consumer.request}. */ + request(path: Path.Valid, options?: RequestOptions): Requesting; /** The available broadcasts under `scope`, as a live stream; see {@link Consumer.announced}. */ announced(scope?: Path.Pattern): announce.Consumer; } +/** Options for resolving a broadcast path. */ +export interface RequestOptions { + /** Wait for a routed announcement when discovery is supported; otherwise subscribe blindly. */ + announced?: boolean; +} + /** * The write side of an origin: create broadcasts by path and advertise them. * @@ -377,6 +378,21 @@ export class Producer implements Table { // identity across reads. #reader = makeConsumer(this.#state); + constructor() { + const thisProducer = this; + registerWire(this, { + receive: (prefix, route) => this.#receive(prefix, route), + attach: (discovery) => this.#attach(discovery), + expect: () => this.#expect(), + get requests() { + return thisProducer.#state.requests; + }, + changed: () => this.#changed(), + answer: (path, front) => this.#answer(path, front), + routes: (path) => wireOf(this.#reader).routes(path), + }); + } + /** * Settles once the origin closes: `null` on a clean close, or the abort {@link Error}. * Peek it synchronously (`undefined` while open), observe it reactively, or `await` it. @@ -459,10 +475,10 @@ export class Producer implements Table { * consumers narrow with a {@link Path.Pattern} locally. The advertisement is * visible to {@link Consumer.announced} and forwarded by sessions for as long as * the returned {@link Dynamic} lives. A consumer resolving a path under it that - * no local broadcast covers is handed to the handle as a {@link BroadcastRequest}. + * no local broadcast covers is handed to the handle as a {@link Request}. */ dynamic( - prefix: Path.Valid | string, + prefix: Path.Valid, route: Route | { hops?: Route["hops"]; cost?: Route["cost"] | bigint } = Route.default, ): Dynamic { return this.#insertRoute(prefix, Route.normalize(route), true); @@ -474,15 +490,14 @@ export class Producer implements Table { * * @internal */ - receive( - prefix: Path.Valid | string, + #receive( + prefix: Path.Valid, route: Route | { hops?: Route["hops"]; cost?: Route["cost"] | bigint } = Route.default, ): Dynamic { return this.#insertRoute(prefix, Route.normalize(route), false); } - #insertRoute(raw: Path.Valid | string, route: Route, originated: boolean): Dynamic { - const prefix = Path.from(raw); + #insertRoute(prefix: Path.Valid, route: Route, originated: boolean): Dynamic { const server = new ServeState(); server.onChange = (path) => this.#state.refresh(path); const entry: RouteEntry = { @@ -535,9 +550,9 @@ export class Producer implements Table { * * @internal */ - attach(discovery: boolean): Dispose { + #attach(discovery: boolean): Dispose { this.#sessions(1, discovery); - const release = this.expect(); + const release = this.#expect(); let detached = false; return () => { if (detached) return; @@ -566,7 +581,7 @@ export class Producer implements Table { * * @internal */ - expect(): Dispose { + #expect(): Dispose { this.#state.answerers.update((count) => count + 1); let released = false; return () => { @@ -578,23 +593,13 @@ export class Producer implements Table { }; } - /** - * The open requests, watched by attached sessions to answer them; see - * {@link Consumer.request}. Undefined once the origin closes. - * - * @internal - */ - get requests(): Getter | undefined> { - return this.#state.requests; - } - /** * Resolves once anything a serving session scans changes: the open requests, or either * side of the routing table. * * @internal */ - changed(): Promise { + #changed(): Promise { return Signal.race(this.#state.requests, this.#state.local, this.#state.routes, this.#state.advertisedLocal); } @@ -609,7 +614,7 @@ export class Producer implements Table { * * @internal */ - answer(path: Path.Valid, front: broadcast.Consumer): Dispose | undefined { + #answer(path: Path.Valid, front: broadcast.Consumer): Dispose | undefined { const slot = this.#state.requests.peek()?.get(path); if (!slot || slot.answer !== undefined) { front.close(); @@ -640,14 +645,9 @@ export class Producer implements Table { return this.#reader.discovery; } - /** Resolve `path`, without waiting for an announcement; see {@link Consumer.request}. */ - request(path: Path.Valid): Request { - return this.#reader.request(path); - } - - /** Whether the table routes `path` itself; see {@link Consumer.routes}. @internal */ - routes(path: Path.Valid): boolean { - return this.#reader.routes(path); + /** Resolve `path`, optionally waiting for an announcement; see {@link Consumer.request}. */ + request(path: Path.Valid, options?: RequestOptions): Requesting { + return this.#reader.request(path, options); } /** The available broadcasts under `scope`, as a live stream; see {@link Consumer.announced}. */ @@ -696,27 +696,27 @@ export class Producer implements Table { // that would leak the unexported OriginState. Assigned in the class's static block. let makeConsumer: (state: OriginState) => Consumer; -// Same for Request: a public constructor would let a caller forge a handle that no origin +// Same for Requesting: a public constructor would let a caller forge a handle that no origin // ever registered, whose lifecycle guarantees are then false. `@internal` alone would not // stop it, since the declaration emit keeps the constructor. -let makeRequest: ( +let makeRequesting: ( path: Path.Valid, active: Getter, unroutable: Getter, dispose: Dispose, -) => Request; +) => Requesting; let makeDynamic: (prefix: Path.Valid, entry: RouteEntry, state: OriginState, retract: Dispose) => Dynamic; -let makeBroadcastRequest: (path: Path.Valid, server: ServeState) => BroadcastRequest; -let finishBroadcastRequest: (request: BroadcastRequest, err: Error) => void; +let makeRequest: (path: Path.Valid, server: ServeState) => Request; +let finishRequest: (request: Request, err: Error) => void; /** * An open request for a path nothing announced; see {@link Consumer.request}. * * @public */ -export class Request { +export class Requesting { /** The requested path. */ readonly path: Path.Valid; @@ -763,7 +763,7 @@ export class Request { } static { - makeRequest = (path, active, unroutable, dispose) => new Request(path, active, unroutable, dispose); + makeRequesting = (path, active, unroutable, dispose) => new Requesting(path, active, unroutable, dispose); } /** Withdraw the request. The path stays routed for any other open request. Idempotent. */ @@ -794,6 +794,16 @@ export class Consumer { this.#discovery = new Derived([state.sessions], ({ total, discovery }) => total === 0 ? undefined : discovery === total, ); + registerWire(this, { + routes: (path) => this.#routes(path), + get broadcasts() { + return state.local; + }, + get advertised() { + return state.originated; + }, + demand: (path) => this.#demand(path), + }); } static { @@ -832,19 +842,21 @@ export class Consumer { * * @internal */ - routes(path: Path.Valid): boolean { + #routes(path: Path.Valid): boolean { if (this.#state.local.peek()?.has(path)) return true; return this.#state.bestEntry(path) !== undefined; } /** - * Resolve `path`, without waiting for an announcement. + * Resolve `path`, optionally waiting for an announcement. * - * The one way to consume by path. {@link Request.active} follows whatever the table + * The one way to consume by path. {@link Requesting.active} follows whatever the table * routes (a local publish, or any feeding session's announcement, swapping on a * republish); when nothing does, the request stands and whichever attached session * answers first provides a blind subscription instead, re-answered across reconnects. - * Close the request when done. On a closed origin it never resolves. + * With `announced: true`, an unrouted request waits while discovery is supported and + * falls back to that blind behavior only when discovery is unavailable. Close the request + * when done. On a closed origin it never resolves. * * With several sessions on one origin the first to answer wins, and it may be one that * does not carry the path. Nothing corrects that: a missing broadcast surfaces as a reset @@ -855,11 +867,11 @@ export class Consumer { * answer. Prefer {@link unroutable} and announcements over blind requests when the origin * feeds from more than one connection. */ - request(path: Path.Valid): Request { + request(path: Path.Valid, options: RequestOptions = {}): Requesting { const requests = this.#state.requests.peek(); if (!requests) { // Closed origin: a request that can never resolve, and says so. - return makeRequest(path, new Signal(undefined), getter(true), () => {}); + return makeRequesting(path, new Signal(undefined), getter(true), () => {}); } let slot = requests.get(path); @@ -870,13 +882,34 @@ export class Consumer { // clear it, so a seeded route retracting to undefined would look like no change and // notify nobody. const refused = new Set(); - const created: RequestSlot = { count: 0, refused, route: new Signal(this.#state.route(path, { refused })) }; + const created: RequestSlot = { + count: 0, + blind: 0, + refused, + route: new Signal(this.#state.route(path, { refused })), + }; slot = created; this.#state.requests.mutate((map) => { map?.set(path, created); }); } slot.count += 1; + let blind = !options.announced || this.#discovery.peek() === false; + if (blind) slot.blind += 1; + this.#state.requests.mutate(() => {}); + + // An announcement-gated request falls back to a blind subscription only while at + // least one attached session cannot announce. It returns to the gate if discovery + // becomes complete again, and remains gated with no session attached. + const unsubscribeDiscovery = options.announced + ? this.#discovery.subscribe((discovery) => { + const next = discovery === false; + if (next === blind) return; + blind = next; + slot.blind += next ? 1 : -1; + this.#state.requests.mutate(() => {}); + }) + : () => {}; // Hand out a handle of the request's own rather than the table's. Closing a consumer // closes the broadcast once it was the last one, and the table often holds the only @@ -915,15 +948,18 @@ export class Consumer { // answer. const unroutable = new Derived([route, this.#state.answerers], (front, answerers) => !front && answerers === 0); - return makeRequest(path, active, unroutable, () => { + return makeRequesting(path, active, unroutable, () => { // Releases this request's handle; the route itself belongs to the table. released = true; + unsubscribeDiscovery(); unsubscribe(); handle?.close(); handle = undefined; source = undefined; taken.count -= 1; + if (blind) taken.blind -= 1; + this.#state.requests.mutate(() => {}); if (taken.count > 0) return; // Defer the teardown a microtask: an effect whose rerun was triggered by the @@ -1025,27 +1061,19 @@ export class Consumer { * * @internal */ - get broadcasts(): Getter | undefined> { - return this.#state.local; - } - /** * Originated advertisements a session should forward: exact-path announces plus * originated dynamics. Undefined once the origin closes. * * @internal */ - get advertised(): Getter | undefined> { - return this.#state.originated; - } - /** * Resolve `path` for serving: a local broadcast, or wait for an originated dynamic * to accept it. Undefined when nothing here can serve the path. * * @internal */ - async demand(path: Path.Valid): Promise { + async #demand(path: Path.Valid): Promise { const local = this.#state.local.peek()?.get(path); if (local) return local; @@ -1128,11 +1156,11 @@ export class Dynamic { this.#retract(); } - /** Requests under this prefix, as they arrive, each to {@link BroadcastRequest.accept} or reject. */ - async *requested(): AsyncIterableIterator { + /** Requests under this prefix, as they arrive, each to {@link Request.accept} or reject. */ + async *requested(): AsyncIterableIterator { const server = this.#entry.server; if (!server) return; - let current: BroadcastRequest | undefined; + let current: Request | undefined; const drop = () => { current?.reject(noCapacity()); current = undefined; @@ -1167,7 +1195,7 @@ export class Dynamic { * * @public */ -export class BroadcastRequest { +export class Request { /** The path that was requested. */ readonly path: Path.Valid; @@ -1180,8 +1208,8 @@ export class BroadcastRequest { } static { - makeBroadcastRequest = (path, server) => new BroadcastRequest(path, server); - finishBroadcastRequest = (request, err) => { + makeRequest = (path, server) => new Request(path, server); + finishRequest = (request, err) => { request.#done = true; void err; }; diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 1ed25c8cd3..aff41f755d 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -1,7 +1,8 @@ import { expect, setSystemTime, test } from "bun:test"; -import { Producer as GroupProducer, Lagged, MAX_GROUP_FRAMES } from "./group.ts"; +import { TooFarBehind } from "./error.ts"; +import { Producer as GroupProducer, MAX_GROUP_FRAMES } from "./group.ts"; import { hooks } from "./internal.ts"; -import { Timescale, Timestamp } from "./time.ts"; +import { Milli, Timescale, Timestamp } from "./time.ts"; import { infoDefaults, Producer as TrackProducer } from "./track.ts"; const enc = new TextEncoder(); @@ -136,7 +137,7 @@ test("insertDatagram preserves an explicit sequence", async () => { test("recvDatagram leaves the ordered group cursor alone", async () => { const producer = new TrackProducer("test"); const datagrams = producer.subscribe(); - const track = producer.subscribe({ maxAge: 5000 }).ordered(); + const track = producer.subscribe({ maxAge: Milli(5000) }).ordered(); producer.insertDatagram(5, Timestamp.fromMillis(5), enc.encode("x")); expect((await datagrams.recvDatagram())?.sequence).toBe(5); @@ -209,20 +210,23 @@ test("appendDatagram rejects a payload over the QUIC datagram frame ceiling", () test("subscriber options and updates are forwarded to the producer's aggregate", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ startGroup: 0 }); + const track = producer.subscribe({ groups: { start: { included: 0 } } }); // The initial options are available before the request is accepted or put on the wire. expect(producer.subscription.peek()).toEqual({ priority: 0, - maxAge: 0, - startGroup: 0, - endGroup: undefined, + maxAge: Milli(0), + groups: { start: { included: 0 } }, }); // The wire layer watches the producer's signal to emit SUBSCRIBE_UPDATE. const next = producer.subscription.changed(); - track.update({ priority: 7, maxAge: 250, startGroup: 2, endGroup: 9 }); - expect(await next).toEqual({ priority: 7, maxAge: 250, startGroup: 2, endGroup: 9 }); + track.update({ priority: 7, maxAge: Milli(250), groups: { start: { included: 2 }, end: { excluded: 9 } } }); + expect(await next).toEqual({ + priority: 7, + maxAge: Milli(250), + groups: { start: { included: 2 }, end: { excluded: 9 } }, + }); }); test("a fractional maxAge is rounded up before the wire sees it", async () => { @@ -231,16 +235,16 @@ test("a fractional maxAge is rounded up before the wire sees it", async () => { // Subscribers derive this from measurements (a jitter estimate scaled off RTT), so a // fractional millisecond is expected. The wire encodes it as a varint, which throws on a // non-integer, and rounding down would shorten a budget the subscriber asked for. - const track = producer.subscribe({ maxAge: 38.75 }); - expect(producer.subscription.peek()?.maxAge).toBe(39); + const track = producer.subscribe({ maxAge: Milli(38.75) }); + expect(producer.subscription.peek()?.maxAge).toBe(Milli(39)); const next = producer.subscription.changed(); - track.update({ maxAge: 500.25 }); - expect((await next)?.maxAge).toBe(501); + track.update({ maxAge: Milli(500.25) }); + expect((await next)?.maxAge).toBe(Milli(501)); // The publisher half of the budget lands on the wire through TRACK_INFO, with the same hazard. - producer.accept({ maxAge: 1000.5 }); - expect((await producer.info()).maxAge).toBe(1001); + producer.accept({ maxAge: Milli(1000.5) }); + expect((await producer.info()).maxAge).toBe(Milli(1001)); }); test("a maxAge that is not a duration is refused, not rounded into one", () => { @@ -248,13 +252,13 @@ test("a maxAge that is not a duration is refused, not rounded into one", () => { // The wire carries an unsigned varint, so rounding these would encode a budget the // caller never asked for: -0.5 would ceil to zero and silently take the live edge. - expect(() => producer.subscribe({ maxAge: -0.5 })).toThrow(RangeError); - expect(() => producer.subscribe({ maxAge: -100 })).toThrow(RangeError); - expect(() => producer.subscribe({ maxAge: Number.NaN })).toThrow(RangeError); - expect(() => producer.subscribe({ maxAge: Number.POSITIVE_INFINITY })).toThrow(RangeError); - expect(() => producer.accept({ maxAge: -0.5 })).toThrow(RangeError); - expect(() => producer.accept({ maxAge: Number.MAX_SAFE_INTEGER + 1 })).toThrow(RangeError); - expect(() => producer.subscribe({ maxAge: Number.MAX_SAFE_INTEGER + 1 })).toThrow(RangeError); + expect(() => producer.subscribe({ maxAge: Milli(-0.5) })).toThrow(RangeError); + expect(() => producer.subscribe({ maxAge: Milli(-100) })).toThrow(RangeError); + expect(() => producer.subscribe({ maxAge: Milli(Number.NaN) })).toThrow(RangeError); + expect(() => producer.subscribe({ maxAge: Milli(Number.POSITIVE_INFINITY) })).toThrow(RangeError); + expect(() => producer.accept({ maxAge: Milli(-0.5) })).toThrow(RangeError); + expect(() => producer.accept({ maxAge: Milli(Number.MAX_SAFE_INTEGER + 1) })).toThrow(RangeError); + expect(() => producer.subscribe({ maxAge: Milli(Number.MAX_SAFE_INTEGER + 1) })).toThrow(RangeError); }); test("priority 255 is accepted and 256 is refused", () => { @@ -269,31 +273,33 @@ test("priority 255 is accepted and 256 is refused", () => { test("infoDefaults re-validates timescale and the safe maxAge bound", () => { expect(infoDefaults({ timescale: Timescale.MICRO }).timescale).toBe(Timescale.MICRO); - expect(infoDefaults({ maxAge: Number.MAX_SAFE_INTEGER }).maxAge).toBe(Number.MAX_SAFE_INTEGER); + 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: Number.MAX_SAFE_INTEGER + 1 })).toThrow(RangeError); + expect(() => infoDefaults({ maxAge: Milli(Number.MAX_SAFE_INTEGER + 1) })).toThrow(RangeError); }); test("multiple subscriber options aggregate like Rust", async () => { const producer = new TrackProducer("test"); - const bounded = producer.subscribe({ priority: 2, maxAge: 100, startGroup: 10, endGroup: 20 }); - const live = producer.subscribe({ priority: 7, maxAge: 250, startGroup: 5 }); + const bounded = producer.subscribe({ + priority: 2, + maxAge: Milli(100), + groups: { start: { included: 10 }, end: { excluded: 20 } }, + }); + const live = producer.subscribe({ priority: 7, maxAge: Milli(250), groups: { start: { included: 5 } } }); expect(producer.subscription.peek()).toEqual({ priority: 7, - maxAge: 250, - startGroup: 5, - endGroup: undefined, + maxAge: Milli(250), + groups: { start: { included: 5 } }, }); const narrowed = producer.subscription.changed(); live.close(); expect(await narrowed).toEqual({ priority: 2, - maxAge: 100, - startGroup: 10, - endGroup: 20, + maxAge: Milli(100), + groups: { start: { included: 10 }, end: { excluded: 20 } }, }); const none = producer.subscription.changed(); @@ -303,13 +309,13 @@ test("multiple subscriber options aggregate like Rust", async () => { test("the producer aggregate is clamped without changing subscriber options", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxAge: 10_000 }); + const track = producer.subscribe({ maxAge: Milli(10_000) }); const clamped = producer.subscription.changed(); - producer.accept({ maxAge: 2_000 }); + producer.accept({ maxAge: Milli(2_000) }); - expect((await clamped)?.maxAge).toBe(2_000); - expect(track.subscription.peek()?.maxAge).toBe(10_000); + expect((await clamped)?.maxAge).toBe(Milli(2_000)); + expect(track.subscription.peek()?.maxAge).toBe(Milli(10_000)); }); test("nextGroup skips late arrivals", async () => { @@ -332,7 +338,7 @@ test("nextGroup skips late arrivals", async () => { test("nextGroup returns buffered groups in sequence", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxAge: 5000 }).ordered(); + const track = producer.subscribe({ maxAge: Milli(5000) }).ordered(); producer.writeGroup(new GroupProducer(3)); producer.writeGroup(new GroupProducer(5)); @@ -345,8 +351,8 @@ test("nextGroup returns buffered groups in sequence", async () => { // is level with the live edge still owes the reader every frame in it, so judging it by // its first timestamp would discard exactly the group being filled. test("a long group is not stale while its tail reaches the edge", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 5000 }); - const sub = producer.subscribe({ maxAge: 500 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); + const sub = producer.subscribe({ maxAge: Milli(500) }); // Group 0 spans 0..2000ms; group 1 starts at 2000ms, where group 0 ends. const long = producer.appendGroup(); @@ -361,8 +367,8 @@ test("a long group is not stale while its tail reaches the edge", async () => { }); test("a long group is stale once its successor falls behind", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 5000 }); - const sub = producer.subscribe({ maxAge: 500 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); + const sub = producer.subscribe({ maxAge: Milli(500) }); const long = producer.appendGroup(); for (const ms of [0, 500, 1000]) { @@ -383,7 +389,7 @@ test("a long group is stale once its successor falls behind", async () => { // track using both channels needs one subscription, not two. test("the ordered handle carries datagrams", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxAge: 5000 }).ordered(); + const track = producer.subscribe({ maxAge: Milli(5000) }).ordered(); producer.insertDatagram(5, Timestamp.fromMillis(5), enc.encode("x")); producer.writeGroup(new GroupProducer(3)); @@ -396,7 +402,7 @@ test("the ordered handle carries datagrams", async () => { // Both cursors write a backlog off: a buffered group is not free to deliver, since a // consumer reading one that is already too old plays it at 1x and never catches up. test("the latency budget skips a buffered timeline on either cursor", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 5000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); const arrival = producer.subscribe(); const ordered = producer.subscribe().ordered(); @@ -408,7 +414,7 @@ test("the latency budget skips a buffered timeline on either cursor", async () = expect((await ordered.nextGroup())?.sequence).toBe(2); // The budget is the only gate: one that spans the backlog bursts it in order. - const replay = producer.subscribe({ maxAge: 30_000 }).ordered(); + const replay = producer.subscribe({ maxAge: Milli(30_000) }).ordered(); expect((await replay.nextGroup())?.sequence).toBe(0); expect((await replay.nextGroup())?.sequence).toBe(1); expect((await replay.nextGroup())?.sequence).toBe(2); @@ -418,7 +424,7 @@ test("the latency budget skips a buffered timeline on either cursor", async () = // group proves nothing about where the successor will begin, and shrinking the bound // is the unsafe direction. test("an unstamped immediate successor leaves reach unbounded", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 5000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); const track = producer.subscribe(); producer.writeFrame({ payload: enc.encode("0"), timestamp: Timestamp.fromMillis(0) }); @@ -434,8 +440,8 @@ test("an unstamped immediate successor leaves reach unbounded", async () => { // The ordered frame helpers ride the same cursor, so they see the same budget: a // backlog inside it is drained in full, and what is past it is skipped. test("ordered frame reads follow the budget", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 5000 }); - const ordered = producer.subscribe({ maxAge: 5000 }).ordered(); + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); + const ordered = producer.subscribe({ maxAge: Milli(5000) }).ordered(); const live = producer.subscribe().ordered(); for (const timestamp of [0, 1000, 2000]) { @@ -454,7 +460,7 @@ test("ordered frame reads follow the budget", async () => { // read never runs backwards. test("nextGroup abandons the frame helpers' group when it passes it", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxAge: 5000 }).ordered(); + const track = producer.subscribe({ maxAge: Milli(5000) }).ordered(); const zero = new GroupProducer(0); zero.writeString("0.0"); @@ -486,7 +492,7 @@ test("nextGroup abandons the frame helpers' group when it passes it", async () = // after a higher one was read is skipped, never fed to the caller backwards. test("ordered frame reads skip a late lower-sequence group", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxAge: 5000 }).ordered(); + const track = producer.subscribe({ maxAge: Milli(5000) }).ordered(); const five = new GroupProducer(5); five.writeString("five"); @@ -510,7 +516,7 @@ test("ordered frame reads skip a late lower-sequence group", async () => { test("zero latency takes the latest group when ages are equal", async () => { const clock = mockMonotonicTime(10_000); try { - const producer = new TrackProducer("test").accept({ maxAge: 5000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); const track = producer.subscribe(); producer.writeString("old"); producer.writeString("new"); @@ -522,8 +528,8 @@ test("zero latency takes the latest group when ages are equal", async () => { }); test("latency budget admits groups within its presentation-time window", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 5000 }); - const track = producer.subscribe({ maxAge: 1500 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); + const track = producer.subscribe({ maxAge: Milli(1500) }); for (const timestamp of [0, 1000, 2000]) { producer.writeFrame({ payload: enc.encode(`${timestamp}`), timestamp: Timestamp.fromMillis(timestamp) }); @@ -536,7 +542,7 @@ test("latency budget admits groups within its presentation-time window", async ( }); test("a late lower group within the budget is delivered", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 30_000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(30_000) }); for (const [sequence, timestamp] of [ [5, 0], [6, 1000], @@ -548,7 +554,7 @@ test("a late lower group within the budget is delivered", async () => { group.close(); } - const track = producer.subscribe({ maxAge: 5000 }); + const track = producer.subscribe({ maxAge: Milli(5000) }); expect((await track.recvGroup())?.sequence).toBe(5); expect((await track.recvGroup())?.sequence).toBe(6); expect((await track.recvGroup())?.sequence).toBe(7); @@ -567,25 +573,25 @@ test("a late lower group within the budget is delivered", async () => { }); test("a named start is a floor, not a request", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 30_000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(30_000) }); for (const timestamp of [0, 1000, 2000, 3000, 4000]) { producer.writeFrame({ payload: enc.encode(`${timestamp}`), timestamp: Timestamp.fromMillis(timestamp) }); } // The budget is the only thing that asks for data; a named start only bounds how far // back it may reach. - const floored = producer.subscribe({ startGroup: 3, maxAge: 60_000 }); + const floored = producer.subscribe({ groups: { start: { included: 3 } }, maxAge: Milli(60_000) }); expect((await floored.recvGroup())?.sequence).toBe(3); expect((await floored.recvGroup())?.sequence).toBe(4); // Naming group 1 at real time still delivers the live edge alone: the zero budget // calls everything older stale. - const named = producer.subscribe({ startGroup: 1 }); + const named = producer.subscribe({ groups: { start: { included: 1 } } }); expect((await named.recvGroup())?.sequence).toBe(4); }); test("the ordered cursor sheds a stale backlog", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 30_000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(30_000) }); for (const timestamp of [0, 1000, 2000, 3000]) { producer.writeFrame({ payload: enc.encode(`${timestamp}`), timestamp: Timestamp.fromMillis(timestamp) }); } @@ -597,21 +603,21 @@ test("the ordered cursor sheds a stale backlog", async () => { // Only what is provably too old goes. Group 0 reaches 1s, a full 2s behind the 3s // edge; group 1 reaches 2s and could still present inside a 1.5s budget. - const bounded = producer.subscribe({ maxAge: 1500 }).ordered(); + const bounded = producer.subscribe({ maxAge: Milli(1500) }).ordered(); expect((await bounded.nextGroup())?.sequence).toBe(1); expect((await bounded.nextGroup())?.sequence).toBe(2); expect((await bounded.nextGroup())?.sequence).toBe(3); // A budget spanning the history still bursts it in full, gap-free. - const replay = producer.subscribe({ maxAge: 30_000 }).ordered(); + const replay = producer.subscribe({ maxAge: Milli(30_000) }).ordered(); for (const sequence of [0, 1, 2, 3]) { expect((await replay.nextGroup())?.sequence).toBe(sequence); } }); test("the budget is clamped to the publisher's window", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 1500 }); - const track = producer.subscribe({ maxAge: 60_000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(1500) }); + const track = producer.subscribe({ maxAge: Milli(60_000) }); // Spaced so group 0's reach (2s) sits 2s behind the edge (4s): outside the // publisher's 1.5s window, inside the subscriber's requested minute. @@ -629,7 +635,7 @@ test("the budget is clamped to the publisher's window", async () => { test("a stamped successor expires a group stalled before its first frame", async () => { const clock = mockMonotonicTime(10_000); try { - const producer = new TrackProducer("test").accept({ maxAge: 5000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); const track = producer.subscribe(); producer.appendGroup(); // seq 0 has no timestamp @@ -652,7 +658,7 @@ test("real time reads a live stream without truncating it", async () => { // The reader is always a little behind the edge (that is what reading live means), // and it is parked at its group's end when the next one opens, because a group's // close and the next group's first frame are separate events. - const producer = new TrackProducer("test").accept({ maxAge: 60_000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(60_000) }); const track = producer.subscribe(); let open = producer.appendGroup(); @@ -701,8 +707,8 @@ test("a budget is measured from the reader's position", async () => { // for. A straggling frame of the old group must still reach it. Measuring from the // group's first frame instead makes the drift 2000ms, so a budget shorter than one // GOP would drop the tail of every GOP. - const producer = new TrackProducer("test").accept({ maxAge: 60_000 }); - const track = producer.subscribe({ maxAge: 1000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(60_000) }); + const track = producer.subscribe({ maxAge: Milli(1000) }); const open = producer.appendGroup(); open.writeFrame({ payload: enc.encode("key"), timestamp: Timestamp.fromMillis(0) }); @@ -728,7 +734,7 @@ test("a budget is measured from the reader's position", async () => { test("a handed-out group still expires while its first frame is stalled", async () => { const clock = mockMonotonicTime(10_000); try { - const producer = new TrackProducer("test").accept({ maxAge: 5000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); const track = producer.subscribe(); producer.appendGroup(); @@ -753,7 +759,7 @@ test("a handed-out group still expires while its first frame is stalled", async test("committing track info wakes a group newly outside the retention window", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxAge: 10_000 }); + const track = producer.subscribe({ maxAge: Milli(10_000) }); const source = producer.appendGroup(); source.writeFrame({ payload: enc.encode("old"), timestamp: Timestamp.fromMillis(0) }); @@ -767,13 +773,13 @@ test("committing track info wakes a group newly outside the retention window", a // bounded by where its successor begins, so the successor alone never convicts it. producer.writeFrame({ payload: enc.encode("later"), timestamp: Timestamp.fromMillis(2_000) }); await settle(); - producer.accept({ maxAge: 100 }); + producer.accept({ maxAge: Milli(100) }); await expect(waiting).resolves.toBeUndefined(); }); test("a handed-out frame cancels its in-flight operation when it expires", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 5000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); const track = producer.subscribe(); producer.writeString("old"); @@ -793,8 +799,8 @@ test("a handed-out frame cancels its in-flight operation when it expires", async }); test("a guarded write keeps the position of the frame removed from the buffer", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 100 }); - const track = producer.subscribe({ maxAge: 100 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(100) }); + const track = producer.subscribe({ maxAge: Milli(100) }); const source = producer.appendGroup(); source.writeFrame({ payload: enc.encode("old"), timestamp: Timestamp.fromMillis(0) }); source.writeFrame({ payload: enc.encode("future"), timestamp: Timestamp.fromMillis(10_000) }); @@ -820,8 +826,8 @@ test("a guarded write keeps the position of the frame removed from the buffer", }); test("clean source closure stays provisional while a frame write can expire", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 100 }); - const track = producer.subscribe({ maxAge: 100 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(100) }); + const track = producer.subscribe({ maxAge: Milli(100) }); const source = producer.appendGroup(); source.writeFrame({ payload: enc.encode("old"), timestamp: Timestamp.fromMillis(0) }); source.close(); @@ -852,7 +858,7 @@ test("clean source closure stays provisional while a frame write can expire", as }); test("a drained group finishes cleanly after the live edge advances", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 5000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); const track = producer.subscribe(); producer.writeString("old"); @@ -873,8 +879,8 @@ test("a drained group finishes cleanly after the live edge advances", async () = test("retention eviction surfaces as a gap for a handed-out group", async () => { const clock = mockMonotonicTime(10_000); try { - const producer = new TrackProducer("test").accept({ maxAge: 100 }); - const track = producer.subscribe({ maxAge: 100 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(100) }); + const track = producer.subscribe({ maxAge: Milli(100) }); const source = producer.appendGroup(); source.writeString("first"); source.writeString("tail"); @@ -887,7 +893,7 @@ test("retention eviction surfaces as a gap for a handed-out group", async () => clock.set(10_200); producer.appendGroup(); - await expect(group.readFrame()).rejects.toThrow(Lagged); + await expect(group.readFrame()).rejects.toThrow(TooFarBehind); } finally { clock.restore(); } @@ -896,8 +902,8 @@ test("retention eviction surfaces as a gap for a handed-out group", async () => test("retention pruning aborts a held mirror without a new live edge", async () => { const clock = mockMonotonicTime(10_000); try { - const producer = new TrackProducer("test").accept({ maxAge: 100 }); - const track = producer.subscribe({ maxAge: 100 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(100) }); + const track = producer.subscribe({ maxAge: Milli(100) }); const source = producer.appendGroup(); source.writeString("first"); source.writeString("tail"); @@ -908,9 +914,9 @@ test("retention pruning aborts a held mirror without a new live edge", async () expect(new TextDecoder().decode((await group.readFrame())?.payload)).toBe("first"); clock.set(10_200); - producer.subscribe({ maxAge: 100 }); + producer.subscribe({ maxAge: Milli(100) }); - await expect(group.readFrame()).rejects.toBeInstanceOf(Lagged); + await expect(group.readFrame()).rejects.toBeInstanceOf(TooFarBehind); } finally { clock.restore(); } @@ -919,8 +925,8 @@ test("retention pruning aborts a held mirror without a new live edge", async () test("retention reclaims a group the publisher abandoned open", async () => { const clock = mockMonotonicTime(10_000); try { - const producer = new TrackProducer("test").accept({ maxAge: 100 }); - const track = producer.subscribe({ maxAge: 100 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(100) }); + const track = producer.subscribe({ maxAge: Milli(100) }); const stalled = producer.appendGroup(); stalled.writeString("first"); // A successor, so the stalled group is not the live edge the publisher is @@ -932,10 +938,10 @@ test("retention reclaims a group the publisher abandoned open", async () => { expect(await group.readString()).toBe("first"); clock.set(10_200); - producer.subscribe({ maxAge: 100 }); + producer.subscribe({ maxAge: Milli(100) }); // A gap, not a clean finish: the publisher never ended this group. - await expect(group.readFrame()).rejects.toBeInstanceOf(Lagged); + await expect(group.readFrame()).rejects.toBeInstanceOf(TooFarBehind); } finally { clock.restore(); } @@ -944,8 +950,8 @@ test("retention reclaims a group the publisher abandoned open", async () => { test("an abandoned open group ages out with no further write", async () => { // Real time, since this is about the wakeup: nothing writes to the track again, so // without a timer the read below parks forever. - const producer = new TrackProducer("test").accept({ maxAge: 30 }); - const track = producer.subscribe({ maxAge: 30 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(30) }); + const track = producer.subscribe({ maxAge: Milli(30) }); const stalled = producer.appendGroup(); stalled.writeString("first"); producer.appendGroup(); @@ -959,7 +965,7 @@ test("an abandoned open group ages out with no further write", async () => { (err: unknown) => err, ); const timeout = new Promise((resolve) => setTimeout(() => resolve("still parked"), 1000)); - expect(await Promise.race([read, timeout])).toBeInstanceOf(Lagged); + expect(await Promise.race([read, timeout])).toBeInstanceOf(TooFarBehind); }); test("the prune wakeup never exceeds the setTimeout limit", () => { @@ -975,7 +981,7 @@ test("the prune wakeup never exceeds the setTimeout limit", () => { }; try { - const producer = new TrackProducer("test").accept({ maxAge: 2 ** 31 + 10_000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(2 ** 31 + 10_000) }); const stalled = producer.appendGroup(); stalled.writeString("first"); producer.appendGroup(); // the live edge, so the stalled group is prunable @@ -991,8 +997,8 @@ test("the prune wakeup never exceeds the setTimeout limit", () => { test("retention pruning preserves clean EOF for a drained mirror", async () => { const clock = mockMonotonicTime(10_000); try { - const producer = new TrackProducer("test").accept({ maxAge: 100 }); - const track = producer.subscribe({ maxAge: 100 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(100) }); + const track = producer.subscribe({ maxAge: Milli(100) }); const source = producer.appendGroup(); source.writeString("only"); source.close(); @@ -1003,7 +1009,7 @@ test("retention pruning preserves clean EOF for a drained mirror", async () => { expect(await group.readFrame()).toBeUndefined(); clock.set(10_200); - producer.subscribe({ maxAge: 100 }); + producer.subscribe({ maxAge: Milli(100) }); expect(await group.readFrame()).toBeUndefined(); expect(await group.closed).toBeNull(); @@ -1016,8 +1022,8 @@ test("system clock changes do not affect the latency budget", async () => { const clock = mockMonotonicTime(10_000); setSystemTime(new Date(10_000)); try { - const producer = new TrackProducer("test").accept({ maxAge: 100 }); - const track = producer.subscribe({ maxAge: 100 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(100) }); + const track = producer.subscribe({ maxAge: Milli(100) }); producer.writeString("old"); setSystemTime(new Date(20_000)); @@ -1032,8 +1038,8 @@ test("system clock changes do not affect the latency budget", async () => { }); test("frame readiness cancels the losing latency waiter", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 5000 }); - const track = producer.subscribe({ maxAge: 5000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); + const track = producer.subscribe({ maxAge: Milli(5000) }); const source = producer.appendGroup(); const group = await track.recvGroup(); if (!group) throw new Error("missing group"); @@ -1046,7 +1052,7 @@ test("frame readiness cancels the losing latency waiter", async () => { }); test("latency budget retains a consumed live-edge anchor", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 5000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); const track = producer.subscribe(); const edge = new GroupProducer(2); @@ -1065,7 +1071,7 @@ test("latency budget retains a consumed live-edge anchor", async () => { }); test("an aborted live-edge anchor does not make older content stale", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 5000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); const track = producer.subscribe(); const edge = new GroupProducer(2); @@ -1084,8 +1090,8 @@ test("an aborted live-edge anchor does not make older content stale", async () = test("setGroups with an excluded end of 0 is the empty range and does not ride another subscriber's demand", async () => { const producer = new TrackProducer("test"); - const everything = producer.subscribe({ maxAge: 5000 }); - const empty = producer.subscribe({ maxAge: 5000, endGroup: 0 }); + const everything = producer.subscribe({ maxAge: Milli(5000) }); + const empty = producer.subscribe({ maxAge: Milli(5000), groups: { end: { excluded: 0 } } }); empty.setGroups({ end: { excluded: 0 } }); for (let sequence = 0; sequence < 3; sequence++) producer.writeGroup(new GroupProducer(sequence)); @@ -1103,7 +1109,7 @@ test("setGroups with an excluded end of 0 is the empty range and does not ride a }); test("setGroups caps the live edge used by the latency budget", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 5000 }); + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); const track = producer.subscribe(); track.setGroups({ end: { excluded: 2 } }); @@ -1119,8 +1125,8 @@ test("setGroups caps the live edge used by the latency budget", async () => { // The frame helpers ride the group cursor, so its bounds apply to them too: a group // above the cap parks (surviving a clean close) until the cap admits it. test("setGroups caps frame-level reads like the group cursor", async () => { - const producer = new TrackProducer("test").accept({ maxAge: 5000 }); - const track = producer.subscribe({ maxAge: 5000 }).ordered(); + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); + const track = producer.subscribe({ maxAge: Milli(5000) }).ordered(); track.setGroups({ end: { excluded: 1 } }); producer.writeFrame({ payload: enc.encode("zero"), timestamp: Timestamp.fromMillis(0) }); @@ -1142,7 +1148,7 @@ test("setGroups caps frame-level reads like the group cursor", async () => { test("local cursor bounds can skip, pause, and release buffered groups", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxAge: 5000 }).ordered(); + const track = producer.subscribe({ maxAge: Milli(5000) }).ordered(); for (let sequence = 0; sequence < 5; sequence++) producer.writeGroup(new GroupProducer(sequence)); @@ -1165,7 +1171,7 @@ test("local cursor bounds can skip, pause, and release buffered groups", async ( // deliver it; a sequence cursor would skip it permanently. test("recvGroup serves a late arrival after a newer group", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxAge: 5000 }); + const track = producer.subscribe({ maxAge: Milli(5000) }); producer.writeGroup(new GroupProducer(2)); expect((await track.recvGroup())?.sequence).toBe(2); @@ -1184,7 +1190,7 @@ test("recvGroup serves a late arrival after a newer group", async () => { // held, not dropped, and a raised cap re-offers them, even after a clean close. test("setGroups parks recvGroup beyond the cap and a raised cap re-offers", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxAge: 5000 }); + const track = producer.subscribe({ maxAge: Milli(5000) }); for (let sequence = 0; sequence < 3; sequence++) producer.writeGroup(new GroupProducer(sequence)); @@ -1210,7 +1216,7 @@ test("setGroups parks recvGroup beyond the cap and a raised cap re-offers", asyn // a relay can ingest a burst micro-reordered (newest first). test("recvGroup serves in-range groups that arrive behind a capped one", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxAge: 5000 }); + const track = producer.subscribe({ maxAge: Milli(5000) }); track.setGroups({ end: { excluded: 2 } }); @@ -1315,8 +1321,8 @@ test("closing the subscriber releases a recvGroup parked while the producer is l test("the ordered and arrival cursors are independent", async () => { const producer = new TrackProducer("test"); - const ordered = producer.subscribe({ maxAge: 5000 }).ordered(); - const arrival = producer.subscribe({ maxAge: 5000 }); + const ordered = producer.subscribe({ maxAge: Milli(5000) }).ordered(); + const arrival = producer.subscribe({ maxAge: Milli(5000) }); producer.writeGroup(new GroupProducer(5)); expect((await ordered.nextGroup())?.sequence).toBe(5); @@ -1334,7 +1340,7 @@ test("the first group read commits the cursor and refuses the other order", asyn const producer = new TrackProducer("test"); // An arrival read commits to arrival order; ordered() is refused afterwards. - const arrival = producer.subscribe({ maxAge: 5000 }); + const arrival = producer.subscribe({ maxAge: Milli(5000) }); producer.writeGroup(new GroupProducer(5)); expect((await arrival.recvGroup())?.sequence).toBe(5); expect(() => arrival.ordered()).toThrow("arrival order"); @@ -1345,7 +1351,7 @@ test("the first group read commits the cursor and refuses the other order", asyn expect((await arrival.recvGroup())?.sequence).toBe(3); // The other direction: ordered() takes the subscription and arrival reads throw. - const inert = producer.subscribe({ maxAge: 5000 }); + const inert = producer.subscribe({ maxAge: Milli(5000) }); const ordered = inert.ordered(); expect(inert.recvGroup()).rejects.toThrow("sequence order"); expect(() => inert.ordered()).toThrow("sequence order"); @@ -1357,7 +1363,7 @@ test("the first group read commits the cursor and refuses the other order", asyn // the subscription to a group order. test("recvDatagram does not commit the group cursor", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxAge: 5000 }); + const track = producer.subscribe({ maxAge: Milli(5000) }); producer.insertDatagram(0, Timestamp.fromMillis(0), enc.encode("x")); expect((await track.recvDatagram())?.sequence).toBe(0); @@ -1380,7 +1386,7 @@ test("nextGroup returns undefined when track closes", async () => { // the data (mirrors the Rust subscriber). Only a drained closed track reports finished. test("a closed track still delivers a group parked above the cap once the cap is raised", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxAge: 5000 }).ordered(); + const track = producer.subscribe({ maxAge: Milli(5000) }).ordered(); for (let sequence = 0; sequence < 3; sequence++) { const group = new GroupProducer(sequence); @@ -1461,7 +1467,7 @@ test("readFrame does not livelock when a sole group finishes before the next arr // a group: a filter is applied down to the object. test("largest names the newest frame written", async () => { const producer = new TrackProducer("test").accept(); - const subscriber = producer.subscribe({ maxAge: 1_000 }); + const subscriber = producer.subscribe({ maxAge: Milli(1_000) }); // Nothing published yet. expect(subscriber.largest()).toBeUndefined(); @@ -1508,7 +1514,9 @@ test("largest survives a mirror replay of an aborted group", async () => { test("group ranges preserve progress and spell out inclusion", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxAge: 5000 }).withGroups({ start: { included: 1 }, end: { included: 1 } }); + const track = producer + .subscribe({ maxAge: Milli(5000) }) + .withGroups({ start: { included: 1 }, end: { included: 1 } }); for (let sequence = 0; sequence < 4; sequence++) producer.writeGroup(new GroupProducer(sequence)); expect(track.tryRecvGroup()?.sequence).toBe(1); expect(track.tryRecvGroup()).toBeUndefined(); @@ -1522,8 +1530,8 @@ test("group ranges preserve progress and spell out inclusion", async () => { test("serving replaceGroups can lower the floor that setGroups keeps", () => { const producer = new TrackProducer("test"); - const kept = producer.subscribe({ maxAge: 5000, startGroup: 10 }); - const lowered = producer.subscribe({ maxAge: 5000, startGroup: 10 }); + const kept = producer.subscribe({ maxAge: Milli(5000), groups: { start: { included: 10 } } }); + const lowered = producer.subscribe({ maxAge: Milli(5000), groups: { start: { included: 10 } } }); for (const sequence of [5, 6, 10]) producer.writeGroup(new GroupProducer(sequence)); kept.setGroups({ start: { included: 5 } }); @@ -1538,7 +1546,7 @@ test("serving replaceGroups can lower the floor that setGroups keeps", () => { test("malformed group bounds do not partially advance the cursor", () => { const producer = new TrackProducer("test"); - const track = producer.subscribe({ maxAge: 5000 }); + const track = producer.subscribe({ maxAge: Milli(5000) }); producer.writeGroup(new GroupProducer(0)); expect(() => track.setGroups({ start: { included: 3 }, end: { excluded: -1 } })).toThrow(); expect(track.tryRecvGroup()?.sequence).toBe(0); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index da5e0c1448..f74e833058 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -5,15 +5,18 @@ */ import { type Dispose, type GetPromise, type Getter, Once, Signal } from "@moq/signals"; import type { Datagram } from "./datagram.ts"; +import { GroupTooLarge, TooFarBehind } from "./error.ts"; +import { type Frame, type Consumer as GroupConsumer, Producer as GroupProducer } from "./group.ts"; import { - type Frame, - type Consumer as GroupConsumer, - Producer as GroupProducer, - GroupTooLarge, - Lagged, -} from "./group.ts"; -import { hooks, type Recv, type TrackRequestOptions, type TrackSequence, type TrackSequences } from "./internal.ts"; -import { Timescale, type Timestamp } from "./time.ts"; + groupBounds, + hooks, + type Recv, + type TrackRequestOptions, + type TrackSequence, + type TrackSequences, +} from "./internal.ts"; +import { Milli, Timescale, type Timestamp } from "./time.ts"; +import { type Broadcast as BroadcastWire, registerTrackConsumer } from "./wire.ts"; export type { Datagram } from "./datagram.ts"; @@ -22,7 +25,7 @@ export type { Datagram } from "./datagram.ts"; const MAX_TIMEOUT_MS = 2 ** 31 - 1; /** Default {@link Info.maxAge} window (milliseconds) when the publisher does not set one. */ -export const DEFAULT_MAX_AGE_MS = 5000; +export const DEFAULT_MAX_AGE_MS = Milli(5000); /** * How long (milliseconds) a datagram stays in the per-subscriber buffer before it is dropped. @@ -72,7 +75,7 @@ export interface Info { * 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`. */ - maxAge: number; + maxAge: Milli; /** Tie-break priority between subscriptions of equal subscriber priority (`0..=255`). */ priority: number; } @@ -83,7 +86,7 @@ export interface Info { // millisecond is expected; ceil rather than round, because a budget shortened by rounding // skips a group the subscriber still wants. Anything that is not a duration is refused // here, where the field is named, rather than deep in the encoder. -function maxAgeMillis(value: number): number { +function maxAgeMillis(value: Milli): Milli { if (!Number.isFinite(value) || value < 0) { throw new RangeError(`maxAge must be a non-negative number of milliseconds: ${value}`); } @@ -91,7 +94,7 @@ function maxAgeMillis(value: number): number { if (!Number.isSafeInteger(millis)) { throw new RangeError(`maxAge exceeds the safe integer millisecond range: ${value}`); } - return millis; + return Milli(millis); } function priorityByte(value: number): number { @@ -121,22 +124,6 @@ export interface Groups { end?: Bound; } -// Validate before changing a cursor so a malformed end cannot partly advance it. -function groupBounds(groups: Groups): { start: number; end?: number } { - const bound = (value: Bound | undefined, start: boolean): number | undefined => { - if (value === undefined) return undefined; - if ((value.included === undefined) === (value.excluded === undefined)) { - throw new Error("a group bound must be either included or excluded"); - } - const sequence = value.included ?? value.excluded; - if (sequence === undefined || !Number.isSafeInteger(sequence) || sequence < 0) { - throw new Error("a group bound must be a non-negative safe integer"); - } - return sequence + (start ? Number(value.excluded !== undefined) : Number(value.included !== undefined)); - }; - return { start: bound(groups.start, true) ?? 0, end: bound(groups.end, false) }; -} - /** * Per-subscription options, requested when a subscription opens and adjustable later via * {@link Subscriber.update}. Mirrors the Rust `Subscription`. @@ -150,7 +137,7 @@ export interface Subscription { * shortened. A negative or non-finite value, or one past `Number.MAX_SAFE_INTEGER` * after rounding, is refused. */ - maxAge?: number; + maxAge?: Milli; /** * The lowest group the publisher may deliver (a floor), or omit for none. * @@ -159,22 +146,20 @@ export interface Subscription { * above the live edge simply waits there (a resumed subscription naming where it left * off). */ - startGroup?: number; - /** - * First group the publisher should not deliver (exclusive), or omit for no end. - * `0` is the empty range. - */ - endGroup?: number; + groups?: Groups; } // Materialize the defaults at the model boundary so every layer observes a complete // subscription rather than interpreting an omitted field differently. function subscriptionDefaults(subscription: Subscription = {}): Subscription { + const bounds = groupBounds(subscription.groups ?? {}); return { priority: priorityByte(subscription.priority ?? 0), - maxAge: maxAgeMillis(subscription.maxAge ?? 0), - startGroup: subscription.startGroup, - endGroup: subscription.endGroup, + maxAge: maxAgeMillis(subscription.maxAge ?? Milli.zero), + groups: { + start: subscription.groups?.start === undefined ? undefined : { included: bounds.start }, + end: bounds.end === undefined ? undefined : { excluded: bounds.end }, + }, }; } @@ -190,27 +175,25 @@ function combineSubscriptions(states: Iterable): Subscription | unde } combined.priority = Math.max(combined.priority ?? 0, subscription.priority ?? 0); - combined.maxAge = Math.max(combined.maxAge ?? 0, subscription.maxAge ?? 0); + combined.maxAge = Milli(Math.max(combined.maxAge ?? Milli.zero, subscription.maxAge ?? Milli.zero)); // A floor only restricts, so a subscriber without one clears the aggregate: // its budget may reach below any floor the others set. - if (combined.startGroup === undefined || subscription.startGroup === undefined) { - combined.startGroup = undefined; - } else { - combined.startGroup = Math.min(combined.startGroup, subscription.startGroup); - } - - if (combined.endGroup === undefined || subscription.endGroup === undefined) { - combined.endGroup = undefined; - } else { - combined.endGroup = Math.max(combined.endGroup, subscription.endGroup); - } + const a = groupBounds(combined.groups ?? {}); + const b = groupBounds(subscription.groups ?? {}); + combined.groups = { + start: + combined.groups?.start === undefined || subscription.groups?.start === undefined + ? undefined + : { included: Math.min(a.start, b.start) }, + end: a.end === undefined || b.end === undefined ? undefined : { excluded: Math.max(a.end, b.end) }, + }; } return combined; } /** - * A request for a track the peer wants, yielded by `Broadcast.Producer.requested`. + * A request for a track the peer wants, delivered to the publishing wire layer. * * Created internally by the broadcast when a subscription (or info lookup) needs a track * served; answer it with {@link accept} or {@link reject}. @@ -267,22 +250,6 @@ export interface FetchGroupOptions { priority?: number; } -/** - * The per-track operations a lazy {@link Consumer} delegates to the broadcast it came from. - * - * Implemented by `broadcast.Producer` / `broadcast.Consumer` (and the wire-layer subclasses - * that resolve them over the network), so a track handle holds a reference to its broadcast - * and calls methods on it rather than capturing a bag of callbacks. - */ -export interface Broadcast { - /** Open a live subscription to the named track. */ - subscribe(name: string, options?: Subscription): Subscriber; - /** Resolve the named track's immutable info. */ - resolveTrackInfo(name: string): Promise; - /** Fetch a single group of the named track by sequence. */ - fetchGroup(name: string, sequence: number, options?: FetchGroupOptions): Promise; -} - /** * A lazy handle to a track on a consumed broadcast. * @@ -292,13 +259,17 @@ export class Consumer { /** The track name. */ readonly name: string; - #broadcast: Broadcast; + #broadcast: BroadcastWire; - constructor(name: string, broadcast: Broadcast) { + private constructor(name: string, broadcast: BroadcastWire) { this.name = name; this.#broadcast = broadcast; } + static { + registerTrackConsumer((name, broadcast) => new Consumer(name, broadcast)); + } + /** * Open a live subscription to the track. * @@ -597,7 +568,7 @@ export class Producer { #updateSubscription(): void { const combined = combineSubscriptions(this.#sinks); const retained = this.#state.info.peek()?.maxAge; - if (combined && retained !== undefined) combined.maxAge = Math.min(combined.maxAge ?? 0, retained); + if (combined && retained !== undefined) combined.maxAge = Milli.min(combined.maxAge ?? Milli.zero, retained); this.#state.update.set(combined); } @@ -622,7 +593,7 @@ export class Producer { // the mirrors below would otherwise report a clean finish to a reader that had // drained it. The usual case, an already-closed group aging out, keeps its own // terminal state. - if (!entry.group.isClosed) entry.group.close(new Lagged()); + if (!entry.group.isClosed) entry.group.close(new TooFarBehind()); for (const [sink, mirror] of entry.mirrors) { hooks.evictGroup(mirror); sink.groups.mutate((groups) => { @@ -982,7 +953,7 @@ export class Subscriber { // The cursor's floor is the group the subscription named, or 0. A floor is the // only thing a start contributes; {@link Subscription.maxAge} is what asks for // data, and delivery skips everything above the floor that the budget convicts. - this.#cursor.set({ start: state.update.peek()?.startGroup ?? 0 }); + this.#cursor.set({ start: groupBounds(state.update.peek()?.groups ?? {}).start }); } static { @@ -1309,7 +1280,7 @@ export class Subscriber { * so frames never run backwards: a late lower-sequence group is skipped, and so is * one every frame of which `maxAge` proves is too old. A group the budget abandons * mid-stall ends cleanly and the cursor resyncs from the next group; a gap inside a - * group still surfaces as {@link Lagged} or {@link GroupTooLarge}. + * group still surfaces as {@link TooFarBehind} or {@link GroupTooLarge}. */ async #readFrame(): Promise<({ group: number; frame: number } & Frame) | undefined> { for (;;) { @@ -1327,7 +1298,7 @@ export class Subscriber { // only what the caller can act on (a gap, or the track's own abort). this.#frameGroup = undefined; group.close(); - if (err instanceof Lagged || err instanceof GroupTooLarge) throw err; + if (err instanceof TooFarBehind || err instanceof GroupTooLarge) throw err; const closed = this.#state.closed.peek(); if (closed instanceof Error) throw closed; continue; diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts new file mode 100644 index 0000000000..aa837e666c --- /dev/null +++ b/js/net/src/wire.ts @@ -0,0 +1,99 @@ +/** + * Package-private capabilities used by the protocol implementations. + * + * Public handles register their wire view when they are constructed. Keeping the view in a + * WeakMap means applications cannot call transport-facing operations on the handles they own, + * while the protocol layers still share one implementation. + * + * @module + */ +import type { Dispose, Getter } from "@moq/signals"; +import type * as broadcast from "./broadcast.ts"; +import type { Consumer as GroupConsumer } from "./group.ts"; +import type { Route } from "./hop.ts"; +import type * as origin from "./origin.ts"; +import type * as Path from "./path.ts"; +import type * as track from "./track.ts"; + +/** The protocol-facing operations behind a broadcast handle. */ +export interface Broadcast { + subscribe(name: string, options?: track.Subscription): track.Subscriber; + resolveTrackInfo(name: string): Promise; + fetchGroup(name: string, sequence: number, options?: track.FetchGroupOptions): Promise; + requested(): Promise; +} + +/** The protocol-facing operations behind an origin producer. */ +export interface OriginProducer { + receive( + prefix: Path.Valid, + route?: Route | { hops?: Route["hops"]; cost?: Route["cost"] | bigint }, + ): origin.Dynamic; + attach(discovery: boolean): Dispose; + expect(): Dispose; + readonly requests: Getter | undefined>; + changed(): Promise; + answer(path: Path.Valid, front: broadcast.Consumer): Dispose | undefined; + routes(path: Path.Valid): boolean; +} + +/** The protocol-facing operations behind an origin consumer. */ +export interface OriginConsumer { + routes(path: Path.Valid): boolean; + readonly broadcasts: Getter | undefined>; + readonly advertised: Getter | undefined>; + demand(path: Path.Valid): Promise; +} + +/** One originated advertisement exposed to the publishing wire. */ +export interface Advertised { + readonly identity: object; + readonly route: Route; +} + +/** The protocol-facing operation behind an established session. */ +export interface Established { + consume(path: Path.Valid): broadcast.Consumer; +} + +type View = Broadcast | OriginProducer | OriginConsumer | Established; +const views = new WeakMap(); + +/** Register the package-private view for a handle. */ +export function registerWire(handle: object, view: View): void { + views.set(handle, view); +} + +/** Replace selected operations on a broadcast's package-private view. */ +export function overrideBroadcastWire( + handle: broadcast.Consumer, + overrides: Partial>, +): void { + const view = views.get(handle); + if (!view) throw new Error("broadcast has no wire view"); + views.set(handle, { ...(view as Broadcast), ...overrides }); +} + +export function wireOf(handle: broadcast.Producer | broadcast.Consumer): Broadcast; +export function wireOf(handle: origin.Producer): OriginProducer; +export function wireOf(handle: origin.Consumer): OriginConsumer; +export function wireOf(handle: import("./connection/established.ts").Established): Established; +/** Return the package-private view for a handle. */ +export function wireOf(handle: object): View { + const view = views.get(handle); + if (!view) throw new Error("handle has no wire view"); + return view; +} + +let makeTrack: ((name: string, source: Broadcast) => track.Consumer) | undefined; + +/** Install the private Track.Consumer constructor. */ +export function registerTrackConsumer(factory: (name: string, source: Broadcast) => track.Consumer): void { + makeTrack = factory; +} + +/** Create a track handle backed by a broadcast's private wire view. */ +export function trackOf(name: string, source: broadcast.Producer | broadcast.Consumer): track.Consumer { + if (!makeTrack) throw new Error("track module is not loaded"); + return makeTrack(name, wireOf(source)); +} diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index e699fc4265..cdc5937621 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -103,7 +103,7 @@ test("rendition.close() unregisters the name and drops it from the catalog", asy broadcast.close(); }); -test("serving a subscription hands the producer to the rendition and clears it when the track closes", async () => { +test("subscriber demand hands the producer to the rendition and clears it when the track closes", async () => { const broadcast = new Broadcast({ enabled: true, origin: new Origin.Producer(), name: Path.from("test.hang") }); await settle(); @@ -111,7 +111,8 @@ test("serving a subscription hands the producer to the rendition and clears it w if (!net) throw new Error("expected a network producer once connected"); const rendition = broadcast.video("video"); - const subscriber = net.subscribe("video"); + await settle(); + const subscriber = net.track("video").subscribe(); await settle(); // The request loop accepted the subscription and handed the producer to the rendition. @@ -128,7 +129,7 @@ test("serving a subscription hands the producer to the rendition and clears it w broadcast.close(); }); -test("serves the catalog through the request loop and releases the scope when the subscriber leaves", async () => { +test("serves the catalog through a shared static track", async () => { const broadcast = new Broadcast({ enabled: true, origin: new Origin.Producer(), name: Path.from("test.hang") }); broadcast.video("video").config.set(videoConfig); await settle(); @@ -136,8 +137,7 @@ test("serves the catalog through the request loop and releases the scope when th const net = broadcast.net.peek(); if (!net) throw new Error("expected a network producer once connected"); - // Subscribing to the catalog track drives the per-subscription serving scope. - const subscriber = net.subscribe(Broadcast.CATALOG_TRACK); + const subscriber = net.track(Broadcast.CATALOG_TRACK).subscribe(); const catalog = await new Json.Snapshot.Consumer({ track: subscriber }).next(); expect(catalog?.video?.renditions.video?.codec).toBe("avc1.640028"); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 4ad8695fc1..5fecc3a2fa 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -38,12 +38,11 @@ export type BroadcastInput = { * A retention budget, not a delivery one, so lowering it does not reduce latency: it only * shortens how far back a fetch can reach. */ - maxAge: Getter; + maxAge: Getter; }; /** - * A published broadcast: the network broadcast plus a catalog producer, minting per-rendition track - * handles on demand. + * A published broadcast: the network broadcast plus a catalog producer and its rendition tracks. * * Register renditions with {@link video} / {@link audio}; each returns a {@link Rendition} whose * producer (usually an encoder) fills the catalog config and encodes into the demand-gated track. @@ -75,7 +74,7 @@ export class Broadcast { readonly #renditions = new Signal>>({}); // The writable track producer signals backing each Rendition's read-only `track`, keyed by name. - // The request loop sets these on accept; teardown and unregister clear them. + // A static network track is exposed here only while at least one subscriber uses it. readonly #tracks = new Map>(); #signals = new Effect(); @@ -222,76 +221,42 @@ export class Broadcast { else broadcast.unannounce(); }); - // Close every active rendition track when the broadcast tears down (disable/rename), so an - // encoder stops encoding into a dead producer. The Rendition handles themselves stay registered. - effect.cleanup(() => { - for (const track of this.#tracks.values()) { - track.peek()?.close(); - track.set(undefined); - } - }); - // Expose it before serving so an application reacting to `net` can insert its own tracks. this.net.set(broadcast); effect.cleanup(() => { if (this.net.peek() === broadcast) this.net.set(undefined); }); - effect.spawn(this.#runBroadcast.bind(this, broadcast, effect)); - } + // Catalog tracks are shared across every subscriber and always hold the latest value. + for (const [name, compression] of [ + [Broadcast.CATALOG_TRACK, false], + [Broadcast.CATALOG_TRACK_COMPRESSED, true], + ] as const) { + const track = broadcast.createTrack(name, { priority: Catalog.PRIORITY.catalog }); + effect.cleanup(() => track.close()); + this.catalog.serve(track, effect, { compression }); + } - async #runBroadcast(broadcast: Moq.Broadcast.Producer, effect: Effect) { - for (;;) { - const request = await broadcast.requested(); - if (!request) break; - - if (request.name === Broadcast.CATALOG_TRACK || request.name === Broadcast.CATALOG_TRACK_COMPRESSED) { - const compression = request.name === Broadcast.CATALOG_TRACK_COMPRESSED; - // The catalog keeps the bare retention defaults (it is read at the live edge, which - // is always retained) but still declares its priority, so a relay forwards it ahead - // of the media it describes. Matches `hang::Catalog::default_track_info`. - const track = request.accept({ priority: Catalog.PRIORITY.catalog }); - - // Serve from a per-subscription child scope. Releasing it when this subscriber leaves keeps - // serving state from piling up on the connection-lifetime effect as viewers come and go. - const dispose = effect.run((effect) => { - effect.cleanup(() => track.close()); - this.catalog.serve(track, effect, { compression }); + // Static tracks fan out to every subscriber. Keep the encoder-facing handle demand-gated + // so capture and encoding still stop when the final subscriber leaves. + effect.run((tracks) => { + const renditions = tracks.get(this.#renditions); + const maxAge = tracks.get(this.in.maxAge); + + for (const rendition of Object.values(renditions)) { + const signal = this.#tracks.get(rendition.name); + if (!signal) continue; + + const track = broadcast.createTrack( + rendition.name, + Container.trackInfo({ maxAge, priority: Catalog.PRIORITY[rendition.kind] }), + ); + tracks.cleanup(() => track.close()); + tracks.run((demand) => { + demand.set(signal, demand.get(track.used) ? track : undefined); }); - void track.closed.then(dispose); - continue; - } - - const signal = this.#tracks.get(request.name); - if (!signal) { - console.error("received subscription for unknown track", request.name); - request.reject(new Error(`Unknown track: ${request.name}`)); - continue; } - - // Media, so declare the retention a FETCH-based consumer needs (the catalog above - // keeps the bare defaults: it is read at the live edge, which is always retained), - // plus the priority for what this rendition carries. Matches what a Rust publisher - // declares via `hang::container::track_info`; `Kind` and `PRIORITY` share their names, - // so a new kind can't be added on one side without the other noticing. - const kind = this.#renditions.peek()[request.name]?.kind; - const track = request.accept( - Container.trackInfo({ - maxAge: this.in.maxAge.peek(), - priority: kind ? Catalog.PRIORITY[kind] : Catalog.PRIORITY.video, - }), - ); - - // A second subscription for the same name supersedes the first: close the old producer. - signal.peek()?.close(); - signal.set(track); - - // Clear the signal when this track closes on its own, unless it's already been replaced. A - // plain promise callback (no child effect) so nothing lingers on the connection effect. - void track.closed.then(() => { - if (signal.peek() === track) signal.set(undefined); - }); - } + }); } close() { diff --git a/js/publish/src/catalog.ts b/js/publish/src/catalog.ts index 64b02a864c..130a7c8507 100644 --- a/js/publish/src/catalog.ts +++ b/js/publish/src/catalog.ts @@ -4,7 +4,7 @@ import type * as Moq from "@moq/net"; import type { Effect } from "@moq/signals"; /** - * A stable catalog producer that fans out to on-demand subscription tracks. + * A stable catalog producer that fans out to one or more network tracks. * * Unlike a raw track producer, this exists independently of any subscription: edit it at any time * with {@link mutate}, and each subscriber (including a relay that reconnects) is seeded with the @@ -34,7 +34,7 @@ export class CatalogProducer { } /** - * Serve a subscription request: seed it with the current catalog, then forward updates. + * Serve a track: seed it with the current catalog, then forward updates. * * Pass `opts.compression` to DEFLATE-compress this subscriber's frames, so the same catalog can be * served both plaintext and compressed (e.g. `catalog.json` and `catalog.json.z`). diff --git a/js/room/src/metadata.ts b/js/room/src/metadata.ts index d0d721d9fa..0dd054d176 100644 --- a/js/room/src/metadata.ts +++ b/js/room/src/metadata.ts @@ -13,7 +13,7 @@ import type { Root as CatalogRoot } from "@moq/hang/catalog"; import * as Json from "@moq/json"; -import type * as Moq from "@moq/net"; +import * as Moq from "@moq/net"; import type * as Publish from "@moq/publish"; import { Effect, type Getter, type Readonlys, readonlys, Signal } from "@moq/signals"; import type * as Watch from "@moq/watch"; @@ -152,7 +152,7 @@ function serveSnapshot( if (!net) return; // A day-long cache so a late joiner still replays the latest value. - const track = net.createTrack(name, { maxAge: 86_400_000, priority: PRIORITY }); + const track = net.createTrack(name, { maxAge: Moq.Time.Milli(86_400_000), priority: PRIORITY }); effect.cleanup(() => track.close()); const producer = new Json.Snapshot.Producer({ track }); diff --git a/js/room/src/room.test.ts b/js/room/src/room.test.ts index cc65abd433..e9a421e176 100644 --- a/js/room/src/room.test.ts +++ b/js/room/src/room.test.ts @@ -12,20 +12,27 @@ async function flush() { } test("room restores the announce prefix and reconciles local identity changes", async () => { - const streams: Net.Announce.Producer[] = []; + const streams: object[] = []; const connection = { origin: new Signal({ announced(scope: Net.Path.Pattern) { expect(scope.equals(Net.Path.Pattern.subtree(Net.Path.from("room-a")))).toBe(true); - const stream = new Net.Announce.Producer(); - streams.push(stream); - stream.append({ + let update: Net.Announce.Update | undefined = { path: Net.Path.from("room-a/bob/camera.hang"), captures: [Net.Path.Pattern.literal(Net.Path.from("bob/camera.hang"))], kind: "announced", route: { hops: [], cost: { warm: 0n, cold: 0n } }, - }); - return stream.consume(); + }; + const stream = { + next: async () => { + const current = update; + update = undefined; + return current; + }, + close: () => {}, + }; + streams.push(stream); + return stream as Net.Announce.Consumer; }, }), } as unknown as Net.Connection; diff --git a/js/watch/src/broadcast.test.ts b/js/watch/src/broadcast.test.ts index 4b27015f7f..4a90144068 100644 --- a/js/watch/src/broadcast.test.ts +++ b/js/watch/src/broadcast.test.ts @@ -207,10 +207,13 @@ describe("blind resolution", () => { await settle(); - // Stand in for a session's serving loop answering the request. + // Stand in for a dynamic route answering the request. const upstream = new Moq.Broadcast.Producer(); - const withdraw = owner.answer(Path.from("blind.hang"), upstream.consume()); - expect(withdraw).toBeDefined(); + const route = owner.dynamic(Path.from("blind.hang")); + const requests = route.requested(); + const next = await requests.next(); + expect(next.done).toBe(false); + next.value?.accept(upstream); await settle(); const active = source.out.active.peek(); @@ -223,6 +226,7 @@ describe("blind resolution", () => { expect(upstream.closed.peek()).toBeUndefined(); source.close(); + route.close(); owner.close(); await settle(); }); diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index 22a688994f..5d448f7e16 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -2,7 +2,7 @@ import * as Catalog from "@moq/hang/catalog"; import * as Json from "@moq/json"; import * as Msf from "@moq/msf"; import type * as Moq from "@moq/net"; -import { Announce, Path, StreamError } from "@moq/net"; +import { Announce, Error as NetError, Path } from "@moq/net"; import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; import { toHang } from "./msf"; @@ -256,7 +256,7 @@ export class Broadcast { return; } - const announced = new Announce.Broadcast({ origin: this.in.origin, path: name }); + const announced = origin.request(name, { announced: true }); effect.cleanup(() => announced.close()); effect.run((nested) => { @@ -332,7 +332,8 @@ export class Broadcast { this.#out.status.set("live"); } } catch (err) { - if (err instanceof StreamError) console.debug("catalog subscription ended", this.in.name.peek(), err); + if (err instanceof NetError.Stream) + console.debug("catalog subscription ended", this.in.name.peek(), err); else console.error("error fetching catalog", this.in.name.peek(), err); } finally { this.#raw.set(undefined); diff --git a/js/watch/src/media.test.ts b/js/watch/src/media.test.ts index 5a972c4c1d..5f0f00ef09 100644 --- a/js/watch/src/media.test.ts +++ b/js/watch/src/media.test.ts @@ -32,20 +32,20 @@ test("media max age is present on the initial subscription and later updates", a priority: 7, maxAge, }); - expect(initial).toEqual({ priority: 7, maxAge: 250 }); + expect(initial).toEqual({ priority: 7, maxAge: Time.Milli(250) }); maxAge.set(Time.Milli(500)); await flush(); - expect(updates.at(-1)).toEqual({ priority: 7, maxAge: 500 }); + expect(updates.at(-1)).toEqual({ priority: 7, maxAge: Time.Milli(500) }); effect.close(); }); for (const end of [ - new Moq.StreamError(Moq.StreamCode.Cancel), - new Moq.StreamError(Moq.StreamCode.Internal), - new Moq.StreamError(Moq.StreamCode(1234)), - new Moq.SessionError(Moq.SessionCode.Internal), + new Moq.Error.Stream(Moq.StreamCode.Cancel), + new Moq.Error.Stream(Moq.StreamCode.Internal), + new Moq.Error.Stream(Moq.StreamCode(1234)), + new Moq.Error.Session(Moq.SessionCode.Internal), new Error("decoder failed"), ]) { test(`media subscription end: ${end}`, async () => { @@ -54,7 +54,7 @@ for (const end of [ try { const pending = nextMedia(consumer); track.close(end); - if (end instanceof Moq.StreamError) expect(await pending).toBeUndefined(); + if (end instanceof Moq.Error.Stream) expect(await pending).toBeUndefined(); else await expect(pending).rejects.toBe(end); } finally { consumer.close(); diff --git a/js/watch/src/media.ts b/js/watch/src/media.ts index e7f2aa3401..baa0224bd9 100644 --- a/js/watch/src/media.ts +++ b/js/watch/src/media.ts @@ -1,6 +1,6 @@ import type { Container } from "@moq/hang"; import type * as Moq from "@moq/net"; -import { StreamError, type Time } from "@moq/net"; +import { Error as NetError, type Time } from "@moq/net"; import type { Effect, Getter } from "@moq/signals"; /** @@ -34,7 +34,7 @@ export async function nextMedia(consumer: Container.Consumer) { try { return await consumer.next(); } catch (err) { - if (!(err instanceof StreamError)) throw err; + if (!(err instanceof NetError.Stream)) throw err; // The subscription is over, even when other tracks on the session are still live. console.debug("media subscription ended", err); return undefined; diff --git a/js/watch/src/text/renderer.ts b/js/watch/src/text/renderer.ts index 44f752edf5..6669c178fd 100644 --- a/js/watch/src/text/renderer.ts +++ b/js/watch/src/text/renderer.ts @@ -1,6 +1,6 @@ import * as Catalog from "@moq/hang/catalog"; import * as Container from "@moq/hang/container"; -import { StreamError, Time } from "@moq/net"; +import { Error as NetError, Time } from "@moq/net"; import { Effect, type Getter, getter, type Inputs, type Readonlys } from "@moq/signals"; import { CaptionsRenderer, parseText, VTTCue, type VTTRegion } from "media-captions"; // media-captions positions and styles cues purely through these stylesheets (via `[part]` @@ -274,7 +274,7 @@ export class Renderer { effect.spawn(async () => { for (;;) { const group = await sub.recvGroup().catch((err) => { - if (!(err instanceof StreamError)) throw err; + if (!(err instanceof NetError.Stream)) throw err; console.debug("captions subscription ended", err); return undefined; }); @@ -290,7 +290,7 @@ export class Renderer { } } } catch (err) { - if (!(err instanceof StreamError)) throw err; + if (!(err instanceof NetError.Stream)) throw err; } finally { group.close(); } diff --git a/quest/m1/README.md b/quest/m1/README.md index 64f38665c7..f917cce56c 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -34,7 +34,6 @@ the transport line in m2 assumes a single stack. - [PathPrefixes](/quest/m1/api-path-prefixes.md) - the unused moq_net::PathPrefixes type is deleted before the release - [Route cost](/quest/m1/api-route-cost.md) - `Route::with_hop` and `Cost: From<(u64, u64)>` go; ffi and libmoq build `Hops` and `Cost::from_warm_cold` - [moq-tokio shapes](/quest/m1/api-tokio-shapes.md) - a `Drop` on `Listener`, a worker `Member` that cannot be cross-wired, `std::time::Duration` fields, one construction idiom, no six-argument merge -- [@moq/net API](/quest/m1/api-js-net.md) - one error namespace with Rust's names, one connect shape, one path-to-broadcast call, `Time.Milli` everywhere, wire-layer methods internal - [Catalog types](/quest/m1/api-hang-catalog.md) - `hang::Catalog` is the one section list, `Clock` holds a `Timestamp`, `Timeline` folds into `Archive` - [Rendition ownership](/quest/m1/api-mux-rendition.md) - one handle publishes a media track and reports its estimate, instead of five - [Watch and publish shapes](/quest/m1/api-watch-publish.md) - props objects everywhere, silent `latency`/`jitter` aliases refuse, `Sync` stops needing a jitter bridge, rooms get bandwidth diff --git a/quest/m1/api-js-net.md b/quest/m1/api-js-net.md deleted file mode 100644 index 1b78fa0456..0000000000 --- a/quest/m1/api-js-net.md +++ /dev/null @@ -1,61 +0,0 @@ -# [M] @moq/net mirrors moq-net in names, units, and one way per task - -## Goal - -`@moq/net` 0.4 is the shape a consumer learns once: an error family under -one namespace with Rust's names, one way to open a connection, one way to -turn a path into a broadcast, one duration type, and no wire-layer method -on the handles an application holds. - -## Plan - -- Errors: `Net.Error.{Session, Stream, NotFound, TooFarBehind, FrameTooLarge, - GroupTooLarge, ProtocolViolation}` beside `SessionCode`/`StreamCode`. - Today `Lagged` names `StreamCode.TooFarBehind`, `NotFound` is at the - root, three classes hang off `Group`, and `ProtocolViolation` is thrown - but not exported. -- Connection: `Connection.connect({ url, ...props })` and - `Connection.accept({ transport, url, ...props })` take the props object - the constructor takes; re-export the session type as - `Connection.Established`, which `Announce.BroadcastProps.connection` names - in public but nobody can write; `Connection.Delay` becomes - `Connection.Backoff` to match `moq_tokio::Backoff`; one suffix across - `WebTransportProps`/`WebSocketOptions`/`ConnectionProps`. -- Origin: JS `Origin.BroadcastRequest` (the handler side) becomes - `Origin.Request` as in Rust, and the consumer handle takes the name the - Rust one settles on in [origin scoping](/quest/m1/api-net-origin.md); - `dynamic()` stops accepting a bare string where `announced()` does not. -- Path to broadcast: `origin.request(path, { announced?: boolean })` is the - one call; `Announce.Broadcast`, `Established.consume`, and - `announcedBroadcast` fold into it and `Announce.Broadcast.closed` - (`Promise` where every other handle is `GetPromise`) - goes with them. -- Durations: `Track.Info.maxAge`, `Subscription.maxAge`, - `WebSocketOptions.delay`, and `DEFAULT_MAX_AGE_MS` are `Time.Milli` like - `linger`, `Delay.*`, and `rtt`. -- Bounds: `Subscription.groups?: Groups` replaces `startGroup`/`endGroup` - (exclusive) beside `setGroups({ start: { included } })`; Rust has one - `RangeBounds` shape for both. -- Private, not `@internal`: `Broadcast.Producer/Consumer.{subscribe, - resolveTrackInfo, fetchGroup, requested}`, `Track.Broadcast`, and the - eight `@internal` members on `origin.ts` move behind a friend module - (`wire.ts` exporting `wireOf(handle)` over a `WeakMap` the constructors - register into) that `index.ts` never re-exports; the package `exports` - map already lists only `.` and `./zod`, so consumers cannot reach it at - type level or at runtime. `@internal` only strips the declaration and - leaves the method callable. `Announce.Producer`, `Bandwidth.Want`/ - `allocate`, and `Track.Consumer`'s constructor go the same way. -- Delete `NO_DISCOVERY_HOSTS` in `connection/connect.ts`; Cloudflare's - draft-16 relay supports announcements now, so the list is stale and - `discovery` defaults to true everywhere. -- Bugs in the same files: a second handle's `linger` is silently ignored on - a shared connection (refuse or take the max); a partial private `delay` - inherits the 10 s default timeout where none means retry forever. - -Public API: breaking on @moq/net, so on dev. Wire: none. Consumers: -@moq/hang, @moq/watch, @moq/publish, @moq/room, `demo/web`, moq.pro's app. - -## Related - -- [@moq/net additive](/quest/m2/js-net-additive.md) - the iterators and getters that follow on main -- [Broadcast route](/quest/m2/js-broadcast-route.md) - what `announced: true` matches diff --git a/quest/m1/api-review-gate.md b/quest/m1/api-review-gate.md index 4dc823eb6f..27cac419e9 100644 --- a/quest/m1/api-review-gate.md +++ b/quest/m1/api-review-gate.md @@ -20,7 +20,6 @@ The list: [Announce event](/quest/m1/api-net-announce.md), [Origin scoping](/quest/m1/api-net-origin.md), [Route cost](/quest/m1/api-route-cost.md), [moq-tokio shapes](/quest/m1/api-tokio-shapes.md), -[@moq/net API](/quest/m1/api-js-net.md), [Catalog types](/quest/m1/api-hang-catalog.md), [Rendition ownership](/quest/m1/api-mux-rendition.md), [Watch and publish shapes](/quest/m1/api-watch-publish.md), diff --git a/quest/m2/js-net-additive.md b/quest/m2/js-net-additive.md index 3278758524..ba226ccbd9 100644 --- a/quest/m2/js-net-additive.md +++ b/quest/m2/js-net-additive.md @@ -26,5 +26,4 @@ Public API: additive on @moq/net. Wire: none. ## Required -- [@moq/net API](/quest/m1/api-js-net.md) - the shapes these extend - [Merge dev](/quest/m1/merge-dev.md) - starts on main