Skip to content

Commit af2f727

Browse files
Rames Jussoclaude
andcommitted
fix(bundler): inline runtime body, drop bare-semi joins, drop empty catch 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 heygen-com#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>
1 parent 21ec5f8 commit af2f727

3 files changed

Lines changed: 120 additions & 14 deletions

File tree

packages/core/src/compiler/compositionScoping.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,11 @@ export function wrapScopedCompositionScript(
212212
value: __hfFindRoot(),
213213
configurable: true,
214214
});
215-
} catch (_err) {}
215+
} catch {
216+
// Best-effort: timelines coming from user code may have a frozen target
217+
// or a non-extensible defineProperty path. Swallow — the scoped root
218+
// is an enrichment, not a correctness invariant for playback.
219+
}
216220
return timeline;
217221
};
218222
var __hfBaseGsap = typeof gsap === "undefined" ? window.gsap : gsap;

packages/core/src/compiler/htmlBundler.test.ts

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,82 @@ describe("bundleToSingleHtml", () => {
3838
)?.[0];
3939

4040
expect(runtimeBlock).toBeDefined();
41-
expect(runtimeBlock).not.toContain("getElementById");
41+
// The runtime block must contain the inlined HF runtime IIFE — bundled
42+
// output is self-contained, so the bundle's runtime body is loaded inline,
43+
// not referenced via src.
44+
expect(runtimeBlock).toMatch(/data-hyperframes-preview-runtime="1">/);
45+
expect(runtimeBlock).not.toMatch(/src=""/);
46+
// The author's specific composition script must NOT be merged INTO the
47+
// runtime tag — it stays as its own <script> elsewhere in the document.
48+
expect(runtimeBlock).not.toContain("window.__timelines.main = { duration:");
4249
expect(bundled).toContain('document.getElementById("scene")');
4350
});
4451

