Skip to content
5 changes: 3 additions & 2 deletions bench/browser/page/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ async function publish(config: Transport): Promise<void> {
// Fixture generation stays outside every timed interval.
const fixtures = Array.from({ length: config.frames }, (_, frame) => payload(config.size, frame));
const origin = new Moq.Origin.Producer();
const producer = origin.createBroadcast(Moq.Path.from(config.path));
const publication = origin.createBroadcast(Moq.Path.from(config.path));
const producer = publication.broadcast;
// Retention covers the whole run, so a slow consumer cannot lose a group to eviction.
const track = producer.createTrack("data", { timescale: Moq.Time.Timescale.MILLI, maxAge: Milli(600_000) });
const demand = track.demand().wanted;
producer.announce();
publication.announce();
const connection = await Moq.Connection.connect({
url: new URL(config.url),
publish: origin.consume(),
Expand Down
10 changes: 6 additions & 4 deletions demo/carla/web/src/truck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,8 @@ export class Truck {
const driverId = crypto.randomUUID().replaceAll("-", "").slice(0, 12);
this.driverId.set(driverId);

const driverBroadcast = conn.createBroadcast(driverPath(this.name, driverId));
const driverBroadcastPublication = conn.createBroadcast(driverPath(this.name, driverId));
const driverBroadcast = driverBroadcastPublication.broadcast;
effect.cleanup(() => {
driverBroadcast.close();
this.driverId.set(undefined);
Expand All @@ -429,7 +430,7 @@ export class Truck {
effect.cleanup(() => producer.finish());
effect.run(this.#runControlTrack.bind(this, track, producer));
// A fresh epoch per lease: the truck binds its single driver to this path and epoch.
driverBroadcast.announce({ epoch: Moq.Epoch.mint() });
driverBroadcastPublication.announce({ epoch: Moq.Epoch.mint() });
}

#runSafetyControl(connection: Moq.Connection, effect: Moq.Signals.Effect) {
Expand All @@ -438,9 +439,10 @@ export class Truck {
if (!conn || !command) return;

const driverId = `safety-${crypto.randomUUID().replaceAll("-", "").slice(0, 12)}`;
const broadcast = conn.createBroadcast(driverPath(this.name, driverId));
const broadcastPublication = conn.createBroadcast(driverPath(this.name, driverId));
const broadcast = broadcastPublication.broadcast;
const safety = new SafetyCommandBroadcast(command, broadcast);
broadcast.announce({ epoch: Moq.Epoch.mint() });
broadcastPublication.announce({ epoch: Moq.Epoch.mint() });
effect.cleanup(() => safety.close());
}

Expand Down
11 changes: 6 additions & 5 deletions doc/lib/js/net.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,17 @@ const connection = await Moq.Connection.connect({
consume: origin,
});

const broadcast = origin.createBroadcast(Moq.Path.from("chat.room"));
const publication = origin.createBroadcast(Moq.Path.from("chat.room"));
const broadcast = publication.broadcast;
const track = broadcast.createTrack("messages", { timescale: Moq.Time.Timescale.MILLI });
const group = track.appendGroup();
group.writeFrame({ payload: new TextEncoder().encode("hello"), timestamp: Moq.Time.Timestamp.now() });
group.close();
broadcast.announce();
publication.announce();
```

- **The origin holds the broadcasts, not the connection.** Closing a session unannounces them and leaves them created for the next one. A broadcast is invisible, locally and remotely, until `announce()`.
- **Answer claimed paths with `using`.** `dynamic(prefix)` claims a prefix and yields each requested path. Use `using prepared = request.prepare(epoch)`, fill its broadcast, then `prepared.publish()` to answer with a new publisher instance. Leaving the scope without publishing, including on a throw, refuses the request as unroutable. Explicit `prepared.close()` or closing its unpublished broadcast does the same; disposal after publishing leaves the returned producer live. Reject before preparing with `request.reject(err)`. A retained, unclosed prepared answer keeps the request pending; JavaScript garbage collection provides no prompt cleanup guarantee.
- **The origin holds the broadcasts, not the connection.** Closing a session unannounces them and leaves them created for the next one. A publication controls announcements; its `broadcast` fills tracks. The broadcast is invisible, locally and remotely, until the publication calls `announce()`.
- **Answer claimed paths with `using`.** `dynamic(prefix)` claims a prefix and yields each requested path. Use `using prepared = request.prepare(epoch)`, fill its broadcast, then `prepared.publish()` to answer with a new publisher instance and keep the `Publication` it returns. Leaving the scope without publishing, including on a throw, refuses the request as unroutable. Explicit `prepared.close()` or closing its unpublished broadcast does the same; disposal after publishing leaves the returned publication and its fill broadcast live. Reject before preparing with `request.reject(err)`. A retained, unclosed prepared answer keeps the request pending; JavaScript garbage collection provides no prompt cleanup guarantee.
- **End content by closing tracks.** A broadcast has no finished state. When the input ends, close each track cleanly; the broadcast stays announced and keeps serving them until you `close()` it. How long to keep it up for late readers is yours to decide.
- **Requests can pin an epoch.** `consumer.request(path, { epoch })` resolves only through a route announcing that [publisher epoch](/concept/moq-lite#publisher-epochs). The resolved broadcast's `epoch` names the route it came through, pinned or not.
- **Follow restarts.** `origin.request(path, { announced: true })` follows whichever publisher instance serves the path: its `active` changes to a fresh broadcast on a restart, clears on an end, and resolves again on the next start, so read it reactively and close the request when done. Instances are never stitched, so subscribe again to each new broadcast. Another route of the same epoch taking over (epochs travel only on `moq-lite-07-wip`) keeps `active` as is, and each track subscription resumes there from the first group it lacks. A refusal ends the request. Add `epoch` to pin one instance; a plain `request(path)` keeps its accepted instance, can resolve blindly, and ends once that instance stops serving. To list what is live, iterate `announced(scope)`.
Expand All @@ -47,7 +48,7 @@ broadcast.announce();
- **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Omitting it means untimed, as in Rust. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers.
- **Subscriber staleness is `maxDelay`.** It counts from a group's successor, by media time and by wall clock, whichever reaches it first. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`.
- **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts).
- **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription or track listing the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Auth` it returns (`broadcast.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. A `Connection`'s `auth` handle (a getter on a pooled connection, following its URL) outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`.
- **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription or track listing the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Auth` it returns (`publication.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. A `Connection`'s `auth` handle (a getter on a pooled connection, following its URL) outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`.
- **A graceful close waits.** `await connection.close()` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions wait only for transport ACKs. IETF media streams are not drained. `abort()` ends immediately.

Examples:
Expand Down
6 changes: 3 additions & 3 deletions js/e2ee/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ import { Time } from "@moq/net";

const credential = new Credential({ context, kid, secret });
const generation = credential.produce();
const broadcast = origin.createBroadcast(await credential.path("meeting.hang"));
const video = await new Broadcast.Producer(broadcast, generation).createTrack("video", {
const publication = origin.createBroadcast(await credential.path("meeting.hang"));
const video = await new Broadcast.Producer(publication.broadcast, generation).createTrack("video", {
timescale: Time.Timescale.MILLI,
});
broadcast.announce({ epoch: generation.epoch });
publication.announce({ epoch: generation.epoch });

const group = video.appendGroup();
await group.writeFrame(Time.Timestamp.now(), frame);
Expand Down
6 changes: 3 additions & 3 deletions js/e2ee/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
* ```ts
* const credential = new Credential({ context, kid, secret });
* const generation = credential.produce();
* const broadcast = origin.createBroadcast(await credential.path("meeting.hang"));
* const video = await new Broadcast.Producer(broadcast, generation).createTrack("video", { timescale });
* broadcast.announce({ epoch: generation.epoch });
* const publication = origin.createBroadcast(await credential.path("meeting.hang"));
* const video = await new Broadcast.Producer(publication.broadcast, generation).createTrack("video", { timescale });
* publication.announce({ epoch: generation.epoch });
*
* const group = video.appendGroup();
* await group.writeFrame(timestamp, frame);
Expand Down
10 changes: 6 additions & 4 deletions js/e2ee/src/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,12 @@ for (const [name, protocol, routed, timed] of transports) {
const cred = credential();
const generation = cred.produce();
const path = await cred.path("meeting.hang");
const broadcast = publisher.createBroadcast(path);
const broadcastPublication = publisher.createBroadcast(path);
const broadcast = broadcastPublication.broadcast;
const video = await new Protected.Producer(broadcast, generation).createTrack("video", {
timescale: Time.Timescale.MILLI,
});
broadcast.announce({ epoch: generation.epoch });
broadcastPublication.announce({ epoch: generation.epoch });

const group = video.appendGroup();
await group.writeFrame(ms(1), encoder.encode("keyframe"));
Expand Down Expand Up @@ -117,11 +118,12 @@ for (const [name, protocol] of [
const cred = credential();
const generation = cred.produce();
const path = await cred.path("meeting.hang");
const broadcast = publisher.createBroadcast(path);
const broadcastPublication = publisher.createBroadcast(path);
const broadcast = broadcastPublication.broadcast;
const audio = await new Protected.Producer(broadcast, generation).createTrack("audio", {
timescale: Time.Timescale.MILLI,
});
broadcast.announce({ epoch: generation.epoch });
broadcastPublication.announce({ epoch: generation.epoch });

// A group first, so the subscription is serving before datagrams, which are never cached.
const group = audio.appendGroup();
Expand Down
5 changes: 3 additions & 2 deletions js/hang/src/catalog/consumer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,11 @@ function referencing(broadcast: string): Root {
// Watch the catalog of `room/alice`, published through an origin and requested from it.
async function watchRequested(root: Root) {
const origin = new Moq.Origin.Producer();
const broadcast = origin.createBroadcast(Moq.Path.from("room/alice"));
const broadcastPublication = origin.createBroadcast(Moq.Path.from("room/alice"));
const broadcast = broadcastPublication.broadcast;
const track = broadcast.createTrack(TRACK, { timescale: Moq.Time.Timescale.MILLI });
const producer = new Json.Snapshot.Producer<Root>({ track, deltas: false });
broadcast.announce();
broadcastPublication.announce();
const request = origin.request(Moq.Path.from("room/alice"));
const active = request.active.peek();
if (!active) throw new Error("request did not resolve");
Expand Down
5 changes: 3 additions & 2 deletions js/moq-boy/src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,10 +317,11 @@ export class Game {
const viewerId = Math.random().toString(36).slice(2, 8);
this.viewerId.set(viewerId);

const viewerBroadcast = origin.createBroadcast(
const viewerBroadcastPublication = origin.createBroadcast(
Moq.Path.from(`${this.#viewerPrefix}/${this.sessionId}/${viewerId}`),
);
viewerBroadcast.announce({ epoch: Moq.Epoch.mint() });
const viewerBroadcast = viewerBroadcastPublication.broadcast;
viewerBroadcastPublication.announce({ epoch: Moq.Epoch.mint() });
effect.cleanup(() => {
viewerBroadcast.close();
this.viewerId.set(undefined);
Expand Down
22 changes: 18 additions & 4 deletions js/net/MIGRATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ How to update your code for each breaking change, newest first.

## Unreleased

### Publication handles own announcement control

`Origin.Producer.createBroadcast(path)` and `Origin.Prepared.publish()` return an `Origin.Publication`.
Keep that handle for `announce(route)`, `unannounce()`, and `route`; fill tracks through its readonly `broadcast` producer.
`Broadcast.Producer` no longer exposes announcement methods or route metadata, including standalone producers and `prepared.broadcast` before admission.
Closing `publication.broadcast` still retracts and ends the broadcast. Keeping only that fill handle leaves the broadcast and its announcement live; releasing a publication reference does not retract it.

```ts
const publication = origin.createBroadcast(path);
const broadcast = publication.broadcast;
broadcast.createTrack("data");
publication.announce({ epoch: Epoch.mint() });
```

### `Varint.sizeLeadingOnes` is gone

Take the length of `Varint.encodeLeadingOnes(v)`.
Expand Down Expand Up @@ -51,9 +65,9 @@ Treat a rejected group read as the loss of that group and keep reading the track
### A claim answers by publishing (#5678)

`Origin.Producer.dynamic(prefix)` takes no route: an application claim has no epoch, cost, or hops.
Answer each `Origin.Request` with `using prepared = request.prepare(epoch)` (a fresh `Epoch.mint()` per output run), fill `prepared.broadcast`, then `prepared.publish()`, which announces the broadcast at the exact path under that epoch and returns its producer.
Only `publish` announces it: `prepared.broadcast.announce()` throws.
`Prepared.reject(err)` is replaced by idempotent `close()` and `[Symbol.dispose]()`; either refuses the unpublished answer as `Unroutable` and closes its broadcast. Closing that broadcast also refuses the request. `using` handles normal scope exit and exceptions; after `publish()`, disposal leaves the returned producer live.
Answer each `Origin.Request` with `using prepared = request.prepare(epoch)` (a fresh `Epoch.mint()` per output run), fill `prepared.broadcast`, then `prepared.publish()`, which announces the broadcast at the exact path under that epoch and returns its publication handle.
Only `publish` announces it: `prepared.broadcast` has no announcement methods.
`Prepared.reject(err)` is replaced by idempotent `close()` and `[Symbol.dispose]()`; either refuses the unpublished answer as `Unroutable` and closes its broadcast. Closing that broadcast also refuses the request. `using` handles normal scope exit and exceptions; after `publish()`, disposal leaves the returned publication and its fill producer live.
Use `request.reject(err)` to refuse before preparing. A retained, unclosed `Prepared` keeps its request pending; garbage collection provides no prompt cleanup guarantee.
Construct a `Prepared` only with `request.prepare()`. Its constructor and the `Request.publisher`, `publishPrepared`, and `rejectPrepared` completion hooks are no longer public.
`Dynamic.update` is no longer part of the public API, and `Request` has no `accept` (#6496): only the built-in sessions answer with a broadcast they already hold.
Expand Down Expand Up @@ -216,7 +230,7 @@ Call `close()` with no argument.

### Origins hold broadcasts (#2705, #3225)

`connection.publish(path, broadcast)` is `origin.createBroadcast(path)` then `broadcast.announce()`.
`connection.publish(path, broadcast)` is `origin.createBroadcast(path)` then `publication.announce()` on the returned handle.
`connection.announced(prefix)` is `origin.announced(scope)`, yielding `{ prefix, kind, route }`.
`ignoreSelf` is gone: reflected announces are always dropped.

Expand Down
28 changes: 17 additions & 11 deletions js/net/api/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@ declare namespace Origin_2 {
Consumer_5 as Consumer,
Dynamic,
Request_3 as Request,
Publication,
Prepared
}
}
Expand All @@ -543,6 +544,7 @@ declare namespace Origin_3 {
Consumer_5 as Consumer,
Dynamic,
Request_3 as Request,
Publication,
Prepared
}
}
Expand Down Expand Up @@ -612,7 +614,7 @@ class Prepared {
[Symbol.dispose](): void;
readonly broadcast: Broadcast_2.Producer;
close(): void;
publish(): Broadcast_2.Producer;
publish(): Publication;
}

interface Probe {
Expand Down Expand Up @@ -653,20 +655,12 @@ class Producer_2 {

class Producer_3 {
constructor();
announce(route?: Route | {
epoch?: Route["epoch"];
hops?: Route["hops"];
cost?: Route["cost"];
auth?: Route["auth"];
}): void;
close(): void;
get closed(): GetPromise<null>;
consume(): Consumer_4;
createTrack(name: string, info?: Partial<track.Info>): track.Producer;
removeTrack(name: string): void;
get route(): Route | undefined;
track(name: string): track.Consumer;
unannounce(): void;
}

class Producer_4 {
Expand All @@ -682,7 +676,7 @@ class Producer_5 implements Table {
close(abort?: Error): void;
get closed(): GetPromise<Error | null>;
consume(): Consumer_5;
createBroadcast(path: Path_2.Valid): Broadcast_2.Producer;
createBroadcast(path: Path_2.Valid): Publication;
get discovery(): Getter<boolean | undefined>;
dynamic(prefix: Path_2.Valid): Dynamic;
request(path: Path_2.Valid, options?: RequestOptions): Requesting;
Expand All @@ -697,6 +691,18 @@ class ProtocolViolation extends Error {
readonly code: SessionCode;
}

class Publication {
announce(route?: Route | {
epoch?: Route["epoch"];
hops?: Route["hops"];
cost?: Route["cost"];
auth?: Route["auth"];
}): void;
readonly broadcast: Broadcast_2.Producer;
get route(): Route | undefined;
unannounce(): void;
}

interface ReadOptions {
from?: number;
}
Expand Down Expand Up @@ -899,7 +905,7 @@ interface Subscription {
interface Table {
announced(scope?: Path_2.Pattern, options?: Announce_3.Options): Announce_3.Consumer;
readonly closed: GetPromise<Error | null>;
createBroadcast(path: Path_2.Valid): Broadcast_2.Producer;
createBroadcast(path: Path_2.Valid): Publication;
readonly discovery: Getter<boolean | undefined>;
dynamic(prefix: Path_2.Valid): Dynamic;
request(path: Path_2.Valid, options?: RequestOptions): Requesting;
Expand Down
Loading
Loading