Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b7b6a9e
feat(js/net)!: route publish and consume through Origins, share one c…
kixelated Aug 6, 2026
b8800a2
fix(js/net): survive overlapping sessions and hold blind answers steady
kixelated Aug 6, 2026
2e86afb
fix(js/net): follow the table before discovery, and lend origins with…
kixelated Aug 6, 2026
4558b86
feat(js/net)!: make Request the one way to consume by path, and hide …
kixelated Aug 6, 2026
560a75b
fix(js/net): keep shared connections and discovery recoverable
kixelated Aug 6, 2026
0f2a810
feat(js/signals): add Derived, a mapped view with no lifecycle
kixelated Aug 11, 2026
74208a3
fix(js/net): brand the origin getters and seal the Request constructor
kixelated Aug 11, 2026
a1d5c5a
fix(js/net): keep the ReloadDelay defaults on an explicitly undefined…
kixelated Aug 11, 2026
17393cf
docs(js): update the origin and component examples
kixelated Aug 11, 2026
26a9ca2
fix(js/net): port dev's new tests onto the origin API
kixelated Aug 12, 2026
9dc03a8
fix(js/signals): relay every source notification from Derived
kixelated Aug 12, 2026
9c462b8
fix(js/net): answer a request whose slot was replaced under it
kixelated Aug 12, 2026
845a764
fix(js/net): release announcement pumps as they close
kixelated Aug 12, 2026
8a57ab5
fix(js/net): gate discovery on every attached session, not any
kixelated Aug 12, 2026
7bcf13f
fix(js/net): give each request its own handle on the route
kixelated Aug 12, 2026
dc14a08
build(js): strip @internal symbols from the published declarations
kixelated Aug 12, 2026
cbe7d75
fix(js/net): retire a request's route when it retracts, not when it i…
kixelated Aug 12, 2026
9a4e1e6
perf(js/net): wake a request only for its own path
kixelated Aug 12, 2026
6b67531
feat(js/net): tell a request nothing can serve it
kixelated Aug 12, 2026
46c6aa1
docs(js/net): record why a wrong answer to a request is not corrected
kixelated Aug 12, 2026
75ac33e
test(js/net): port the served-subscription helper to the origin API
kixelated Aug 13, 2026
5f4aba1
fix(js/net): stop a request from missing the retraction of a seeded r…
kixelated Aug 13, 2026
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
6 changes: 3 additions & 3 deletions demo/web/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const metaSignal = new Signals.Signal<unknown>(undefined);
const relayUrl = new Signals.Signal<URL | undefined>(new URL(RELAY_URL));

// Discovery connection (the tiles each open their own connection internally).
const connection = new Net.Connection.Reload({ url: relayUrl, enabled: true });
const connection = new Net.Connection.Shared({ url: relayUrl });

