Skip to content

Add MSF catalog format support with format negotiation#1330

Merged
kixelated merged 6 commits into
mainfrom
claude/add-catalog-signal-AFPpW
Apr 20, 2026
Merged

Add MSF catalog format support with format negotiation#1330
kixelated merged 6 commits into
mainfrom
claude/add-catalog-signal-AFPpW

Conversation

@kixelated
Copy link
Copy Markdown
Collaborator

Summary

This PR adds support for the MSF (MOQT Streaming Format) catalog format alongside the existing hang format, with intelligent format negotiation that races both formats and selects the first successful response.

Key Changes

  • New @moq/msf package: Created a new package with MSF catalog types and utilities

    • catalog.ts: Defines MSF catalog schema with Zod validation and encode/decode functions
    • Supports parsing MSF track metadata (codec, dimensions, sample rate, bitrate, etc.)
  • MSF to hang conversion: Added js/watch/src/msf.ts with conversion utilities

    • toHang(): Converts MSF catalogs to hang format for unified internal representation
    • toVideoConfig() and toAudioConfig(): Extract and normalize track configurations
    • toContainer(): Parses CMAF init segments with fallback to legacy format
  • Catalog format negotiation in Broadcast:

    • Added catalogFormats signal to allow configurable format preferences
    • Implements race-based format selection: hang format gets a 100ms headstart, then both formats compete via Promise.any()
    • Winner continues streaming updates; loser track is closed
    • Supports single or multiple format preferences
  • HTML element API: Extended <moq-watch> element

    • New catalog attribute: accepts "hang", "msf", or "auto" (tries both)
    • New catalog property: getter/setter for programmatic control
    • Attribute parsing with parseCatalogAttr() utility

Implementation Details

  • Format negotiation uses Promise.race() for the initial fetch (with MSF delayed by 100ms) and Promise.any() to ensure only successful fetches compete
  • MSF catalog fetches are wrapped to reject on empty results, ensuring meaningful competition
  • The winning format continues to be used for subsequent updates, avoiding format switching
  • All MSF numeric values are wrapped with u53() for safe integer handling
  • Base64 decoding of init data includes error handling with fallback to legacy format

https://claude.ai/code/session_012gjc9wFMcfGj83fazaJ5ij

claude added 2 commits April 19, 2026 18:35
Adds a `catalog` signal (and matching `catalog` attribute on <moq-watch>)
that selects between hang (`catalog.json`) and msf (`catalog`) catalog
formats. Default is `hang`.

- New `@moq/msf` package with Zod schemas for the MSF catalog format
- MSF→Hang converter in `@moq/watch/msf.ts` (parses CMAF init segments
  via `Cmaf.decodeInitSegment` to extract timescale/trackId)
- `Broadcast` subscribes to both tracks when catalog is `auto`; hang gets
  a 100ms headstart and the loser track is closed

https://claude.ai/code/session_012gjc9wFMcfGj83fazaJ5ij
- `catalogFormats: Signal<CatalogFormat[]>` → `catalogFormat: Signal<CatalogFormat>`
- Drop hang/msf race and HANG_HEADSTART_MS; subscribe to one track based
  on the configured format
- Drop "auto" attribute value; the app picks hang or msf explicitly
- `@moq/msf` now uses `zod/mini` to match the other packages
- `@moq/watch` no longer depends on zod directly

https://claude.ai/code/session_012gjc9wFMcfGj83fazaJ5ij
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 19, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a new package at js/msf with package.json, tsconfig, README, and src files: a Zod-based MSF catalog schema (src/catalog.ts) with encode/decode/fetch helpers and src/index.ts re-export. Registers the workspace and adds the package as a dependency of js/watch. Extends js/watch to support selectable catalog formats ("hang" | "msf"), exposes a catalogFormat signal and element attribute/accessors, updates catalog fetching to handle both formats, and adds a converter (js/watch/src/msf.ts) that maps MSF catalogs into the existing hang catalog shape.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main changes: adding MSF catalog format support and implementing format negotiation between formats.
Description check ✅ Passed The description comprehensively details the key changes, implementation approach, and rationale for the PR, directly relating to the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/add-catalog-signal-AFPpW
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/add-catalog-signal-AFPpW

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (2)
js/watch/src/element.ts (1)

259-268: Avoid exposing the mutable catalog format array.

catalog returns and stores the Signal array by reference, so callers can mutate it without triggering Signal.set(); e.g. el.catalog.push("msf") changes internal state but may not restart negotiation. Return/store a copy, and consider validating array values before setting.

