Skip to content

Conversation

@kixelated
Copy link
Collaborator

@kixelated kixelated commented Aug 27, 2025

Summary by CodeRabbit

  • New Features

    • Frame module with end-to-end frame encode/decode and keyframe support.
    • New read/write helpers for strings, JSON, and booleans across tracks/groups.
    • Chat reworked into Message and Typing components; catalog exposes both.
    • Preview/video/location watchers now use schema-validated reads.
    • Effect.timeout and flexible DOM render/setClass utilities.
  • Refactor

    • Legacy container/frame/bool/position replaced by Frame and direct track APIs; audio/video flows updated.
  • Documentation

    • Examples updated to string-based publish/subscribe.
  • Chores

    • Zod helpers exposed; zod added as peer dependency; dependency bumps.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 27, 2025

Walkthrough

Refactors framing from Container to a new Frame module with varint timestamps; introduces typed read/write helpers (string/json/bool) across moq; splits Chat into Message/Typing for publish and watch; replaces many container wrappers with direct TrackProducer/TrackConsumer usage and adds Zod-backed read/write flows. Signals DOM render API and Effect gains timeout.

Changes

Cohort / File(s) Summary
Frame module introduction
js/hang/src/frame.ts, js/hang/src/index.ts
Adds Frame encoding/decoding (varint timestamp), Producer/Consumer classes and exports Frame namespace; replaces Container re-export with Frame.
Container removals & barrel cleanup
js/hang/src/container/*
js/hang/src/container/index.ts
Deletes frame.ts, position.ts, bool.ts, vint.ts; removes their re-exports from container barrel.
Catalog/schema tweaks
js/hang/src/catalog/chat.ts, js/hang/src/catalog/root.ts
ChatSchema removes required track, adds optional message and typing; root fetch uses readFrame() and passes raw frame to decode(frame).
Publish: chat split
js/hang/src/publish/chat/index.ts, js/hang/src/publish/chat/message.ts, js/hang/src/publish/chat/typing.ts, (js/hang/src/publish/chat.ts deleted)
Introduces composed Chat using Message (writes strings) and Typing (writes bools); removes monolithic Chat publisher.
Publish: audio/video/captions/speaking
js/hang/src/publish/audio/*, js/hang/src/publish/video/*, js/hang/src/publish/audio/captions.ts, js/hang/src/publish/audio/speaking.ts
Switches framing to Frame.encode/decode; captions use writeString; speaking replaces BoolProducer with direct TrackProducer and writeBool.
Publish: preview/location
js/hang/src/publish/preview.ts, js/hang/src/publish/location.ts
Preview now uses writeJson and guards undefined; Location drops PositionProducer in favor of direct TrackProducer.writeJson and signal type change.
Watch: chat split
js/hang/src/watch/chat/index.ts, js/hang/src/watch/chat/message.ts, js/hang/src/watch/chat/typing.ts, (js/hang/src/watch/chat.ts deleted)
Adds watcher Chat composed of Message (reads strings) and Typing (reads bools); removes monolithic watcher.
Watch: audio/video/captions/speaking
js/hang/src/watch/audio/*, js/hang/src/watch/video/*, js/hang/src/watch/audio/captions.ts, js/hang/src/watch/audio/speaking.ts
Move to Frame APIs and readFrame(); captions use readString; speaking reads booleans via readBool, removing BoolConsumer.
Watch: preview/location/detection/broadcast
js/hang/src/watch/preview.ts, js/hang/src/watch/location.ts, js/hang/src/watch/video/detection.ts, js/hang/src/watch/broadcast.ts
PreviewWatch → Preview with Zod.read parsing; Location and Detection use Zod.read; Broadcast now constructs Preview.
moq track/group helpers & API rename
js/moq/src/track.ts, js/moq/src/group.ts, js/moq/src/ietf/publisher.ts, js/moq/src/lite/publisher.ts, js/moq/src/track.test.ts
Adds writeString/writeJson/writeBool to producers; adds readFrame/readString/readJson/readBool to consumers; renames nextFramereadFrame and updates callers/tests.
moq Zod helpers & packaging
js/moq/src/zod.ts, js/moq/package.json
Adds read/write Zod helpers for typed I/O and exposes ./zod export; adds zod peerDependency.
Examples & clock changes
js/moq/README.md, js/moq-clock/src/main.ts
Docs and clock demo migrate to string-based group I/O (writeString/readString) and updated read APIs.
Signals API changes
js/signals/src/dom.ts, js/signals/src/index.ts
DOM render API reworked to Element model and render(effect, parent, element) signature; adds setClass; Effect adds timeout and DEV flag rename.
Misc & packaging
js/moq/src/util/watch.ts, rs/justfile, various package.json bumps
DEV constant rename and idempotent guards; remove cargo patch step in justfile; multiple dev dependency bumps.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant App
  participant FrameProducer as Frame.Producer
  participant Track as Moq.TrackProducer
  participant Group as Moq.GroupProducer

  App->>FrameProducer: encode(data, ts, keyframe)
  alt keyframe
    FrameProducer->>Track: close current group (if any)
    FrameProducer->>Track: open new group
  end
  FrameProducer->>Group: writeFrame(encode(data, ts))
  note right of Group: Timestamp varint + payload
Loading
sequenceDiagram
  autonumber
  participant Publish as Chat.Publish
  participant Message as Chat.Message
  participant Typing as Chat.Typing
  participant Broadcast as Moq.BroadcastProducer

  Publish->>Message: new(broadcast, props.message)
  Publish->>Typing: new(broadcast, props.typing)
  rect rgba(200,235,255,0.3)
    Message->>Broadcast: insert track (enabled)
    Typing->>Broadcast: insert track (enabled)
    Message->>Broadcast: writeString(latest)
    Typing->>Broadcast: writeBool(active)
  end
  Publish-->>Publish: compute catalog { message, typing }
Loading
sequenceDiagram
  autonumber
  participant Watch as Preview
  participant Catalog
  participant Broadcast as Moq.BroadcastConsumer
  participant Track as Moq.TrackConsumer
  participant Z as Zod

  Watch->>Catalog: get preview track
  alt track available
    Watch->>Broadcast: subscribe(name, priority)
    Broadcast-->>Track: TrackConsumer
    Watch->>Z: read(Track, InfoSchema)
    Z-->>Watch: Info | undefined
    Watch-->>Watch: set preview signal
  else no track
    Watch-->>Watch: no-op
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

hop-hop, I shuffle frames with care,
varints snug and timestamps there.
chat split neat—message, typing true,
Zod checks all, the streams renew.
signals bloom, DOM finds its art—
I thump my nose, delighted heart. 🐇✨

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch revamp-chat

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@claude
Copy link
Contributor

claude bot commented Aug 27, 2025

Claude finished @kixelated's task —— View job


Code Review Complete 👍

Reviewing the "Revamp chat typing and audio speaking" PR for bugs and security issues.

Todo List:

  • Set up development environment
  • Review architectural changes and file restructuring
  • Examine new chat typing implementation
  • Review audio speaking changes
  • Check container consolidation (frame.ts)
  • Look for security vulnerabilities
  • Check error handling and input validation
  • Review signal system changes
  • Provide final assessment

✅ No significant bugs or security issues found.

The PR shows a solid architectural refactoring with proper security practices:

Security Analysis:

  • JSON parsing is properly wrapped in try-catch blocks with Zod validation (js/hang/src/catalog/root.ts:33-38)
  • Boolean frame validation includes proper bounds checking (js/moq/src/track.ts:255-256)
  • Worker instantiation uses proper URL imports with import.meta.url for trusted paths
  • No unsafe HTML manipulation (innerHTML, dangerouslySetInnerHTML, etc.) detected
  • Buffer operations in frame encoding/decoding use DataView with proper bounds checking

Code Quality:

  • Clean separation of concerns with chat split into message.ts and typing.ts
  • Proper resource cleanup with Effect system
  • Container utilities well-consolidated into frame.ts with QUIC VarInt encoding
  • Consistent error handling patterns throughout

Architecture:

  • Sound reactive pattern usage with Signals and Effects
  • Proper async cancellation patterns with Promise.race
  • Worker termination handled correctly in cleanup

The refactoring maintains security best practices while improving code organization.


Copy link
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: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
js/hang/src/preview/member.ts (1)

44-47: Don’t close externally-owned broadcast in close().

Member didn’t create the BroadcastConsumer; closing it here can tear down other consumers.

  close() {
-		this.signals.close();
-		this.broadcast.close();
+		this.signals.close();
  }
js/moq/README.md (1)

69-71: Update README.md subscribe example and remove hard tabs

  • Verified that BroadcastConsumer.subscribe(track: string, priority: number): TrackConsumer is a synchronous API. The README example should drop await and pass a priority argument (e.g. 0).
  • Lines 73–83 in the code block use hard tabs (markdownlint MD010). Replace all tabs with spaces in this snippet (or disable MD010 for the block).

File: js/moq/README.md (L69–71, L73–83)

Proposed diff:

- // Subscribe to a specific track
- const track = await broadcast.subscribe("chat");
+ // Subscribe to a specific track
+ const track = broadcast.subscribe("chat", 0);

Be sure to convert any remaining tabs in the snippet to spaces.

js/hang/src/watch/location.ts (1)

86-105: Per-frame parse failures currently abort the entire consumer loop.

A single invalid frame throws and exits; skip bad frames instead.

Apply:

 async function runConsumer(
   consumer: Moq.TrackConsumer,
   location: Signal<Catalog.Position | undefined>,
   cancel: Promise<void>,
 ) {
   try {
     for (;;) {
-      const position = await Promise.race([Zod.read(consumer, Catalog.PositionSchema), cancel]);
-      if (!position) break;
+      let position: Catalog.Position | undefined;
+      try {
+        position = await Promise.race([Zod.read(consumer, Catalog.PositionSchema), cancel]);
+      } catch (e) {
+        console.warn("invalid location frame; skipping", e);
+        continue;
+      }
+      if (position === undefined) break;
 
       location.set(position);
     }
 
     location.set(undefined);
   } catch (err) {
js/moq-clock/src/main.ts (1)

1-204: Update remaining nextFrame references to readFrame

The repository-wide rename of nextFramereadFrame is not yet complete. Please update the following locations:

  • js/hang/src/frame.ts (around line 62)
    // Before
    const next = await this.#track.nextFrame();
    
    // After
    const next = await this.#track.readFrame();
  • js/moq/src/track.ts
    • Lines 185–187: update comment and helper reference
    // Before
    // Update the state needed for the nextFrame() helper.
    
    // After
    // Update the state needed for the readFrame() helper.
    • Lines 197–201: the nextFrame() method definition remains; either rename it to readFrame() (and adjust callers) or remove it if deprecated.
  • js/moq/src/track.ts (around line 235)
    // Before
    const next = await this.nextFrame();
    
    // After
    const next = await this.readFrame();
  • js/hang/src/watch/video/index.ts (around line 114)
    // Before
    const next = await Promise.race([sub.nextFrame(), cancel]);
    
    // After
    const next = await Promise.race([sub.readFrame(), cancel]);

After these edits, rerun a global search for nextFrame( to confirm no remaining references.

js/moq/src/util/watch.ts (2)

41-45: FinalizationRegistry: guard usage to avoid crashes in environments that lack it.

Register only when the registry exists; otherwise older runtimes can throw at module load.

Apply this diff:

-    if (DEV) {
+    if (DEV && WatchProducer.finalizer) {
       const debugInfo = new Error("Created here").stack ?? "No stack";
-      WatchProducer.finalizer.register(this, debugInfo, this);
+      WatchProducer.finalizer.register(this, debugInfo, this);
     }

Additionally, update the static definition (outside this hunk) to make the registry optional:

// near line 33
private static finalizer?: FinalizationRegistry<string> =
  typeof FinalizationRegistry !== "undefined"
    ? new FinalizationRegistry<string>((debugInfo) => {
        console.warn(`WatchProducer was garbage collected without being closed:\n${debugInfo}`);
      })
    : undefined;

143-147: Consumer finalizer: guard register and make registry optional.

-    if (DEV) {
+    if (DEV && WatchConsumer.finalizer) {
       const debugInfo = new Error("Created here").stack ?? "No stack";
-      WatchConsumer.finalizer.register(this, debugInfo, this);
+      WatchConsumer.finalizer.register(this, debugInfo, this);
     }

Also mirror the producer’s optional static definition (outside this hunk):

// near the WatchConsumer.finalizer declaration
private static finalizer?: FinalizationRegistry<string> =
  typeof FinalizationRegistry !== "undefined"
    ? new FinalizationRegistry<string>((debugInfo) => {
        console.warn(`WatchConsumer was garbage collected without being closed:\n${debugInfo}`);
      })
    : undefined;
🧹 Nitpick comments (33)
js/hang/src/watch/audio/speaking.ts (1)

47-55: Good migration to direct readBool; add lightweight error guard in loop

Loop + cancel handling looks correct. To avoid noisy effect-level error logs if sub.readBool() rejects, guard locally.

 effect.spawn(async (cancel) => {
   for (;;) {
-    const speaking = await Promise.race([sub.readBool(), cancel]);
+    let speaking: boolean | undefined;
+    try {
+      speaking = await Promise.race([sub.readBool(), cancel]);
+    } catch (e) {
+      console.warn("speaking read error", e);
+      break;
+    }
     if (speaking === undefined) break;

     this.#active.set(speaking);
   }
 });
js/hang/src/publish/location.ts (1)

96-127: Sanitize handle before embedding in track name

If handle can be user-provided, path injection or invalid names are possible (slashes, .., control chars). Sanitize or constrain to a safe charset.

- const track = new Moq.TrackProducer(`peer/${handle}/location.json`, 0);
+ const safe = handle.replace(/[^a-zA-Z0-9._-]/g, "_");
+ const track = new Moq.TrackProducer(`peer/${safe}/location.json`, 0);

If a stricter policy is desired, validate and bail out instead of coercing.

Would you like me to add a small helper (e.g., to utils/path.ts) and unit tests to centralize this sanitization?

js/signals/src/dom.ts (1)

111-121: setClass is fine; optional: support SVGElement

If you plan to call setClass on SVG elements, broaden the element type to Element & { classList: DOMTokenList }.

-export function setClass(effect: Effect, element: HTMLElement, ...classNames: string[]) {
+export function setClass(effect: Effect, element: Element & { classList: DOMTokenList }, ...classNames: string[]) {
js/hang/src/publish/video/index.ts (1)

138-140: Decouple keyframe logic and note writeFrame is synchronous

I looked into the GroupProducer implementation in js/moq/src/group.ts—its writeFrame(frame: Uint8Array) method is a plain, synchronous call that returns void (no promise) and simply appends the frame to an in-memory buffer. This means there’s no built-in backpressure or async boundary to await—if your pipeline needs to throttle or block on downstream consumers, it must do so before or after this call.

Locations of interest:

  • js/hang/src/publish/video/index.ts at lines 138–140
  • js/hang/src/publish/audio/index.ts at lines 208–209

Suggested optional refactor to embed keyframe metadata (requires extending Frame.encode API):

- const buffer = Frame.encode(frame, frame.timestamp);
+ // Propagate keyFrame flag so consumers needn’t assume object ID 0 is always a keyframe
+ const buffer = Frame.encode(frame, frame.timestamp /*, { keyFrame: frame.isKey } */);
  group?.writeFrame(buffer);

Note:

  • GroupProducer.writeFrame is synchronous (no promise), so backpressure won’t be enforced here—ensure upstream write logic handles pacing if needed.
js/hang/src/watch/video/index.ts (1)

117-123: Align VideoTrackConsumer API with AudioTrackConsumer
Video currently uses nextFrame(), whereas audio and catalog consumers use readFrame(). For consistency and future maintainability, consider unifying these method names or clearly documenting the distinction:

• Video consumer calls
– js/hang/src/frame.ts:62 → this.#track.nextFrame()
– js/hang/src/watch/video/index.ts:114 → sub.nextFrame()
• Audio consumer calls
– js/hang/src/watch/audio/index.ts:153 → sub.readFrame()
• Catalog uses
– js/hang/src/catalog/root.ts:42 → track.readFrame()

Options:

  • Rename (or alias) nextFrame() to readFrame() in the VideoTrackConsumer so all tracks share the same API surface.
  • If keeping both, add a note in the video code explaining why it uses nextFrame() and that frame === 0 denotes a key frame (e.g. // key frame when frameId === 0).
  • There’s no readObject() yet—if you’d like to expose both frame ID and payload in one call in the future, you could introduce a readObject() method.
js/hang/src/publish/audio/captions.ts (2)

69-75: Rename misleading log label ("VAD worker error").

This worker is captions/transcription; the log label is confusing.

Apply this change:

-				console.error("VAD worker error:", data.message);
+				console.error("Captions worker error:", data.message);

131-134: Close the track on teardown to avoid resource leaks.

Captions.close() doesn't close #track, unlike Detection which does. Add a close to ensure underlying resources are released when the component is disposed.

Apply this diff:

  close() {
-		this.signals.close();
+		this.signals.close();
+		this.#track.close();
  }
js/hang/src/publish/video/detection.ts (1)

36-36: Avoid double-closing the track.

signals.cleanup(() => this.#track.close()) plus this.#track.close() in close() can call close() twice. Make one the single source of truth (prefer relying on signals.cleanup).

Apply this diff:

  close() {
    this.signals.close();
-    this.#track.close();
  }

Also applies to: 86-89

js/hang/src/watch/audio/captions.ts (1)

51-52: Optional: normalize empty caption to undefined.

If the UI treats “no caption” as absence rather than empty string, normalize here.

-				this.#text.set(result);
+				this.#text.set(result === "" ? undefined : result);

Confirm whether the UI expects empty string or undefined to clear captions; adjust accordingly.

js/hang/src/preview/member.ts (2)

29-39: Add error handling around Zod.read loop to avoid unhandled rejections.

Mirror watch/preview.ts and log parse failures.

-			effect.spawn(async (cancel) => {
-				try {
-					for (;;) {
-						const frame = await Promise.race([Zod.read(track, Preview.InfoSchema), cancel]);
-						if (!frame) break;
-
-						this.info.set(frame);
-					}
-				} finally {
-					this.info.set(undefined);
-				}
-			});
+			effect.spawn(async (cancel) => {
+				try {
+					for (;;) {
+						const info = await Promise.race([Zod.read(track, Preview.InfoSchema), cancel]);
+						if (!info) break;
+						this.info.set(info);
+					}
+				} catch (error) {
+					console.warn("Failed to parse preview JSON:", error);
+				} finally {
+					this.info.set(undefined);
+				}
+			});

15-22: Make Effect instance private for consistency with other classes.

Other modules use #signals; align naming and encapsulation.

-	signals = new Effect();
+	#signals = new Effect();
@@
-		this.signals.effect((effect) => {
+		this.#signals.effect((effect) => {
@@
-		this.signals.close();
+		this.#signals.close();

Also applies to: 45-45

js/hang/src/watch/video/detection.ts (1)

48-53: Add try/catch around Zod.read to surface parse errors.

Aligns with preview/watch handling and avoids silent task rejection.

-			effect.spawn(async (cancel) => {
-				for (;;) {
-					const frame = await Promise.race([Zod.read(track, Catalog.DetectionObjectsSchema), cancel]);
-					if (!frame) break;
-
-					// Use a function to avoid the dequal check.
-					this.objects.set(() => frame);
-				}
-			});
+			effect.spawn(async (cancel) => {
+				try {
+					for (;;) {
+						const frame = await Promise.race([Zod.read(track, Catalog.DetectionObjectsSchema), cancel]);
+						if (!frame) break;
+						// Use a function to avoid the dequal check.
+						this.objects.set(() => frame);
+					}
+				} catch (error) {
+					console.warn("Failed to parse detection JSON:", error);
+				}
+			});
js/hang/src/publish/preview.ts (1)

1-4: Validate and encode with Zod to catch malformed preview payloads.

Align publisher with watch-side Zod.read by validating before write.

Apply:

-import * as Moq from "@kixelated/moq";
+import * as Moq from "@kixelated/moq";
+import { write as ZodWrite } from "@kixelated/moq/zod";
+import { InfoSchema } from "../preview";

-	#publish(preview: Info) {
-		this.#track.writeJson(preview);
-	}
+	#publish(preview: Info) {
+		ZodWrite(this.#track, preview, InfoSchema);
+	}

Also applies to: 41-43

js/hang/src/watch/location.ts (1)

42-51: Race noted in TODO: initialize from catalog.initial only once per stream.

Consider moving initial assignment into the consumer start (emit initial before first update) to avoid stale overwrites when catalogs churn.

js/hang/src/watch/chat/index.ts (2)

16-18: Expose a readonly catalog getter for consistency and reuse.

Message/Typing expose catalog; Chat should too.

Apply:

-import { Effect, Signal } from "@kixelated/signals";
+import { Effect, type Getter, Signal } from "@kixelated/signals";
@@
-	#catalog = new Signal<Catalog.Chat | undefined>(undefined);
+	#catalog = new Signal<Catalog.Chat | undefined>(undefined);
+	readonly catalog: Getter<Catalog.Chat | undefined> = this.#catalog;

27-37: Minor: avoid churn by no-op setting when both tracks absent.

Current effect returns early when both undefined (good); consider also clearing catalog explicitly to undefined for clarity (functional behavior already achieved via Effect.set cleanup).

js/hang/src/watch/chat/typing.ts (1)

5-9: Tweak comment: this class manages typing state, not “downloading the chat.”

Minor doc nit to avoid confusion.

 export interface TypingProps {
-  // Whether to start downloading the chat.
-  // Defaults to false so you can make sure everything is ready before starting.
+  // Whether to start watching the typing indicator.
+  // Defaults to false so you can make sure everything is ready before starting.
   enabled?: boolean;
 }
js/moq-clock/src/main.ts (2)

163-170: Use readString() for the base frame too.

You write the base with writeString; read it with readString for consistency and less boilerplate.

-    const baseFrame = await group.readFrame();
-    if (!baseFrame) {
+    const base = await group.readString();
+    if (!base) {
       console.warn("❌ No base frame found");
       continue;
     }
-
-    const base = new TextDecoder().decode(baseFrame);

173-185: Guard against invalid second frames.

parseInt can produce NaN if a bad frame slips through. Cheap validation avoids downstream weirdness.

-      const seconds = parseInt(frame, 10);
+      const seconds = parseInt(frame, 10);
+      if (Number.isNaN(seconds)) {
+        console.warn("⚠️ Invalid seconds frame:", frame);
+        continue;
+      }
js/hang/src/publish/chat/index.ts (1)

25-33: Only publish a catalog object when there’s something to publish.

Currently you always set { message, typing } even if both are undefined. Align with the watch-side behavior and reduce noise.

     this.#signals.effect((effect) => {
       const message = effect.get(this.message.catalog);
       const typing = effect.get(this.typing.catalog);
-
-      this.#catalog.set({
-        message,
-        typing,
-      });
+      const next = message || typing ? { message, typing } : undefined;
+      this.#catalog.set(next);
     });
