Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 4 additions & 21 deletions packages/cli/src/commands/layout.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -107,27 +107,10 @@ async function seekTo(page: import("puppeteer-core").Page, time: number): Promis
}

async function bundleProjectHtml(projectDir: string): Promise<string> {
// `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(
/<script[^>]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/,
() => `<script data-hyperframes-preview-runtime="1">${runtimeSource}</script>`,
);
}

return html;
return bundleToSingleHtml(projectDir);
}

async function alignViewportToComposition(
Expand Down
17 changes: 3 additions & 14 deletions packages/cli/src/commands/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
/<script[^>]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/,
() => `<script data-hyperframes-preview-runtime="1">${runtimeSource}</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);

Expand Down
24 changes: 5 additions & 19 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
/<script[^>]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/,
() => `<script data-hyperframes-preview-runtime="1">${runtimeSource}</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");
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,11 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
async bundle(dir: string): Promise<string | null> {
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"',
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/compiler/compositionScoping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -282,5 +286,5 @@ ${source}
};
__hfFindRoot();
__hfRun();
})()`;
})();`;
}
118 changes: 115 additions & 3 deletions packages/core/src/compiler/htmlBundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script> elsewhere in the document.
expect(runtimeBlock).not.toContain("window.__timelines.main = { duration:");
expect(bundled).toContain('document.getElementById("scene")');
});

it("produces a self-contained runtime script when no HYPERFRAME_RUNTIME_URL is set", async () => {
// Regression guard: hf#XXX. The bundler used to emit
// <script ... src=""></script> 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": `<!doctype html>
<html><body>
<div data-composition-id="root" data-width="320" data-height="180"></div>
</body></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(
/<script\b[^>]*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": `<!doctype html>
<html><body>
<div data-composition-id="root" data-width="320" data-height="180"></div>
<script src="local-a.js"></script>
<script src="local-b.js"></script>
<script>window.__timelines = window.__timelines || {}; window.__timelines.root = {}</script>
</body></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": `<!doctype html>
<html><body>
<div data-composition-id="root" data-width="320" data-height="180">
<div id="child-host"
data-composition-id="child"
data-composition-src="compositions/child.html"
data-start="0" data-duration="2"></div>
</div>
<script src="local-a.js"></script>
<script src="local-b.js"></script>
<script>window.__timelines = window.__timelines || {}; window.__timelines.root = {}</script>
</body></html>`,
"local-a.js": "window.__a = 1",
"local-b.js": "window.__b = 2",
"compositions/child.html": `<template id="child-template">
<div data-composition-id="child" data-width="320" data-height="180">
<script>window.__c = 3</script>
</div>
</template>`,
});

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": `<!doctype html>
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading