diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts index a8a713963..fa27dd70b 100644 --- a/packages/cli/src/commands/layout.ts +++ b/packages/cli/src/commands/layout.ts @@ -1,6 +1,6 @@ import { defineCommand } from "citty"; import { existsSync, readFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; @@ -107,27 +107,10 @@ async function seekTo(page: import("puppeteer-core").Page, time: number): Promis } async function bundleProjectHtml(projectDir: string): Promise { + // `bundleToSingleHtml` now inlines the runtime IIFE by default, so the + // previous post-bundle runtime substitution is no longer needed. const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); - let html = await bundleToSingleHtml(projectDir); - - const runtimePath = resolve( - __dirname, - "..", - "..", - "..", - "core", - "dist", - "hyperframe.runtime.iife.js", - ); - if (existsSync(runtimePath)) { - const runtimeSource = readFileSync(runtimePath, "utf-8"); - html = html.replace( - /]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/, - () => ``, - ); - } - - return html; + return bundleToSingleHtml(projectDir); } async function alignViewportToComposition( diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 2c1d26cce..0fd2a81da 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -97,20 +97,9 @@ async function captureSnapshots( const numFrames = opts.frames ?? 5; - // 1. Bundle - let html = await bundleToSingleHtml(projectDir); - - // Inject local runtime if available. - // Uses the same multi-strategy resolver as the studio preview server - // (runtimeSource.ts) so snapshot works in dev (tsx), built CLI, and npx. - const { loadRuntimeSource } = await import("../server/runtimeSource.js"); - const runtimeSource = await loadRuntimeSource(); - if (runtimeSource) { - html = html.replace( - /]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/, - () => ``, - ); - } + // 1. Bundle. `bundleToSingleHtml` now inlines the runtime IIFE by default, + // so the previous post-bundle runtime substitution is no longer needed. + const html = await bundleToSingleHtml(projectDir); const server = await serveStaticProjectHtml(projectDir, html); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index ed2bd7f99..8c9a2fba0 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -1,6 +1,6 @@ import { defineCommand } from "citty"; import { existsSync, readFileSync } from "node:fs"; -import { resolve, join, dirname } from "node:path"; +import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { resolveProject } from "../utils/project.js"; import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; @@ -113,24 +113,10 @@ async function validateInBrowser( const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); const { ensureBrowser } = await import("../browser/manager.js"); - let html = await bundleToSingleHtml(projectDir); - - const runtimePath = resolve( - __dirname, - "..", - "..", - "..", - "core", - "dist", - "hyperframe.runtime.iife.js", - ); - if (existsSync(runtimePath)) { - const runtimeSource = readFileSync(runtimePath, "utf-8"); - html = html.replace( - /]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/, - () => ``, - ); - } + // `bundleToSingleHtml` now inlines the runtime IIFE by default, so the + // previous post-bundle regex substitution (which matched `src="..."` on the + // runtime tag) is no longer needed — there's no `src` attribute to match. + const html = await bundleToSingleHtml(projectDir); const { createServer } = await import("node:http"); const { getMimeType } = await import("@hyperframes/core/studio-api"); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index ccceccffb..3397d7f33 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -153,8 +153,11 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { async bundle(dir: string): Promise { try { const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); - let html = await bundleToSingleHtml(dir); - // Fix empty runtime src from bundler — point to the local runtime endpoint + // Studio dev server: ask the bundler for an empty `src=""` placeholder so + // we can point it at our hot-reloadable local runtime endpoint. Inlining + // ~150 KB of runtime body on every preview render would defeat browser + // caching across composition edits. + let html = await bundleToSingleHtml(dir, { runtime: "placeholder" }); html = html.replace( 'data-hyperframes-preview-runtime="1" src=""', 'data-hyperframes-preview-runtime="1" src="/api/runtime.js"', diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts index ea3c55953..bc7153011 100644 --- a/packages/core/src/compiler/compositionScoping.ts +++ b/packages/core/src/compiler/compositionScoping.ts @@ -212,7 +212,11 @@ export function wrapScopedCompositionScript( value: __hfFindRoot(), configurable: true, }); - } catch (_err) {} + } catch { + // Best-effort: timelines coming from user code may have a frozen target + // or a non-extensible defineProperty path. Swallow — the scoped root + // is an enrichment, not a correctness invariant for playback. + } return timeline; }; var __hfBaseGsap = typeof gsap === "undefined" ? window.gsap : gsap; @@ -282,5 +286,5 @@ ${source} }; __hfFindRoot(); __hfRun(); -})()`; +})();`; } diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index e6a24ddb0..665ff9811 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -38,10 +38,116 @@ describe("bundleToSingleHtml", () => { )?.[0]; expect(runtimeBlock).toBeDefined(); - expect(runtimeBlock).not.toContain("getElementById"); + // The runtime block must contain the inlined HF runtime IIFE — bundled + // output is self-contained, so the bundle's runtime body is loaded inline, + // not referenced via src. + expect(runtimeBlock).toMatch(/data-hyperframes-preview-runtime="1">/); + expect(runtimeBlock).not.toMatch(/src=""/); + // The author's specific composition script must NOT be merged INTO the + // runtime tag — it stays as its own when no runtime URL was configured. An + // empty src resolves to the page URL itself, which Chrome flags as an + // infinite-fetch hazard. Verify that bundleToSingleHtml inlines the + // runtime body so the bundle is genuinely self-contained. + const dir = makeTempProject({ + "index.html": ` + +
+`, + }); + + const previousUrl = process.env.HYPERFRAME_RUNTIME_URL; + delete process.env.HYPERFRAME_RUNTIME_URL; + let bundled: string; + try { + bundled = await bundleToSingleHtml(dir); + } finally { + if (previousUrl !== undefined) process.env.HYPERFRAME_RUNTIME_URL = previousUrl; + } + + const runtimeBlock = bundled.match( + /]*data-hyperframes-preview-runtime[^>]*>[\s\S]*?<\/script>/i, + )?.[0]; + expect(runtimeBlock).toBeDefined(); + // Must NOT have an empty src attribute (would self-fetch). + expect(runtimeBlock).not.toMatch(/src=""/); + // Must have a non-trivial inlined body (the runtime IIFE is ~150KB). + const innerLength = (runtimeBlock!.match(/>([\s\S]*?)<\/script>/)?.[1] ?? "").length; + expect(innerLength).toBeGreaterThan(1000); + }); + + it("preserves chunk integrity when a chunk ends with a line comment (ASI hazard guard)", async () => { + // Regression guard for the joinJsChunks helper. If a chunk ends with `// ...` + // and we naively appended `;` on the same line, the appended semicolon would + // be eaten by the comment, leaving the next chunk's first statement attached + // to the previous chunk's last expression. Verify the helper appends `\n;` + // instead so the comment terminates and the semicolon stands alone. + const dir = makeTempProject({ + "index.html": ` + +
+ + + +`, + // Chunk A ends with a // line comment — without the \n separator before + // the appended ;, that ; would be eaten by the comment. + "local-a.js": "window.__a = 1 // trailing line comment", + "local-b.js": "window.__b = 2", + }); + + const bundled = await bundleToSingleHtml(dir); + // Run every inline script body through esbuild; if the line comment ate + // the separator, parse would fail with an unexpected-token error somewhere + // around the chunk boundary. Use a real HTML parser (CodeQL flags regex- + // based script extraction as bad-tag-filter). + const { transformSync } = await import("esbuild"); + const { document } = parseHTML(bundled); + for (const script of document.querySelectorAll("script")) { + const body = script.textContent; + if (!body || !body.trim()) continue; + expect(() => transformSync(body, { loader: "js", minify: false })).not.toThrow(); + } + }); + + it("does not produce stray bare-semicolon lines between concatenated JS chunks", async () => { + // Regression guard: hf#XXX. Earlier the bundler joined script chunks with + // `\n;\n`, which produces a lone `;` on its own line between chunks. Valid + // JS but reads as a code smell. Each chunk should end in `;` and chunks + // should join with `\n`. + const dir = makeTempProject({ + "index.html": ` + +
+
+
+ + + +`, + "local-a.js": "window.__a = 1", + "local-b.js": "window.__b = 2", + "compositions/child.html": ``, + }); + + const bundled = await bundleToSingleHtml(dir); + // No line is JUST a bare semicolon (with optional surrounding whitespace). + expect(bundled).not.toMatch(/\n\s*;\s*\n/); + }); + it("hoists external CDN scripts from sub-compositions into the bundle", async () => { const dir = makeTempProject({ "index.html": ` @@ -84,8 +190,14 @@ describe("bundleToSingleHtml", () => { // GSAP CDN from main doc should still be present expect(bundled).toContain("cdn.jsdelivr.net/npm/gsap"); - // data-composition-src should be stripped (composition was inlined) - expect(bundled).not.toContain("data-composition-src"); + // data-composition-src should be stripped from the host element (composition + // was inlined). The literal string may still appear inside the inlined + // runtime IIFE that knows how to look up that attribute — so check the DOM, + // not the raw text. + const { document: doc } = parseHTML(bundled); + const hostEl = doc.getElementById("rockets-host"); + expect(hostEl).toBeTruthy(); + expect(hostEl?.hasAttribute("data-composition-src")).toBe(false); }); it("does not duplicate CDN scripts already present in the main document", async () => { diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 3a80d58c9..def7c8463 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -10,6 +10,7 @@ import { import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths"; import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping"; import { validateHyperframeHtmlContract } from "./staticGuard"; +import { getHyperframeRuntimeScript } from "../generated/runtime-inline"; /** Resolve a relative path within projectDir, rejecting traversal outside it. */ function safePath(projectDir: string, relativePath: string): string | null { @@ -26,12 +27,28 @@ function getRuntimeScriptUrl(): string { return configured || DEFAULT_RUNTIME_SCRIPT_URL; } -function injectInterceptor(html: string): string { +function injectInterceptor(html: string, runtimeMode: "inline" | "placeholder" = "inline"): string { const sanitized = stripEmbeddedRuntimeScripts(html); if (sanitized.includes(RUNTIME_BOOTSTRAP_ATTR)) return sanitized; - const runtimeScriptUrl = getRuntimeScriptUrl().replace(/"/g, """); - const tag = ``; + // Three modes for the runtime `; + } else if (runtimeMode === "placeholder") { + tag = ``; + } else { + const inlinedRuntime = getHyperframeRuntimeScript(); + tag = ``; + } if (sanitized.includes("")) { return sanitized.replace("", `${tag}\n`); } @@ -268,11 +285,7 @@ function coalesceHeadStylesAndBodyScripts(document: Document): void { return !type || type === "text/javascript" || type === "application/javascript"; }); if (bodyInlineScripts.length > 0) { - const mergedJs = bodyInlineScripts - .map((el) => (el.textContent || "").trim()) - .filter(Boolean) - .join("\n;\n") - .trim(); + const mergedJs = joinJsChunks(bodyInlineScripts.map((el) => el.textContent || "")); for (const el of bodyInlineScripts) el.remove(); if (mergedJs) { const stripped = stripJsCommentsParserSafe(mergedJs); @@ -283,6 +296,31 @@ function coalesceHeadStylesAndBodyScripts(document: Document): void { } } +/** + * Concatenate JS chunks safely. Goals: + * - Each chunk's last statement is terminated, so joining can't introduce ASI + * surprises (e.g. `a()` followed by `(b)()` — the second chunk would parse + * as a call on the first's return value). + * - In the common case (chunk already ends with `;` — typical of esbuild + * output and IIFE-wrapped composition scripts ending in `})();`), the join + * produces clean output: chunks separated by `\n` with no stray bare + * semicolon lines. + * - Defensive against trailing line comments. If a chunk ends with `// ...` + * and we appended `;` on the same line, the appended semicolon would be + * swallowed by the comment, leaving the next chunk's first statement + * attached to the previous chunk's last expression — exactly the ASI + * hazard this helper exists to prevent. So when a chunk doesn't already + * end in `;`, we append `\n;` instead — the newline closes any line + * comment, and the standalone `;` becomes the statement separator. + */ +function joinJsChunks(chunks: string[]): string { + return chunks + .map((chunk) => chunk.trim()) + .filter((chunk) => chunk.length > 0) + .map((chunk) => (chunk.endsWith(";") ? chunk : chunk + "\n;")) + .join("\n"); +} + function stripJsCommentsParserSafe(source: string): string { if (!source) return source; try { @@ -296,6 +334,22 @@ function stripJsCommentsParserSafe(source: string): string { export interface BundleOptions { /** Optional media duration prober (e.g., ffprobe). If omitted, media durations are not resolved. */ probeMediaDuration?: MediaDurationProber; + /** + * How to handle the HyperFrames runtime ` so the caller can + * substitute it with a real URL via string replace. Used by the dev studio + * server and vite preview to point at a local runtime endpoint, which keeps + * the runtime cacheable across hot-reloads instead of re-inlining ~150 KB + * on every change. + * + * The `HYPERFRAME_RUNTIME_URL` env var, when set, takes precedence over both + * modes and emits `