Skip to content

Set up moq-boy for publishing and add CDN infrastructure#1205

Merged
kixelated merged 3 commits into
mainfrom
moq-boy-demo
Apr 3, 2026
Merged

Set up moq-boy for publishing and add CDN infrastructure#1205
kixelated merged 3 commits into
mainfrom
moq-boy-demo

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Summary

  • Rust: Add publishing metadata (keywords/categories) to rs/moq-boy/Cargo.toml, add moq-boy to nix overlay + flake packages
  • JS: Restructure js/moq-boy from a monolithic SPA into a publishable web component library with <moq-boy> (game grid with Fullscreen API) and <moq-boy-preview> (single-game thumbnail with audio on hover)
  • CDN: Add cdn/boy/ Terraform module — g6-standard-2 Linode instance running 6 Game Boy emulators as separate systemd services, with ROM downloads from rom.moq.dev

Test plan

  • just check passes (Rust, JS, Nix, Terraform)
  • bun run build in js/moq-boy produces dist/ with publint validation
  • Nix flake check validates moq-boy derivation
  • Manual: bun run dev in js/moq-boy against a local relay
  • Manual: tofu plan in cdn/ shows expected resources

🤖 Generated with Claude Code

- Add keywords/categories to rs/moq-boy Cargo.toml for crates.io
- Add moq-boy to nix overlay and flake.nix packages
- Restructure js/moq-boy from SPA into publishable web component library:
  - <moq-boy> element: game grid with fullscreen API expansion
  - <moq-boy-preview> element: single-game thumbnail with audio on hover
  - Library exports: GameCard class, types, no side effects
- Add cdn/boy/ Terraform module with per-ROM systemd services on g6-standard-2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 3, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Adds a new cdn/boy module and related deployment, packaging, and UI artifacts. Introduces Terraform to provision a Linode instance, firewall, generated systemd unit files, and a DNS A record; a Justfile for remote deploy operations; systemd unit templates (boy-prepare and boy); a Nix flake and overlay package entry for moq-boy; and Linode/Terraform variable/outputs. The JavaScript package js/moq-boy is reworked into ESM library form, adding two web components (<moq-boy> and <moq-boy-preview>), styles, build config changes, and package.json exports. .gitignore and crate metadata were also updated.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: publishing setup for moq-boy (Rust metadata and web components restructuring) plus new CDN infrastructure (Terraform module for Game Boy emulator deployment).
Description check ✅ Passed The description is well-related to the changeset, detailing Rust publishing additions, JS web component restructuring, and CDN Terraform infrastructure with a comprehensive test plan covering automated and manual validation steps.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch moq-boy-demo
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch moq-boy-demo

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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 6

🧹 Nitpick comments (10)
cdn/boy/versions.tf (1)

1-7: Consider adding a version constraint for the Linode provider.

While the parent module may pin the provider version, adding a version constraint here documents the minimum compatible version and prevents accidental use with incompatible provider versions.

♻️ Suggested improvement
 terraform {
   required_providers {
     linode = {
       source = "linode/linode"
+      version = ">= 2.0"
     }
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cdn/boy/versions.tf` around lines 1 - 7, Add a provider version constraint to
the terraform required_providers block for linode (the required_providers.linode
entry) to document and enforce a minimum compatible provider version; update the
linode provider declaration to include an appropriate version constraint (e.g.,
a minimum or a semantic pin) that matches the parent module's compatibility, and
ensure any changes are reflected in module documentation or comments so future
consumers know the supported provider range.
cdn/boy/outputs.tf (1)

1-4: Minor: Description could clarify this is a list of IPs.

The linode_instance.boy.ipv4 attribute returns a list of IPv4 addresses. The current description is slightly misleading as it implies a single IP. This is functionally correct for the DNS rrdatas field which expects a list.

📝 Suggested description update
 output "instance_ip" {
-  description = "Boy instance IP"
+  description = "Boy instance IPv4 addresses"
   value       = linode_instance.boy.ipv4
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cdn/boy/outputs.tf` around lines 1 - 4, The output "instance_ip" description
is misleading because linode_instance.boy.ipv4 returns a list; update the output
"instance_ip" description to indicate it's a list of IPv4 addresses (e.g., "List
of Boy instance IPv4 addresses" or "Boy instance IPv4 addresses (list)") and
ensure any consumers (e.g., DNS rrdatas) treat the value as a list; locate the
output block that references linode_instance.boy.ipv4 and change only the
description string accordingly.
cdn/boy/main.tf (1)

62-69: Consider restricting SSH access to known IP ranges.

SSH is open to the entire internet (0.0.0.0/0 and ::/0). While key-based authentication provides protection, restricting source IPs to your operations network or bastion ranges would add defense-in-depth.

If open SSH is intentional for operational flexibility, consider documenting this decision or adding fail2ban via the stackscript.

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

In `@cdn/boy/main.tf` around lines 62 - 69, The inbound block labeled "allow-ssh"
(protocol TCP, ports "22") currently allows IPv4 "0.0.0.0/0" and IPv6 "::/0";
update this rule to restrict SSH to your known operations/bastion IP ranges
instead of open access, or if open SSH is required, add a clear
comment/documentation in the Terraform or the stackscript and/or provision
host-level mitigation (e.g., fail2ban) to accompany the wide-open "allow-ssh"
rule.
cdn/boy/boy-prepare.service.tftpl (1)

11-11: Consider adding wget retry options for transient network failures.

The wget command has no retry flags. If the ROM server is temporarily unavailable during boot, the prepare service will fail and block the main service. Since RemainAfterExit=yes keeps the unit active on success, a failed download requires manual intervention.

Consider adding retry flags:

-ExecStart=/var/lib/moq/pkg/bin/wget -nv https://rom.moq.dev/${rom} -O /var/lib/moq/${rom}
+ExecStart=/var/lib/moq/pkg/bin/wget -nv --tries=5 --retry-connrefused --waitretry=10 https://rom.moq.dev/${rom} -O /var/lib/moq/${rom}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cdn/boy/boy-prepare.service.tftpl` at line 11, The ExecStart line invoking
wget currently has no retry behavior; update the wget invocation (the ExecStart
entry that calls /var/lib/moq/pkg/bin/wget) to add retry flags for transient
network failures—e.g., include --tries=<n> (e.g. 10), --waitretry=<s> (e.g. 5),
--timeout=<s> (e.g. 30) and --retry-connrefused so the unit will retry the ROM
download instead of failing the prepare service; keep the same output path (-O
/var/lib/moq/${rom}) and ensure flags are ordered before the URL.
cdn/boy/boy.service.tftpl (1)

18-20: Comment is misleading — RestartSec=10s is a fixed delay, not exponential backoff.

RestartSec=10s alone does not implement exponential backoff. If exponential backoff is desired, you'd need RestartSteps= and RestartMaxDelaySec= (systemd 254+), or handle it in a wrapper script.

If a fixed 10-second delay is intentional, update the comment:

-# Restart with exponential backoff
+# Restart after fixed delay
 Restart=always
 RestartSec=10s
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cdn/boy/boy.service.tftpl` around lines 18 - 20, The comment claiming
"Restart with exponential backoff" is incorrect for the unit using
Restart=always and RestartSec=10s; either change the comment to state a fixed
10s restart delay or enable systemd exponential backoff by replacing/augmenting
RestartSec with RestartSteps= and RestartMaxDelaySec= (requires systemd 254+)
and keep Restart=always; update the comment and unit to reflect which behavior
you choose and reference the Restart=always, RestartSec, RestartSteps, and
RestartMaxDelaySec settings when making the change.
cdn/boy/justfile (2)

93-98: Same sync issue in restart target.

The game names are hardcoded here as well. If the ROM list changes, this command will reference nonexistent services or miss new ones.

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

In `@cdn/boy/justfile` around lines 93 - 98, The restart target currently
hardcodes service names (in the restart target's ssh systemctl restart command),
which will break if the ROM list changes; update the restart target to compute
the service list dynamically instead of embedding names: call or run the
existing ROM-listing command/target (e.g., replace the hardcoded names in ssh
root@$HOST "systemctl restart ..." with a command substitution that invokes the
project's ROM enumeration target/command and maps ROM names to their
corresponding boy-<rom> service names), then feed that generated list to
systemctl so new or removed ROM services are handled automatically.

55-78: Hardcoded service names must stay in sync with local.roms in main.tf.

The service names (boy-big2small, boy-dangan, etc.) are duplicated here and in cdn/boy/main.tf:2-9. Adding or removing a ROM requires updating both files, which is error-prone.

Consider generating a list file during tofu apply or using a loop over a shared source:

# Example: derive names from generated service files
for svc in gen/boy-*-prepare.service; do
  name=$(basename "$svc" .service)
  ssh root@$HOST "systemctl enable $name && systemctl start $name"
done
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cdn/boy/justfile` around lines 55 - 78, The hardcoded systemd unit names in
justfile (the repeated "systemctl enable/start/restart boy-...") must be
replaced with a generated/shared source that is kept in sync with local.roms in
main.tf; update your pipeline so tofu apply writes a canonical list (or
generates service unit files under gen/ like gen/boy-*-prepare.service) and
change the justfile step to iterate over that list or the gen/ files to derive
unit base names and run systemctl enable/start/restart for each, removing all
literal "boy-big2small", "boy-dangan", "boy-opossum", "boy-capybara",
"boy-fofk", "boy-gb-run" entries so additions/removals in local.roms (main.tf)
are automatically reflected.
js/moq-boy/src/element.ts (3)

8-8: FinalizationRegistry is a fallback, not a guarantee.

The FinalizationRegistry callback is not guaranteed to run promptly (or at all). This is appropriate as a last-resort cleanup, but the primary cleanup path should be through disconnectedCallback or explicit close() calls. The current implementation correctly uses #enabled.set(false) in disconnectedCallback, but if the element is GC'd without being disconnected (e.g., orphaned in JS), the connection may remain open until GC runs—if ever.

Consider whether exposing a public close() method for explicit teardown would be beneficial for consumers who manage lifecycle manually.

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

In `@js/moq-boy/src/element.ts` at line 8, The FinalizationRegistry usage
(cleanup) is only a fallback; add an explicit public close() method on the
element that performs the same teardown currently done in disconnectedCallback
(e.g., call the existing logic that sets `#enabled.set`(false), closes any
connections/signals and releases resources), update disconnectedCallback to call
this.close(), and keep the FinalizationRegistry as a last-resort fallback;
reference FinalizationRegistry/cleanup, disconnectedCallback,
`#enabled.set`(false), and close() when implementing the change so consumers can
explicitly call close() to deterministically tear down the element.

55-57: Consider extracting the emoji to a named constant.

The Unicode escape \u{1F3AE} (🎮) is a magic value. Extracting it to a named constant improves readability and makes it easier to change if needed.

Suggested change
+const GAMEPAD_ICON = "\u{1F3AE}"; // 🎮
+
 // ... in constructor:
 const emptyIcon = document.createElement("div");
 emptyIcon.className = "icon";
-emptyIcon.textContent = "\u{1F3AE}";
+emptyIcon.textContent = GAMEPAD_ICON;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/moq-boy/src/element.ts` around lines 55 - 57, The emoji literal
"\u{1F3AE}" used when creating emptyIcon is a magic value; extract it into a
named constant (e.g., GAME_EMOJI or EMPTY_ICON_EMOJI) near the top of element.ts
and replace the inline escape in the emptyIcon.textContent assignment with that
constant so the symbol is documented and easy to change; ensure the constant is
exported if used elsewhere.

111-112: Extract status colors to named constants.

The inline hex color values are magic strings that lack semantic meaning. Per coding guidelines, named constants should be used instead.

Suggested refactor
+const STATUS_COLORS = {
+	connected: "#8bac0f",
+	connecting: "#facc15",
+	disconnected: "#888",
+} as const;
+
 // ... in the effect:
 this.#statusEl.style.color =
-	status === "connected" ? "#8bac0f" : status === "connecting" ? "#facc15" : "#888";
+	STATUS_COLORS[status as keyof typeof STATUS_COLORS] ?? STATUS_COLORS.disconnected;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/moq-boy/src/element.ts` around lines 111 - 112, Replace the inline hex
color magic strings used when setting this.#statusEl.style.color (the ternary
that checks status === "connected" / "connecting" / else) with named constants
(e.g., STATUS_COLOR_CONNECTED, STATUS_COLOR_CONNECTING, STATUS_COLOR_DEFAULT or
STATUS_COLOR_DISCONNECTED) declared at module or class scope; update the
assignment in the method where this.#statusEl.style.color is set to use those
constants so the intent is clear and colors are centralized for reuse and
testing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cdn/boy/justfile`:
- Around line 37-38: The recursive chmod line (chmod -R 755 /var/lib/moq) will
override the JWT file permission change (chmod 600 /var/lib/moq/*.jwt); update
the deployment steps so JWTs are protected after any recursive permission
changes — either run the jwt chmod after the recursive step, or replace chmod -R
755 /var/lib/moq with a more selective approach (e.g., set directory permissions
only and set normal file perms excluding *.jwt, or use find to exclude *.jwt) so
that the chmod -R 755 /var/lib/moq and chmod 600 /var/lib/moq/*.jwt commands do
not conflict.

In `@js/moq-boy/README.md`:
- Around line 1-3: The logo src currently points to the GitHub blob HTML page;
update the img src in the README's <img> tag to use the raw asset URL (replace
the blob path with raw.githubusercontent.com and the same repo/branch/path) so
the SVG bytes are served directly and the image renders in consumers like npm;
locate the <img> element in README.md and change its src accordingly.

In `@js/moq-boy/src/element.ts`:
- Around line 169-181: attributeChangedCallback and the url setter call new
URL(...) without protection and will throw on malformed input; wrap the URL
construction in a try/catch in both attributeChangedCallback(name: Observed,
...) and set url(value: string | URL | undefined) so that if value is already a
URL you use it directly, and if string parsing fails you call
this.connection.url.set(undefined) (and optionally log the error via
console.error or a logger) instead of letting the exception escape.
- Around line 116-154: The effect registered on this.#signals.run can re-enter
on reconnection while this.#sessions still holds old GameCard instances, causing
orphaned DOM cards; fix by clearing existing sessions when (re)entering the
effect or when connection.established becomes falsy: iterate this.#sessions,
call card.close(), remove card.el from the DOM, and this.#sessions.clear()
before subscribing to the new announced stream (the block that creates announced
via this.connection.established and spawns the loop), and also ensure the same
cleanup happens in effect.cleanup() so stale cards are removed on disconnect;
reference: this.#signals.run, connection.established, announced, this.#sessions,
GameCard, effect.cleanup.

In `@js/moq-boy/src/index.ts`:
- Around line 40-41: The mute handler is being bound using
root.querySelector('.mute-btn') which selects from the shared root and causes
incorrect/null bindings because GameCard is constructed before card.el is
appended; update the constructor and any binding sites in GameCard (e.g., the
constructor and the method that binds the mute button around lines referencing
'.mute-btn') to query the mute button from this card's element (card.el or
this.el) instead of root, and attach the event listener directly to that element
so each GameCard instance wires its own mute button rather than rebinding the
shared root element.

In `@js/moq-boy/src/preview.ts`:
- Around line 65-70: The reactive runner in preview.ts (the this.#signals.run
callback) only sets broadcastName when name is truthy, leaving the previous
broadcast active if name becomes empty; change the callback in the
this.#signals.run used for preview name handling to explicitly clear or disable
the broadcast when name is falsy (e.g., call broadcastName.clear() or set to
undefined/null) instead of no-op, and make the same change for the other two
identical blocks referenced (the handlers around lines 143-147 and 165-167) so
that removing or setting preview.name = "" stops the old boy/<name> broadcast.

---

Nitpick comments:
In `@cdn/boy/boy-prepare.service.tftpl`:
- Line 11: The ExecStart line invoking wget currently has no retry behavior;
update the wget invocation (the ExecStart entry that calls
/var/lib/moq/pkg/bin/wget) to add retry flags for transient network
failures—e.g., include --tries=<n> (e.g. 10), --waitretry=<s> (e.g. 5),
--timeout=<s> (e.g. 30) and --retry-connrefused so the unit will retry the ROM
download instead of failing the prepare service; keep the same output path (-O
/var/lib/moq/${rom}) and ensure flags are ordered before the URL.

In `@cdn/boy/boy.service.tftpl`:
- Around line 18-20: The comment claiming "Restart with exponential backoff" is
incorrect for the unit using Restart=always and RestartSec=10s; either change
the comment to state a fixed 10s restart delay or enable systemd exponential
backoff by replacing/augmenting RestartSec with RestartSteps= and
RestartMaxDelaySec= (requires systemd 254+) and keep Restart=always; update the
comment and unit to reflect which behavior you choose and reference the
Restart=always, RestartSec, RestartSteps, and RestartMaxDelaySec settings when
making the change.

In `@cdn/boy/justfile`:
- Around line 93-98: The restart target currently hardcodes service names (in
the restart target's ssh systemctl restart command), which will break if the ROM
list changes; update the restart target to compute the service list dynamically
instead of embedding names: call or run the existing ROM-listing command/target
(e.g., replace the hardcoded names in ssh root@$HOST "systemctl restart ..."
with a command substitution that invokes the project's ROM enumeration
target/command and maps ROM names to their corresponding boy-<rom> service
names), then feed that generated list to systemctl so new or removed ROM
services are handled automatically.
- Around line 55-78: The hardcoded systemd unit names in justfile (the repeated
"systemctl enable/start/restart boy-...") must be replaced with a
generated/shared source that is kept in sync with local.roms in main.tf; update
your pipeline so tofu apply writes a canonical list (or generates service unit
files under gen/ like gen/boy-*-prepare.service) and change the justfile step to
iterate over that list or the gen/ files to derive unit base names and run
systemctl enable/start/restart for each, removing all literal "boy-big2small",
"boy-dangan", "boy-opossum", "boy-capybara", "boy-fofk", "boy-gb-run" entries so
additions/removals in local.roms (main.tf) are automatically reflected.

In `@cdn/boy/main.tf`:
- Around line 62-69: The inbound block labeled "allow-ssh" (protocol TCP, ports
"22") currently allows IPv4 "0.0.0.0/0" and IPv6 "::/0"; update this rule to
restrict SSH to your known operations/bastion IP ranges instead of open access,
or if open SSH is required, add a clear comment/documentation in the Terraform
or the stackscript and/or provision host-level mitigation (e.g., fail2ban) to
accompany the wide-open "allow-ssh" rule.

In `@cdn/boy/outputs.tf`:
- Around line 1-4: The output "instance_ip" description is misleading because
linode_instance.boy.ipv4 returns a list; update the output "instance_ip"
description to indicate it's a list of IPv4 addresses (e.g., "List of Boy
instance IPv4 addresses" or "Boy instance IPv4 addresses (list)") and ensure any
consumers (e.g., DNS rrdatas) treat the value as a list; locate the output block
that references linode_instance.boy.ipv4 and change only the description string
accordingly.

In `@cdn/boy/versions.tf`:
- Around line 1-7: Add a provider version constraint to the terraform
required_providers block for linode (the required_providers.linode entry) to
document and enforce a minimum compatible provider version; update the linode
provider declaration to include an appropriate version constraint (e.g., a
minimum or a semantic pin) that matches the parent module's compatibility, and
ensure any changes are reflected in module documentation or comments so future
consumers know the supported provider range.

In `@js/moq-boy/src/element.ts`:
- Line 8: The FinalizationRegistry usage (cleanup) is only a fallback; add an
explicit public close() method on the element that performs the same teardown
currently done in disconnectedCallback (e.g., call the existing logic that sets
`#enabled.set`(false), closes any connections/signals and releases resources),
update disconnectedCallback to call this.close(), and keep the
FinalizationRegistry as a last-resort fallback; reference
FinalizationRegistry/cleanup, disconnectedCallback, `#enabled.set`(false), and
close() when implementing the change so consumers can explicitly call close() to
deterministically tear down the element.
- Around line 55-57: The emoji literal "\u{1F3AE}" used when creating emptyIcon
is a magic value; extract it into a named constant (e.g., GAME_EMOJI or
EMPTY_ICON_EMOJI) near the top of element.ts and replace the inline escape in
the emptyIcon.textContent assignment with that constant so the symbol is
documented and easy to change; ensure the constant is exported if used
elsewhere.
- Around line 111-112: Replace the inline hex color magic strings used when
setting this.#statusEl.style.color (the ternary that checks status ===
"connected" / "connecting" / else) with named constants (e.g.,
STATUS_COLOR_CONNECTED, STATUS_COLOR_CONNECTING, STATUS_COLOR_DEFAULT or
STATUS_COLOR_DISCONNECTED) declared at module or class scope; update the
assignment in the method where this.#statusEl.style.color is set to use those
constants so the intent is clear and colors are centralized for reuse and
testing.
🪄 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: ea1f58af-d1bf-4836-a1cf-211a608796d6

📥 Commits

Reviewing files that changed from the base of the PR and between de8a8e9 and c8ee47c.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (23)
  • cdn/boy/.gitignore
  • cdn/boy/boy-prepare.service.tftpl
  • cdn/boy/boy.service.tftpl
  • cdn/boy/flake.nix
  • cdn/boy/justfile
  • cdn/boy/main.tf
  • cdn/boy/outputs.tf
  • cdn/boy/variables.tf
  • cdn/boy/versions.tf
  • cdn/dns.tf
  • cdn/main.tf
  • flake.nix
  • js/moq-boy/README.md
  • js/moq-boy/package.json
  • js/moq-boy/src/element.ts
  • js/moq-boy/src/index.html
  • js/moq-boy/src/index.ts
  • js/moq-boy/src/preview.ts
  • js/moq-boy/src/styles.ts
  • js/moq-boy/tsconfig.json
  • js/moq-boy/vite.config.ts
  • nix/overlay.nix
  • rs/moq-boy/Cargo.toml

Comment thread cdn/boy/justfile Outdated
Comment thread js/moq-boy/README.md
Comment thread js/moq-boy/src/element.ts
Comment on lines +116 to +154
this.#signals.run((effect) => {
const conn = effect.get(this.connection.established);
if (!conn) return;

const announced = conn.announced(Moq.Path.from("boy"));
effect.cleanup(() => announced.close());

effect.spawn(async () => {
for (;;) {
const entry = await Promise.race([effect.cancel, announced.next()]);
if (!entry) break;

// Strip "boy/" prefix, skip nested paths like "boy/x/viewer/..."
const suffix = Moq.Path.stripPrefix(Moq.Path.from("boy"), entry.path);
if (!suffix || suffix.includes("/")) continue;

const id = suffix;
if (entry.active && !this.#sessions.has(id)) {
const card = new GameCard({
sessionId: id,
connection: this.connection,
expanded: this.#expanded,
root: shadow,
});
this.#sessions.set(id, card);
this.#gridEl.appendChild(card.el);
this.#updateEmptyState();
} else if (!entry.active) {
const card = this.#sessions.get(id);
if (card) {
card.close();
card.el.remove();
this.#sessions.delete(id);
this.#updateEmptyState();
}
}
}
});
});

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

Potential stale cards on reconnection.

When the connection drops and re-establishes, this effect re-runs, but the #sessions map still contains cards from the previous connection. The announced stream from the new connection won't emit active: false for sessions that were known before, leaving orphaned cards in the DOM.

Consider clearing all sessions when the connection is lost or when re-entering this effect:

Suggested fix
 this.#signals.run((effect) => {
 	const conn = effect.get(this.connection.established);
-	if (!conn) return;
+	if (!conn) {
+		// Clear all sessions when disconnected
+		for (const [id, card] of this.#sessions) {
+			card.close();
+			card.el.remove();
+		}
+		this.#sessions.clear();
+		this.#updateEmptyState();
+		return;
+	}
 
 	const announced = conn.announced(Moq.Path.from("boy"));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/moq-boy/src/element.ts` around lines 116 - 154, The effect registered on
this.#signals.run can re-enter on reconnection while this.#sessions still holds
old GameCard instances, causing orphaned DOM cards; fix by clearing existing
sessions when (re)entering the effect or when connection.established becomes
falsy: iterate this.#sessions, call card.close(), remove card.el from the DOM,
and this.#sessions.clear() before subscribing to the new announced stream (the
block that creates announced via this.connection.established and spawns the
loop), and also ensure the same cleanup happens in effect.cleanup() so stale
cards are removed on disconnect; reference: this.#signals.run,
connection.established, announced, this.#sessions, GameCard, effect.cleanup.

Comment thread js/moq-boy/src/element.ts
Comment on lines +169 to +181
attributeChangedCallback(name: Observed, _oldValue: string | null, newValue: string | null) {
if (name === "url") {
this.connection.url.set(newValue ? new URL(newValue) : undefined);
}
}

get url(): URL | undefined {
return this.connection.url.peek();
}

set url(value: string | URL | undefined) {
this.connection.url.set(value ? new URL(value) : undefined);
}

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

URL parsing can throw on invalid input.

Both attributeChangedCallback and the url setter call new URL(value) without error handling. If a malformed URL is passed (either via attribute or property), this will throw an uncaught exception.

Suggested defensive handling
 attributeChangedCallback(name: Observed, _oldValue: string | null, newValue: string | null) {
 	if (name === "url") {
-		this.connection.url.set(newValue ? new URL(newValue) : undefined);
+		try {
+			this.connection.url.set(newValue ? new URL(newValue) : undefined);
+		} catch {
+			console.warn(`Invalid URL for moq-boy: ${newValue}`);
+			this.connection.url.set(undefined);
+		}
 	}
 }

 set url(value: string | URL | undefined) {
-	this.connection.url.set(value ? new URL(value) : undefined);
+	if (!value) {
+		this.connection.url.set(undefined);
+		return;
+	}
+	try {
+		this.connection.url.set(value instanceof URL ? value : new URL(value));
+	} catch {
+		console.warn(`Invalid URL for moq-boy: ${value}`);
+		this.connection.url.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
attributeChangedCallback(name: Observed, _oldValue: string | null, newValue: string | null) {
if (name === "url") {
this.connection.url.set(newValue ? new URL(newValue) : undefined);
}
}
get url(): URL | undefined {
return this.connection.url.peek();
}
set url(value: string | URL | undefined) {
this.connection.url.set(value ? new URL(value) : undefined);
}
attributeChangedCallback(name: Observed, _oldValue: string | null, newValue: string | null) {
if (name === "url") {
try {
this.connection.url.set(newValue ? new URL(newValue) : undefined);
} catch {
console.warn(`Invalid URL for moq-boy: ${newValue}`);
this.connection.url.set(undefined);
}
}
}
get url(): URL | undefined {
return this.connection.url.peek();
}
set url(value: string | URL | undefined) {
if (!value) {
this.connection.url.set(undefined);
return;
}
try {
this.connection.url.set(value instanceof URL ? value : new URL(value));
} catch {
console.warn(`Invalid URL for moq-boy: ${value}`);
this.connection.url.set(undefined);
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/moq-boy/src/element.ts` around lines 169 - 181, attributeChangedCallback
and the url setter call new URL(...) without protection and will throw on
malformed input; wrap the URL construction in a try/catch in both
attributeChangedCallback(name: Observed, ...) and set url(value: string | URL |
undefined) so that if value is already a URL you use it directly, and if string
parsing fails you call this.connection.url.set(undefined) (and optionally log
the error via console.error or a logger) instead of letting the exception
escape.

