Skip to content
Closed
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
5 changes: 5 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ ignore = [
# http-cache (via http-cache-reqwest in moq-relay) still depends on
# bincode 1.x. Awaits upstream bump to bincode 2.x.
"RUSTSEC-2025-0141",

# proc-macro-error2 (unmaintained) via getset -> neli -> local-ip-address
# -> cf-rustracing-jaeger -> foundations, reached through quiche in
# moq-native. Build-time only proc-macro; no safe upgrade is available.
"RUSTSEC-2026-0173",
]

[licenses]
Expand Down
33 changes: 33 additions & 0 deletions doc/concept/layer/hang.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,39 @@ Here is Big Buck Bunny's `catalog.json` as of 2026-02-02:
}
```

### Extensions

The base catalog only describes `video` and `audio`. Applications add their own root sections (for example `scte35` ad-splice signaling) without modifying hang: define a schema for the section and compose it onto the base catalog, then publish and subscribe through the same JSON snapshot/delta track helper.

Because the base catalog ignores unknown sections, an extended catalog stays readable by a plain hang viewer; it just won't see the extra sections.

In TypeScript, extend the schema and hand it to `@moq/json`:

```ts
import * as z from "zod/mini";
import * as Catalog from "@moq/hang/catalog";
import * as Json from "@moq/json";

const Scte35Schema = z.object({ track: z.string() });
const RootSchema = z.extend(Catalog.RootSchema, { scte35: z.optional(Scte35Schema) });
type Root = z.infer<typeof RootSchema>;

const consumer = new Json.Consumer<Root>(track, { schema: RootSchema });
```

In Rust, flatten the base catalog into your own type and use it with [`moq-json`](https://docs.rs/moq-json):

```rust
#[derive(serde::Serialize, serde::Deserialize)]
struct AppCatalog {
#[serde(flatten)]
base: hang::Catalog,
scte35: Option<Scte35>,
}
```

On the publish side, `@moq/publish`'s `Broadcast` accepts a `sections` input that is merged into the published catalog, plus a `tracks` map for serving any extra tracks a section references.

### Audio

[See the latest schema](https://github.com/moq-dev/moq/blob/main/js/hang/src/catalog/audio.ts).
Expand Down
22 changes: 0 additions & 22 deletions js/hang/src/catalog/capabilities.ts

This file was deleted.

9 changes: 0 additions & 9 deletions js/hang/src/catalog/chat.ts

This file was deleted.

5 changes: 0 additions & 5 deletions js/hang/src/catalog/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
export * from "./audio";
export * from "./capabilities";
export * from "./chat";
export * from "./container";
export * from "./format";
export * from "./integers";
export * from "./location";
export * from "./preview";
export * from "./priority";
export * from "./root";
export * from "./track";
export * from "./user";
export * from "./video";
45 changes: 0 additions & 45 deletions js/hang/src/catalog/location.ts

This file was deleted.

15 changes: 0 additions & 15 deletions js/hang/src/catalog/preview.ts

This file was deleted.

8 changes: 2 additions & 6 deletions js/hang/src/catalog/priority.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
// We define all of the priorities for tracks here.
// That way it's easier to make sure they are in the right order.
// Default priorities for the base catalog's tracks, kept together so the ordering is easy to
// eyeball. Applications pick their own priorities for sections they add (slotting around these).
export const PRIORITY = {
catalog: 100,
chat: 90,
audio: 80,
video: 60,
typing: 40,
location: 20,
preview: 10,
} as const;
31 changes: 31 additions & 0 deletions js/hang/src/catalog/root.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { expect, test } from "bun:test";
import * as z from "zod/mini";
import { RootSchema } from "./root.ts";

// An application-defined section, the kind that lives in the app layer (e.g. hang.live) rather
// than in the base catalog.
const Scte35Schema = z.object({
track: z.string(),
spliceCount: z.optional(z.number()),
});

// Compose the base catalog with the extension, exactly as an application would.
const ExtendedSchema = z.extend(RootSchema, { scte35: z.optional(Scte35Schema) });

test("base catalog drops unknown sections", () => {
// The base schema is closed: an app section round-trips to nothing, so you must extend it.
expect(RootSchema.parse({ scte35: { track: "splice.json" } })).toEqual({});
});

test("extended catalog preserves the section and the base fields", () => {
const parsed = ExtendedSchema.parse({
audio: { renditions: {} },
scte35: { track: "splice.json", spliceCount: 2 },
});
expect(parsed.audio).toEqual({ renditions: {} });
expect(parsed.scte35).toEqual({ track: "splice.json", spliceCount: 2 });
});

test("extended catalog still rejects an invalid section", () => {
expect(() => ExtendedSchema.parse({ scte35: { spliceCount: 1 } })).toThrow();
});
43 changes: 9 additions & 34 deletions js/hang/src/catalog/root.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,20 @@
import type * as Moq from "@moq/net";
import * as z from "zod/mini";

import { AudioSchema } from "./audio";
import { CapabilitiesSchema } from "./capabilities";
import { ChatSchema } from "./chat";
import { LocationSchema } from "./location";
import { TrackSchema } from "./track";
import { UserSchema } from "./user";
import { VideoSchema } from "./video";

// The base catalog: just the media tracks every hang broadcast carries.
//
// Applications layer their own sections on top with `z.extend`, e.g.
//
// const MyRoot = z.extend(RootSchema, { scte35: z.optional(Scte35Schema) });
//
// and feed that schema to `@moq/json`'s Producer/Consumer to publish and subscribe with
// the same snapshot/delta semantics and validation as the base catalog. App-specific sections
// (chat, user, location, ...) live in the application layer, not here.
export const RootSchema = z.object({
video: z.optional(VideoSchema),
audio: z.optional(AudioSchema),
location: z.optional(LocationSchema),
user: z.optional(UserSchema),
chat: z.optional(ChatSchema),
capabilities: z.optional(CapabilitiesSchema),
preview: z.optional(TrackSchema),
});

export type Root = z.infer<typeof RootSchema>;

export function encode(root: Root): Uint8Array {
const encoder = new TextEncoder();
return encoder.encode(JSON.stringify(root));
}

export function decode(raw: Uint8Array): Root {
const decoder = new TextDecoder();
const str = decoder.decode(raw);
try {
const json = JSON.parse(str);
return RootSchema.parse(json);
} catch (error) {
console.warn("invalid catalog", str);
throw error;
}
}

export async function fetch(track: Moq.Track): Promise<Root | undefined> {
const frame = await track.readFrame();
if (!frame) return undefined;
return decode(frame);
}
10 changes: 0 additions & 10 deletions js/hang/src/catalog/user.ts

This file was deleted.

Loading
Loading