js/moq/src/group.ts (3)

33-39: Avoid per-call TextEncoder allocations.

Instantiate once and reuse to reduce GC pressure on hot paths.

-  writeString(str: string) {
-    this.writeFrame(new TextEncoder().encode(str));
-  }
+  writeString(str: string) {
+    this.writeFrame(TE.encode(str));
+  }

Add at top-level (outside the selected range):

// Reuse encoders/decoders to avoid allocations
const TE = new TextEncoder();
const TD = new TextDecoder();

106-114: Same for readString/readJson; reuse a single TextDecoder.

-  async readString(): Promise<string | undefined> {
-    const frame = await this.readFrame();
-    return frame ? new TextDecoder().decode(frame) : undefined;
-  }
+  async readString(): Promise<string | undefined> {
+    const frame = await this.readFrame();
+    return frame ? TD.decode(frame) : undefined;
+  }

116-119: Guard zero-length frames in readBool.

Defensive check avoids false negatives if a producer sends an empty frame.

-  async readBool(): Promise<boolean | undefined> {
-    const frame = await this.readFrame();
-    return frame ? frame[0] === 1 : undefined;
-  }
+  async readBool(): Promise<boolean | undefined> {
+    const frame = await this.readFrame();
+    if (!frame || frame.length === 0) return undefined;
+    return frame[0] === 1;
+  }
js/hang/src/publish/chat/typing.ts (1)

29-40: Unset catalog on disable to avoid stale metadata

Without clearing catalog, watchers may keep subscribing to a removed track after enabled flips false. Add a cleanup to set it back to undefined.

 			broadcast.insertTrack(this.#track.consume());
 			effect.cleanup(() => broadcast.removeTrack(this.#track.name));

 			this.catalog.set({
 				name: this.#track.name,
 				priority: u8(this.#track.priority),
 			});
+			effect.cleanup(() => this.catalog.set(undefined));
js/hang/src/watch/chat/message.ts (1)

61-63: Align code with comment (avoid dequal check)

The comment says to avoid the dequal check, but the code doesn’t. Use a function setter.

-				// Use a function to avoid the dequal check.
-				this.#latest.set(frame);
+				// Use a function to avoid the dequal check.
+				this.#latest.set(() => frame);
js/hang/src/publish/chat/message.ts (2)

29-37: Unset catalog on disable to avoid stale metadata

Mirror the Typing publisher: clear the catalog on cleanup so watchers don’t chase a removed track.

 			broadcast.insertTrack(this.#track.consume());
 			effect.cleanup(() => broadcast.removeTrack(this.#track.name));

 			this.catalog.set({ name: this.#track.name, priority: u8(this.#track.priority) });
+			effect.cleanup(() => this.catalog.set(undefined));

39-45: Remove redundant nullish coalescing

latest is Signal<string>; it’s never undefined here.

 			const latest = effect.get(this.latest);
-			this.#track.writeString(latest ?? "");
+			this.#track.writeString(latest);
js/hang/src/frame.ts (4)

8-17: Simplify encode buffer sizing and slicing

Avoid redundant ternaries and use byteLength directly for both variants.

-export function encode(source: Uint8Array | Source, timestamp: number): Uint8Array {
-	const data = new Uint8Array(8 + (source instanceof Uint8Array ? source.byteLength : source.byteLength));
-	const size = setVint53(data, timestamp).byteLength;
-	if (source instanceof Uint8Array) {
-		data.set(source, size);
-	} else {
-		source.copyTo(data.subarray(size));
-	}
-	return data.subarray(0, (source instanceof Uint8Array ? source.byteLength : source.byteLength) + size);
-}
+export function encode(source: Uint8Array | Source, timestamp: number): Uint8Array {
+	const len = source.byteLength;
+	const data = new Uint8Array(8 + len);
+	const size = setVint53(data, timestamp).byteLength;
+	if (source instanceof Uint8Array) {
+		data.set(source, size);
+	} else {
+		source.copyTo(data.subarray(size));
+	}
+	return data.subarray(0, len + size);
+}

84-88: Use unsigned read for 2-byte varint branch

getUint16 is clearer/safer than getInt16 here.

-		v = view.getInt16(0) & 0x3fff;
+		v = view.getUint16(0) & 0x3fff;

48-51: Close group before track

Close the active group first to flush/cleanup before closing the parent track.

-		this.#track.close();
-		this.#group?.close();
+		this.#group?.close();
+		this.#track.close();

41-42: Non-null assertion clarifies invariants

After the keyframe/non-keyframe checks, #group must exist. Optional chaining hides real failures.

-		this.#group?.writeFrame(encode(data, timestamp));
+		this.#group!.writeFrame(encode(data, timestamp));
js/moq/src/util/watch.ts (1)

22-22: Rename looks good; improve DEV detection and drop ts-ignore.

Avoid the ts-ignore and support both Vite and Node by safely checking import.meta and process.env.

Apply this diff:

-// @ts-ignore - Some environments don't recognize import.meta.env
-const DEV = typeof import.meta.env !== "undefined" && import.meta.env?.MODE !== "production";
+const DEV =
+  (typeof import.meta !== "undefined" && (import.meta as any).env?.MODE !== "production") ||
+  (typeof process !== "undefined" && process.env?.NODE_ENV !== "production");
js/moq/package.json (1)

25-27: Peer dep on Zod v4: confirm necessity and consider optional peer to avoid noise for non-users.

Zod 4 is stable; the range ^4.0.0 is appropriate. If only consumers of @kixelated/moq/zod need Zod, mark it optional to prevent peer warnings for others.

   "peerDependencies": {
     "zod": "^4.0.0"
   },
+  "peerDependenciesMeta": {
+    "zod": {
+      "optional": true
+    }
+  },

Reference: Zod 4 is stable and introduces new APIs like z.strictObject, which this repo is beginning to use. (zod.dev, v4.zod.dev)

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 9c42e0d and 818fd0e.

⛔ Files ignored due to path filters (2)
  • js/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • rs/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (47)
  • js/hang/src/catalog/chat.ts (1 hunks)
  • js/hang/src/catalog/root.ts (1 hunks)
  • js/hang/src/container/bool.ts (0 hunks)
  • js/hang/src/container/frame.ts (0 hunks)
  • js/hang/src/container/index.ts (0 hunks)
  • js/hang/src/container/position.ts (0 hunks)
  • js/hang/src/container/vint.ts (0 hunks)
  • js/hang/src/frame.ts (1 hunks)
  • js/hang/src/index.ts (1 hunks)
  • js/hang/src/preview/info.ts (1 hunks)
  • js/hang/src/preview/member.ts (2 hunks)
  • js/hang/src/publish/audio/captions.ts (1 hunks)
  • js/hang/src/publish/audio/index.ts (2 hunks)
  • js/hang/src/publish/audio/speaking.ts (3 hunks)
  • js/hang/src/publish/chat.ts (0 hunks)
  • js/hang/src/publish/chat/index.ts (1 hunks)
  • js/hang/src/publish/chat/message.ts (1 hunks)
  • js/hang/src/publish/chat/typing.ts (1 hunks)
  • js/hang/src/publish/location.ts (3 hunks)
  • js/hang/src/publish/preview.ts (1 hunks)
  • js/hang/src/publish/video/detection.ts (1 hunks)
  • js/hang/src/publish/video/index.ts (2 hunks)
  • js/hang/src/watch/audio/captions.ts (1 hunks)
  • js/hang/src/watch/audio/index.ts (2 hunks)
  • js/hang/src/watch/audio/speaking.ts (1 hunks)
  • js/hang/src/watch/broadcast.ts (3 hunks)
  • js/hang/src/watch/chat.ts (0 hunks)
  • js/hang/src/watch/chat/index.ts (1 hunks)
  • js/hang/src/watch/chat/message.ts (1 hunks)
  • js/hang/src/watch/chat/typing.ts (1 hunks)
  • js/hang/src/watch/location.ts (4 hunks)
  • js/hang/src/watch/preview.ts (2 hunks)
  • js/hang/src/watch/video/detection.ts (2 hunks)
  • js/hang/src/watch/video/index.ts (2 hunks)
  • js/moq-clock/src/main.ts (4 hunks)
  • js/moq/README.md (2 hunks)
  • js/moq/package.json (2 hunks)
  • js/moq/src/group.ts (2 hunks)
  • js/moq/src/ietf/publisher.ts (1 hunks)
  • js/moq/src/lite/publisher.ts (1 hunks)
  • js/moq/src/track.test.ts (1 hunks)
  • js/moq/src/track.ts (4 hunks)
  • js/moq/src/util/watch.ts (6 hunks)
  • js/moq/src/zod.ts (1 hunks)
  • js/signals/src/dom.ts (1 hunks)
  • js/signals/src/index.ts (13 hunks)
  • rs/justfile (0 hunks)
💤 Files with no reviewable changes (8)
  • js/hang/src/container/index.ts
  • js/hang/src/container/frame.ts
  • js/hang/src/container/vint.ts
  • js/hang/src/watch/chat.ts
  • js/hang/src/container/position.ts
  • rs/justfile
  • js/hang/src/container/bool.ts
  • js/hang/src/publish/chat.ts
🧰 Additional context used
📓 Path-based instructions (2)
{rs,js}/hang/**

📄 CodeRabbit inference engine (CLAUDE.md)

All media logic belongs in the hang layer (rs/hang and js/hang)

Files:

  • js/hang/src/catalog/chat.ts
  • js/hang/src/preview/info.ts
  • js/hang/src/index.ts
  • js/hang/src/watch/audio/index.ts
  • js/hang/src/catalog/root.ts
  • js/hang/src/publish/audio/captions.ts
  • js/hang/src/publish/video/detection.ts
  • js/hang/src/watch/audio/captions.ts
  • js/hang/src/watch/audio/speaking.ts
  • js/hang/src/watch/video/index.ts
  • js/hang/src/watch/chat/index.ts
  • js/hang/src/publish/video/index.ts
  • js/hang/src/watch/broadcast.ts
  • js/hang/src/preview/member.ts
  • js/hang/src/publish/chat/typing.ts
  • js/hang/src/watch/location.ts
  • js/hang/src/publish/location.ts
  • js/hang/src/publish/audio/index.ts
  • js/hang/src/publish/audio/speaking.ts
  • js/hang/src/publish/chat/message.ts
  • js/hang/src/frame.ts
  • js/hang/src/watch/chat/message.ts
  • js/hang/src/watch/chat/typing.ts
  • js/hang/src/watch/video/detection.ts
  • js/hang/src/publish/chat/index.ts
  • js/hang/src/watch/preview.ts
  • js/hang/src/publish/preview.ts
js/**

📄 CodeRabbit inference engine (CLAUDE.md)

Place JS/TS packages under the js/ directory

Files:

  • js/hang/src/catalog/chat.ts
  • js/hang/src/preview/info.ts
  • js/hang/src/index.ts
  • js/hang/src/watch/audio/index.ts
  • js/hang/src/catalog/root.ts
  • js/hang/src/publish/audio/captions.ts
  • js/hang/src/publish/video/detection.ts
  • js/moq/src/util/watch.ts
  • js/hang/src/watch/audio/captions.ts
  • js/moq/package.json
  • js/moq/src/lite/publisher.ts
  • js/moq/src/group.ts
  • js/hang/src/watch/audio/speaking.ts
  • js/moq/src/ietf/publisher.ts
  • js/hang/src/watch/video/index.ts
  • js/moq/src/zod.ts
  • js/hang/src/watch/chat/index.ts
  • js/moq/README.md
  • js/hang/src/publish/video/index.ts
  • js/hang/src/watch/broadcast.ts
  • js/moq/src/track.ts
  • js/hang/src/preview/member.ts
  • js/hang/src/publish/chat/typing.ts
  • js/hang/src/watch/location.ts
  • js/hang/src/publish/location.ts
  • js/signals/src/dom.ts
  • js/hang/src/publish/audio/index.ts
  • js/signals/src/index.ts
  • js/hang/src/publish/audio/speaking.ts
  • js/hang/src/publish/chat/message.ts
  • js/moq-clock/src/main.ts
  • js/hang/src/frame.ts
  • js/hang/src/watch/chat/message.ts
  • js/hang/src/watch/chat/typing.ts
  • js/moq/src/track.test.ts
  • js/hang/src/watch/video/detection.ts
  • js/hang/src/publish/chat/index.ts
  • js/hang/src/watch/preview.ts
  • js/hang/src/publish/preview.ts
🧬 Code graph analysis (24)
js/hang/src/catalog/chat.ts (1)
js/hang/src/catalog/track.ts (1)
  • TrackSchema (4-7)
js/hang/src/watch/audio/index.ts (1)
js/moq/src/lite/publisher.ts (2)
  • sub (172-194)
  • sub (203-226)
js/hang/src/watch/audio/captions.ts (1)
js/moq/src/lite/publisher.ts (2)
  • sub (172-194)
  • sub (203-226)
js/moq/src/lite/publisher.ts (2)
js/moq/src/ietf/connection.ts (1)
  • stream (246-255)
js/moq/src/lite/connection.ts (3)
  • stream (125-140)
  • stream (142-158)
  • stream (179-187)
js/hang/src/watch/audio/speaking.ts (1)
js/moq/src/lite/publisher.ts (2)
  • sub (172-194)
  • sub (203-226)
js/hang/src/watch/video/index.ts (2)
js/moq/src/ietf/object.ts (1)
  • Frame (49-96)
js/moq/src/stream.ts (1)
  • next (412-416)
js/moq/src/zod.ts (2)
js/moq/src/track.ts (2)
  • TrackConsumer (143-322)
  • TrackProducer (9-136)
js/moq/src/group.ts (2)
  • GroupConsumer (78-147)
  • GroupProducer (8-71)
js/hang/src/watch/chat/index.ts (3)
js/hang/src/watch/chat/message.ts (2)
  • MessageProps (5-9)
  • Message (11-70)
js/hang/src/watch/chat/typing.ts (2)
  • TypingProps (5-9)
  • Typing (11-66)
js/signals/src/index.ts (2)
  • Signal (19-76)
  • Effect (81-433)
js/hang/src/publish/video/index.ts (1)
js/moq/src/ietf/object.ts (1)
  • Frame (49-96)
js/hang/src/watch/broadcast.ts (1)
js/hang/src/watch/preview.ts (1)
  • Preview (11-54)
js/moq/src/track.ts (2)
rs/hang/src/catalog/root.rs (1)
  • next (210-230)
rs/hang/src/model/location.rs (1)
  • next (40-60)
js/hang/src/preview/member.ts (2)
js/hang/src/publish/preview.ts (1)
  • Preview (10-48)
js/hang/src/watch/preview.ts (1)
  • Preview (11-54)
js/hang/src/publish/chat/typing.ts (4)
js/hang/src/watch/chat/typing.ts (2)
  • TypingProps (5-9)
  • Typing (11-66)
js/moq/src/broadcast.ts (1)
  • BroadcastProducer (17-113)
js/signals/src/index.ts (2)
  • Signal (19-76)
  • Effect (81-433)
js/hang/src/catalog/track.ts (1)
  • Track (9-9)
js/hang/src/watch/location.ts (2)
js/moq/src/track.ts (1)
  • TrackConsumer (143-322)
js/hang/src/catalog/location.ts (1)
  • Position (42-42)
js/hang/src/publish/location.ts (2)
js/hang/src/watch/location.ts (2)
  • LocationPeer (107-158)
  • effect (140-153)
js/moq/src/track.ts (1)
  • TrackProducer (9-136)
js/signals/src/dom.ts (1)
js/signals/src/index.ts (2)
  • effect (294-304)
  • Effect (81-433)
js/hang/src/publish/audio/speaking.ts (3)
js/hang/src/publish/audio/captions.ts (1)
  • effect (39-129)
js/hang/src/publish/video/index.ts (3)
  • effect (91-103)
  • effect (105-183)
  • effect (349-387)
js/hang/src/watch/audio/speaking.ts (1)
  • effect (32-56)
js/hang/src/publish/chat/message.ts (3)
js/hang/src/watch/chat/message.ts (2)
  • MessageProps (5-9)
  • Message (11-70)
js/moq/src/broadcast.ts (1)
  • BroadcastProducer (17-113)
js/signals/src/index.ts (2)
  • Signal (19-76)
  • Effect (81-433)
js/hang/src/watch/chat/message.ts (7)
js/hang/src/publish/chat/message.ts (2)
  • MessageProps (6-8)
  • Message (10-52)
js/signals/src/index.ts (3)
  • Signal (19-76)
  • Getter (10-13)
  • Effect (81-433)
js/moq/src/broadcast.ts (1)
  • BroadcastConsumer (122-180)
js/hang/src/catalog/track.ts (1)
  • Track (9-9)
js/hang/src/watch/broadcast.ts (3)
  • catalog (146-163)
  • effect (81-117)
  • effect (119-130)
js/hang/src/watch/audio/captions.ts (1)
  • effect (32-55)
js/hang/src/watch/audio/speaking.ts (1)
  • effect (32-56)
js/hang/src/watch/chat/typing.ts (6)
js/hang/src/publish/chat/typing.ts (2)
  • TypingProps (6-8)
  • Typing (10-55)
js/signals/src/index.ts (3)
  • Signal (19-76)
  • Getter (10-13)
  • Effect (81-433)
js/moq/src/broadcast.ts (1)
  • BroadcastConsumer (122-180)
js/hang/src/watch/broadcast.ts (3)
  • catalog (146-163)
  • effect (81-117)
  • effect (119-130)
js/hang/src/watch/audio/captions.ts (1)
  • effect (32-55)
js/hang/src/watch/audio/speaking.ts (1)
  • effect (32-56)
js/hang/src/watch/video/detection.ts (1)
js/hang/src/publish/video/detection-worker.ts (1)
  • frame (35-86)
js/hang/src/publish/chat/index.ts (4)
js/hang/src/watch/chat/index.ts (2)
  • ChatProps (7-10)
  • Chat (12-45)
js/hang/src/publish/chat/message.ts (2)
  • MessageProps (6-8)
  • Message (10-52)
js/hang/src/publish/chat/typing.ts (2)
  • TypingProps (6-8)
  • Typing (10-55)
js/hang/src/catalog/chat.ts (1)
  • Chat (9-9)
js/hang/src/watch/preview.ts (4)
js/hang/src/publish/preview.ts (2)
  • PreviewProps (5-8)
  • Preview (10-48)
js/moq/src/broadcast.ts (1)
  • BroadcastConsumer (122-180)
js/hang/src/preview/info.ts (2)
  • Info (13-13)
  • InfoSchema (3-11)
js/hang/src/connection.ts (1)
  • effect (56-98)
js/hang/src/publish/preview.ts (2)
js/hang/src/meet/room.ts (1)
  • preview (47-49)
js/hang/src/preview/info.ts (1)
  • Info (13-13)
🪛 markdownlint-cli2 (0.17.2)
js/moq/README.md

78-78: Hard tabs
Column: 1

(MD010, no-hard-tabs)


79-79: Hard tabs
Column: 1

(MD010, no-hard-tabs)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Check
🔇 Additional comments (35)
js/signals/src/index.ts (2)

8-8: LGTM: Consistent environment flag standardization

The rename from dev to DEV maintains consistent development-mode gating behavior while aligning with the broader codebase standardization effort. All conditional checks have been properly updated.

Also applies to: 100-100, 107-107, 145-145, 185-185, 207-207, 227-227, 240-240, 257-257, 281-281, 296-296, 309-309, 390-390, 403-403, 429-429


254-277: LGTM: Well-designed timeout method with proper cleanup

The new timeout method provides a clean API for running effects with automatic cleanup after a delay. The implementation correctly:

  • Creates a nested effect for the provided function
  • Schedules cleanup via setTimeout
  • Ensures proper resource cleanup on both timeout and parent effect disposal
  • Guards against usage on closed effects with appropriate DEV warnings

The method complements the existing timer method by focusing on effect lifecycle management rather than simple function execution.

js/moq/src/track.ts (4)

69-73: LGTM: Consistent method naming

The rename from appendFrame to writeFrame improves API consistency with the write-oriented naming pattern used throughout the codebase (e.g., writeString, writeJson, writeBool).


75-91: LGTM: Useful convenience methods

The new writeString, writeJson, and writeBool methods provide convenient wrappers that follow a consistent pattern: create a group, delegate to the group's write method, then close the group. This aligns well with the Frame-based I/O approach mentioned in the AI summary.


189-189: LGTM: Consistent frame reading method

The internal calls now use readFrame() instead of nextFrame() for frame advancement, which aligns with the new read-oriented API naming convention.

Also applies to: 210-210, 230-230


234-258: LGTM: Comprehensive read helper methods

The new read methods provide a clean, type-safe API for consuming different data types:

  • readFrame(): Returns raw bytes
  • readString(): Uses TextDecoder for proper string conversion
  • readJson(): Handles JSON parsing with proper error propagation
  • readBool(): Validates single-byte boolean format (0 or 1) with appropriate error handling

The boolean validation is particularly well-implemented, ensuring data integrity by throwing an error for invalid boolean frames.

js/hang/src/publish/audio/speaking.ts (1)

24-53: Direct TrackProducer usage is clean and consistent

Nice removal of the Bool wrapper, correct use of insert/remove and writeBool, and proper lifecycle cleanup.

Also applies to: 113-116

js/hang/src/publish/location.ts (2)

25-65: Location updates via writeJson: looks good

Track lifecycle, catalog publication (with cleanup back to undefined), and writeJson on position changes all look correct.


83-88: Producer type change ripple-check complete

A full-text search for producer.update and producer.write(Json|JSON|Position) across all .ts files yielded no occurrences, confirming that downstream code no longer calls the removed methods and is using the new Signal<Moq.TrackProducer> API as intended. No further changes are required here.

js/moq/src/lite/publisher.ts (1)

210-216: readFrame swap is correct and matches new API

Graceful break on undefined and race with stream.closed() mirrors the rest of the pipeline.

js/hang/src/publish/video/index.ts (1)

5-5: Switch to Frame module import looks correct.
Matches the PR’s direction to centralize framing under js/hang/src/frame.ts and keeps media logic within the hang layer as per guidelines.

js/moq/src/ietf/publisher.ts (1)

162-169: Good migration to readFrame(); I/O loop remains robust.
Using group.readFrame() aligns with the new typed read helpers and preserves end-of-group signaling via undefined payload.

js/hang/src/index.ts (1)

7-7: No migration shim needed — Frame export is additive and non-breaking
A ripgrep scan for any remaining Container.* usages in js/ (excluding dist/build) returned no results, confirming there are no existing calls to Container.encodeFrame or Container.decodeFrame. Exporting Frame simply adds a new surface—it doesn’t remove or alter any existing API. Accordingly, the suggested temporary shim isn’t necessary. Feel free to proceed without the shim, and document the new Frame export in the release notes as an additive enhancement.

js/hang/src/catalog/root.ts (1)

42-45: readFrame() + direct decode is correct and tighter.
This matches the new read* helpers and avoids the extra .data unwrap.

js/hang/src/watch/video/index.ts (1)

4-4: Import switch to Frame is aligned with the new API.
Keeps media logic in the hang layer and matches other modules.

js/hang/src/publish/audio/captions.ts (1)

59-59: Good migration to typed I/O (writeString).

Direct string write removes needless TextEncoder work and matches the new API.

js/hang/src/publish/video/detection.ts (1)

76-76: Switch to writeJson(result) looks good.

Cleaner and allows typed consumption on the watch side.

js/moq/src/track.test.ts (1)

94-109: LGTM: tests updated to readFrame() API.

Covers cloned group readers and independent consumption semantics correctly.

js/hang/src/publish/audio/index.ts (1)

5-5: Frame module migration looks correct.

Frame.encode(frame, frame.timestamp) and subsequent writeFrame align with the new framing path.

Also applies to: 208-210

js/hang/src/watch/audio/index.ts (2)

153-157: API migration to readFrame + Frame.decode looks correct.

Matches the new Frame-based I/O path and moq-lite publisher usage.


156-162: ✅ Confirmed timestamp units are in microseconds

  • In js/hang/src/frame.ts, Frame.decode simply returns the number that was originally passed to Frame.encode (via getVint53)—there’s no hidden unit conversion there.
  • In js/hang/src/publish/audio/index.ts, the raw sample-count timestamp from the AudioWorklet is explicitly converted to microseconds by
    timestamp: (1_000_000 * data.timestamp) / worklet.context.sampleRate
    before being fed into Frame.encode.
  • In the video pipeline (js/hang/src/publish/video/index.ts), the WebCodecs-provided VideoFrame.timestamp (already in µs) is passed directly to Frame.encode.

No mismatches detected—the decoded timestamps match WebCodecs’ expected microsecond units.

js/moq/README.md (1)

51-57: Docs update to writeString is clear and aligned with new helpers.

js/hang/src/preview/info.ts (2)

10-11: Adding typing?: boolean is fine and backward compatible.


3-14: No lingering PreviewSchema references found – removal is safe

I’ve searched the entire repository for any occurrences of PreviewSchema (both within js/ and across all files) and found none. You can safely finalize the removal of the PreviewSchema alias without impacting the build.

js/hang/src/watch/video/detection.ts (1)

2-2: Zod-based read integration looks good.

js/hang/src/publish/preview.ts (1)

23-29: Track lifecycle insertion/removal is correct.

Insert on enable, remove on cleanup. Close() will trigger removal via Effect cleanup.

js/hang/src/watch/location.ts (1)

62-73: Good switch to TrackConsumer + Zod.read with proper cleanup.

Subscription, spawn, and teardown are wired correctly.

js/hang/src/watch/broadcast.ts (1)

8-9: PreviewWatch→Preview rename integration looks correct.

Imports, field type, construction, and close path all align with the new Preview API.

Also applies to: 47-48, 70-71

js/hang/src/watch/chat/index.ts (1)

24-26: Decomposition into Message/Typing subcomponents is clean.

Composition matches publish-side structure and keeps responsibilities isolated.

js/hang/src/watch/preview.ts (1)

32-44: Nice simplification with Zod.read and direct subscribe to preview.json.

Cleaner and safer than manual JSON.parse, and it decouples preview bootstrap from the catalog nicely.

js/moq-clock/src/main.ts (1)

97-107: Good adoption of typed I/O helpers (writeString/readString/readFrame).

The publish/subscribe paths align with the new Group/Track APIs and keep the example readable.

Also applies to: 162-179

js/hang/src/publish/chat/index.ts (1)

12-24: Solid composition and catalog merge.

Clear separation of concerns and mirrors the watch-side structure neatly.

js/moq/src/group.ts (1)

101-104: Rename looks good; behavior preserved.

readFrame maintains the same semantics as the old nextFrame, with a clearer name.

js/hang/src/catalog/chat.ts (1)

5-6: No stale ChatSchema.track references detected; change is safe to merge

I searched for any occurrences of a single track field in ChatSchema or related imports and found none. All remaining .track usages are in the watch/* modules (video and audio), which are unrelated to the chat schema.

• Ran targeted ripgrep for catalog.chat.track and ChatSchema.*track – no matches
• Broad .track search under js/hang/src only surfaced watch/video and watch/audio usages

The z.strictObject refactor on ChatSchema can be applied confidently without breaking existing code.

js/moq/package.json (1)

13-15: Packaging script correctly rewrites all subpath exports (including ./zod)

The rewritePath function in js/scripts/package.ts
– replaces ^./src with . and converts *.ts(x)? to .js for every string value in pkg.exports (lines 11–23)
– therefore "./zod": "./src/zod.ts" will become "./zod": "./zod.js" automatically

No further action required.

Comment on lines +61 to +67
async decode(): Promise<{ data: Uint8Array; timestamp: number; keyframe: boolean } | undefined> {
const next = await this.#track.nextFrame();
if (!next) return undefined;

const { timestamp, data } = decode(next.data);
return { data, timestamp, keyframe: next.frame === 0 };
}
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Use readFrame() (API rename) instead of nextFrame()

The Track API rename in this PR implies nextFrame()readFrame(). Using the old name will break at compile/runtime.

Apply this change:

-		const next = await this.#track.nextFrame();
+		const next = await this.#track.readFrame();

Run to confirm the API across the repo:


🏁 Script executed:

#!/bin/bash
# Verify TrackConsumer API usage
rg -nP 'nextFrame\s*\(' -C2
rg -nP 'readFrame\s*\(' -C2
rg -nP '(class|interface)\s+TrackConsumer\b' -n -C3

Length of output: 7274


Rename all nextFrame() calls to readFrame() (Track API rename)

To align with the updated Track API and avoid compile/runtime errors, every invocation of nextFrame() must be replaced with readFrame(). Specifically, update the following locations:

  • js/hang/src/frame.ts (around line 62)
  • const next = await this.#track.nextFrame();
  • const next = await this.#track.readFrame();
- **js/hang/src/watch/video/index.ts** (around line 114)  
```diff
-   const next = await Promise.race([sub.nextFrame(), cancel]);
+   const next = await Promise.race([sub.readFrame(), cancel]);

You can verify that no other nextFrame() calls remain in your Hang codebase:

#!/bin/bash
# Search only under js/hang/src for any leftover nextFrame() usage
rg -nP 'nextFrame\s*\(' -C2 js/hang/src

All nextFrame() references should now be readFrame().

🤖 Prompt for AI Agents
In js/hang/src/frame.ts around lines 61 to 67, the code calls
this.#track.nextFrame() but the Track API was renamed to readFrame(); replace
the nextFrame() invocation with readFrame() and adjust any local variable names
if needed (e.g., const next = await this.#track.readFrame(); and use next as
before). Also update the other occurrence noted in
js/hang/src/watch/video/index.ts around line 114 the same way, and run the
provided ripgrep command to ensure no remaining nextFrame() calls exist in
js/hang/src.

Comment on lines +31 to +34
this.#signals.effect((effect) => {
if (!effect.get(this.enabled)) return;
this.#catalog.set(effect.get(catalog)?.chat?.typing);
});
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Don’t gate catalog extraction on enabled; also clear it on teardown.

When enabled flips false, #catalog stays set and #run can continue subscribing. Always mirror the catalog and reset on cleanup; gate subscription in #run instead.

-    this.#signals.effect((effect) => {
-      if (!effect.get(this.enabled)) return;
-      this.#catalog.set(effect.get(catalog)?.chat?.typing);
-    });
+    this.#signals.effect((effect) => {
+      this.#catalog.set(effect.get(catalog)?.chat?.typing);
+      effect.cleanup(() => this.#catalog.set(undefined));
+    });
📝 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
this.#signals.effect((effect) => {
if (!effect.get(this.enabled)) return;
this.#catalog.set(effect.get(catalog)?.chat?.typing);
});
this.#signals.effect((effect) => {
this.#catalog.set(effect.get(catalog)?.chat?.typing);
effect.cleanup(() => this.#catalog.set(undefined));
});
🤖 Prompt for AI Agents
In js/hang/src/watch/chat/typing.ts around lines 31-34, the current effect
handler gates catalog extraction on this.enabled which leaves #catalog populated
when enabled flips false; change the handler to always mirror the catalog value
by setting this.#catalog = effect.get(catalog)?.chat?.typing (no enabled check)
and ensure that on teardown/cleanup you explicitly clear this.#catalog (set to
undefined/null) so subscribers in #run don’t keep seeing stale data; move the
enabled gating logic into the #run subscription control instead of here.

Comment on lines +78 to 81
if (DEV) {
WatchProducer.finalizer.unregister(this);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Unregister: add the same guard.

-    if (DEV) {
-      WatchProducer.finalizer.unregister(this);
+    if (DEV && WatchProducer.finalizer) {
+      WatchProducer.finalizer.unregister(this);
     }
📝 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
if (DEV) {
WatchProducer.finalizer.unregister(this);
}
}
if (DEV && WatchProducer.finalizer) {
WatchProducer.finalizer.unregister(this);
}
}
🤖 Prompt for AI Agents
In js/moq/src/util/watch.ts around lines 78 to 81, the unregister call needs the
same safety guard used elsewhere: ensure you only call
WatchProducer.finalizer.unregister(this) when DEV is true and the
finalizer/unregister function actually exists; wrap the unregister call with the
same DEV check and a runtime existence check (e.g., confirm
WatchProducer.finalizer and WatchProducer.finalizer.unregister are present)
before invoking it.

Comment on lines +87 to 90
if (DEV) {
WatchProducer.finalizer.unregister(this);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Abort path: guard unregister too.

-    if (DEV) {
-      WatchProducer.finalizer.unregister(this);
+    if (DEV && WatchProducer.finalizer) {
+      WatchProducer.finalizer.unregister(this);
     }
📝 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
if (DEV) {
WatchProducer.finalizer.unregister(this);
}
}
if (DEV && WatchProducer.finalizer) {
WatchProducer.finalizer.unregister(this);
}
🤖 Prompt for AI Agents
In js/moq/src/util/watch.ts around lines 87 to 90, the abort/early-exit path
currently does not unregister the instance from WatchProducer.finalizer; add the
same DEV-only unregister call (WatchProducer.finalizer.unregister(this)) into
the abort/cleanup branch so the object is unregistered when the operation is
aborted, ensuring you use the same `this` reference and keep the DEV guard to
avoid unregistering in production.

Comment on lines +89 to +109
node = document.createDocumentFragment();
for (const child of e) {
render(effect, node, child);
}
} else if (typeof e === "number" || typeof e === "boolean" || typeof e === "string") {
node = document.createTextNode(e.toString());
} else {
const exhaustive: never = e;
throw new Error(`Invalid element type: ${exhaustive}`);
}

parent.appendChild(node);
effect.cleanup(() => {
try {
parent.removeChild(node);
} catch (e) {
console.log("cleanup failed", parent, node);
throw e;
}
});
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Bug: removing a DocumentFragment will always throw; arrays leak DOM nodes

After appendChild, the fragment is emptied and is not a child of parent; cleanup parent.removeChild(node) throws and leaves children mounted.

Apply boundary markers so arrays mount/unmount reliably:

 export function render(effect: Effect, parent: Node, element: Element | ((effect: Effect) => Element)) {
   const e = typeof element === "function" ? element(effect) : element;
   if (e === undefined || e === null) return;

-  let node: Node;
-  if (e instanceof Node) {
-    node = e;
-  } else if (Array.isArray(e)) {
-    node = document.createDocumentFragment();
-    for (const child of e) {
-      render(effect, node, child);
-    }
-  } else if (typeof e === "number" || typeof e === "boolean" || typeof e === "string") {
-    node = document.createTextNode(e.toString());
-  } else {
-    const exhaustive: never = e;
-    throw new Error(`Invalid element type: ${exhaustive}`);
-  }
-
-  parent.appendChild(node);
-  effect.cleanup(() => {
-    try {
-      parent.removeChild(node);
-    } catch (e) {
-      console.log("cleanup failed", parent, node);
-      throw e;
-    }
-  });
+  // Arrays: render between boundary markers so cleanup can remove the exact range.
+  if (Array.isArray(e)) {
+    const start = document.createComment("signals:start");
+    const end = document.createComment("signals:end");
+    parent.appendChild(start);
+    for (const child of e) render(effect, parent, child);
+    parent.appendChild(end);
+    effect.cleanup(() => {
+      let cur = start.nextSibling;
+      while (cur && cur !== end) {
+        const next = cur.nextSibling!;
+        parent.removeChild(cur);
+        cur = next;
+      }
+      parent.removeChild(start);
+      parent.removeChild(end);
+    });
+    return;
+  }
+
+  let node: Node;
+  if (e instanceof Node) {
+    node = e;
+  } else if (typeof e === "number" || typeof e === "boolean" || typeof e === "string") {
+    node = document.createTextNode(e.toString());
+  } else {
+    const exhaustive: never = e as never;
+    throw new Error(`Invalid element type: ${exhaustive}`);
+  }
+
+  parent.appendChild(node);
+  effect.cleanup(() => {
+    if (parent.contains(node)) parent.removeChild(node);
+  });
 }
📝 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
node = document.createDocumentFragment();
for (const child of e) {
render(effect, node, child);
}
} else if (typeof e === "number" || typeof e === "boolean" || typeof e === "string") {
node = document.createTextNode(e.toString());
} else {
const exhaustive: never = e;
throw new Error(`Invalid element type: ${exhaustive}`);
}
parent.appendChild(node);
effect.cleanup(() => {
try {
parent.removeChild(node);
} catch (e) {
console.log("cleanup failed", parent, node);
throw e;
}
});
}
export function render(effect: Effect, parent: Node, element: Element | ((effect: Effect) => Element)) {
const e = typeof element === "function" ? element(effect) : element;
if (e === undefined || e === null) return;
// Arrays: render between boundary markers so cleanup can remove the exact range.
if (Array.isArray(e)) {
const start = document.createComment("signals:start");
const end = document.createComment("signals:end");
parent.appendChild(start);
for (const child of e) render(effect, parent, child);
parent.appendChild(end);
effect.cleanup(() => {
let cur = start.nextSibling;
while (cur && cur !== end) {
const next = cur.nextSibling!;
parent.removeChild(cur);
cur = next;
}
parent.removeChild(start);
parent.removeChild(end);
});
return;
}
let node: Node;
if (e instanceof Node) {
node = e;
} else if (typeof e === "number" || typeof e === "boolean" || typeof e === "string") {
node = document.createTextNode(e.toString());
} else {
const exhaustive: never = e as never;
throw new Error(`Invalid element type: ${exhaustive}`);
}
parent.appendChild(node);
effect.cleanup(() => {
if (parent.contains(node)) parent.removeChild(node);
});
}

@claude
Copy link
Contributor

claude bot commented Aug 27, 2025

Claude finished @kixelated's task —— View job


Code Review Complete 👍

Reviewing the "Revamp chat typing and audio speaking" PR for bugs and security issues.

Todo List:

  • Set up development environment
  • Review architectural changes and file restructuring
  • Examine new chat typing implementation
  • Review audio speaking changes
  • Check container consolidation (frame.ts)
  • Look for security vulnerabilities
  • Check error handling and input validation
  • Review signal system changes
  • Provide final assessment

✅ No significant bugs or security issues found.

The PR shows a solid architectural refactoring with proper security practices:

Security Analysis:

  • JSON parsing is properly wrapped in try-catch blocks with Zod validation (js/hang/src/catalog/root.ts:33-38)
  • Boolean frame validation includes proper bounds checking (js/moq/src/track.ts:255-256)
  • Worker instantiation uses proper URL imports with import.meta.url for trusted paths
  • No unsafe HTML manipulation (innerHTML, dangerouslySetInnerHTML, etc.) detected
  • Buffer operations in frame encoding/decoding use DataView with proper bounds checking

Code Quality:

  • Clean separation of concerns with chat split into message.ts and typing.ts
  • Proper resource cleanup with Effect system
  • Container utilities well-consolidated into frame.ts with QUIC VarInt encoding
  • Consistent error handling patterns throughout

Architecture:

  • Sound reactive pattern usage with Signals and Effects
  • Proper async cancellation patterns with Promise.race
  • Worker termination handled correctly in cleanup

The refactoring maintains security best practices while improving code organization.


Copy link
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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
js/hang/package.json (1)

8-22: Replace “./container” export with “./frame” in js/hang/package.json

Verification confirms that js/hang/src/frame.ts exists and there are no remaining imports referencing @kixelated/hang/container or ./container. Update the export map accordingly to avoid exposing a stale path.

• Confirmed js/hang/src/frame.ts is present.
• No code in js/ (outside dist/) imports ./container or @kixelated/hang/container.

Apply this diff to the exports block in js/hang/package.json:

   "exports": {
     ".": "./src/index.ts",
     "./publish": "./src/publish/index.ts",
     "./publish/element": "./src/publish/element.ts",
     "./watch": "./src/watch/index.ts",
     "./watch/element": "./src/watch/element.ts",
     "./meet": "./src/meet/index.ts",
     "./meet/element": "./src/meet/element.ts",
     "./catalog": "./src/catalog/index.ts",
-    "./container": "./src/container/index.ts",
+    "./frame": "./src/frame.ts",
     "./support": "./src/support/index.ts",
     "./support/element": "./src/support/element.ts",
     "./connection": "./src/connection.ts",
     "./preview": "./src/preview/index.ts"
   },
♻️ Duplicate comments (2)
js/hang/src/watch/chat/typing.ts (1)

31-34: Always mirror catalog; don’t gate on enabled, and clear on teardown.

Gating leaves a stale #catalog when enabled flips false; if the catalog changes while disabled, re-enable will resubscribe to the old track.

Apply:

-		this.#signals.effect((effect) => {
-			if (!effect.get(this.enabled)) return;
-			this.#catalog.set(effect.get(catalog)?.chat?.typing);
-		});
+		this.#signals.effect((effect) => {
+			this.#catalog.set(effect.get(catalog)?.chat?.typing);
+			effect.cleanup(() => this.#catalog.set(undefined));
+		});
js/hang/src/watch/chat/message.ts (1)

32-36: Always mirror catalog; don’t gate on enabled, and clear on teardown.

Same stale-catalog risk as Typing when toggling enabled.

-		this.#signals.effect((effect) => {
-			if (!effect.get(this.enabled)) return;
-			this.#catalog.set(effect.get(catalog)?.chat?.message);
-		});
+		this.#signals.effect((effect) => {
+			this.#catalog.set(effect.get(catalog)?.chat?.message);
+			effect.cleanup(() => this.#catalog.set(undefined));
+		});
🧹 Nitpick comments (5)
js/hang/src/watch/chat/typing.ts (2)

6-9: Nit: adjust comment to reflect typing, not “chat download.”

Minor clarity tweak for docs.

-	// Whether to start downloading the chat.
+	// Whether to subscribe to typing activity.

51-58: Nit: use a more descriptive loop variable.

Rename value -> typing to improve readability.

-				const value = await Promise.race([track.readBool(), cancel]);
-				if (value === undefined) break;
-				this.active.set(value);
+				const typing = await Promise.race([track.readBool(), cancel]);
+				if (typing === undefined) break;
+				this.active.set(typing);
js/hang/src/watch/chat/message.ts (1)

53-56: Avoid double teardown writes; simplify latest initialization.

effect.set plus an explicit cleanup causes two writes on teardown. Either set directly and keep the cleanup, or pass a cleanup value to effect.set.

-		// Undefined is only when we're not subscribed to the track.
-		effect.set(this.#latest, "");
-		effect.cleanup(() => this.#latest.set(undefined));
+		// Undefined is only when we're not subscribed to the track.
+		this.#latest.set("");
+		effect.cleanup(() => this.#latest.set(undefined));
js/hang-demo/package.json (1)

22-22: Consider using the workspace protocol for TypeScript to prevent version drift

Optional: in js/hang-demo/package.json, switch the typescript dependency to workspace:^ so the demo always uses the repo’s root TS version:

-    "typescript": "^5.9.2",
+    "typescript": "workspace:^",

Then verify the package still type-checks against the workspace TS (note that your PNPM workspace root lives under js/):

#!/usr/bin/env bash
set -euo pipefail

# Move into the JS workspace root so pnpm picks up js/pnpm-workspace.yaml
pushd js >/dev/null

if command -v pnpm >/dev/null 2>&1; then
  # Run the hang-demo check script from the workspace
  pnpm -w -F @kixelated/hang-demo run check
else
  # Fallback to pure tsc invocation
  (cd hang-demo && npx tsc --noEmit)
fi

popd >/dev/null

– If you still see the “--workspace-root may only be used inside a workspace” error, make sure you’re running this script from the repo root (so js/ contains the workspace file).
– Once this passes without error, you can be confident the demo will always align with your root TS version.

js/moq-token/package.json (1)

22-23: Align Zod Versions Across Packages
No Node.js engine bump is needed—[email protected] doesn’t declare any engines requirement—so focus on unifying your Zod minor versions for consistency.

• In js/moq/package.json, bump Zod from ^4.0.0 to ^4.1.3 to match the other packages.
• No changes required for js/moq-token or js/hang, as they’re already on ^4.1.3.

--- a/js/moq/package.json
+++ b/js/moq/package.json
@@ peerDependencies
-    "zod": "^4.0.0"
+    "zod": "^4.1.3"
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 818fd0e and b4b5cb7.

⛔ Files ignored due to path filters (1)
  • js/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (9)
  • js/hang-demo/package.json (1 hunks)
  • js/hang/package.json (1 hunks)
  • js/hang/src/watch/audio/captions.ts (1 hunks)
  • js/hang/src/watch/chat/message.ts (1 hunks)
  • js/hang/src/watch/chat/typing.ts (1 hunks)
  • js/moq-token/package.json (1 hunks)
  • js/moq/src/util/watch.ts (5 hunks)
  • js/moq/src/zod.ts (1 hunks)
  • js/package.json (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • js/package.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • js/moq/src/util/watch.ts
  • js/hang/src/watch/audio/captions.ts
  • js/moq/src/zod.ts
🧰 Additional context used
📓 Path-based instructions (2)
js/**

📄 CodeRabbit inference engine (CLAUDE.md)

Place JS/TS packages under the js/ directory

Files:

  • js/hang-demo/package.json
  • js/moq-token/package.json
  • js/hang/package.json
  • js/hang/src/watch/chat/message.ts
  • js/hang/src/watch/chat/typing.ts
{rs,js}/hang/**

📄 CodeRabbit inference engine (CLAUDE.md)

All media logic belongs in the hang layer (rs/hang and js/hang)

Files:

  • js/hang/package.json
  • js/hang/src/watch/chat/message.ts
  • js/hang/src/watch/chat/typing.ts
🧬 Code graph analysis (2)
js/hang/src/watch/chat/message.ts (5)
js/hang/src/publish/chat/message.ts (2)
  • MessageProps (6-8)
  • Message (10-52)
js/signals/src/index.ts (3)
  • Signal (19-76)
  • Getter (10-13)
  • Effect (81-433)
js/moq/src/broadcast.ts (1)
  • BroadcastConsumer (122-180)
js/hang/src/watch/audio/captions.ts (1)
  • effect (32-55)
js/hang/src/watch/chat/typing.ts (1)
  • effect (39-61)
js/hang/src/watch/chat/typing.ts (6)
js/hang/src/publish/chat/typing.ts (2)
  • TypingProps (6-8)
  • Typing (10-55)
js/signals/src/index.ts (3)
  • Signal (19-76)
  • Getter (10-13)
  • Effect (81-433)
js/moq/src/broadcast.ts (1)
  • BroadcastConsumer (122-180)
js/hang/src/watch/audio/captions.ts (1)
  • effect (32-55)
js/hang/src/watch/chat/message.ts (1)
  • effect (41-66)
js/hang/src/watch/audio/speaking.ts (1)
  • effect (32-56)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: claude-review
  • GitHub Check: Check
🔇 Additional comments (6)
js/hang/package.json (2)

41-41: Transformers.js bump to 3.7.2 — check runtime/bundle impact.

Minor bump is fine; verify no breaking API, model asset URL changes, or unexpected ESM/CJS shifts and watch for bundle size regressions in web builds.


46-46: Zod 4.1.3 bump LGTM.

Incremental v4 bump; low risk. Ensure any zod schema usage aligns with 4.x semantics.

js/hang/src/watch/chat/typing.ts (1)

39-61: Subscription gating and cleanup look correct.

Enabled check, null-guards, track.close(), cancel-aware loop, and resetting active to undefined are consistent with audio watchers.

js/hang/src/watch/chat/message.ts (1)

41-51: LGTM on gating and subscription lifecycle.

Enabled guard, null-guards, and track.close() are correct.

js/hang-demo/package.json (1)

17-21: No duplicate Tailwind registration detected.

Inspected the configuration: only @tailwindcss/vite is registered in vite.config.ts and there is no postcss.config.* file present. No further changes needed.

js/moq-token/package.json (1)

22-23: Version bumps look aligned with repo-wide Zod + IO changes.

Patch updates for jose and zod are reasonable and consistent with the PR’s Zod-backed flows.

Comment on lines +41 to +43
"@huggingface/transformers": "^3.7.2",
"@kixelated/moq": "link:../moq",
"@kixelated/signals": "link:../signals",
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Using link: for local workspaces can break publish/install; consider peer+dev split or publish-time rewrite.

link: is great locally but is not publishable to npm and will break external consumers if it escapes packaging. Prefer:

  • Declare @kixelated/moq and @kixelated/signals as peerDependencies (runtime contract).
  • Keep local development via devDependencies (can stay link:) or workspace:*.
  • Ensure your ../scripts/package.ts rewrites these to proper semver ranges before publish.

To verify, after your packaging step, inspect the tarball’s package.json has no link::


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# from repo root
pnpm -w -r build || true
# produce tarball for this package (adjust if your packaging script already emits one)
tarball=$(cd js/hang && npm pack --json | jq -r '.[0].filename')
tmpdir=$(mktemp -d)
tar -xzf "js/hang/$tarball" -C "$tmpdir"
jq '.dependencies' "$tmpdir/package/package.json"
if jq -e 'to_entries[] | select(.value|tostring|test("^link:"))' "$tmpdir/package/package.json" >/dev/null; then
  echo "Error: link: deps leaked into published manifest"
  exit 1
fi
echo "OK: No link: deps in publish manifest"

Length of output: 751


Fix leaking link: dependencies in published package

Manual inspection of the js/hang tarball (kixelated-hang-0.3.12.tgz) shows that its bundled package.json still contains local link: entries under .dependencies:

  • @kixelated/moq: "link:../moq"
  • @kixelated/signals: "link:../signals"

This will break installs for external consumers, since npm cannot resolve link: specifiers in published packages. To resolve:

• Move these to peerDependencies with proper semver ranges (e.g. "^X.Y.Z").
• Keep the local link: specifiers (or workspace:*) in devDependencies for local development.
• Enhance your scripts/package.ts (or packaging pipeline) to rewrite all link:/workspace: specifiers to actual version ranges before publishing.

Update your post-pack verification script to correctly test the .dependencies field, for example:

#!/usr/bin/env bash
set -euo pipefail
cd js/hang
# Pack the module
tarball=$(npm pack --json | jq -r '.[0].filename')
tmpdir=$(mktemp -d)
tar -xzf "$tarball" -C "$tmpdir"
# Fail if any dependency still uses link:
if jq -e '.dependencies 
            | to_entries[] 
            | select(.value | test("^link:"))' \
         "$tmpdir/package/package.json" >/dev/null; then
  echo "Error: link: deps leaked into dependencies"
  exit 1
fi
echo "OK: No link: deps in dependencies"
🤖 Prompt for AI Agents
In js/hang/package.json around lines 41-43, the packaged package.json still
contains local "link:../moq" and "link:../signals" entries in dependencies; move
these two entries from dependencies into peerDependencies with appropriate
semver ranges (e.g. "^X.Y.Z") and keep local link: or workspace:* entries in
devDependencies for local development; update scripts/package.ts (or the
packaging pipeline) to rewrite any "link:"/"workspace:" specifiers to real
version ranges when creating the published tarball, and add/update the post-pack
verification script to extract the generated tarball and assert that
package/package.json .dependencies contains no "link:" or "workspace:"
specifiers (failing the pack if any are found).

@kixelated kixelated merged commit 59f5507 into main Aug 27, 2025
5 checks passed
@kixelated kixelated deleted the revamp-chat branch August 27, 2025 04:31
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