Proposed defensive copy
 get catalog(): CatalogFormat[] {
-	return this.broadcast.catalogFormats.peek();
+	return [...this.broadcast.catalogFormats.peek()];
 }

 set catalog(value: CatalogAttr | CatalogFormat[]) {
 	if (typeof value === "string") {
 		this.broadcast.catalogFormats.set(parseCatalogAttr(value));
 	} else {
-		this.broadcast.catalogFormats.set(value);
+		this.broadcast.catalogFormats.set([...value]);
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/watch/src/element.ts` around lines 259 - 268, The catalog getter currently
returns the internal Signal array by reference (via
broadcast.catalogFormats.peek()), and the setter stores arrays directly,
allowing external mutation to bypass Signal.set(); change the getter (catalog)
to return a shallow copy of the array so callers cannot mutate internal state,
and update the setter to validate/normalize entries (when given CatalogAttr or
CatalogFormat[]) and pass a new array instance to broadcast.catalogFormats.set()
(use parseCatalogAttr for string inputs, then validate each CatalogFormat item)
so all mutations go through Signal.set() and invalid values are rejected.
js/msf/src/catalog.ts (1)

4-61: Document the new public MSF API surface.

These exported schemas and helpers define the @moq/msf package contract. Please add brief comments for expected catalog shape, decode throwing behavior, and fetch returning undefined when the track closes without a frame. As per coding guidelines, “Document public APIs with clear docstrings or comments.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/msf/src/catalog.ts` around lines 4 - 61, Add brief doc comments for the
public MSF API exports to document the package contract: annotate
PackagingSchema and RoleSchema with allowed values summary, describe
TrackSchema/CatalogSchema shape and the expected catalog version=1 structure,
and add docstrings for encode (serializes Catalog to Uint8Array), decode (parses
JSON and documents that it will throw on invalid data or malformed JSON), and
fetch (documents it reads one frame from Moq.Track, decodes it, and returns
undefined if the track closes without a frame). Use the exported symbol names
PackagingSchema, RoleSchema, TrackSchema, CatalogSchema, encode, decode, and
fetch so the comments sit directly above those declarations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@js/msf/src/catalog.ts`:
- Around line 45-55: The decode function currently uses TextDecoder in
replacement mode and logs the entire decoded string; change it to create the
TextDecoder with { fatal: true } so invalid UTF-8 throws before JSON.parse
(refer to decode and TextDecoder usage), and update the catch block around
JSON.parse / CatalogSchema.parse to avoid printing the full str — log only a
minimal safe message (e.g., "invalid MSF catalog" plus a small fixed-size
hex/byte-length or prefix) or rethrow the error without the full payload to
prevent exposing large or sensitive initData.
- Around line 20-28: TrackSchema currently allows negative, fractional and
unsafe numeric values and accepts any string for initData; update TrackSchema so
numeric fields (width, height, framerate, samplerate, bitrate, renderGroup,
altGroup) use integer, non-negative, 53-bit-safe validation (e.g.
z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)) to prevent
fractional/unsafe values being passed to u53(), and tighten initData to require
valid base64 (e.g. z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/)). Locate and
modify the TrackSchema declaration and replace the loose z.number().optional() /
z.string().optional() entries for those symbols with the stronger validators.

In `@js/watch/src/broadcast.ts`:
- Around line 132-134: The current .then((c) => { if (c) return { kind: "msf" as
const, root: toHang(c) }; ... }) returns an MSF win even when toHang(c) produces
an empty root ({}); change the callback to validate the converted root from
toHang(c) and reject (throw) when it's empty. Specifically, call const root =
toHang(c) and check (e.g., Object.keys(root).length === 0) before returning {
kind: "msf", root }, throwing a descriptive Error like "msf catalog empty after
conversion" if the root is empty so MSF cannot win negotiation with no playable
renditions.

In `@js/watch/src/msf.ts`:
- Around line 92-99: The loop over msf.tracks currently ignores tracks with no
role set; update the logic in the block that iterates msf.tracks so tracks with
undefined role are not dropped but instead are inspected with the same
heuristics used by toVideoConfig and toAudioConfig (e.g., check codec, mime,
resolution/width/height or channel count) and routed into
videoRenditions[track.name] via toVideoConfig or audioRenditions[track.name] via
toAudioConfig as appropriate; keep the existing behavior when role === "video"
or "audio" but add a fallback branch that calls toVideoConfig first (or
toAudioConfig first) based on the track metadata and assigns the resulting
config if truthy.

---

Nitpick comments:
In `@js/msf/src/catalog.ts`:
- Around line 4-61: Add brief doc comments for the public MSF API exports to
document the package contract: annotate PackagingSchema and RoleSchema with
allowed values summary, describe TrackSchema/CatalogSchema shape and the
expected catalog version=1 structure, and add docstrings for encode (serializes
Catalog to Uint8Array), decode (parses JSON and documents that it will throw on
invalid data or malformed JSON), and fetch (documents it reads one frame from
Moq.Track, decodes it, and returns undefined if the track closes without a
frame). Use the exported symbol names PackagingSchema, RoleSchema, TrackSchema,
CatalogSchema, encode, decode, and fetch so the comments sit directly above
those declarations.

In `@js/watch/src/element.ts`:
- Around line 259-268: The catalog getter currently returns the internal Signal
array by reference (via broadcast.catalogFormats.peek()), and the setter stores
arrays directly, allowing external mutation to bypass Signal.set(); change the
getter (catalog) to return a shallow copy of the array so callers cannot mutate
internal state, and update the setter to validate/normalize entries (when given
CatalogAttr or CatalogFormat[]) and pass a new array instance to
broadcast.catalogFormats.set() (use parseCatalogAttr for string inputs, then
validate each CatalogFormat item) so all mutations go through Signal.set() and
invalid values are rejected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bb5f9c11-924d-4a75-942f-34f77c6c954f

📥 Commits

Reviewing files that changed from the base of the PR and between e3f0f92 and 4b89948.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • js/msf/package.json
  • js/msf/src/catalog.ts
  • js/msf/src/index.ts
  • js/msf/tsconfig.json
  • js/watch/package.json
  • js/watch/src/broadcast.ts
  • js/watch/src/element.ts
  • js/watch/src/msf.ts
  • package.json

Comment thread js/msf/src/catalog.ts Outdated
Comment on lines +20 to +28
width: z.number().optional(),
height: z.number().optional(),
framerate: z.number().optional(),
samplerate: z.number().optional(),
channelConfig: z.string().optional(),
bitrate: z.number().optional(),
initData: z.string().optional(),
renderGroup: z.number().optional(),
altGroup: z.number().optional(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify the Zod version and inspect downstream assumptions for MSF numeric/base64 fields.

fd -i '^package\.json$' js/msf -x sh -c 'echo "== $1 =="; sed -n "/\"zod\"/p" "$1"' sh {}

rg -n -C3 '\bu53\s*\(|base64ToBytes\s*\(|\b(width|height|framerate|samplerate|bitrate|renderGroup|altGroup|initData)\b' --type=ts

Repository: moq-dev/moq

Length of output: 50368


🌐 Web query:

Zod 4.1.5 base64 validator z.base64

💡 Result:

In Zod 4.1.5, the base64 validator is z.base64, a top-level string format validator that checks if a string is valid Base64 (RFC 4648 standard, including padding with =). It validates the character set (A-Z, a-z, 0-9, +, /, =), proper length (multiple of 4), and padding. Invalid examples like "should" throw "invalid base64 format". Usage: import { z } from "zod"; const schema = z.base64; schema.parse("SGVsbG8gd29ybGQ="); // valid: "Hello world" schema.parse("should"); // throws ZodError: invalid base64 format It differs from z.base64url, which is URL-safe (uses - and _ instead of + and /, no padding allowed in Zod 4). This feature was added in Zod 3.23 via PR #3047 and remains unchanged in 4.x, including 4.1.5. For decoding, use z.codec(z.base64, z.instanceof(Uint8Array)) with z.util.base64ToUint8Array.

Citations:


Tighten validation for MSF track metadata to prevent downstream failures.

The TrackSchema currently accepts negative, fractional, and unsafe numeric values. Additionally, initData accepts any string even though js/watch/src/msf.ts decodes it as base64. Without validation here, decoded Catalog objects will fail when numeric fields are passed to u53() for 53-bit safe integer conversion, or when initData is decoded via atob().

🛡️ Proposed schema hardening
+const U53Schema = z.number().int().nonnegative();
+const NonNegativeNumberSchema = z.number().nonnegative();
+
 export const TrackSchema = z.object({
 	name: z.string(),
 	packaging: PackagingSchema,
 	isLive: z.boolean(),
 	role: RoleSchema.optional(),
 	codec: z.string().optional(),
-	width: z.number().optional(),
-	height: z.number().optional(),
-	framerate: z.number().optional(),
-	samplerate: z.number().optional(),
+	width: U53Schema.optional(),
+	height: U53Schema.optional(),
+	framerate: NonNegativeNumberSchema.optional(),
+	samplerate: U53Schema.optional(),
 	channelConfig: z.string().optional(),
-	bitrate: z.number().optional(),
-	initData: z.string().optional(),
-	renderGroup: z.number().optional(),
-	altGroup: z.number().optional(),
+	bitrate: U53Schema.optional(),
+	initData: z.base64().optional(),
+	renderGroup: U53Schema.optional(),
+	altGroup: U53Schema.optional(),
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/msf/src/catalog.ts` around lines 20 - 28, TrackSchema currently allows
negative, fractional and unsafe numeric values and accepts any string for
initData; update TrackSchema so numeric fields (width, height, framerate,
samplerate, bitrate, renderGroup, altGroup) use integer, non-negative,
53-bit-safe validation (e.g.
z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)) to prevent
fractional/unsafe values being passed to u53(), and tighten initData to require
valid base64 (e.g. z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/)). Locate and
modify the TrackSchema declaration and replace the loose z.number().optional() /
z.string().optional() entries for those symbols with the stronger validators.