52+
it("produces a self-contained runtime script when no HYPERFRAME_RUNTIME_URL is set", async () => {
53+
// Regression guard: hf#XXX. The bundler used to emit
54+
// <script ... src=""></script> when no runtime URL was configured. An
55+
// empty src resolves to the page URL itself, which Chrome flags as an
56+
// infinite-fetch hazard. Verify that bundleToSingleHtml inlines the
57+
// runtime body so the bundle is genuinely self-contained.
58+
const dir = makeTempProject({
59+
"index.html": `<!doctype html>
60+
<html><body>
61+
<div data-composition-id="root" data-width="320" data-height="180"></div>
62+
</body></html>`,
63+
});
64+
65+
const previousUrl = process.env.HYPERFRAME_RUNTIME_URL;
66+
delete process.env.HYPERFRAME_RUNTIME_URL;
67+
let bundled: string;
68+
try {
69+
bundled = await bundleToSingleHtml(dir);
70+
} finally {
71+
if (previousUrl !== undefined) process.env.HYPERFRAME_RUNTIME_URL = previousUrl;
72+
}
73+
74+
const runtimeBlock = bundled.match(
75+
/<script\b[^>]*data-hyperframes-preview-runtime[^>]*>[\s\S]*?<\/script>/i,
76+
)?.[0];
77+
expect(runtimeBlock).toBeDefined();
78+
// Must NOT have an empty src attribute (would self-fetch).
79+
expect(runtimeBlock).not.toMatch(/src=""/);
80+
// Must have a non-trivial inlined body (the runtime IIFE is ~150KB).
81+
const innerLength = (runtimeBlock!.match(/>([\s\S]*?)<\/script>/)?.[1] ?? "").length;
82+
expect(innerLength).toBeGreaterThan(1000);
83+
});
84+
85+
it("does not produce stray bare-semicolon lines between concatenated JS chunks", async () => {
86+
// Regression guard: hf#XXX. Earlier the bundler joined script chunks with
87+
// `\n;\n`, which produces a lone `;` on its own line between chunks. Valid
88+
// JS but reads as a code smell. Each chunk should end in `;` and chunks
89+
// should join with `\n`.
90+
const dir = makeTempProject({
91+
"index.html": `<!doctype html>
92+
<html><body>
93+
<div data-composition-id="root" data-width="320" data-height="180">
94+
<div id="child-host"
95+
data-composition-id="child"
96+
data-composition-src="compositions/child.html"
97+
data-start="0" data-duration="2"></div>
98+
</div>
99+
<script src="local-a.js"></script>
100+
<script src="local-b.js"></script>
101+
<script>window.__timelines = window.__timelines || {}; window.__timelines.root = {}</script>
102+
</body></html>`,
103+
"local-a.js": "window.__a = 1",
104+
"local-b.js": "window.__b = 2",
105+
"compositions/child.html": `<template id="child-template">
106+
<div data-composition-id="child" data-width="320" data-height="180">
107+
<script>window.__c = 3</script>
108+
</div>
109+
</template>`,
110+
});
111+
112+
const bundled = await bundleToSingleHtml(dir);
113+
// No line is JUST a bare semicolon (with optional surrounding whitespace).
114+
expect(bundled).not.toMatch(/\n\s*;\s*\n/);
115+
});
116+
45117
it("hoists external CDN scripts from sub-compositions into the bundle", async () => {
46118
const dir = makeTempProject({
47119
"index.html": `<!doctype html>
@@ -84,8 +156,14 @@ describe("bundleToSingleHtml", () => {
84156
// GSAP CDN from main doc should still be present
85157
expect(bundled).toContain("cdn.jsdelivr.net/npm/gsap");
86158

87-
// data-composition-src should be stripped (composition was inlined)
88-
expect(bundled).not.toContain("data-composition-src");
159+
// data-composition-src should be stripped from the host element (composition
160+
// was inlined). The literal string may still appear inside the inlined
161+
// runtime IIFE that knows how to look up that attribute — so check the DOM,
162+
// not the raw text.
163+
const { document: doc } = parseHTML(bundled);
164+
const hostEl = doc.getElementById("rockets-host");
165+
expect(hostEl).toBeTruthy();
166+
expect(hostEl?.hasAttribute("data-composition-src")).toBe(false);
89167
});
90168

91169
it("does not duplicate CDN scripts already present in the main document", async () => {

packages/core/src/compiler/htmlBundler.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths";
1111
import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
1212
import { validateHyperframeHtmlContract } from "./staticGuard";
13+
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
1314

1415
/** Resolve a relative path within projectDir, rejecting traversal outside it. */
1516
function safePath(projectDir: string, relativePath: string): string | null {
@@ -30,8 +31,20 @@ function injectInterceptor(html: string): string {
3031
const sanitized = stripEmbeddedRuntimeScripts(html);
3132
if (sanitized.includes(RUNTIME_BOOTSTRAP_ATTR)) return sanitized;
3233

33-
const runtimeScriptUrl = getRuntimeScriptUrl().replace(/"/g, "&quot;");
34-
const tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1" src="${runtimeScriptUrl}"></script>`;
34+
// When a runtime URL is configured (HYPERFRAME_RUNTIME_URL env var), the bundle
35+
// points at it via src=… and the host page serves the script. When no URL is
36+
// configured — the common `bundleToSingleHtml` use case — inline the runtime
37+
// body so the bundle is genuinely self-contained. An empty src="" attribute
38+
// would otherwise resolve to the page URL and trigger an infinite-fetch loop.
39+
const runtimeScriptUrl = getRuntimeScriptUrl();
40+
let tag: string;
41+
if (runtimeScriptUrl) {
42+
const escaped = runtimeScriptUrl.replace(/"/g, "&quot;");
43+
tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1" src="${escaped}"></script>`;
44+
} else {
45+
const inlinedRuntime = getHyperframeRuntimeScript();
46+
tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1">${inlinedRuntime}</script>`;
47+
}
3548
if (sanitized.includes("</head>")) {
3649
return sanitized.replace("</head>", `${tag}\n</head>`);
3750
}
@@ -268,11 +281,7 @@ function coalesceHeadStylesAndBodyScripts(document: Document): void {
268281
return !type || type === "text/javascript" || type === "application/javascript";
269282
});
270283
if (bodyInlineScripts.length > 0) {
271-
const mergedJs = bodyInlineScripts
272-
.map((el) => (el.textContent || "").trim())
273-
.filter(Boolean)
274-
.join("\n;\n")
275-
.trim();
284+
const mergedJs = joinJsChunks(bodyInlineScripts.map((el) => el.textContent || ""));
276285
for (const el of bodyInlineScripts) el.remove();
277286
if (mergedJs) {
278287
const stripped = stripJsCommentsParserSafe(mergedJs);
@@ -283,6 +292,20 @@ function coalesceHeadStylesAndBodyScripts(document: Document): void {
283292
}
284293
}
285294

295+
/**
296+
* Concatenate JS chunks safely. Each chunk gets a trailing `;` if it doesn't
297+
* already end in one, so the joined output never inserts a stray bare-semicolon
298+
* line between chunks (the `\n;\n` separator pattern produces a lone `;` on its
299+
* own line, which is valid JS but reads as a code smell to most linters).
300+
*/
301+
function joinJsChunks(chunks: string[]): string {
302+
return chunks
303+
.map((chunk) => chunk.trim())
304+
.filter((chunk) => chunk.length > 0)
305+
.map((chunk) => (chunk.endsWith(";") ? chunk : chunk + ";"))
306+
.join("\n");
307+
}
308+
286309
function stripJsCommentsParserSafe(source: string): string {
287310
if (!source) return source;
288311
try {
@@ -379,12 +402,13 @@ export async function bundleToSingleHtml(
379402
}
380403
if (localJsChunks.length > 0) {
381404
const anchor = document.querySelector('script[data-hf-bundled-local-js="1"]');
405+
const joinedJs = joinJsChunks(localJsChunks);
382406
if (anchor) {
383407
anchor.removeAttribute("data-hf-bundled-local-js");
384-
anchor.textContent = localJsChunks.join("\n;\n");
408+
anchor.textContent = joinedJs;
385409
} else {
386410
const script = document.createElement("script");
387-
script.textContent = localJsChunks.join("\n;\n");
411+
script.textContent = joinedJs;
388412
document.body.appendChild(script);
389413
}
390414
}
@@ -623,7 +647,7 @@ export async function bundleToSingleHtml(
623647
}
624648
if (compScriptChunks.length) {
625649
const compScript = document.createElement("script");
626-
compScript.textContent = compScriptChunks.join("\n;\n");
650+
compScript.textContent = joinJsChunks(compScriptChunks);
627651
document.body.appendChild(compScript);
628652
}
629653

0 commit comments

Comments
 (0)