Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bench/browser/page/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ async function watch(config: Media): Promise<{ first_frame_ms: number }> {
}, 250);
const first = new Promise<number>((resolve) => {
let last: number | undefined;
const stop = player?.renderer.out.frame.subscribe((frame) => {
const stop = player?.renderer.painted.subscribe((frame) => {
// A resize or catalog change repaints the same frame; count each frame once.
if (!frame || frame.timestamp === last) return;
last = frame.timestamp;
Expand Down
2 changes: 1 addition & 1 deletion bench/browser/page/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/
import { Milli } from "@moq/clock";
import * as Moq from "@moq/net";
import Session from "@moq/qmux";
import { Session } from "@moq/qmux";
import { check, payload } from "../fixture.ts";
import "./observe.ts";

Expand Down
8 changes: 4 additions & 4 deletions demo/carla/web/src/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1056,11 +1056,11 @@ export function mount(root: ShadowRoot, connection: Moq.Connection, origin: Moq.
const visible =
effect.get(focused) === this.truck.id ||
(!effect.get(focused) && effect.get(hovered) === this.truck.id);
const frame = effect.get(this.truck.videoDecoder.out.frame);
const decoded = effect.get(this.truck.videoDecoder.out.timestamp) !== undefined;
const stalled = effect.get(this.truck.videoDecoder.out.stalled);
const live = visible && Boolean(frame) && !stalled;
q(".badge").textContent = live ? "Live" : frame ? "Interrupted" : "Connecting";
q(".video-state").textContent = frame ? "Camera interrupted" : "Waiting for camera";
const live = visible && decoded && !stalled;
q(".badge").textContent = live ? "Live" : decoded ? "Interrupted" : "Connecting";
q(".video-state").textContent = decoded ? "Camera interrupted" : "Waiting for camera";
q(".badge").dataset.live = String(live);
q(".video-state").hidden = live;
});
Expand Down
4 changes: 2 additions & 2 deletions doc/bin/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,8 +383,8 @@ The RTMP, SRT, and WHIP ingests mint one per connection instead, `import
rtsp` one per RTSP session, and `import ts --program all` one per program.
No importer continues its group sequences across a restart, so `moq` has no
flag to set a stable instance and refuses one. A publisher that does continue
them sets it on its route in a library, such as `Route::with_instance` in Rust
or `announce({ instance, epoch })` in JavaScript; see
them sets it on its route in a library, such as `RouteOptions::with_instance` in Rust
or `origin.route(path, { instance: { id, epoch } })` in JavaScript; see
[identity and recovery](/concept/identity).

A restarted process takes the name at once: viewers see the broadcast restart,
Expand Down
4 changes: 2 additions & 2 deletions doc/concept/identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ no credential, and auth decides who may publish the path.
keeps its instance on every route, which is what lets a subscription move
between them.
- **A stable instance is a promise.** A publisher may name its own instance
(`Route::with_instance(instance, epoch)` in Rust, `announce({ instance, epoch })`
in JavaScript) only if it continues the sequence space across restarts and
(`RouteOptions::with_instance(instance, epoch)` in Rust,
`origin.route(path, { instance: { id, epoch } })` in JavaScript) only if it continues the sequence space across restarts and
announces a higher **epoch** each time, for example a counter persisted on
disk. Breaking the promise splices unrelated groups into every reader's
tracks. Two encoders with identical settings fed the same source still
Expand Down
4 changes: 2 additions & 2 deletions doc/lib/js/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ root. Tokens are interchangeable with the Rust side. Grants and claims are
[`Pattern`](https://www.npmjs.com/package/@moq/pattern) unions: `foo` is one
broadcast, `foo/**` is a subtree, `**` is everything. A claim or grant role may
also carry a bitrate cap, parsed into `publish_rate` or `subscribe_rate` beside
the pattern arrays; key scopes bound paths only, and `authorize` checks paths
only. Answer a capped grant with `encodeGrant`, which writes the cap where the
the pattern arrays, which is also how `Key.sign` takes it; key scopes bound
paths only, and `authorize` checks paths only. Answer a capped grant with `encodeGrant`, which writes the cap where the
relay reads it.

```bash
Expand Down
2 changes: 1 addition & 1 deletion doc/lib/js/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ small examples. The [API reference](/lib/js/api/) covers every package; source l
| [@moq/watch](/lib/js/watch) | Subscribe, decode, and render. `<moq-watch>` plus an optional UI overlay. |
| [@moq/video](/lib/js/video) | Draw video frames into a canvas through WebGPU or Canvas2D. Used by `@moq/watch` and `@moq/publish`. |
| [@moq/publish](/lib/js/publish) | Capture, encode, and publish. `<moq-publish>` plus an optional UI overlay. |
| [@moq/ui](/lib/js/api/ui) | Presentation components shared by the watch and publish overlays: control buttons, fullscreen, stats cards and graphs, and the support banner. |
| [@moq/ui](/lib/js/api/ui) | The base of the watch and publish browser support banners. |
| [@moq/room](/lib/js/room) | Headless rooms: announce-derived roster, local publish, remote watch, and a chat track. |
| [@moq/auth](/lib/js/auth) | Validate a relay's request, build its grant, and mint and verify relay JWTs. |
| [@moq/clock](/lib/js/clock) | Logical monotonic time, wall time, and deterministic timers. |
Expand Down
4 changes: 2 additions & 2 deletions doc/lib/js/net.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ route.announce();
- **The origin holds the routes, not the connection.** `origin.route(path)` creates a route that owns a new broadcast; fill its tracks through `route.broadcast`. Closing a session withdraws its announcements and leaves the routes for the next one. A route is visible at once unless created `{ hidden: true }`, which keeps it invisible, locally and remotely, until `announce()`. `withdraw()` hides it again and `announce()` brings the same stream back. `close()` retracts it and closes its broadcast, as closing the broadcast does.
- **Answer claimed paths with a route.** `origin.routePrefix(prefix)` claims a prefix, and `route.requested()` yields each requested path. Answer with `request.route()`, a new exact route under the claim's instance (a fresh one if the claim names its own), so on `moq-lite-07-wip` readers resolved through the claim continue (older wires mint another instance for it, restarting their subscription), or `request.route({ hidden: true })` to fill its broadcast before `announce()` answers the requesters; closing it or its broadcast before then refuses them as unroutable, so `using job = request.route({ hidden: true })` refuses on a throw. Serve a broadcast you already hold with `request.serve(broadcast)`, or refuse with `request.reject(err)`.
- **End content by finishing tracks.** A broadcast has no finished state. When the input ends, `finish()` each track, then `close()` it; closing a track without finishing aborts it. The broadcast stays announced and keeps serving them until you `close()` it. How long to keep it up for late readers is yours to decide.
- **Requests can pin an instance.** `consumer.request(path, { instance })` resolves only through a route announcing that [publisher instance](/concept/moq-lite#publisher-instances). The resolved broadcast's `instance` names the stream it came through, pinned or not. `origin.route(path)` mints a random instance when it is created and keeps it across `withdraw()` and `announce()`. Unless one is named, a claim moves to a fresh instance each time a job holding its current one ends, and a job takes the claim's. Pass `origin.route(path, { instance, epoch })` only for a publisher that continues its group sequences across restarts and raises the epoch each time.
- **Requests can pin an instance.** `consumer.request(path, { instance })` resolves only through a route announcing that [publisher instance](/concept/moq-lite#publisher-instances). The resolved broadcast's `instance` names the stream it came through, pinned or not. `origin.route(path)` mints a random instance when it is created and keeps it across `withdraw()` and `announce()`. Unless one is named, a claim moves to a fresh instance each time a job holding its current one ends, and a job takes the claim's. Pass `origin.route(path, { instance: { id, epoch } })` only for a publisher that continues its group sequences across restarts and raises the epoch each time.
- **Follow restarts.** `origin.follow(path)` follows whichever publisher instance serves the path: its `active` changes to a fresh broadcast on a restart, clears on an end, and resolves again on the next start, so read it reactively and close it when done. Instances are never stitched, so subscribe again to each new broadcast. Another route of the same instance taking over (instances travel only on `moq-lite-07-wip`; older wires mint one per announcement) keeps `active` as is, and each track subscription resumes there from the first group it lacks. A refusal ends it. Pass `{ instance }` to pin one stream, which it waits for again after an end. `request(path)` instead keeps its accepted instance and ends once that instance stops serving; while pending, it fails `Unroutable` once discovery of its path has synced without a route for it. To list what is live, iterate `announced(scope)`. See [identity and recovery](/concept/identity) to choose between following and holding one instance.
- **Discovery follows what you watch.** A `consume` origin asks the relay for announcements only under the prefixes its readers watch with `announced(scope)`, `request(path)`, or `follow(path)`, stops asking about a prefix 2s after nothing at or beneath it is watched, and asks again on reconnect. Every request and follower watches its path from creation until closed, so it outlives the listing that found it. A relay that refuses an announce request downgrades that session: requests subscribe blind through it, and followers keep waiting for a session that can discover.
- **Connections share by URL and versions.** `new Connection({ url })` pools and reconnects with backoff, which the elements use. An ordered `versions` list, spelled like `moq-lite-06` as in Rust, applies to both WebTransport and WebSocket; equivalent lists share, while a different order or an omitted list gets its own pool entry. Supplying your own transport options or origin selects a private loop. Receive estimates are on demand: observe `connection.probe` reactively or subscribe while you need them; `peek()` alone opens no PROBE stream.
Expand All @@ -53,7 +53,7 @@ route.announce();
- **Serve history on request.** `track.requests()` opens a handle that serves the groups readers miss: ones their ranges name below the newest group written that the cache no longer holds, or never did. Each run of misses arrives as a `Track.Fill` from `await requests.requestedFill()`, shared by every reader missing it. `accept(sequence)` it into the cache and write the group's frames as first published, `dropGroups(start, end)` what the source lacks (sequences are `bigint`), and serve `wanted()` until it is `undefined`: a reader that leaves or narrows stops wanting its groups. `close()` the fill when done; whatever is still wanted is named dropped. A `fetchGroup` of an uncached group asks the handle at once, even for a group not written yet, which a live write then answers; with no handle open it fails with `NotFound`, as in Rust, and a reader's miss is given up. A reader only asks for what its `delay.max` still wants, so a reader at the live edge never asks. This serves lite FETCH, single-group IETF FETCH, and lite-07 ranged subscriptions from the same track, as Rust's `requests()` does.
- **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Omitting it means untimed, as in Rust. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` producers.
- **A subscription's `delay` is a `Track.Delay { min, max }`, as in Rust.** Staleness is `delay.max`: it counts from a group's successor, by media time and by wall clock, whichever reaches it first. `delay.min` is the target latency, carried on moq-lite 07; a peer that cannot carry it leaves it `null` (not stated). Passing the old `maxAge` or `maxDelay` key throws a `TypeError` naming `delay.max`. The publisher's own staleness bound is the separate `Track.Info.maxAge`, which caps `delay.max` and fetches.
- **Authored tracks default to a zero `maxAge`.** `createTrack` and `accept` take a `Track.InfoInit`: an omitted `maxAge` is zero, so readers get only the live edge; `null` sets no publisher limit, and a number is that bound, as `Info::default()` and `with_max_age(None)` do in Rust. A track whose readers reach back for superseded groups (history, a fetch, a range from an absolute start) sets one explicitly; media takes `@moq/hang`'s 30-second `Container.MAX_AGE`. `maxAge` is how long a superseded group stays fresh, never the newest group, so a track that writes one snapshot stays readable to a late joiner. Received metadata keeps absence as no limit; serve a received `Track.Info` onward with `maxAge: info.maxAge ?? null`.
- **Authored tracks default to a zero `maxAge`.** `createTrack` and `accept` take a `Track.InfoInit`: an omitted `maxAge` is zero, so readers get only the live edge; `null` sets no publisher limit, and a number is that bound, as `Info::default()` and `with_max_age(None)` do in Rust. A track whose readers reach back for superseded groups (history, a fetch, a range from an absolute start) sets one explicitly; media takes `@moq/hang`'s 30-second `Container.MAX_AGE`. `maxAge` is how long a superseded group stays fresh, never the newest group, so a track that writes one snapshot stays readable to a late joiner. A received `Track.Info` uses `null` for none (`maxAge`, `timescale`, `refresh`), so it serves onward unchanged.
- **The cache keeps an idle group for 30 seconds.** A track evicts a group nothing has written for 30 seconds, matching the Rust cache's default expiry; a live track's newest group is exempt until a newer one arrives, unless it failed. Neither `Track.Info.maxAge` nor `delay.max` changes that window: they only decide which cached groups a reader still receives.
- **Subscription defaults.** `Track.Subscription.default()` returns the default subscription, whose zero `delay.max` skips a superseded group at once; calling `subscribe()` with no options means the same. An omitted `delay` field means the same zero; `max: null` sets no subscriber limit.
- **A subscription asks for `ranges`.** Each [range](/concept/moq-lite#subscriptions) starts and ends at an `absolute` group, one `relative` to the latest, or the group containing a `time` (a `Timestamp`), and a reader reads only its own ranges. A read whose ranges all end finishes once every group in them is handed out, dropped, or skipped, as in Rust. A time anchor needs a timed track and `moq-lite-07-wip` over a session. Passing the old `groups` key throws a `TypeError` naming `ranges`; `withRange` only narrows reads locally.
Expand Down
6 changes: 3 additions & 3 deletions doc/lib/js/publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ encoder's `muted` instead: it sends nothing, keeps the rendition enabled, and
marks where each utterance ends, so listeners stay subscribed and treat the gap
as intentional silence.

Pass `onDemand: false` to `Publish.Broadcast` to encode audio continuously too.
`onDemand: true` also gates video, which then loses its unsubscribed past.
Pass `onDemand: "off"` to `Publish.Broadcast` to encode audio continuously too.
`onDemand: "all"` also gates video, which then loses its unsubscribed past.
Bandwidth reservations still follow subscriptions; unwatched video uses its
configured bitrate.

Expand Down Expand Up @@ -188,7 +188,7 @@ const broadcast = new Publish.Broadcast({
});

// Video encodes while enabled. Audio buffers raw PCM until someone subscribes.
// Pass `onDemand: false` to the broadcast to encode both continuously.
// Pass `onDemand: "off"` to the broadcast to encode both continuously.
new Publish.Video.Encoder("video/hd", { broadcast, capture, enabled: true });
new Publish.Video.Encoder("video/sd", { broadcast, capture, enabled: true, config: { maxScale: 0.25 } });
const audioSource = new Publish.Signals.Computed((effect) => effect.get(microphone.out.source)?.audio);
Expand Down
5 changes: 3 additions & 2 deletions doc/lib/js/room.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ demo at [`demo/web`](https://github.com/moq-dev/moq/tree/main/demo/web) (`meet.h
does that under `anon/meet/{room}`.

Each remote member owns a `Watch.Player` and starts muted. Display video with
`member.canvas.set(canvas)` and unmute with `member.muted.set(false)`, or use
`member.player` for the full pipeline. `Chat` adds a text chat track with a
`member.canvas.set(canvas)` and unmute with `member.muted.set(false)`;
`member.video` and `member.audio` report the playing timestamp and receive
stats. `Chat` adds a text chat track with a
short retained history.

Under a CSP that refuses `blob:`, host the worklets and worker as described
Expand Down
3 changes: 2 additions & 1 deletion js/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ contract, the relay flags, and worked examples, see the
Grants and claims name paths with patterns from `@moq/pattern`: `foo` is one
broadcast, `foo/**` is a subtree, `**` is everything. A role may also carry a
bitrate cap, written `{"path": [...], "rate": bps}` and parsed into
`publish_rate` or `subscribe_rate` beside the patterns.
`publish_rate` or `subscribe_rate` beside the patterns. `Key.sign` takes that
parsed form.

## Installation

Expand Down
12 changes: 8 additions & 4 deletions js/auth/api/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export const Key: {
generate: (algorithm: Algorithm, kid?: string) => Promise<Key>;
parse: (jwk: string) => Key;
public: (key: Key) => PublicKey;
sign: (key: Key, claims: {
sign: (key: Key, claims: Omit<{
exp?: number;
get?: string | string[];
iat?: number;
Expand All @@ -196,8 +196,10 @@ export const Key: {
path: string[];
rate: number;
};
} & {
}, "publish" | "subscribe"> & {
publish?: string[];
publish_rate?: number;
subscribe?: string[];
subscribe_rate?: number;
}) => Promise<string>;
verify: (key: PublicKey | SymmetricKey, token: string) => Promise<Claims>;
Expand Down Expand Up @@ -403,7 +405,7 @@ export const KeySet: {
find: (set: KeySet, kid: string) => Key | undefined;
parse: (jwks: string) => KeySet;
public: (set: KeySet) => PublicKeySet;
sign: (set: KeySet, claims: {
sign: (set: KeySet, claims: Omit<{
exp?: number;
get?: string | string[];
iat?: number;
Expand All @@ -421,8 +423,10 @@ export const KeySet: {
path: string[];
rate: number;
};
} & {
}, "publish" | "subscribe"> & {
publish?: string[];
publish_rate?: number;
subscribe?: string[];
subscribe_rate?: number;
}) => Promise<string>;
verify: (set: KeySet, token: string) => Promise<Claims>;
Expand Down
9 changes: 7 additions & 2 deletions js/auth/src/key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -836,8 +836,10 @@ test("caps survive signing, and a key scope bounds only their paths", async () =
const key: Key = { ...Key.parse(encodeJwk(testKey)), scope: { root: "room", publish: ["**"], subscribe: ["**"] } };
const token = await Key.sign(key, {
root: "room",
publish: { path: ["alice/**"], rate: 8000 },
subscribe: { path: ["bob/**"], rate: 16000 },
publish: ["alice/**"],
publish_rate: 8000,
subscribe: ["bob/**"],
subscribe_rate: 16000,
});
const claims = await Key.verify(key, token);
expect(claims).toMatchObject({
Expand All @@ -851,6 +853,9 @@ test("caps survive signing, and a key scope bounds only their paths", async () =
const resigned = await Key.verify(key, await Key.sign(key, claims));
expect(resigned).toMatchObject({ publish_rate: 8000, subscribe_rate: 16000 });
await expect(Key.sign(key, { ...claims, publish_rate: 0 })).rejects.toThrow(/rate must be a positive integer/);
// The wire form is not a second spelling.
const wire = { root: "room", publish: { path: ["alice/**"], rate: 8000 } } as unknown as Claims;
await expect(Key.sign(key, wire)).rejects.toThrow(/pattern list/);
});

test("key scope treats absolute and rooted grants alike", async () => {
Expand Down
Loading
Loading