Comment thread js/msf/src/catalog.ts
Comment on lines +45 to +55
export function decode(raw: Uint8Array): Catalog {
const decoder = new TextDecoder();
const str = decoder.decode(raw);
try {
const json = JSON.parse(str);
return CatalogSchema.parse(json);
} catch (error) {
console.warn("invalid MSF catalog", str);
throw error;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Reject malformed UTF-8 and avoid logging the full catalog payload.

TextDecoder defaults to replacement mode, so invalid UTF-8 can be silently converted before JSON.parse. Also, logging str can expose user-provided metadata or large initData payloads.

🛡️ Proposed decode hardening
 export function decode(raw: Uint8Array): Catalog {
-	const decoder = new TextDecoder();
-	const str = decoder.decode(raw);
 	try {
+		const decoder = new TextDecoder("utf-8", { fatal: true });
+		const str = decoder.decode(raw);
 		const json = JSON.parse(str);
 		return CatalogSchema.parse(json);
 	} catch (error) {
-		console.warn("invalid MSF catalog", str);
+		const message = error instanceof Error ? error.message : String(error);
+		console.warn("invalid MSF catalog", { byteLength: raw.byteLength, error: message });
 		throw error;
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/msf/src/catalog.ts` around lines 45 - 55, The decode function currently
uses TextDecoder in replacement mode and logs the entire decoded string; change
it to create the TextDecoder with { fatal: true } so invalid UTF-8 throws before
JSON.parse (refer to decode and TextDecoder usage), and update the catch block
around JSON.parse / CatalogSchema.parse to avoid printing the full str — log
only a minimal safe message (e.g., "invalid MSF catalog" plus a small fixed-size
hex/byte-length or prefix) or rethrow the error without the full payload to
prevent exposing large or sensitive initData.

Comment thread js/watch/src/broadcast.ts Outdated
Comment on lines +132 to +134
.then((c) => {
if (c) return { kind: "msf" as const, root: toHang(c) };
throw new Error("msf catalog empty");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reject empty MSF conversions before letting MSF win negotiation.

Msf.fetch(msfTrack) can return a catalog that toHang(c) converts to {} when no usable renditions are found. Because this checks only c, MSF can win with an empty root and set the broadcast live with nothing playable.

Validate the converted root
 .then((c) => {
-	if (c) return { kind: "msf" as const, root: toHang(c) };
+	if (c) {
+		const root = toHang(c);
+		if (root.video || root.audio) return { kind: "msf" as const, root };
+	}
 	throw new Error("msf catalog empty");
 })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.then((c) => {
if (c) return { kind: "msf" as const, root: toHang(c) };
throw new Error("msf catalog empty");
.then((c) => {
if (c) {
const root = toHang(c);
if (root.video || root.audio) return { kind: "msf" as const, root };
}
throw new Error("msf catalog empty");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/watch/src/broadcast.ts` around lines 132 - 134, The current .then((c) => {
if (c) return { kind: "msf" as const, root: toHang(c) }; ... }) returns an MSF
win even when toHang(c) produces an empty root ({}); change the callback to
validate the converted root from toHang(c) and reject (throw) when it's empty.
Specifically, call const root = toHang(c) and check (e.g.,
Object.keys(root).length === 0) before returning { kind: "msf", root }, throwing
a descriptive Error like "msf catalog empty after conversion" if the root is
empty so MSF cannot win negotiation with no playable renditions.

Comment thread js/watch/src/msf.ts
Comment on lines +92 to +99
for (const track of msf.tracks) {
if (track.role === "video") {
const config = toVideoConfig(track);
if (config) videoRenditions[track.name] = config;
} else if (track.role === "audio") {
const config = toAudioConfig(track);
if (config) audioRenditions[track.name] = config;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Handle MSF tracks without an explicit role.

Msf.Track.role is optional, but this loop drops any role-less track even when it has usable video/audio metadata. That can turn a valid MSF catalog into an empty hang catalog and prevent playback.

Possible fallback inference
 for (const track of msf.tracks) {
-	if (track.role === "video") {
+	const looksVideo = track.width != null || track.height != null || track.framerate != null;
+	const looksAudio = track.samplerate != null || track.channelConfig != null;
+
+	if (track.role === "video" || (track.role == null && looksVideo)) {
 		const config = toVideoConfig(track);
 		if (config) videoRenditions[track.name] = config;
-	} else if (track.role === "audio") {
+	} else if (track.role === "audio" || (track.role == null && looksAudio)) {
 		const config = toAudioConfig(track);
 		if (config) audioRenditions[track.name] = config;
 	}
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const track of msf.tracks) {
if (track.role === "video") {
const config = toVideoConfig(track);
if (config) videoRenditions[track.name] = config;
} else if (track.role === "audio") {
const config = toAudioConfig(track);
if (config) audioRenditions[track.name] = config;
}
for (const track of msf.tracks) {
const looksVideo = track.width != null || track.height != null || track.framerate != null;
const looksAudio = track.samplerate != null || track.channelConfig != null;
if (track.role === "video" || (track.role == null && looksVideo)) {
const config = toVideoConfig(track);
if (config) videoRenditions[track.name] = config;
} else if (track.role === "audio" || (track.role == null && looksAudio)) {
const config = toAudioConfig(track);
if (config) audioRenditions[track.name] = config;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/watch/src/msf.ts` around lines 92 - 99, The loop over msf.tracks currently
ignores tracks with no role set; update the logic in the block that iterates
msf.tracks so tracks with undefined role are not dropped but instead are
inspected with the same heuristics used by toVideoConfig and toAudioConfig
(e.g., check codec, mime, resolution/width/height or channel count) and routed
into videoRenditions[track.name] via toVideoConfig or
audioRenditions[track.name] via toAudioConfig as appropriate; keep the existing
behavior when role === "video" or "audio" but add a fallback branch that calls
toVideoConfig first (or toAudioConfig first) based on the track metadata and
assigns the resulting config if truthy.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
js/watch/src/broadcast.ts (1)

108-111: ⚠️ Potential issue | 🟡 Minor

toHang() can produce an empty {} root on MSF — status still flips to "live".

toHang(update) returns {} when the MSF catalog has no video/audio renditions (see js/watch/src/msf.ts lines 87-113: only track.role === "video"|"audio" are mapped, everything else is dropped). The loop then treats {} as a valid update, sets #catalog to an empty root and transitions status to "live" with nothing playable. Consider treating an empty converted root as "no update" (skip the iteration) or as stream end, so downstream consumers of status/catalog don't see a spurious live state.

 : async () => {
 		const update = await Msf.fetch(track);
-		return update ? toHang(update) : undefined;
+		if (!update) return undefined;
+		const root = toHang(update);
+		return root.video || root.audio ? root : undefined;
 	};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/watch/src/broadcast.ts` around lines 108 - 111, The converted root from
toHang(update) can be an empty object which currently counts as a valid update
and flips status to "live"; modify the async updater (the lambda that calls
Msf.fetch(track) in broadcast.ts) to inspect the result of toHang(update) and
treat an empty root as no update: call const root = update ? toHang(update) :
undefined; then if root is an object with no own keys (Object.keys(root).length
=== 0) return undefined (or otherwise signal end), otherwise return root; this
prevents setting `#catalog` to an empty root and avoids spurious "live"
transitions.
🧹 Nitpick comments (2)
js/watch/src/broadcast.ts (2)

101-111: Extract the track-name and fetcher mapping out of #runCatalog.

The "catalog.json" / "catalog" track names and the format→fetcher switch are format-policy details buried inside the effect. Extracting a small map (or two tiny helpers) would remove the inline ternaries, avoid magic string literals, and make it trivial to add future formats.

+const CATALOG_TRACK: Record<CatalogFormat, string> = {
+	hang: "catalog.json",
+	msf: "catalog",
+};

Then const track = broadcast.subscribe(CATALOG_TRACK[format], Catalog.PRIORITY.catalog); and a fetchCatalog(format, track) helper.

As per coding guidelines: "Avoid using magic numbers; use named constants instead" — the same applies to these magic track-name strings.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/watch/src/broadcast.ts` around lines 101 - 111, The runCatalog effect
currently inlines format-specific track names and a ternary fetcher; extract
those into named mappings and helpers: create a CATALOG_TRACK map keyed by
format values that maps to "catalog.json" or "catalog" and a
fetchCatalog(format, track) function that returns the appropriate async fetcher
(delegating to Catalog.fetch for "hang" and to Msf.fetch + toHang for other
formats). Replace uses of trackName, fetchNext and the inline ternaries in
runCatalog with broadcast.subscribe(CATALOG_TRACK[format],
Catalog.PRIORITY.catalog) and fetchCatalog(format, track) to remove magic
strings and simplify the effect.

28-29: API naming: catalog prop vs catalog getter collide.

BroadcastProps.catalog now means “catalog format” (a CatalogFormat), while the Broadcast class already exposes catalog: Getter<Catalog.Root | undefined> (the decoded catalog) and stores the format as catalogFormat. Two different "catalog" meanings on the same API is confusing for consumers. Consider renaming the prop to catalogFormat to match the field, keeping one consistent noun across prop/field/getter.

-	// Which catalog format to use. Default: "hang"
-	catalog?: CatalogFormat | Signal<CatalogFormat>;
+	// Which catalog format to use. Default: "hang"
+	catalogFormat?: CatalogFormat | Signal<CatalogFormat>;
-		this.catalogFormat = Signal.from(props?.catalog ?? "hang");
+		this.catalogFormat = Signal.from(props?.catalogFormat ?? "hang");

Note: js/watch/src/element.ts already names the attribute/getter catalog on the web component, which would also benefit from being updated for consistency.

Also applies to: 41-41, 59-59

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/watch/src/broadcast.ts` around lines 28 - 29, Rename the prop in
BroadcastProps from catalog to catalogFormat to avoid colliding with the
Broadcast class getter catalog (Getter<Catalog.Root | undefined>) and to match
the existing field name catalogFormat; update all usages and typings where
BroadcastProps.catalog is referenced (including the web component
attribute/getter in element.ts) to use catalogFormat instead so the
prop/field/getter terminology is consistent across Broadcast, BroadcastProps,
and the web component.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@js/watch/src/broadcast.ts`:
- Around line 108-111: The converted root from toHang(update) can be an empty
object which currently counts as a valid update and flips status to "live";
modify the async updater (the lambda that calls Msf.fetch(track) in
broadcast.ts) to inspect the result of toHang(update) and treat an empty root as
no update: call const root = update ? toHang(update) : undefined; then if root
is an object with no own keys (Object.keys(root).length === 0) return undefined
(or otherwise signal end), otherwise return root; this prevents setting `#catalog`
to an empty root and avoids spurious "live" transitions.

---

Nitpick comments:
In `@js/watch/src/broadcast.ts`:
- Around line 101-111: The runCatalog effect currently inlines format-specific
track names and a ternary fetcher; extract those into named mappings and
helpers: create a CATALOG_TRACK map keyed by format values that maps to
"catalog.json" or "catalog" and a fetchCatalog(format, track) function that
returns the appropriate async fetcher (delegating to Catalog.fetch for "hang"
and to Msf.fetch + toHang for other formats). Replace uses of trackName,
fetchNext and the inline ternaries in runCatalog with
broadcast.subscribe(CATALOG_TRACK[format], Catalog.PRIORITY.catalog) and
fetchCatalog(format, track) to remove magic strings and simplify the effect.
- Around line 28-29: Rename the prop in BroadcastProps from catalog to
catalogFormat to avoid colliding with the Broadcast class getter catalog
(Getter<Catalog.Root | undefined>) and to match the existing field name
catalogFormat; update all usages and typings where BroadcastProps.catalog is
referenced (including the web component attribute/getter in element.ts) to use
catalogFormat instead so the prop/field/getter terminology is consistent across
Broadcast, BroadcastProps, and the web component.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 24c77dcb-e2e0-4041-b43c-ea03c77c1027

📥 Commits

Reviewing files that changed from the base of the PR and between 4b89948 and 529f549.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (4)
  • js/msf/src/catalog.ts
  • js/watch/package.json
  • js/watch/src/broadcast.ts
  • js/watch/src/element.ts
✅ Files skipped from review due to trivial changes (1)
  • js/watch/package.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • js/watch/src/element.ts

claude added 2 commits April 19, 2026 22:43
`BroadcastProps.catalog` was ambiguous with `Broadcast.catalog` (the
resolved `Getter<Catalog.Root>`). Rename the prop/attr/getter to
`catalogFormat` / `catalog-format` so the format selector is consistent
across Broadcast, BroadcastProps, and <moq-watch>.

https://claude.ai/code/session_012gjc9wFMcfGj83fazaJ5ij
The common build script copies README.md into dist/, so the package must
have one or CI's build step fails with ENOENT.

https://claude.ai/code/session_012gjc9wFMcfGj83fazaJ5ij
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
js/watch/src/broadcast.ts (1)

105-123: ⚠️ Potential issue | 🟠 Major

MSF can publish an empty catalog as "live".

toHang(update) returns {} when the MSF catalog has no usable video/audio renditions (see toHang in js/watch/src/msf.ts:87-113). In that case update is truthy, so the loop still calls this.#catalog.set({}) and transitions status to "live" with nothing playable. Consider rejecting/skipping when the converted root has neither video nor audio:

♻️ Proposed guard
 				: async () => {
 						const update = await Msf.fetch(track);
-						return update ? toHang(update) : undefined;
+						if (!update) return undefined;
+						const root = toHang(update);
+						if (!root.video && !root.audio) return undefined;
+						return root;
 					};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/watch/src/broadcast.ts` around lines 105 - 123, The loop currently treats
any truthy MSF fetch result as a valid update, but toHang(update) can return an
empty object (no `video` or `audio`) causing this.#catalog.set({}) and
status.set("live") with nothing playable; change the fetch/consume logic (the
fetchNext branch using Msf.fetch and the consumer in effect.spawn) to detect and
skip empty converted catalogs (i.e., where the object from toHang has neither
`video` nor `audio`) — when detected, continue the loop without calling
this.#catalog.set or this.status.set so empty MSF publications are ignored.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@js/watch/src/broadcast.ts`:
- Around line 105-123: The loop currently treats any truthy MSF fetch result as
a valid update, but toHang(update) can return an empty object (no `video` or
`audio`) causing this.#catalog.set({}) and status.set("live") with nothing
playable; change the fetch/consume logic (the fetchNext branch using Msf.fetch
and the consumer in effect.spawn) to detect and skip empty converted catalogs
(i.e., where the object from toHang has neither `video` nor `audio`) — when
detected, continue the loop without calling this.#catalog.set or this.status.set
so empty MSF publications are ignored.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bf1a598d-c03e-4876-9d8b-361f155d983d

📥 Commits

Reviewing files that changed from the base of the PR and between 529f549 and 9ebf55f.

📒 Files selected for processing (2)
  • js/watch/src/broadcast.ts
  • js/watch/src/element.ts

Mirrors the hang catalog's `jitter` field: maximum delay (ms) between
consecutive frames on a track, which the player must buffer for to avoid
underruns. Not in the MSF spec, but needed to drive the jitter buffer
from MSF-only publishers.

Wired through `toHang()` so MSF tracks producing a `jitter` value flow
straight into hang's existing `jitter` field on VideoConfig/AudioConfig.

https://claude.ai/code/session_012gjc9wFMcfGj83fazaJ5ij
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
js/msf/src/catalog.ts (2)

54-63: ⚠️ Potential issue | 🟡 Minor

Reject malformed UTF-8 and avoid logging the catalog payload.

TextDecoder defaults to replacement mode, and console.warn(..., str) can leak user-provided metadata or large initData values.

🛡️ Proposed decode hardening
 export function decode(raw: Uint8Array): Catalog {
-	const decoder = new TextDecoder();
-	const str = decoder.decode(raw);
 	try {
+		const decoder = new TextDecoder("utf-8", { fatal: true });
+		const str = decoder.decode(raw);
 		const json = JSON.parse(str);
 		return CatalogSchema.parse(json);
 	} catch (error) {
-		console.warn("invalid MSF catalog", str);
+		const message = error instanceof Error ? error.message : String(error);
+		console.warn("invalid MSF catalog", { byteLength: raw.byteLength, error: message });
 		throw error;
 	}
 }

Please verify the runtime behavior for your supported JS environments:

MDN TextDecoder fatal option invalid UTF-8 replacement behavior
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/msf/src/catalog.ts` around lines 54 - 63, The decode function currently
uses TextDecoder in replacement mode and logs the decoded payload, risking
silent acceptance of malformed UTF-8 and leaking sensitive data; update decode
to construct TextDecoder with the fatal option (new TextDecoder(undefined, {
fatal: true })) so invalid UTF-8 raises on decode, remove any logging of the
decoded catalog payload (do not console.warn(str)), and in the catch block log
only a generic message (e.g., "invalid MSF catalog") and rethrow or wrap the
error; ensure CatalogSchema.parse remains used for JSON validation but do not
expose the raw str in logs or exceptions.

24-37: ⚠️ Potential issue | 🟠 Major

Tighten MSF metadata validation before downstream u53() conversion.

width, height, samplerate, bitrate, renderGroup, altGroup, and the new jitter field can still be negative, fractional, or unsafe. js/watch/src/msf.ts converts these values with u53(), so malformed catalogs can fail after decode() succeeds. initData should also be validated as base64 before the CMAF parser sees it.

🛡️ Proposed schema hardening
+const U53Schema = z.int().check(z.nonnegative());
+const NonNegativeNumberSchema = z.number().check(z.nonnegative());
+
 export const TrackSchema = z.object({
 	name: z.string(),
 	packaging: PackagingSchema,
 	isLive: z.boolean(),
 	role: z.optional(RoleSchema),
 	codec: z.optional(z.string()),
-	width: z.optional(z.number()),
-	height: z.optional(z.number()),
-	framerate: z.optional(z.number()),
-	samplerate: z.optional(z.number()),
+	width: z.optional(U53Schema),
+	height: z.optional(U53Schema),
+	framerate: z.optional(NonNegativeNumberSchema),
+	samplerate: z.optional(U53Schema),
 	channelConfig: z.optional(z.string()),
-	bitrate: z.optional(z.number()),
-	initData: z.optional(z.string()),
-	renderGroup: z.optional(z.number()),
-	altGroup: z.optional(z.number()),
+	bitrate: z.optional(U53Schema),
+	initData: z.optional(z.base64()),
+	renderGroup: z.optional(U53Schema),
+	altGroup: z.optional(U53Schema),
 
 	// Non-standard: maximum delay (ms) between consecutive frames on this track.
 	// The player's buffer must be at least this large to avoid underruns.
 	// Mirrors the `jitter` field in the hang catalog.
-	jitter: z.optional(z.number()),
+	jitter: z.optional(U53Schema),
 });

Please verify these Zod Mini helpers against the pinned Zod version before applying:

For zod@4.1.5 with import from "zod/mini", confirm that z.int().check(z.nonnegative()), z.number().check(z.nonnegative()), and z.base64() are supported APIs.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/msf/src/catalog.ts` around lines 24 - 37, The schema in catalog.ts allows
negative, fractional, NaN/Infinity and unsafe integers which later break u53()
in js/watch/src/msf.ts during decode(); update the Zod validators for the fields
width, height, samplerate, bitrate, renderGroup, altGroup, and jitter to require
nonnegative integers within safe integer range (use z.int()/z.number() with
checks for nonnegative, finite, and <= Number.MAX_SAFE_INTEGER as supported by
your zod/mini version) and validate initData with a base64 check (z.base64() or
equivalent) so malformed values are rejected early; confirm the zod/mini APIs
z.int().check(z.nonnegative()), z.number().check(z.nonnegative()), and
z.base64() exist for the pinned zod version before applying.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@js/msf/src/catalog.ts`:
- Around line 54-63: The decode function currently uses TextDecoder in
replacement mode and logs the decoded payload, risking silent acceptance of
malformed UTF-8 and leaking sensitive data; update decode to construct
TextDecoder with the fatal option (new TextDecoder(undefined, { fatal: true }))
so invalid UTF-8 raises on decode, remove any logging of the decoded catalog
payload (do not console.warn(str)), and in the catch block log only a generic
message (e.g., "invalid MSF catalog") and rethrow or wrap the error; ensure
CatalogSchema.parse remains used for JSON validation but do not expose the raw
str in logs or exceptions.
- Around line 24-37: The schema in catalog.ts allows negative, fractional,
NaN/Infinity and unsafe integers which later break u53() in js/watch/src/msf.ts
during decode(); update the Zod validators for the fields width, height,
samplerate, bitrate, renderGroup, altGroup, and jitter to require nonnegative
integers within safe integer range (use z.int()/z.number() with checks for
nonnegative, finite, and <= Number.MAX_SAFE_INTEGER as supported by your
zod/mini version) and validate initData with a base64 check (z.base64() or
equivalent) so malformed values are rejected early; confirm the zod/mini APIs
z.int().check(z.nonnegative()), z.number().check(z.nonnegative()), and
z.base64() exist for the pinned zod version before applying.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1cc48209-d011-4ace-b3e8-de8fe27243b1

📥 Commits

Reviewing files that changed from the base of the PR and between a66f7b0 and 8b3a847.

📒 Files selected for processing (2)
  • js/msf/src/catalog.ts
  • js/watch/src/msf.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • js/watch/src/msf.ts

@kixelated kixelated enabled auto-merge (squash) April 20, 2026 13:51
@kixelated kixelated merged commit f65b1aa into main Apr 20, 2026
1 check passed
@kixelated kixelated deleted the claude/add-catalog-signal-AFPpW branch April 20, 2026 13:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants