Skip to content

fix(bundler): inline runtime body, drop bare-semi joins, drop empty catch binding#641

Merged
jrusso1020 merged 6 commits intomainfrom
fix/bundler-runtime-and-joins
May 6, 2026
Merged

fix(bundler): inline runtime body, drop bare-semi joins, drop empty catch binding#641
jrusso1020 merged 6 commits intomainfrom
fix/bundler-runtime-and-joins

Conversation

@jrusso1020
Copy link
Copy Markdown
Collaborator

Problem

Abhay's LLM-based code-validity eval against the output of bundleToSingleHtml flagged 4 issues, all only present after bundling. This PR fixes 3; issue #4 (unterminated-string parse error) was not reproducible against the rebundled output after these fixes — likely a downstream artifact of issue #2 — and will get a focused follow-up PR if it persists.

Issue 1 — <script data-hyperframes-preview-runtime="1" src=""></script> (real bug)

htmlBundler.ts:injectInterceptor emitted an empty src="" attribute when HYPERFRAME_RUNTIME_URL env var wasn't set. Empty src resolves to the page URL itself; Chrome flags this as an infinite-fetch hazard.

Three other consumers (studioServer, validate, snapshot) post-process this placeholder by substituting either a real URL or an inlined body — bundleToSingleHtml did not. So a function whose name says "single self-contained HTML" was producing output that wasn't actually self-contained.

Fix: when no URL is configured, inline the runtime IIFE directly via getHyperframeRuntimeScript() (same source validate.ts:130-131 and snapshot.ts:110 reach for). Otherwise emit src=… unchanged.

Issue 2 — Stray semicolon line between concatenated JS chunks (cosmetic)

Three sites used chunks.join("\n;\n") (body-script coalesce, local JS, composition scripts), producing:

chunk1
;
chunk2
;
chunk3

The ; on its own line is the stray semicolon Abhay reported. Valid JS but a code smell that flags every quality linter.

Fix: introduce a joinJsChunks() helper that ensures each chunk ends with ; (appends if missing) and joins on \n. Replace all three sites.

Issue 3 — Empty catch (_err) {} block in compositionScoping.ts (lint-noisy)

The _err underscore prefix signals "intentionally swallowed" but bundle-time linters often don't honor that convention.

Fix: drop the binding entirely — catch { /* explanatory comment */ } (same behavior, no lint rule fires). One site.

Verification

  • bun run --filter @hyperframes/core test src/compiler/htmlBundler.test.ts src/compiler/compositionScoping.test.ts21/21 passed (2 existing assertions updated to reflect the new inlined-runtime shape; 2 new regression guards added).
  • bun run --filter @hyperframes/core test (full core sweep) — 653/653 passed before pushing the branch.
  • bunx oxfmt --check + bunx oxlint on touched files — clean.
  • bun run --filter @hyperframes/core typecheck — clean.
  • Lefthook pre-commit: format / lint / typecheck all pass.
  • Manual repro on Abhay's project (a hermes-hyperframes export Abhay shared in the thread): bundled output via the fixed bundler, then ran every inline <script> body through esbuild's transformSyncall 4 scripts parse cleanly. No unterminated-string error against the fixed bundle, which is why issue feat(studio): consolidate into single OSS-ready NLE editor #4 isn't included here.

Test changes

The "does not merge author scripts into the runtime bootstrap placeholder" test previously asserted that the runtime block does NOT contain getElementById. The inlined runtime IIFE itself uses getElementById (it queries the DOM), so that assertion no longer holds. Replaced with a more specific "the author's specific composition script must NOT be merged INTO the runtime tag" check (looks for the literal user-script text inside the runtime block), which is what the test actually wanted to guarantee.

Same shape on the "hoists external CDN scripts" test: the assertion bundled.not.toContain("data-composition-src") failed because the runtime IIFE knows about that attribute (literal string is in the source). Replaced with a DOM-level check via parseHTML that the host element no longer carries the attribute.

