mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core): preserve replace-pattern characters in bundled runtime IIFE
`injectInterceptor` used `String.prototype.replace(target, replacement)` to inject the runtime `<script>` before `</head>`. The replacement string is a substitution template — `$&` expands to the matched substring, and the minified runtime IIFE contains legitimate `$&` sequences (e.g. `if(te&&$&!y.hasAttribute(...))`), so every `$&` in the body was silently rewritten to `</head>`, producing `Unexpected token '<'` SyntaxErrors and breaking every timeline in the bundle. Switch to the function-replacer form so the runtime body is passed through verbatim. Add a regression test that diffs the bundled runtime body against `getHyperframeRuntimeScript()` and asserts only one `</head>` survives in the document — the test exercises the `<head>`-present injection path (the only branch that uses the substitution template; the no-`<head>` fallback uses slice+concat and was unaffected). Only the bundler is affected — `producer/fileServer.ts` already uses the function form via `injectScriptsIntoHtml` in `htmlDocument.ts`, so render output was correct. Snapshot, preview, studio, layout, and validate all consume `bundleToSingleHtml` and were broken before this fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
ee4e088434
commit
69b7965446
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { bundleToSingleHtml } from "./htmlBundler";
|
||||
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
|
||||
|
||||
function makeTempProject(files: Record<string, string>): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-bundler-test-"));
|
||||
@@ -82,6 +83,55 @@ describe("bundleToSingleHtml", () => {
|
||||
expect(innerLength).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it("preserves `$&` replace-pattern characters in the inlined runtime body", async () => {
|
||||
// Regression guard: `injectInterceptor` used to insert the runtime via
|
||||
// `sanitized.replace("</head>", `${tag}\n</head>`)`. `String.prototype.replace`'s
|
||||
// second argument is a substitution template — `$&` expands to the matched
|
||||
// substring (here, `</head>`). The minified runtime IIFE contains legitimate
|
||||
// `$&` sequences (e.g. `if(te&&$&!y.hasAttribute(...))`), so the bundler
|
||||
// silently injected stray `</head>` tags inside the runtime, producing a JS
|
||||
// SyntaxError that broke every timeline in the bundle. Switching to the
|
||||
// function-replacer form passes the runtime body through verbatim.
|
||||
// Use a document with an explicit `<head>` so the bundler takes the
|
||||
// `sanitized.replace("</head>", …)` injection path — the only branch that
|
||||
// exercises the substitution-template behavior. Authoring without a
|
||||
// `<head>` falls back to slice+concat (safe but doesn't catch this bug).
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head></head><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 original = getHyperframeRuntimeScript();
|
||||
// Sanity: the built runtime exercises this regression (no `$&` means the
|
||||
// test would tautologically pass even with the broken implementation).
|
||||
expect(original).toContain("$&");
|
||||
|
||||
const runtimeBlock = bundled.match(
|
||||
/<script\b[^>]*data-hyperframes-preview-runtime[^>]*>([\s\S]*?)<\/script>/i,
|
||||
);
|
||||
expect(runtimeBlock).not.toBeNull();
|
||||
const runtimeBody = runtimeBlock?.[1] ?? "";
|
||||
expect(runtimeBody).toBe(original);
|
||||
|
||||
// Defense in depth: the entire bundled document should contain exactly one
|
||||
// `</head>` — the real closing tag. Before the fix, every `$&` in the
|
||||
// runtime expanded to an extra `</head>` inside the inlined IIFE,
|
||||
// producing a `Unexpected token '<'` SyntaxError at parse time.
|
||||
const headCloses = bundled.match(/<\/head>/g) ?? [];
|
||||
expect(headCloses.length).toBe(1);
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
@@ -52,7 +52,14 @@ function injectInterceptor(html: string, runtimeMode: "inline" | "placeholder" =
|
||||
tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1">${inlinedRuntime}</script>`;
|
||||
}
|
||||
if (sanitized.includes("</head>")) {
|
||||
return sanitized.replace("</head>", `${tag}\n</head>`);
|
||||
// Use a function replacer so `String.prototype.replace`'s substitution
|
||||
// patterns (`$&`, `$$`, `$'`, `` $` ``, `$1`–`$99`) inside the inlined
|
||||
// runtime IIFE are passed through verbatim. The minified runtime
|
||||
// contains the literal sequence `$&` as part of legitimate JS, and
|
||||
// the older `(pattern, string)` form would expand it to the matched
|
||||
// `</head>`, silently corrupting the runtime and breaking every
|
||||
// timeline in the bundle with a parse-time SyntaxError.
|
||||
return sanitized.replace("</head>", () => `${tag}\n</head>`);
|
||||
}
|
||||
const htmlOpenMatch = sanitized.match(/<html\b[^>]*>/i);
|
||||
if (htmlOpenMatch?.index != null) {
|
||||
|
||||
Reference in New Issue
Block a user