Comment thread js/moq-boy/src/index.ts Outdated
Comment thread js/moq-boy/src/preview.ts
Comment on lines +65 to +70
this.#signals.run((effect) => {
const name = effect.get(this.#name);
if (name) {
broadcastName.set(Moq.Path.from(`boy/${name}`));
}
});

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

Reset or disable the broadcast when name becomes empty.

Line 67 only updates broadcastName for truthy values. If a consumer removes the name attribute or sets preview.name = "", this element keeps streaming the previous boy/<old-name> broadcast instead of disconnecting or clearing.

Also applies to: 143-147, 165-167

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

In `@js/moq-boy/src/preview.ts` around lines 65 - 70, The reactive runner in
preview.ts (the this.#signals.run callback) only sets broadcastName when name is
truthy, leaving the previous broadcast active if name becomes empty; change the
callback in the this.#signals.run used for preview name handling to explicitly
clear or disable the broadcast when name is falsy (e.g., call
broadcastName.clear() or set to undefined/null) instead of no-op, and make the
same change for the other two identical blocks referenced (the handlers around
lines 143-147 and 165-167) so that removing or setting preview.name = "" stops
the old boy/<name> broadcast.

kixelated and others added 2 commits April 3, 2026 13:54
- Fix GameCard mute button: query from own #buildControls() instead of
  root.querySelector() which would bind the wrong/null button
- Use raw.githubusercontent.com for README logo so it renders on npm
- Use find-based chmod in deploy to avoid overriding JWT file permissions
- Merge with main (clean, no conflicts)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
js/moq-boy/src/index.ts (1)

12-17: Remove unused root property from GameCardConfig.

The root property is defined in the interface but never destructured or used in the constructor (line 41 only extracts sessionId, connection, expanded). This appears to be leftover from the previous implementation that used root.querySelector(".mute-btn") before the mute button fix.

Consider removing it from both the interface and the call sites (e.g., element.ts:134-139).

♻️ Proposed fix
 export interface GameCardConfig {
 	sessionId: string;
 	connection: Moq.Connection.Reload;
 	expanded: Moq.Signals.Signal<string | undefined>;
-	root: ShadowRoot | HTMLElement;
 }

And in js/moq-boy/src/element.ts:

 const card = new GameCard({
 	sessionId: id,
 	connection: this.connection,
 	expanded: this.#expanded,
-	root: shadow,
 });

Also applies to: 40-41

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

In `@js/moq-boy/src/index.ts` around lines 12 - 17, Remove the unused `root`
property from the `GameCardConfig` interface and from any places that
construct/pass that config (e.g., the call site currently creating the config
object in element.ts); also remove any references to `root` in the `GameCard`
constructor (the constructor currently only destructures `sessionId, connection,
expanded`) so its parameter type no longer requires `root`. Update the
`GameCardConfig` type definition to exclude `root`, update all callers that
build a `GameCardConfig` object to stop passing `root`, and remove any
now-unused variables/imports related to `root`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@js/moq-boy/src/index.ts`:
- Around line 12-17: Remove the unused `root` property from the `GameCardConfig`
interface and from any places that construct/pass that config (e.g., the call
site currently creating the config object in element.ts); also remove any
references to `root` in the `GameCard` constructor (the constructor currently
only destructures `sessionId, connection, expanded`) so its parameter type no
longer requires `root`. Update the `GameCardConfig` type definition to exclude
`root`, update all callers that build a `GameCardConfig` object to stop passing
`root`, and remove any now-unused variables/imports related to `root`.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9f26b0ca-ff01-4bba-8934-de0238af2c63

📥 Commits

Reviewing files that changed from the base of the PR and between c8ee47c and b6d12f0.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • cdn/boy/justfile
  • js/moq-boy/README.md
  • js/moq-boy/src/index.ts
✅ Files skipped from review due to trivial changes (2)
  • js/moq-boy/README.md
  • cdn/boy/justfile

@kixelated kixelated merged commit f45943f into main Apr 3, 2026
1 of 2 checks passed
@kixelated kixelated deleted the moq-boy-demo branch April 3, 2026 23:04
This was referenced Apr 3, 2026
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.

1 participant