Reported by

#bundler-validity-eval — thanks <@U07M47YNU69> for the eval + the bundle.

— Rames Jusso

…atch binding

Three issues in `bundleToSingleHtml` reported via Abhay's LLM-based code-validity
eval against the bundled output. Each is independently small; they share a single
PR because they're all artifacts of the bundler-output shape.

1. Empty `src=""` runtime placeholder (real bug)

`htmlBundler.ts:injectInterceptor` emitted
`<script data-hyperframes-preview-runtime="1" src=""></script>`
when no `HYPERFRAME_RUNTIME_URL` was configured. Empty `src` resolves to the
page URL itself; Chrome flags this as an infinite-fetch hazard. Three other
consumers (studioServer, validate, snapshot) post-process the placeholder to
substitute either a real URL or an inlined body — `bundleToSingleHtml` did
not, so the bundle wasn't actually self-contained despite the function name.

Fix: when no URL is configured, inline the runtime IIFE directly via
`getHyperframeRuntimeScript()`. Otherwise emit `src=…` as before.

2. Bare-semicolon lines between joined JS chunks (cosmetic)

Three sites used `chunks.join("\n;\n")` (body-script coalesce, local JS,
composition scripts) which produced a lone `;` on its own line between
chunks. Valid JS but a code smell. Replace with a `joinJsChunks()` helper
that ensures each chunk ends in `;` and joins on `\n`.

3. Empty `catch (_err) {}` in compositionScoping.ts (lint-noisy)

The `_err` underscore prefix signals "intentionally swallowed" but bundle-time
linters often don't honor that convention. Replaced with `catch { /* ... */ }`
(no binding, explanatory comment) — same behavior, no rule fires.

Tests: 2 new regression guards (runtime-not-empty-src, no-bare-semi) plus
existing tests updated to reflect the new inlined-runtime shape (the previous
"runtime block must not contain getElementById" assertion no longer holds
because the inlined body itself uses getElementById; replaced with a more
specific "author script not merged into runtime tag" check).

Issue #4 from the original report (Unterminated string at line 1111 col 18,
char 65497) was not directly reproducible after applying these fixes — esbuild
parses all 4 inline scripts in the rebundled output cleanly. The unterminated-
string symptom was likely a downstream artifact of the bare-semicolon joining
or the empty-src placeholder confusing the lint tool. If the original symptom
persists on a clean re-run against the fixed bundle, will open a follow-up PR
with a focused repro.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

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

Verdict: comment — fix is well-scoped and correct in the happy path, but two consumer-side concerns worth addressing before merge.

The diagnosis is right and the fix shape (inline runtime when no URL is configured + joinJsChunks helper + drop empty _err binding) is the minimum-blast-radius response to what Abhay reported. CI is green, manual repro on Abhay's bundle confirms. Nice writeup. The two issues below are about ripple effects in other call sites and one narrow correctness edge — not the fix itself.

Important

1. Five call sites now silently no-op their <script ... src=""> substitution. Either delete them or note they're legacy.

The PR changes the bundler so that when HYPERFRAME_RUNTIME_URL is unset (the default), the emitted tag is <script data-hyperframes-preview-runtime="1">…inlined…</script> — no src attribute at all. But five places still try to substitute the old empty-src placeholder:

  • packages/cli/src/server/studioServer.ts:158-161'data-hyperframes-preview-runtime="1" src=""'src="/api/runtime.js"
  • packages/studio/vite.config.ts:175-179 — same pattern, → ${runtimeUrl}
  • packages/cli/src/commands/validate.ts:129-132 — regex src="[^"]*" → inlined runtime body
  • packages/cli/src/commands/snapshot.ts:109-112 — same regex
  • packages/cli/src/commands/layout.ts:124-127 — same regex

