From f328964fd0583c7f6ad00e61f7b387c2218a7071 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 8 Jun 2026 10:36:44 -0700 Subject: [PATCH 1/2] feat(hang): generic base catalog with app-layer extensions Make the base catalog carry only `video`/`audio` and let applications add their own root sections (e.g. SCTE-35) without modifying hang. The mechanism is plain composition on top of the already-generic `@moq/json`: - JS: `z.extend(Catalog.RootSchema, { scte35: ... })` - Rust: `#[serde(flatten)] base: hang::Catalog` The base catalog ignores unknown sections, so an extended catalog stays readable by a plain hang consumer. App-specific sections (chat, user, preview, location, capabilities) and their watch/publish implementations are removed; they belong in the application layer (hang.live) and remain in git history for that move. `@moq/publish`'s `Broadcast` gains a `sections` input (merged into the published catalog) and a `tracks` map (serve app-defined tracks) so the app layer can reuse it; watch needs no hook since the app subscribes to `catalog.json` with its own schema. Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/concept/layer/hang.md | 33 ++++++++++++ js/hang/src/catalog/capabilities.ts | 22 -------- js/hang/src/catalog/chat.ts | 9 ---- js/hang/src/catalog/index.ts | 5 -- js/hang/src/catalog/location.ts | 45 ---------------- js/hang/src/catalog/preview.ts | 15 ------ js/hang/src/catalog/priority.ts | 8 +-- js/hang/src/catalog/root.test.ts | 31 +++++++++++ js/hang/src/catalog/root.ts | 43 ++++----------- js/hang/src/catalog/user.ts | 10 ---- js/publish/src/broadcast.ts | 77 ++++++++++++--------------- js/publish/src/chat/index.ts | 40 -------------- js/publish/src/chat/message.ts | 45 ---------------- js/publish/src/chat/typing.ts | 45 ---------------- js/publish/src/index.ts | 4 -- js/publish/src/location/index.ts | 44 ---------------- js/publish/src/location/peers.ts | 44 ---------------- js/publish/src/location/window.ts | 57 -------------------- js/publish/src/preview.ts | 42 --------------- js/publish/src/user.ts | 46 ---------------- js/watch/src/chat/index.ts | 65 ----------------------- js/watch/src/chat/message.ts | 77 --------------------------- js/watch/src/chat/typing.ts | 73 -------------------------- js/watch/src/index.ts | 4 -- js/watch/src/location/index.ts | 35 ------------- js/watch/src/location/peers.ts | 70 ------------------------- js/watch/src/location/window.ts | 81 ----------------------------- js/watch/src/preview.ts | 66 ----------------------- js/watch/src/user.ts | 48 ----------------- rs/hang/src/catalog/root.rs | 62 ++++++++++++++++++++++ 30 files changed, 169 insertions(+), 1077 deletions(-) delete mode 100644 js/hang/src/catalog/capabilities.ts delete mode 100644 js/hang/src/catalog/chat.ts delete mode 100644 js/hang/src/catalog/location.ts delete mode 100644 js/hang/src/catalog/preview.ts create mode 100644 js/hang/src/catalog/root.test.ts delete mode 100644 js/hang/src/catalog/user.ts delete mode 100644 js/publish/src/chat/index.ts delete mode 100644 js/publish/src/chat/message.ts delete mode 100644 js/publish/src/chat/typing.ts delete mode 100644 js/publish/src/location/index.ts delete mode 100644 js/publish/src/location/peers.ts delete mode 100644 js/publish/src/location/window.ts delete mode 100644 js/publish/src/preview.ts delete mode 100644 js/publish/src/user.ts delete mode 100644 js/watch/src/chat/index.ts delete mode 100644 js/watch/src/chat/message.ts delete mode 100644 js/watch/src/chat/typing.ts delete mode 100644 js/watch/src/location/index.ts delete mode 100644 js/watch/src/location/peers.ts delete mode 100644 js/watch/src/location/window.ts delete mode 100644 js/watch/src/preview.ts delete mode 100644 js/watch/src/user.ts diff --git a/doc/concept/layer/hang.md b/doc/concept/layer/hang.md index c193638e38..7be24fab55 100644 --- a/doc/concept/layer/hang.md +++ b/doc/concept/layer/hang.md @@ -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; + +const consumer = new Json.Consumer(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, +} +``` + +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). diff --git a/js/hang/src/catalog/capabilities.ts b/js/hang/src/catalog/capabilities.ts deleted file mode 100644 index b1f4df8ce6..0000000000 --- a/js/hang/src/catalog/capabilities.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as z from "zod/mini"; - -export const VideoCapabilitiesSchema = z.object({ - hardware: z.optional(z.array(z.string())), - software: z.optional(z.array(z.string())), - unsupported: z.optional(z.array(z.string())), -}); - -export const AudioCapabilitiesSchema = z.object({ - hardware: z.optional(z.array(z.string())), - software: z.optional(z.array(z.string())), - unsupported: z.optional(z.array(z.string())), -}); - -export const CapabilitiesSchema = z.object({ - video: z.optional(VideoCapabilitiesSchema), - audio: z.optional(AudioCapabilitiesSchema), -}); - -export type Capabilities = z.infer; -export type VideoCapabilities = z.infer; -export type AudioCapabilities = z.infer; diff --git a/js/hang/src/catalog/chat.ts b/js/hang/src/catalog/chat.ts deleted file mode 100644 index d012b96fef..0000000000 --- a/js/hang/src/catalog/chat.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as z from "zod/mini"; -import { TrackSchema } from "./track"; - -export const ChatSchema = z.object({ - message: z.optional(TrackSchema), - typing: z.optional(TrackSchema), -}); - -export type Chat = z.infer; diff --git a/js/hang/src/catalog/index.ts b/js/hang/src/catalog/index.ts index e63ea4f1a9..9c54e65348 100644 --- a/js/hang/src/catalog/index.ts +++ b/js/hang/src/catalog/index.ts @@ -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"; diff --git a/js/hang/src/catalog/location.ts b/js/hang/src/catalog/location.ts deleted file mode 100644 index 52055e5117..0000000000 --- a/js/hang/src/catalog/location.ts +++ /dev/null @@ -1,45 +0,0 @@ -import * as z from "zod/mini"; -import { TrackSchema } from "./track"; - -export const PositionSchema = z.object({ - // The relative X position of the broadcast, from -1 to +1. - // This should be used for audio panning but can also be used for video positioning. - x: z.optional(z.number()), - - // The relative Y position of the broadcast, from -1 to +1. - // This can be used for video positioning, and maybe audio panning. - y: z.optional(z.number()), - - // The relative Z index of the broadcast, where larger values are closer to the viewer. - // This is used to break ties when there are multiple broadcasts at the same position. - z: z.optional(z.number()), - - // The scale of the broadcast, where 1 is 100% - s: z.optional(z.number()), -}); - -export const LocationSchema = z.object({ - // The initial position of the broadcaster, from -1 to +1 in both dimensions. - // If not provided, then the broadcaster is assumed to be at (0,0) - // This should be used for audio panning but can also be used for video positioning. - initial: z.optional(PositionSchema), - - // If provided, then updates to the position are done via a separate Moq track. - // This is used to avoid a full catalog update every time we want to update a few bytes. - // TODO: These updates currently use JSON for simplicity, but we should use a binary format. - track: z.optional(TrackSchema), - - // If set, then this broadcaster allows other peers to request position updates via this handle. - // We will have to discover and subscribe to their position updates. - handle: z.optional(z.string()), - - // If provided, this broadcaster is signaling the location of other peers. - // The payload is a JSON blob keyed by handle for each peer. - peers: z.optional(TrackSchema), -}); - -export type Location = z.infer; -export type Position = z.infer; - -export const PeersSchema = z.record(z.string(), PositionSchema); -export type Peers = z.infer; diff --git a/js/hang/src/catalog/preview.ts b/js/hang/src/catalog/preview.ts deleted file mode 100644 index ae8183b074..0000000000 --- a/js/hang/src/catalog/preview.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as z from "zod/mini"; - -export const PreviewSchema = z.object({ - name: z.optional(z.string()), // name - avatar: z.optional(z.string()), // avatar - - audio: z.optional(z.boolean()), // audio enabled - video: z.optional(z.boolean()), // video enabled - - typing: z.optional(z.boolean()), // actively typing - chat: z.optional(z.boolean()), // chatted recently - screen: z.optional(z.boolean()), // screen sharing -}); - -export type Preview = z.infer; diff --git a/js/hang/src/catalog/priority.ts b/js/hang/src/catalog/priority.ts index b92715878a..438a49d6f5 100644 --- a/js/hang/src/catalog/priority.ts +++ b/js/hang/src/catalog/priority.ts @@ -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; diff --git a/js/hang/src/catalog/root.test.ts b/js/hang/src/catalog/root.test.ts new file mode 100644 index 0000000000..bf8d4d8e0e --- /dev/null +++ b/js/hang/src/catalog/root.test.ts @@ -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(); +}); diff --git a/js/hang/src/catalog/root.ts b/js/hang/src/catalog/root.ts index 22b4bbe3c9..0bd1bab5a0 100644 --- a/js/hang/src/catalog/root.ts +++ b/js/hang/src/catalog/root.ts @@ -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; - -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 { - const frame = await track.readFrame(); - if (!frame) return undefined; - return decode(frame); -} diff --git a/js/hang/src/catalog/user.ts b/js/hang/src/catalog/user.ts deleted file mode 100644 index 7f4a699c2a..0000000000 --- a/js/hang/src/catalog/user.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as z from "zod/mini"; - -export const UserSchema = z.object({ - id: z.optional(z.string()), - name: z.optional(z.string()), - avatar: z.optional(z.string()), // TODO allow using a track instead of a URL? - color: z.optional(z.string()), -}); - -export type User = z.infer; diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index ba086844ce..36545ee03e 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -1,24 +1,30 @@ import * as Catalog from "@moq/hang/catalog"; import * as Json from "@moq/json"; import * as Moq from "@moq/net"; -import { Effect, Signal } from "@moq/signals"; +import { Effect, type Getter, Signal } from "@moq/signals"; import * as Audio from "./audio"; -import * as Chat from "./chat"; -import * as Location from "./location"; -import { Preview, type PreviewProps } from "./preview"; -import * as User from "./user"; import * as Video from "./video"; +// Serves a single application-defined track when subscribed. Same shape as the built-in +// `serve` methods, so an extension can route its own tracks (e.g. a chat message track) +// through the broadcast's request loop. +export type ServeTrack = (track: Moq.Track, effect: Effect) => void; + export type BroadcastProps = { connection?: Moq.Connection.Established | Signal; enabled?: boolean | Signal; name?: Moq.Path.Valid | Signal; audio?: Audio.EncoderProps; video?: Video.Props; - location?: Location.Props; - user?: User.Props; - chat?: Chat.Props; - preview?: PreviewProps; + + // Extra catalog sections merged into the published catalog alongside `video`/`audio`. + // This is how the application layer adds its own root sections (chat, location, scte35, ...) + // without hang knowing about them. + sections?: Record | Signal | undefined>; + + // Handlers for application-defined tracks, keyed by track name. Consulted when a + // subscription arrives for a track the base broadcast doesn't recognize. + tracks?: Record; }; export class Broadcast { @@ -31,10 +37,9 @@ export class Broadcast { audio: Audio.Encoder; video: Video.Root; - location: Location.Root; - chat: Chat.Root; - preview: Preview; - user: User.Info; + // Application-supplied extensions, see `BroadcastProps`. + sections: Getter | undefined>; + tracks: Record; signals = new Effect(); @@ -45,10 +50,9 @@ export class Broadcast { this.audio = new Audio.Encoder(props?.audio); this.video = new Video.Root({ ...props?.video, connection: this.connection }); - this.location = new Location.Root(props?.location); - this.chat = new Chat.Root(props?.chat); - this.preview = new Preview(props?.preview); - this.user = new User.Info(props?.user); + + this.sections = Signal.from(props?.sections); + this.tracks = props?.tracks ?? {}; this.signals.run(this.#run.bind(this)); } @@ -85,22 +89,7 @@ export class Broadcast { switch (request.track.name) { case Broadcast.CATALOG_TRACK: - this.#serveCatalog(new Json.Producer(request.track), effect); - break; - case Location.Window.TRACK: - this.location.window.serve(request.track, effect); - break; - case Location.Peers.TRACK: - this.location.peers.serve(request.track, effect); - break; - case Preview.TRACK: - this.preview.serve(request.track, effect); - break; - case Chat.Typing.TRACK: - this.chat.typing.serve(request.track, effect); - break; - case Chat.Message.TRACK: - this.chat.message.serve(request.track, effect); + this.#serveCatalog(new Json.Producer>(request.track), effect); break; case Audio.Encoder.TRACK: this.audio.serve(request.track, effect); @@ -111,42 +100,40 @@ export class Broadcast { case Video.Root.TRACK_SD: this.video.sd.serve(request.track, effect); break; - default: + default: { + const handler = this.tracks[request.track.name]; + if (handler) { + handler(request.track, effect); + break; + } console.error("received subscription for unknown track", request.track.name); request.track.close(new Error(`Unknown track: ${request.track.name}`)); break; + } } }); } } - #serveCatalog(producer: Json.Producer, effect: Effect): void { + #serveCatalog(producer: Json.Producer>, effect: Effect): void { if (!effect.get(this.enabled)) { // Clear the catalog. producer.update({}); return; } - // Create the new catalog. const catalog: Catalog.Root = { video: effect.get(this.video.catalog), audio: effect.get(this.audio.catalog), - location: effect.get(this.location.catalog), - user: effect.get(this.user.catalog), - chat: effect.get(this.chat.catalog), - preview: effect.get(this.preview.catalog), }; - producer.update(catalog); + // Merge any application-defined sections on top of the base catalog. + producer.update({ ...catalog, ...effect.get(this.sections) }); } close() { this.signals.close(); this.audio.close(); this.video.close(); - this.location.close(); - this.chat.close(); - this.preview.close(); - this.user.close(); } } diff --git a/js/publish/src/chat/index.ts b/js/publish/src/chat/index.ts deleted file mode 100644 index 080bd9c232..0000000000 --- a/js/publish/src/chat/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type * as Catalog from "@moq/hang/catalog"; -import { Effect, type Getter, Signal } from "@moq/signals"; -import { Message, type MessageProps } from "./message"; -import { Typing, type TypingProps } from "./typing"; - -export * from "./message"; -export * from "./typing"; - -export type Props = { - message?: MessageProps; - typing?: TypingProps; -}; - -export class Root { - message: Message; - typing: Typing; - - #catalog = new Signal(undefined); - readonly catalog: Getter = this.#catalog; - - #signals = new Effect(); - - constructor(props?: Props) { - this.message = new Message(props?.message); - this.typing = new Typing(props?.typing); - - this.#signals.run((effect) => { - this.#catalog.set({ - message: effect.get(this.message.catalog), - typing: effect.get(this.typing.catalog), - }); - }); - } - - close() { - this.#signals.close(); - this.message.close(); - this.typing.close(); - } -} diff --git a/js/publish/src/chat/message.ts b/js/publish/src/chat/message.ts deleted file mode 100644 index 1f6f5ce25a..0000000000 --- a/js/publish/src/chat/message.ts +++ /dev/null @@ -1,45 +0,0 @@ -import * as Catalog from "@moq/hang/catalog"; -import type * as Moq from "@moq/net"; -import { Effect, Signal } from "@moq/signals"; - -export type MessageProps = { - enabled?: boolean | Signal; -}; - -export class Message { - static readonly TRACK = "chat/message.txt"; - static readonly PRIORITY = Catalog.PRIORITY.chat; - - enabled: Signal; - - // The latest message to publish. - latest: Signal; - - catalog = new Signal(undefined); - - #signals = new Effect(); - - constructor(props?: MessageProps) { - this.enabled = Signal.from(props?.enabled ?? false); - this.latest = new Signal(""); - - this.#signals.run((effect) => { - const enabled = effect.get(this.enabled); - if (!enabled) return; - - effect.set(this.catalog, { name: Message.TRACK }); - }); - } - - serve(track: Moq.Track, effect: Effect): void { - const enabled = effect.get(this.enabled); - if (!enabled) return; - - const latest = effect.get(this.latest); - track.writeString(latest ?? ""); - } - - close() { - this.#signals.close(); - } -} diff --git a/js/publish/src/chat/typing.ts b/js/publish/src/chat/typing.ts deleted file mode 100644 index cf47c8251e..0000000000 --- a/js/publish/src/chat/typing.ts +++ /dev/null @@ -1,45 +0,0 @@ -import * as Catalog from "@moq/hang/catalog"; -import type * as Moq from "@moq/net"; -import { Effect, Signal } from "@moq/signals"; - -export type TypingProps = { - enabled?: boolean | Signal; -}; - -export class Typing { - static readonly TRACK = "chat/typing.bool"; - static readonly PRIORITY = Catalog.PRIORITY.typing; - - enabled: Signal; - - // Whether the user is typing. - active: Signal; - - catalog = new Signal(undefined); - - #signals = new Effect(); - - constructor(props?: TypingProps) { - this.enabled = Signal.from(props?.enabled ?? false); - this.active = new Signal(false); - - this.#signals.run((effect) => { - const enabled = effect.get(this.enabled); - if (!enabled) return; - - effect.set(this.catalog, { name: Typing.TRACK }); - }); - } - - serve(track: Moq.Track, effect: Effect): void { - const enabled = effect.get(this.enabled); - if (!enabled) return; - - const active = effect.get(this.active); - track.writeBool(active); - } - - close() { - this.#signals.close(); - } -} diff --git a/js/publish/src/index.ts b/js/publish/src/index.ts index 47278b160e..2f4ef5e21c 100644 --- a/js/publish/src/index.ts +++ b/js/publish/src/index.ts @@ -5,11 +5,7 @@ export * as Lite from "@moq/net"; export * as Signals from "@moq/signals"; export * as Audio from "./audio"; export * from "./broadcast"; -export * as Chat from "./chat"; -export * as Location from "./location"; -export * from "./preview"; export * as Source from "./source"; -export * as User from "./user"; export * as Video from "./video"; // NOTE: element is not exported from this module diff --git a/js/publish/src/location/index.ts b/js/publish/src/location/index.ts deleted file mode 100644 index 1063119347..0000000000 --- a/js/publish/src/location/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type * as Catalog from "@moq/hang/catalog"; -import { Effect, Signal } from "@moq/signals"; -import { Peers, type PeersProps } from "./peers"; -import { Window, type WindowProps } from "./window"; - -export * from "./peers"; -export * from "./window"; - -export type Props = { - window?: WindowProps; - peers?: PeersProps; -}; - -export class Root { - window: Window; - peers: Peers; - - catalog = new Signal(undefined); - signals = new Effect(); - - constructor(props?: Props) { - this.window = new Window(props?.window); - this.peers = new Peers(props?.peers); - - this.signals.run(this.#run.bind(this)); - } - - #run(effect: Effect): void { - const myself = effect.get(this.window.catalog); - const peers = effect.get(this.peers.catalog); - if (!myself && !peers) return; - - effect.set(this.catalog, { - peers: peers, - ...myself, - }); - } - - close() { - this.signals.close(); - this.window.close(); - this.peers.close(); - } -} diff --git a/js/publish/src/location/peers.ts b/js/publish/src/location/peers.ts deleted file mode 100644 index 6c8915d8e2..0000000000 --- a/js/publish/src/location/peers.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as Catalog from "@moq/hang/catalog"; -import type * as Moq from "@moq/net"; -import * as Zod from "@moq/net/zod"; -import { Effect, Signal } from "@moq/signals"; - -export interface PeersProps { - enabled?: boolean | Signal; - positions?: Record | Signal>; -} - -export class Peers { - static readonly TRACK = "location/peers.json"; - static readonly PRIORITY = Catalog.PRIORITY.location; - - enabled: Signal; - positions = new Signal>({}); - - catalog = new Signal(undefined); - signals = new Effect(); - - constructor(props?: PeersProps) { - this.enabled = Signal.from(props?.enabled ?? false); - this.positions = Signal.from(props?.positions ?? {}); - - this.signals.run((effect) => { - const enabled = effect.get(this.enabled); - if (!enabled) return; - - effect.set(this.catalog, { name: Peers.TRACK }); - }); - } - - serve(track: Moq.Track, effect: Effect): void { - const values = effect.getAll([this.enabled, this.positions]); - if (!values) return; - const [_, positions] = values; - - Zod.write(track, positions, Catalog.PeersSchema); - } - - close() { - this.signals.close(); - } -} diff --git a/js/publish/src/location/window.ts b/js/publish/src/location/window.ts deleted file mode 100644 index 41b549249b..0000000000 --- a/js/publish/src/location/window.ts +++ /dev/null @@ -1,57 +0,0 @@ -import * as Catalog from "@moq/hang/catalog"; -import type * as Moq from "@moq/net"; -import * as Zod from "@moq/net/zod"; -import { Effect, Signal } from "@moq/signals"; - -export type WindowProps = { - // If true, then we'll publish our position to the broadcast. - enabled?: boolean | Signal; - - // Our current position. - position?: Catalog.Position | Signal; - - // If set, then this broadcaster allows other peers to request position updates via this handle. - handle?: string | Signal; -}; - -export class Window { - static readonly TRACK = "location/window.json"; - static readonly PRIORITY = Catalog.PRIORITY.location; - - enabled: Signal; - position: Signal; - handle: Signal; // Allow other peers to request position updates via this handle. - - catalog = new Signal(undefined); - - signals = new Effect(); - - constructor(props?: WindowProps) { - this.enabled = Signal.from(props?.enabled ?? false); - this.position = Signal.from(props?.position ?? undefined); - this.handle = Signal.from(props?.handle ?? undefined); - - this.signals.run((effect) => { - const enabled = effect.get(this.enabled); - if (!enabled) return; - - effect.set(this.catalog, { - initial: this.position.peek(), - track: { name: Window.TRACK }, - handle: effect.get(this.handle), - }); - }); - } - - serve(track: Moq.Track, effect: Effect): void { - const values = effect.getAll([this.enabled, this.position]); - if (!values) return; - const [_, position] = values; - - Zod.write(track, position, Catalog.PositionSchema); - } - - close() { - this.signals.close(); - } -} diff --git a/js/publish/src/preview.ts b/js/publish/src/preview.ts deleted file mode 100644 index f564f9fa50..0000000000 --- a/js/publish/src/preview.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as Catalog from "@moq/hang/catalog"; -import type * as Moq from "@moq/net"; -import { Effect, Signal } from "@moq/signals"; - -export type PreviewProps = { - enabled?: boolean | Signal; - info?: Catalog.Preview | Signal; -}; - -export class Preview { - static readonly TRACK = "preview.json"; - static readonly PRIORITY = Catalog.PRIORITY.preview; - - enabled: Signal; - info: Signal; - - catalog = new Signal(undefined); - - signals = new Effect(); - - constructor(props?: PreviewProps) { - this.enabled = Signal.from(props?.enabled ?? false); - this.info = Signal.from(props?.info); - - this.signals.run((effect) => { - if (!effect.get(this.enabled)) return; - effect.set(this.catalog, { name: Preview.TRACK }); - }); - } - - serve(track: Moq.Track, effect: Effect): void { - const values = effect.getAll([this.enabled, this.info]); - if (!values) return; - const [_, info] = values; - - track.writeJson(info); - } - - close() { - this.signals.close(); - } -} diff --git a/js/publish/src/user.ts b/js/publish/src/user.ts deleted file mode 100644 index 417b197cef..0000000000 --- a/js/publish/src/user.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type * as Catalog from "@moq/hang/catalog"; -import { Effect, Signal } from "@moq/signals"; - -export type Props = { - enabled?: boolean | Signal; - id?: string | Signal; - name?: string | Signal; - avatar?: string | Signal; - color?: string | Signal; -}; - -export class Info { - enabled: Signal; - - id: Signal; - name: Signal; - avatar: Signal; - color: Signal; - - catalog = new Signal(undefined); - - signals = new Effect(); - - constructor(props?: Props) { - this.enabled = Signal.from(props?.enabled ?? false); - this.id = Signal.from(props?.id); - this.name = Signal.from(props?.name); - this.avatar = Signal.from(props?.avatar); - this.color = Signal.from(props?.color); - - this.signals.run((effect) => { - if (!effect.get(this.enabled)) return; - - effect.set(this.catalog, { - id: effect.get(this.id), - name: effect.get(this.name), - avatar: effect.get(this.avatar), - color: effect.get(this.color), - }); - }); - } - - close() { - this.signals.close(); - } -} diff --git a/js/watch/src/chat/index.ts b/js/watch/src/chat/index.ts deleted file mode 100644 index 55d349c5a3..0000000000 --- a/js/watch/src/chat/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type * as Catalog from "@moq/hang/catalog"; -import type * as Moq from "@moq/net"; -import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; -import { Message, type MessageInput } from "./message"; -import { Typing, type TypingInput } from "./typing"; - -// Signals the component reads. Whoever owns the backing Signal does the writing. -type ChatInput = { - broadcast: Getter; - catalog: Getter; -}; - -type ChatOutput = { - catalog: Signal; -}; - -export class Chat { - readonly input: Readonlys; - - readonly #output: ChatOutput = { - catalog: new Signal(undefined), - }; - readonly output = readonlys(this.#output); - - message: Message; - typing: Typing; - - #signals = new Effect(); - - constructor(props?: Inputs & { message?: Inputs; typing?: Inputs }) { - this.input = { - broadcast: getter(props?.broadcast), - catalog: getter(props?.catalog), - }; - - this.message = new Message({ - ...props?.message, - broadcast: this.input.broadcast, - catalog: this.input.catalog, - }); - this.typing = new Typing({ - ...props?.typing, - broadcast: this.input.broadcast, - catalog: this.input.catalog, - }); - - // Grab the chat section from the catalog (if it's changed). - this.#signals.run((effect) => { - const message = effect.get(this.message.output.catalog); - const typing = effect.get(this.typing.output.catalog); - if (!message && !typing) return; - - effect.set(this.#output.catalog, { - message, - typing, - }); - }); - } - - close() { - this.#signals.close(); - this.message.close(); - this.typing.close(); - } -} diff --git a/js/watch/src/chat/message.ts b/js/watch/src/chat/message.ts deleted file mode 100644 index fdb84d202f..0000000000 --- a/js/watch/src/chat/message.ts +++ /dev/null @@ -1,77 +0,0 @@ -import * as Catalog from "@moq/hang/catalog"; -import type * as Moq from "@moq/net"; -import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; - -// Signals the component reads. Whoever owns the backing Signal does the writing. -export type MessageInput = { - broadcast: Getter; - - // The catalog to grab the chat section from. - catalog: Getter; - - // Whether to start downloading the chat. - // Defaults to false so you can make sure everything is ready before starting. - enabled: Getter; -}; - -type MessageOutput = { - // Empty string is a valid message. - latest: Signal; - - catalog: Signal; -}; - -export class Message { - readonly input: Readonlys; - - readonly #output: MessageOutput = { - latest: new Signal(undefined), - catalog: new Signal(undefined), - }; - readonly output = readonlys(this.#output); - - #signals = new Effect(); - - constructor(props?: Inputs) { - this.input = { - broadcast: getter(props?.broadcast), - catalog: getter(props?.catalog), - enabled: getter(props?.enabled ?? false), - }; - - // Grab the chat section from the catalog (if it's changed). - this.#signals.run((effect) => { - if (!effect.get(this.input.enabled)) return; - this.#output.catalog.set(effect.get(this.input.catalog)?.chat?.message); - }); - - this.#signals.run(this.#run.bind(this)); - } - - #run(effect: Effect) { - const values = effect.getAll([this.input.enabled, this.#output.catalog, this.input.broadcast]); - if (!values) return; - const [_, catalog, broadcast] = values; - - const track = broadcast.subscribe(catalog.name, Catalog.PRIORITY.chat); - effect.cleanup(() => track.close()); - - // Undefined is only when we're not subscribed to the track. - effect.set(this.#output.latest, ""); - effect.cleanup(() => this.#output.latest.set(undefined)); - - effect.spawn(async () => { - for (;;) { - const frame = await track.readString(); - if (frame === undefined) break; - - // Use a function to avoid the dequal check. - this.#output.latest.set(frame); - } - }); - } - - close() { - this.#signals.close(); - } -} diff --git a/js/watch/src/chat/typing.ts b/js/watch/src/chat/typing.ts deleted file mode 100644 index b640edef15..0000000000 --- a/js/watch/src/chat/typing.ts +++ /dev/null @@ -1,73 +0,0 @@ -import * as Catalog from "@moq/hang/catalog"; -import type * as Moq from "@moq/net"; -import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; - -// Signals the component reads. Whoever owns the backing Signal does the writing. -export type TypingInput = { - broadcast: Getter; - - // The catalog to grab the chat section from. - catalog: Getter; - - // Whether to start downloading the chat. - // Defaults to false so you can make sure everything is ready before starting. - enabled: Getter; -}; - -type TypingOutput = { - active: Signal; - - catalog: Signal; -}; - -export class Typing { - readonly input: Readonlys; - - readonly #output: TypingOutput = { - active: new Signal(undefined), - catalog: new Signal(undefined), - }; - readonly output = readonlys(this.#output); - - #signals = new Effect(); - - constructor(props?: Inputs) { - this.input = { - broadcast: getter(props?.broadcast), - catalog: getter(props?.catalog), - enabled: getter(props?.enabled ?? false), - }; - - // Grab the chat section from the catalog (if it's changed). - this.#signals.run((effect) => { - if (!effect.get(this.input.enabled)) return; - this.#output.catalog.set(effect.get(this.input.catalog)?.chat?.typing); - }); - - this.#signals.run(this.#run.bind(this)); - } - - #run(effect: Effect) { - const values = effect.getAll([this.input.enabled, this.#output.catalog, this.input.broadcast]); - if (!values) return; - const [_, catalog, broadcast] = values; - - const track = broadcast.subscribe(catalog.name, Catalog.PRIORITY.typing); - effect.cleanup(() => track.close()); - - effect.spawn(async () => { - for (;;) { - const value = await track.readBool(); - if (value === undefined) break; - - this.#output.active.set(value); - } - }); - - effect.cleanup(() => this.#output.active.set(undefined)); - } - - close() { - this.#signals.close(); - } -} diff --git a/js/watch/src/index.ts b/js/watch/src/index.ts index f686caffb7..6103f721f7 100644 --- a/js/watch/src/index.ts +++ b/js/watch/src/index.ts @@ -6,12 +6,8 @@ export * as Signals from "@moq/signals"; export * as Audio from "./audio"; export * from "./backend"; export * from "./broadcast"; -export * as Chat from "./chat"; -export * as Location from "./location"; export * as Mse from "./mse"; -export * from "./preview"; export * from "./sync"; -export * as User from "./user"; export * as Video from "./video"; // NOTE: element is not exported from this module diff --git a/js/watch/src/location/index.ts b/js/watch/src/location/index.ts deleted file mode 100644 index 676b91f209..0000000000 --- a/js/watch/src/location/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type * as Catalog from "@moq/hang/catalog"; -import type * as Moq from "@moq/net"; -import { Effect, type Getter, getter, type Inputs, type Readonlys } from "@moq/signals"; -import { Peers, type PeersInput } from "./peers"; -import { Window, type WindowInput } from "./window"; - -type RootInput = { - broadcast: Getter; - catalog: Getter; -}; - -export class Root { - readonly input: Readonlys; - - window: Window; - peers: Peers; - - signals = new Effect(); - - constructor(props?: Inputs & { window?: Inputs; peers?: Inputs }) { - this.input = { - broadcast: getter(props?.broadcast), - catalog: getter(props?.catalog), - }; - - this.window = new Window({ ...props?.window, broadcast: this.input.broadcast, catalog: this.input.catalog }); - this.peers = new Peers({ ...props?.peers, broadcast: this.input.broadcast, catalog: this.input.catalog }); - } - - close() { - this.signals.close(); - this.window.close(); - this.peers.close(); - } -} diff --git a/js/watch/src/location/peers.ts b/js/watch/src/location/peers.ts deleted file mode 100644 index d09f21f38b..0000000000 --- a/js/watch/src/location/peers.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as Catalog from "@moq/hang/catalog"; -import type * as Moq from "@moq/net"; -import * as Zod from "@moq/net/zod"; -import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; - -export type PeersInput = { - broadcast: Getter; - catalog: Getter; - enabled: Getter; -}; - -type PeersOutput = { - positions: Signal | undefined>; -}; - -export class Peers { - readonly input: Readonlys; - - readonly #output: PeersOutput = { - positions: new Signal | undefined>(undefined), - }; - readonly output = readonlys(this.#output); - - #catalog = new Signal(undefined); - - signals = new Effect(); - - constructor(props?: Inputs) { - this.input = { - broadcast: getter(props?.broadcast), - catalog: getter(props?.catalog), - enabled: getter(props?.enabled ?? false), - }; - - this.signals.run((effect) => { - this.#catalog.set(effect.get(this.input.catalog)?.location?.peers); - }); - - this.signals.run(this.#run.bind(this)); - } - - #run(effect: Effect) { - const values = effect.getAll([this.input.enabled, this.#catalog, this.input.broadcast]); - if (!values) return; - const [_, catalog, broadcast] = values; - - const track = broadcast.subscribe(catalog.name, Catalog.PRIORITY.location); - effect.cleanup(() => track.close()); - - effect.spawn(this.#runTrack.bind(this, track)); - } - - async #runTrack(track: Moq.Track) { - try { - for (;;) { - const frame = await Zod.read(track, Catalog.PeersSchema); - if (!frame) break; - - this.#output.positions.set(frame); - } - } finally { - this.#output.positions.set(undefined); - track.close(); - } - } - - close() { - this.signals.close(); - } -} diff --git a/js/watch/src/location/window.ts b/js/watch/src/location/window.ts deleted file mode 100644 index 1878bd084e..0000000000 --- a/js/watch/src/location/window.ts +++ /dev/null @@ -1,81 +0,0 @@ -import * as Catalog from "@moq/hang/catalog"; -import type * as Moq from "@moq/net"; -import * as Zod from "@moq/net/zod"; -import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; - -export type WindowInput = { - broadcast: Getter; - catalog: Getter; - enabled: Getter; -}; - -type WindowOutput = { - handle: Signal; - position: Signal; -}; - -export class Window { - readonly input: Readonlys; - - readonly #output: WindowOutput = { - handle: new Signal(undefined), - position: new Signal(undefined), - }; - readonly output = readonlys(this.#output); - - #catalog = new Signal(undefined); - - signals = new Effect(); - - constructor(props?: Inputs) { - this.input = { - broadcast: getter(props?.broadcast), - catalog: getter(props?.catalog), - enabled: getter(props?.enabled ?? false), - }; - - this.signals.run((effect) => { - this.#catalog.set(effect.get(this.input.catalog)?.location); - }); - - this.signals.run((effect) => { - if (!effect.get(this.input.enabled)) return; - this.#output.position.set(effect.get(this.#catalog)?.initial); - }); - - this.signals.run((effect) => { - this.#output.handle.set(effect.get(this.#catalog)?.handle); - }); - - this.signals.run((effect) => { - const broadcast = effect.get(this.input.broadcast); - if (!broadcast) return; - - const updates = effect.get(this.#catalog)?.track; - if (!updates) return; - - const track = broadcast.subscribe(updates.name, Catalog.PRIORITY.location); - effect.cleanup(() => track.close()); - - effect.spawn(this.#runTrack.bind(this, track)); - }); - } - - async #runTrack(track: Moq.Track) { - try { - for (;;) { - const position = await Zod.read(track, Catalog.PositionSchema); - if (!position) break; - - this.#output.position.set(position); - } - } finally { - this.#output.position.set(undefined); - track.close(); - } - } - - close() { - this.signals.close(); - } -} diff --git a/js/watch/src/preview.ts b/js/watch/src/preview.ts deleted file mode 100644 index d2d8a22e84..0000000000 --- a/js/watch/src/preview.ts +++ /dev/null @@ -1,66 +0,0 @@ -import * as Catalog from "@moq/hang/catalog"; -import type * as Moq from "@moq/net"; -import * as Zod from "@moq/net/zod"; -import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; - -type PreviewInput = { - enabled: Getter; - broadcast: Getter; - catalog: Getter; -}; - -type PreviewOutput = { - preview: Signal; -}; - -export class Preview { - readonly input: Readonlys; - - readonly #output: PreviewOutput = { - preview: new Signal(undefined), - }; - readonly output = readonlys(this.#output); - - #catalog = new Signal(undefined); - - #signals = new Effect(); - - constructor(props?: Inputs) { - this.input = { - enabled: getter(props?.enabled ?? false), - broadcast: getter(props?.broadcast), - catalog: getter(props?.catalog), - }; - - this.#signals.run((effect) => { - this.#catalog.set(effect.get(this.input.catalog)?.preview); - }); - - this.#signals.run((effect) => { - const values = effect.getAll([this.input.enabled, this.input.broadcast, this.#catalog]); - if (!values) return; - const [_, broadcast, catalog] = values; - - // Subscribe to the preview.json track directly - const track = broadcast.subscribe(catalog.name, Catalog.PRIORITY.preview); - effect.cleanup(() => track.close()); - - effect.spawn(async () => { - try { - const info = await Zod.read(track, Catalog.PreviewSchema); - if (!info) return; - - this.#output.preview.set(info); - } catch (error) { - console.warn("Failed to parse preview JSON:", error); - } - }); - - effect.cleanup(() => this.#output.preview.set(undefined)); - }); - } - - close() { - this.#signals.close(); - } -} diff --git a/js/watch/src/user.ts b/js/watch/src/user.ts deleted file mode 100644 index 65a914e7a3..0000000000 --- a/js/watch/src/user.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type * as Catalog from "@moq/hang/catalog"; -import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; - -type InfoInput = { - enabled: Getter; - catalog: Getter; -}; - -type InfoOutput = { - id: Signal; - name: Signal; - avatar: Signal; - color: Signal; -}; - -export class Info { - readonly input: Readonlys; - - readonly #output: InfoOutput = { - id: new Signal(undefined), - name: new Signal(undefined), - avatar: new Signal(undefined), - color: new Signal(undefined), - }; - readonly output = readonlys(this.#output); - - signals = new Effect(); - - constructor(props?: Inputs) { - this.input = { - enabled: getter(props?.enabled ?? false), - catalog: getter(props?.catalog), - }; - - this.signals.run((effect) => { - if (!effect.get(this.input.enabled)) return; - - this.#output.id.set(effect.get(this.input.catalog)?.user?.id); - this.#output.name.set(effect.get(this.input.catalog)?.user?.name); - this.#output.avatar.set(effect.get(this.input.catalog)?.user?.avatar); - this.#output.color.set(effect.get(this.input.catalog)?.user?.color); - }); - } - - close() { - this.signals.close(); - } -} diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index 7a4d5bce97..5ad80fa118 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -4,6 +4,24 @@ use crate::catalog::{Audio, Video}; use serde::{Deserialize, Serialize}; /// A catalog track, created by a broadcaster to describe the tracks available in a broadcast. +/// +/// This is the base catalog: just the media tracks every hang broadcast carries. Applications +/// layer their own sections on top by flattening it into their own type, e.g. +/// +/// ``` +/// # use serde::{Serialize, Deserialize}; +/// #[derive(Serialize, Deserialize)] +/// struct AppCatalog { +/// #[serde(flatten)] +/// base: hang::Catalog, +/// scte35: Option, +/// } +/// ``` +/// +/// and feeding that type to [`moq_json`](https://docs.rs/moq-json)'s producer/consumer to publish +/// and subscribe with the same snapshot/delta semantics as the base catalog. The base catalog +/// ignores unknown sections, so an extended catalog stays readable by a base consumer. +/// App-specific sections (chat, user, location, ...) live in the application layer, not here. #[serde_with::serde_as] #[serde_with::skip_serializing_none] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] @@ -245,4 +263,48 @@ mod test { let output = catalog.to_string().expect("failed to encode"); assert_eq!(encoded, output, "encode mismatch"); } + + /// Lock in the extension pattern: an application flattens the base catalog into its own type + /// and adds typed sections. The extension rides alongside the base fields in one JSON object, + /// and a base [`Catalog`] consumer still reads it, ignoring the unknown section. + #[test] + fn extension_roundtrip() { + use serde::{Deserialize, Serialize}; + + #[serde_with::skip_serializing_none] + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] + #[serde(default, rename_all = "camelCase")] + struct Scte35 { + track: String, + splice_count: Option, + } + + #[serde_with::skip_serializing_none] + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] + #[serde(default, rename_all = "camelCase")] + struct AppCatalog { + #[serde(flatten)] + base: Catalog, + scte35: Option, + } + + let app = AppCatalog { + base: Catalog::default(), + scte35: Some(Scte35 { + track: "splice.json".to_string(), + splice_count: Some(2), + }), + }; + + let encoded = serde_json::to_string(&app).expect("failed to encode"); + assert!(encoded.contains(r#""scte35":{"track":"splice.json","spliceCount":2}"#)); + + // Round-trips through the application type. + let decoded: AppCatalog = serde_json::from_str(&encoded).expect("failed to decode"); + assert_eq!(app, decoded); + + // A base consumer reads the same bytes, ignoring the unknown section. + let base = Catalog::from_str(&encoded).expect("base failed to decode"); + assert_eq!(base, Catalog::default()); + } } From 33a903d088d628d49ba616579178143eebed38dd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 8 Jun 2026 10:54:43 -0700 Subject: [PATCH 2/2] chore(deny): ignore RUSTSEC-2026-0173 (proc-macro-error2 unmaintained) Freshly published advisory failing CI repo-wide, unrelated to the catalog change. proc-macro-error2 is pulled transitively via foundations (quiche) in moq-native; it's a build-time-only proc-macro with no safe upgrade. Co-Authored-By: Claude Opus 4.8 (1M context) --- deny.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/deny.toml b/deny.toml index b3548456fe..28005d4698 100644 --- a/deny.toml +++ b/deny.toml @@ -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]