// ---------------------------------------------------------------------------
// Per-broadcast tile (a <moq-watch-ui> in the left column)
Expand Down Expand Up @@ -382,8 +382,8 @@ ui.run((effect) => {
section.hidden = false;

// Report the transport negotiated by the live connection.
const conn = effect.get(connection.established);
$("network-transport").textContent = conn ? (conn.transport === "websocket" ? "WebSocket" : "WebTransport") : "";
const transport = effect.get(connection.transport);
$("network-transport").textContent = transport ? (transport === "websocket" ? "WebSocket" : "WebTransport") : "";

const video = effect.get(watch.video.out.stats);
const audio = effect.get(watch.audio.out.stats);
Expand Down
4 changes: 2 additions & 2 deletions demo/web/src/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,8 +363,8 @@ ui.run((effect) => {

// Report the transport negotiated by the live connection.
ui.run((effect) => {
const conn = effect.get(publish.connection.established);
$("network-transport").textContent = conn ? (conn.transport === "websocket" ? "WebSocket" : "WebTransport") : "";
const transport = effect.get(publish.connection.transport);
$("network-transport").textContent = transport ? (transport === "websocket" ? "WebSocket" : "WebTransport") : "";
});

// Audio: the resolved audio config (codec / sample rate / channels / bitrate).
Expand Down
21 changes: 12 additions & 9 deletions demo/web/src/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,18 +84,18 @@ const selectedNode = new Signals.Signal<string | undefined>(undefined);

// The relay URL, editable at runtime (see the input binding below).
const relayUrl = new Signals.Signal<URL | undefined>(new URL(RELAY_URL));
const connection = new Net.Connection.Reload({ url: relayUrl, enabled: true });
const connection = new Net.Connection.Shared({ url: relayUrl });

// ---- Discover nodes + subscribe to each -----------------------------------

const discovery = new Signals.Effect();
discovery.run((effect) => {
const conn = effect.get(connection.established);
const origin = effect.get(connection.origin);
nodeStats.set({});
if (!conn) return;
if (!origin) return;

const prefix = Net.Path.from(STATS_PREFIX);
const announced = conn.announced(prefix);
const announced = origin.announced(prefix);
effect.cleanup(() => announced.close());

// One sub-effect per node so we can tear a node's subscriptions down when it
Expand All @@ -117,7 +117,7 @@ discovery.run((effect) => {
if (subs.has(node)) continue;
const ne = new Signals.Effect();
subs.set(node, ne);
subscribeNode(ne, conn, path, node);
subscribeNode(ne, origin, path, node);
} else {
subs.get(node)?.close();
subs.delete(node);
Expand All @@ -129,7 +129,7 @@ discovery.run((effect) => {
});
});

function subscribeNode(effect: Signals.Effect, conn: Net.Connection.Established, path: Net.Path.Valid, node: string) {
function subscribeNode(effect: Signals.Effect, origin: Net.Origin.Table, path: Net.Path.Valid, node: string) {
nodeStats.mutate((s) => {
s[node] = {
egress: {},
Expand All @@ -138,8 +138,11 @@ function subscribeNode(effect: Signals.Effect, conn: Net.Connection.Established,
};
});

const consumer = conn.consume(path);
effect.cleanup(() => consumer.close());
// The path was just announced, so the request resolves from the table immediately.
const request = origin.request(path);
effect.cleanup(() => request.close());
const consumer = request.active.peek();
if (!consumer) return;

const sub = <K extends keyof NodeStats>(trackName: string, key: K) => {
const track = consumer.subscribe(trackName);
Expand Down Expand Up @@ -256,7 +259,7 @@ sampler.run((effect) => {
// Only sample while connected; the interval restarts on reconnect. Drop the
// rolling history when disconnected so a reconnect doesn't splice new
// samples onto stale ones across the downtime gap.
if (!effect.get(connection.established)) {
if (effect.get(connection.status) !== "connected") {
history.clear();
clusterMembership = "";
clock.update((n) => n + 1);
Expand Down
31 changes: 31 additions & 0 deletions doc/lib/js/@moq/net.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,37 @@ See [`js/net/examples/discovery.ts`](https://github.com/moq-dev/moq/blob/main/js

## Core Concepts

### Origins

A routing table of broadcasts by path, independent of any connection. Publishing goes through an origin, not a session: create one, publish broadcasts into it, and hand it to a connection via the `publish` option. The connection announces and serves the table for as long as the session lasts, and a reconnect re-announces whatever is still published.

```ts
const origin = new Moq.Origin.Producer();
await Moq.Connection.connect(url, { publish: origin.consume() });

const broadcast = origin.publish(Moq.Path.from("my-broadcast"));
broadcast.createTrack("chat");
```

Closing the connection unannounces the broadcasts but does not close them; they stay in the origin for the next session. Closing a broadcast's producer unpublishes just that path.

The other direction works the same way: pass an origin as the `subscribe` option and everything the peer announces appears in its table, gone when the session dies.

`origin.request(path)` is how you consume by path, whether or not anything announced it:

```ts
// One origin can back both directions of the same connection.
await Moq.Connection.connect(url, { publish: origin.consume(), subscribe: origin });

const request = origin.request(Moq.Path.from("some-broadcast"));
const broadcast = request.active.peek(); // or effect.get(request.active)
request.close(); // when done
```

`request.active` follows whatever the table routes: a local publish first, so a page that publishes and watches the same broadcast reads its own copy with no round trip, then any session's announcement, swapping when a republish takes the path. When nothing routes it (a relay without discovery, or subscribing before the publisher exists on purpose), an attached session answers it blind instead. Either way the request stands across reconnects, so hold it for as long as you want the path, and close it when you don't.

Use `origin.announced(prefix)` to discover what is available rather than asking for a path you already know.

### Broadcasts

A collection of related tracks.
Expand Down
7 changes: 6 additions & 1 deletion doc/lib/js/@moq/publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,14 @@ The overlay has no `simulcast` control; enable it via the attribute on the neste

```typescript
import * as Publish from "@moq/publish";
import * as Moq from "@moq/net";

// A connection shared with every other component pointed at the same URL. Its `origin` is
// where the broadcasts live, so they survive a reconnect.
const connection = new Moq.Connection.Shared({ url: new URL("https://relay.example.com/anon") });

const broadcast = new Publish.Broadcast({
connection,
origin: connection.origin,
enabled: true,
name: "alice.hang",
// Publish two video renditions: video/hd plus a lower-resolution video/sd.
Expand Down
16 changes: 12 additions & 4 deletions doc/lib/js/@moq/watch.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,14 @@ from the broadcast name extension by default. `room/alice.hang` uses hang,

```typescript
import * as Watch from "@moq/watch";
import * as Moq from "@moq/net";

// A connection shared with every other component pointed at the same URL. Its `origin` is
// where the broadcasts live, so the handle spans reconnects.
const connection = new Moq.Connection.Shared({ url: new URL("https://relay.example.com/anon") });

const broadcast = new Watch.Broadcast({
connection,
origin: connection.origin,
enabled: true,
name: "alice.hang",
catalogFormat: "msf",
Expand All @@ -109,7 +114,7 @@ broadcast.catalogFormat.set("msf");
### Manual catalogs

Use `catalog-format="manual"` (or `catalogFormat: "manual"`) to skip the catalog
track entirely and supply a `Catalog.Root` directly. The connection and
track entirely and supply a `Catalog.Root` directly. The origin and
broadcast name are still required, since they're used to subscribe to the media
tracks named by the catalog. Update the catalog at any time by writing to
the signal:
Expand All @@ -118,7 +123,7 @@ the signal:
import * as Watch from "@moq/watch";

const broadcast = new Watch.Broadcast({
connection,
origin: connection.origin,
enabled: true,
name: "alice.hang",
catalogFormat: "manual",
Expand Down Expand Up @@ -268,13 +273,16 @@ The `<moq-watch-ui>` element automatically discovers the nested `<moq-watch>` an

```typescript
import * as Watch from "@moq/watch";
import * as Moq from "@moq/net";
import { Signal } from "@moq/signals";

const connection = new Moq.Connection.Shared({ url: new URL("https://relay.example.com/anon") });

// Inputs are read-only on the component, so keep a handle to anything you want to change later.
const reload = new Signal(true);

const broadcast = new Watch.Broadcast({
connection,
origin: connection.origin,
enabled: true,
name: "alice.hang",
reload,
Expand Down
7 changes: 4 additions & 3 deletions js/clock/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,13 @@ ENVIRONMENT VARIABLES:
}

async function publish(config: Config) {
const connection = await Moq.Connection.connect(new URL(config.url));
// 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() });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Close the clock origin when its session closes

If the relay disconnects after a clock subscriber has connected, the discarded session closes but the new origin deliberately outlives it, so neither the broadcast nor its accepted track is closed. The broadcast.requested() loop remains pending and publishTrack() continues waking every second even though there is no session or reconnect path; retain the connection and close the origin when connection.closed settles, or use Reload to reconnect.

Useful? React with 👍 / 👎.

console.log("✅ Connected to relay:", config.url);

// Create a new "broadcast", which is a collection of tracks.
const broadcast = new Moq.Broadcast.Producer();
connection.publish(Moq.Path.from(config.broadcast), broadcast);
const broadcast = origin.publish(Moq.Path.from(config.broadcast));

console.log("✅ Published broadcast:", config.broadcast);

Expand Down
19 changes: 14 additions & 5 deletions js/moq-boy/src/element.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export default class MoqBoy extends HTMLElement {
static observedAttributes = OBSERVED;

readonly connection: Moq.Connection.Reload;
/** The origin viewer broadcasts are published into, served across reconnects. */
readonly origin = new Moq.Origin.Producer();
readonly expanded = new Moq.Signals.Signal<string | undefined>(undefined);

/** Reactive map of active game sessions. Emits on add/remove. */
Expand All @@ -42,8 +44,15 @@ export default class MoqBoy extends HTMLElement {
super();
cleanup.register(this, this.#signals);

this.connection = new Moq.Connection.Reload({ enabled: this.#enabled });
// One origin, both directions: viewer broadcasts are published into it and the
// relay's announced games arrive in it, with no risk of echoing either back.
this.connection = new Moq.Connection.Reload({
enabled: this.#enabled,
publish: this.origin.consume(),
subscribe: this.origin,
});
this.#signals.cleanup(() => this.connection.close());
this.#signals.cleanup(() => this.origin.close());

// Discover game sessions via announcements.
this.#signals.run(this.#runDiscovery.bind(this));
Expand Down Expand Up @@ -113,15 +122,14 @@ export default class MoqBoy extends HTMLElement {
}

#runDiscovery(effect: Moq.Signals.Effect) {
const conn = effect.get(this.connection.established);
if (!conn) return;

const base = effect.get(this.#prefix);
const gamePrefix = effect.get(this.#gamePrefixOverride) ?? `${base}/game`;
const viewerPrefix = effect.get(this.#viewerPrefixOverride) ?? `${base}/viewer`;
const prefix = Moq.Path.from(gamePrefix);

const announced = conn.announced(prefix);
// The origin's stream spans reconnects: entries retract when the session dies and
// return when the next one re-announces them, so this loop never needs to restart.
const announced = this.origin.consume().announced(prefix);
effect.cleanup(() => announced.close());

effect.spawn(async () => {
Expand All @@ -138,6 +146,7 @@ export default class MoqBoy extends HTMLElement {
const config: GameConfig = {
sessionId: id,
connection: this.connection,
origin: this.origin,
expanded: this.expanded,
gamePrefix,
viewerPrefix,
Expand Down
16 changes: 10 additions & 6 deletions js/moq-boy/src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface GameConfig {
sessionId: string;
/** MoQ connection to the relay. */
connection: Moq.Connection.Reload;
/** The origin viewer broadcasts are published into; the connection serves it. */
origin: Moq.Origin.Producer;
/** Shared signal tracking which game is currently expanded. */
expanded: Moq.Signals.Signal<string | undefined>;
/** MoQ path prefix for game broadcasts (e.g. "anon/boy/game"). */
Expand Down Expand Up @@ -114,7 +116,7 @@ export class Game {

// Video pipeline.
this.broadcast = new Watch.Broadcast({
connection: connection.established,
origin: config.origin,
name: Moq.Path.from(`${gamePrefix}/${sessionId}`),
enabled: true,
});
Expand All @@ -124,6 +126,7 @@ export class Game {
broadcast: this.broadcast,
target: this.#target,
supported: Watch.Video.Decoder.supported,
probe: connection.probe,
});
this.#signals.cleanup(() => this.videoSource.close());

Expand All @@ -138,7 +141,7 @@ export class Game {
const videoJitter = new Moq.Signals.Signal<Moq.Time.Milli | undefined>(undefined);
this.sync = new Watch.Sync({
latency: this.latency,
connection: connection.established,
probe: connection.probe,
video: videoJitter,
audio: this.audioSource.out.jitter,
});
Expand Down Expand Up @@ -185,7 +188,7 @@ export class Game {
this.#signals.run(this.#runStatus.bind(this));

// Command publishing.
this.#signals.run(this.#runCommands.bind(this, connection));
this.#signals.run(this.#runCommands.bind(this, connection, config.origin));
}

/** Send a button state update. */
Expand Down Expand Up @@ -275,7 +278,9 @@ export class Game {
});
}

#runCommands(connection: Moq.Connection.Reload, effect: Moq.Signals.Effect) {
#runCommands(connection: Moq.Connection.Reload, origin: Moq.Origin.Producer, effect: Moq.Signals.Effect) {
// Publishing goes through the origin, but gate on a live connection anyway: a command
// broadcast for a game nobody is connected to is feedback into the void.
const conn = effect.get(connection.established);
if (!conn) return;

Expand All @@ -293,8 +298,7 @@ export class Game {
const viewerId = Math.random().toString(36).slice(2, 8);
this.viewerId.set(viewerId);

const viewerBroadcast = new Moq.Broadcast.Producer();
conn.publish(Moq.Path.from(`${this.#viewerPrefix}/${this.sessionId}/${viewerId}`), viewerBroadcast);
const viewerBroadcast = origin.publish(Moq.Path.from(`${this.#viewerPrefix}/${this.sessionId}/${viewerId}`));
effect.cleanup(() => {
viewerBroadcast.close();
this.viewerId.set(undefined);
Expand Down
12 changes: 6 additions & 6 deletions js/net/examples/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ 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);

// Create a broadcast (a collection of tracks)
const broadcast = new Moq.Broadcast.Producer();
// 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() });

// Create a broadcast (a collection of tracks) at a path on the origin
const broadcast = origin.publish(Moq.Path.from("my-broadcast"));

// Insert the "chat" track up front. A subscriber is served directly from this
// track, no requested() round-trip needed. Mirrors the Rust createTrack/insertTrack.
void publishTrack(broadcast.createTrack("chat"));

// Publish the broadcast to the connection
connection.publish(Moq.Path.from("my-broadcast"), broadcast);
console.log("Published broadcast: my-broadcast");

// Tracks created on demand (instead of up front) are still supported: handle any
Expand Down
Loading
Loading