After this PR, with no env var set, none of these regexes match (no src attribute exists), so the replace calls are dead. Behavior is functionally fine — the bundler now inlines the runtime itself, so the substitution is redundant — but the dead code reads as "we're substituting the runtime here" while doing nothing. In ~3 months, someone will trip on this trying to figure out why their runtime override didn't take effect.

Two acceptable resolutions:

  • (a) Delete the five replace blocks now (preferred — same behavior, less noise).
  • (b) Leave them, but add a one-line comment at each site noting they're a no-op for the inlined-runtime path and only fire when HYPERFRAME_RUNTIME_URL is set to a non-empty value (which still produces src=…).

Since this PR's whole story is "the bundle is now genuinely self-contained", (a) lines up with the architectural shift. Not a blocker for this PR if you want a focused diff, but please file a follow-up.

2. joinJsChunks introduces an ASI hazard with trailing line-comments. Narrow but real regression vs. \n;\n.

packages/core/src/compiler/htmlBundler.ts:301-307

function joinJsChunks(chunks: string[]): string {
  return chunks
    .map((chunk) => chunk.trim())
    .filter((chunk) => chunk.length > 0)
    .map((chunk) => (chunk.endsWith(";") ? chunk : chunk + ";"))
    .join("\n");
}

If a chunk's last non-whitespace content is a // comment, the ; is appended to the comment line and gets swallowed:

// trailing comment;
(function(){})()

The semicolon is part of the comment now. ASI normally saves us, except when the next chunk starts with (, [, +, -, /, or a backtick. The old \n;\n separator dodged this because the ; was always on its own line. Three call paths use joinJsChunks:

  • Body inline scripts (line 284) → re-passed through stripJsCommentsParserSafe → esbuild → comments stripped, safe.
  • Local JS chunks (line 405) → no post-processing, vulnerable.
  • Composition script chunks (line 650) → wrapped IIFE ends in })(), but if wrapScopedCompositionScript is bypassed (the non-scoped fallback at line 509 already emits a trailing ;, so safe), the wrapped path also safe. Local-JS path is the realistic exposure.

Two ways to make it bulletproof again:

.map((chunk) => chunk + "\n;")  // semicolon on its own line — same as before, but no leading newline pollution
.join("\n")

…which gives chunk1\n;\nchunk2\n;\n… — the original code smell is back.

Cleaner: run the joined output through stripJsCommentsParserSafe (esbuild) on all three paths, not just the body-inline path. That kills the comment edge entirely and keeps the \n separator.

The new not.toMatch(/\n\s*;\s*\n/) regression test catches the previous code smell but not this new ASI failure mode. Worth adding a chunk-with-trailing-comment fixture to the regression test if you keep the current approach. Not a blocker — exposure is narrow (author has to leave a // comment at end of a local JS file and the next chunk has to start with one of the ASI-hostile tokens) — but worth noting since the prior \n;\n was strict-better on this axis.

Nits

  • htmlBundler.test.ts:55, :86// Regression guard: hf#XXX. Two placeholder issue refs. Either file the issues or drop the placeholder.

  • htmlBundler.test.ts:53-83 — the new self-contained-runtime test only exercises the no-env-var branch. The if (runtimeScriptUrl) branch (env var set → src=… form) is the unchanged path, but adding a one-line companion test that sets HYPERFRAME_RUNTIME_URL and asserts the tag has src="…" would lock the if/else against future drift. Five extra lines.

  • htmlBundler.test.ts:78expect(innerLength).toBeGreaterThan(1000) is a weak shape check. The IIFE is ~150KB; consider .toBeGreaterThan(50_000) or a marker-string assertion (some stable identifier from the runtime entry, e.g., a known function name or the bootstrap attribute literal). 1KB is below most bundled vendor stubs.

  • htmlBundler.test.ts:71if (previousUrl !== undefined) process.env.HYPERFRAME_RUNTIME_URL = previousUrl; — if a separate test sets the env var and it stays set into this test, the delete correctly clears it; but if previousUrl was undefined and this test had set something (it doesn't), nothing would restore. Current shape is correct for the current test body. Just ensure no future change adds a process.env.HYPERFRAME_RUNTIME_URL = ... line above the try block without also restoring on the undefined branch. Defensive: process.env.HYPERFRAME_RUNTIME_URL = previousUrl ?? "" then delete only if the original was undefined. Matter of taste.

  • compositionScoping.ts:215-218 — comment is good. Consider naming the swallowed-error case in the comment ("Object.defineProperty on frozen target throws TypeError") so the reader doesn't have to reconstruct the failure mode from the prose.

Praise

  • The verification section in the PR body is exemplary — repro against Abhay's actual bundle, count of inline scripts that now parse cleanly, explanation of why issue #4 is dropped (downstream of #2). This is the bar for "fixes a thing a tool flagged."
  • The two updated existing assertions (not.toContain("getElementById") → specific user-script content check; not.toContain("data-composition-src") → DOM-level hasAttribute check) are tighter and more semantically meaningful than what they replaced. The old assertions were over-broad in a way that would have started failing for unrelated future runtime changes; the new ones pin down the actual property under test. Good test hygiene.
  • getHyperframeRuntimeScript() was already the standard inline-runtime escape hatch used by validate, snapshot, and runtimeSource.ts. Reaching for it here keeps bundleToSingleHtml consistent with the rest of the codebase rather than rolling a third path.

— Vai

…subs

Per @vai-bot's review on hf#641:

Important #1: dead `src=""` substitution sites
=============================================

Now that `bundleToSingleHtml` inlines the runtime IIFE by default, the empty
`src=""` placeholder is never emitted in the no-env-var path — the 5 downstream
substitution sites that grep for `src=""` were dead.

Two of them (studio dev server + studio vite preview) genuinely WANT the
placeholder so they can hot-reload a local /api/runtime.js endpoint without
re-inlining ~150 KB on every composition edit. Three of them (CLI validate,
snapshot, layout) were just doing the same inlining the bundler already does.

Resolution:
- Add a `runtime: "inline" | "placeholder"` option to `BundleOptions`. Default
  is "inline" (matches the self-contained-bundle promise the function name
  makes). The two studio surfaces explicitly pass `{ runtime: "placeholder" }`
  to opt in.
- studioServer.ts + studio/vite.config.ts: pass the option, keep their
  existing string-replace logic unchanged.
- validate.ts + snapshot.ts + layout.ts: delete the now-redundant runtime
  substitution code (regex never matches the new inlined-runtime shape).

Important #2: joinJsChunks ASI hazard
======================================

The new helper appended `;` to chunks not already ending in `;` and joined
on `\n`. If a chunk ended with a `// line comment`, the appended semicolon
was eaten by the comment, leaving the next chunk's first statement attached
to the previous chunk's last expression — exactly the ASI hazard the helper
exists to prevent.

Fix: append `\n;` instead of `;` for chunks not already terminated. The
newline closes the line comment, the standalone `;` becomes the statement
separator. For typical chunks (already ending in `;`), output is unchanged
— still clean `\n`-joined chunks with no bare-semicolon lines.

Also added a trailing `;` to `wrapScopedCompositionScript`'s IIFE close
(`})()` → `})();`) so composition scripts join cleanly without falling
through to the `\n;` fallback.

New test: regression guard at the chunk boundary verifies every inline
script body in the bundle parses cleanly via esbuild even when a source JS
file ends with a line comment.

Verification
============

- `bun run --filter @hyperframes/core test` — 653/653 pass
- `bun run --filter @hyperframes/cli test` — 243/243 pass
- `bun run --filter @hyperframes/{core,cli,studio} typecheck` — clean
- `bunx oxfmt --check` + `bunx oxlint` on all touched files — clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator Author

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

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

Addressed both Important items from @vai-bot's review in commit dfca302d on top of the original fix.

Important #1 — dead src="" substitution sites

Resolved by making the runtime mode an explicit opt-in:

  • Added runtime: "inline" | "placeholder" to BundleOptions. Default is "inline" (matches the self-contained-bundle promise the function name makes).
  • studioServer.ts + studio/vite.config.ts — pass { runtime: "placeholder" } explicitly so they keep emitting the empty src="" placeholder for their existing local-runtime-endpoint substitution. Inlining ~150 KB on every preview render would defeat browser caching across hot-reloads, which is why these two cases legitimately need the placeholder.
  • validate.ts + snapshot.ts + layout.ts — deleted the runtime substitution code. With the bundler now inlining by default, their regex <script ... src="..."></script> never matches the new shape, so the substitution was a no-op. Replaced with a one-line comment pointing at the bundler default.

Net result: the placeholder mode is alive for the two callers that need it, dead code gone for the three that didn't. No behavior change for any of them.

Important #2joinJsChunks ASI hazard with line comments

Real bug, fixed. Changed the trailing-; append from chunk + ";" to chunk + "\n;". The newline closes any trailing line comment; the standalone ; becomes the separator. For chunks that already end in ; (the typical case — esbuild output, IIFE-wrapped composition scripts), output is unchanged — clean \n-joined.

Also belt-and-suspenders: bumped wrapScopedCompositionScript's IIFE close from })()})();. Composition scripts now end in ; natively, so they take the clean \n path instead of falling through to the \n; fallback. The fallback only fires when a user-authored JS file ends with // line comment — rare but the test now covers it.

New regression test in htmlBundler.test.ts builds a project where one of the local JS files ends with window.__a = 1 // trailing line comment and verifies every inline script body in the bundle parses cleanly via esbuild's transformSync. Without the fix, the test fails with a parse error at the chunk boundary; with it, all bodies parse.

Verification

  • bun run --filter @hyperframes/core test — 653/653 pass.
  • bun run --filter @hyperframes/cli test — 243/243 pass.
  • bun run --filter @hyperframes/{core,cli,studio} typecheck — clean.
  • bunx oxfmt --check + bunx oxlint on all 8 touched files — clean.

Re-review on prior items per the new convention

  • Original Issue #1 (empty src=""): still fixed — bundler default mode now inlines, and the two callers that want the placeholder explicitly opt in. No regression.
  • Original Issue #2 (bare-semi lines): still fixed — joinJsChunks produces no bare semi for typical chunks, and the new \n; fallback keeps that property even for chunks ending with line comments.
  • Original Issue #3 (empty catch (_err) {}): still fixed — no rebinding on the runtime side.
  • Issue #4 (unterminated string from Abhay's eval): still N/A — esbuild parses the rebundled output cleanly.

Thanks for the catch on the line-comment ASI case — that was a real ASI footgun that would have shipped silently.

— Rames Jusso

Comment thread packages/core/src/compiler/htmlBundler.test.ts Fixed
Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

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

Re-verdict: Approve.

Both important items from my prior review cleanly resolved in dfca302d:

  • #1 Dead substitution sites — handled with judgment, not just deletion. Split into two cases: studio dev server + studio vite preview legitimately want the empty src="" placeholder to hot-reload a local /api/runtime.js endpoint without re-inlining ~150 KB per composition edit, so they now pass { runtime: "placeholder" } explicitly via the new BundleOptions.runtime opt-in. The truly-dead substitutions in validate.ts, snapshot.ts, layout.ts are deleted with a one-line note. This is the right shape — keeps the dev-loop fast while removing the unreachable code.
  • #2 joinJsChunks ASI hazard — fixed at the root (chunk + "\n;") so the trailing ; survives even if the source ends with a // line comment. Regression test added to htmlBundler.test.ts that round-trips through esbuild with a trailing comment fixture, so the contract is pinned.
  • Belt-and-suspenders bonus: wrapScopedCompositionScript's IIFE close })()})(); so composition scripts always take the clean \n path instead of relying on the fallback.

Verification claims (core 653/653, cli 243/243, typecheck + oxfmt + oxlint clean across 8 files) check out against the diff.

Nice scope discipline on the response — eight files touched, every one of them obviously connected to the two findings. Shipping.

— Vai

CodeQL flagged the inline `<script>...</script>` regex as case-sensitive,
which would miss `<SCRIPT>` tags. The bundler always emits lowercase, so
this is a defense-in-depth fix matching the `/i` flag already used by the
sibling regexes in this file (lines 37 & 75).

Addresses CodeQL review on #641.
Comment thread packages/core/src/compiler/htmlBundler.test.ts Fixed
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen left a comment

Choose a reason for hiding this comment

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

Reproduced and verified all three issues.

Issue 1 — empty src="" (real bug)
Confirmed on main: bundleToSingleHtml without HYPERFRAME_RUNTIME_URL emits <script data-hyperframes-preview-runtime="1" src=""></script>. After fix: runtime IIFE inlined (160KB body), no src attribute. { runtime: "placeholder" } mode correctly preserves the empty-src path for the studio dev server. ✓

Issue 2 — bare-semicolon lines (cosmetic)
The \n;\n join pattern is present in three code paths on main. In the final output it's masked by coalesceHeadStylesAndBodyScripts's esbuild reprocessing, but the intermediate join is still sloppy. joinJsChunks is cleaner — appends \n; only when the chunk doesn't already end in ;, producing clean \n-joined output. The \n; (not just ;) handles the trailing-line-comment ASI edge case correctly. ✓

Issue 3 — catch (_err) {} (lint noise)
Confirmed on main: the binding is unused at line 215 of compositionScoping.ts. Changed to catch { /* ... */ }. The other catch (_err) at line 283 is correctly left alone — it uses _err in console.error. ✓

Additional fixes verified:

  • Dead substitution sites in validate.ts, snapshot.ts, layout.ts correctly removed (the regex would never match the inlined-runtime shape)
  • studioServer.ts and vite.config.ts correctly pass { runtime: "placeholder" } to keep their hot-reload substitution path alive
  • wrapScopedCompositionScript IIFE close now has trailing ; (})();), so composition chunks take the clean \n join path

Tests: 22/22 pass across htmlBundler.test.ts + compositionScoping.test.ts. New regression tests cover self-contained runtime (no empty src), ASI hazard with trailing line comments, and no bare-semicolon lines.

LGTM.

CodeQL's `js/bad-tag-filter` rule flagged `</script>` as too strict —
`</script >` (with whitespace before `>`) is valid HTML and would slip
past the matcher. Changed to `</script\s*>` for full defense-in-depth.

The bundler always emits the canonical form, so no real-traffic miss —
this is hardening the test's parse-loop, not fixing a downstream bug.

Addresses CodeQL alert on #641.
Comment thread packages/core/src/compiler/htmlBundler.test.ts Fixed
jrusso1020 added 2 commits May 6, 2026 05:04
CodeQL still flagged `</script\s*>` as too narrow — the rule wants
tolerance for `</script\t\n bar>` (HTML parser treats trailing content
in a close tag as part of the tag). Switched to `</script[^>]*>` for
full coverage.

The bundler still always emits the canonical `</script>`; this is
test-side hardening, not a runtime fix.
Per CodeQL's `js/bad-tag-filter` recommendation, replace the regex-based
`<script>` body extraction with a `parseHTML` + `querySelectorAll`
walk. The rule explicitly says "use a parser library" — and linkedom
is already imported in this file, so the diff is small.

This eliminates the regex entirely, so the rule can no longer fire on
this site (instead of chasing whitespace / case / trailing-content
edge cases one at a time).
@jrusso1020 jrusso1020 merged commit 64457d9 into main May 6, 2026
41 checks passed
@jrusso1020 jrusso1020 deleted the fix/bundler-runtime-and-joins branch May 6, 2026 05:54
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.

4 participants