fix: preview and bundled scripts run where and when the author put them (#5014)

* fix(core): bundled local scripts keep their own place, defer and async

The bundler joined every local <script src> into one inline script at the first one's position. That dropped defer and async (a deferred main.js ran before a deferred CDN gsap), moved later files ahead of CDN scripts between them, and let one file's error stop the rest. Each local script is now inlined where it stands with its attributes; a deferred or async one keeps its tag with its file as a data: URL. Inline-script merging also no longer folds in nomodule scripts, which dropped the attribute.

* fix: preview scripts run after the runtime when the bundler fails, and inlined files are escaped

- When the bundler returns nothing or throws, the preview places the runtime
  where the bundled page does (end of head) instead of after the composition's
  scripts or not at all; core's placement moves to insertRuntimeTag.
- Every HTML-spec JavaScript MIME type counts as a classic script for merging
  and the web-font gate.
- Inlined local files and merged runs are escaped with escapeInlineScriptSource.

* fix: deferred local scripts run late without data: URLs, and authored runtime reads survive

- Local defer/async scripts are inlined in their own tag with the attribute
  kept; the font-gate runner (and its old-runtime fallback) runs an inline
  classic defer script after the classic scripts, in document order, so a page
  policy that allows inline scripts but not data: still runs them.
- stripEmbeddedRuntimeScripts matches the bootstrap attribute and runtime file
  names on the tag, not runtime words in an authored script's text.
- The preview applies its runtime on every path, so an adapter page without
  one still gets it; the inline escaper writes <!-- as \x3C!--, valid in a
  unicode regex.

* fix: a deferred inline script waits for the deferred CDN script before it to load

* fix: an authored script that mentions the runtime marker no longer stops the runtime being added

* fix: only inlined deferred files run late, head ones included, and renders skip a CDN runtime link

* fix: deferred head files run as before, and one selector owns the deferred-file rule

* fix: a local file's legacy <!-- comment no longer breaks its merged run, and escapes keep case

* fix: a local file inlined on its own keeps working with a legacy <!-- comment

* test(core): a local non-JavaScript file keeps its text as written
This commit is contained in:
Miguel Ángel
2026-10-04 22:11:55 +00:00
committed by GitHub
parent b45818f69d
commit 16871a1796
18 changed files with 605 additions and 129 deletions
@@ -2514,6 +2514,190 @@ describe("bundleToSingleHtml script order", () => {
}
});
const LOCAL_ORDER_GSAP = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js";
async function bundledBody(scripts: string, files: Record<string, string>) {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><body>
<div data-composition-id="root" data-width="320" data-height="180"></div>
${scripts}
</body></html>`,
...files,
});
try {
const { document } = parseHTML(await bundleToSingleHtml(dir));
return [...document.querySelectorAll("body script")];
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
it("keeps each local script in its own place around a CDN script", async () => {
const scripts = await bundledBody(
`<script src="setup.js"></script>
<script src="${LOCAL_ORDER_GSAP}"></script>
<script src="main.js"></script>`,
{ "setup.js": "window.SETUP_RAN = 1;", "main.js": "window.MAIN_RAN = gsap.version;" },
);
const setupAt = scripts.findIndex((el) => el.textContent?.includes("SETUP_RAN"));
const gsapAt = scripts.findIndex((el) => el.getAttribute("src") === LOCAL_ORDER_GSAP);
const mainAt = scripts.findIndex((el) => el.textContent?.includes("MAIN_RAN"));
expect(setupAt).toBeGreaterThan(-1);
expect(setupAt).toBeLessThan(gsapAt);
expect(gsapAt).toBeLessThan(mainAt);
});
it.each(["defer", "async"])(
"inlines a local %s script in its own tag, keeping the attribute so the runtime times it",
async (when) => {
const scripts = await bundledBody(
`<script>window.FIRST = 1;</script>
<script ${when} src="main.js"></script>
<script>window.LAST = 1;</script>`,
{ "main.js": "window.MAIN_RAN = 1;" },
);
const main = scripts.find((el) => el.textContent?.includes("MAIN_RAN"));
expect(main?.hasAttribute(when)).toBe(true);
expect(main?.hasAttribute("src")).toBe(false);
expect(main?.getAttribute("data-hf-inlined-src")).toBe("main.js");
expect(main?.textContent).not.toMatch(/FIRST|LAST/);
},
);
it.each(["", ' type="text/babel"'])(
"escapes a local file's script-closing text, so the page keeps its structure (%s)",
async (type) => {
const scripts = await bundledBody(
`<script${type} src="main.js"></script><p id="after">ok</p>`,
{
"main.js": `window.CLOSE = "</script><p id='leak'>x</p>"; window.OPEN = "<!-- <script>";`,
},
);
const holder = scripts.find((el) => el.textContent?.includes("window.CLOSE"));
expect(holder?.textContent).toContain("window.OPEN");
expect(holder?.textContent).not.toMatch(/<\/script|<!--/i);
expect(holder?.ownerDocument.getElementById("leak")).toBeNull();
},
);
it.each([
["a local file", `<script nomodule src="legacy.js"></script>`],
["an inline script", `<script nomodule>window.LEGACY_RAN = 1;</script>`],
])("keeps nomodule on %s, so modern browsers still skip it", async (_, legacy) => {
const scripts = await bundledBody(
`<script>window.FIRST = 1;</script>\n ${legacy}\n <script>window.LAST = 1;</script>`,
{
"legacy.js": "window.LEGACY_RAN = 1;",
},
);
const holder = scripts.find((el) => el.textContent?.includes("LEGACY_RAN"));
expect(holder?.hasAttribute("nomodule")).toBe(true);
expect(holder?.textContent).not.toMatch(/FIRST|LAST/);
});
it("keeps a local script's non-JavaScript type, so it is not run as JavaScript", async () => {
const scripts = await bundledBody(`<script type="text/babel" src="app.jsx"></script>`, {
"app.jsx": "const App = () => <div />;",
});
const holder = scripts.find((el) => el.textContent?.includes("const App"));
expect(holder?.getAttribute("type")).toBe("text/babel");
});
it.each(["", ' type="text/babel"'])(
"keeps a local file's unicode regex for <!-- valid JavaScript (%s)",
async (type) => {
const scripts = await bundledBody(`<script${type} src="main.js"></script>`, {
"main.js": "window.HAS_COMMENT = /<!--/u.test(document.body.innerHTML);",
});
const holder = scripts.find((el) => el.textContent?.includes("HAS_COMMENT"));
expect(() => new Function(holder?.textContent ?? "")).not.toThrow();
},
);
it("keeps an authored script that reads the runtime global", async () => {
const scripts = await bundledBody(
`<script>if (window.__hyperframeRuntime) window.AUTHOR_SEEN = 1;</script>`,
{},
);
expect(scripts.some((el) => el.textContent?.includes("AUTHOR_SEEN"))).toBe(true);
});
it("keeps a run that merges a local file with a legacy <!-- comment valid, every script in order", async () => {
const scripts = await bundledBody(
`<script src="esc.js"></script>
<script src="legacy.js"></script>
<script>window.RAN.push("a");</script>
<script>window.RAN.push("b");</script>
<script src="plain.js"></script>`,
{
"esc.js": 'window.RAN = ["esc"]; window.CLOSE = "</script>";',
"legacy.js": '<!-- a legacy comment\nwindow.RAN.push("legacy");',
"plain.js": 'window.RAN.push("plain");',
},
);
const merged = scripts.find((el) => el.textContent?.includes('"plain"'));
expect(merged?.textContent).not.toMatch(/<\/script|<!--/i);
const page = { RAN: [] as string[] };
new Function("window", merged?.textContent ?? "")(page);
expect(page.RAN).toEqual(["esc", "legacy", "a", "b", "plain"]);
});
it.each([
["in <head>", `<script src="legacy.js"></script>`, ""],
["deferred in <body>", "", `<script defer src="legacy.js"></script>`],
])(
"keeps a local file with a legacy <!-- comment valid when it is inlined on its own (%s)",
async (_, head, body) => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head>${head}</head><body>
<div data-composition-id="root" data-width="320" data-height="180"></div>
${body}
</body></html>`,
"legacy.js": '<!-- a legacy comment\nwindow.RAN = ["legacy"];',
});
try {
const { document } = parseHTML(await bundleToSingleHtml(dir));
const file = document.querySelector("script[data-hf-inlined-src]");
expect(file?.textContent).not.toContain("<!--");
const page = { RAN: [] as string[] };
new Function("window", file?.textContent ?? "")(page);
expect(page.RAN).toEqual(["legacy"]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
it("leaves a local non-JavaScript file's text as written", async () => {
const scripts = await bundledBody(`<script type="application/json" src="data.json"></script>`, {
"data.json": "[1,2]",
});
expect(scripts.find((el) => el.getAttribute("type") === "application/json")?.textContent).toBe(
"[1,2]",
);
});
it("still adds the runtime when an authored script mentions its marker attribute", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head></head><body>
<div data-composition-id="root" data-width="320" data-height="180"></div>
<script>window.MARKER = document.querySelector("[data-hyperframes-preview-runtime]");</script>
</body></html>`,
});
try {
const { document } = parseHTML(await bundleToSingleHtml(dir));
const scripts = [...document.querySelectorAll("script")];
expect(
scripts.filter((el) => el.hasAttribute("data-hyperframes-preview-runtime")),
).toHaveLength(1);
expect(scripts.some((el) => el.textContent?.includes("window.MARKER"))).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("still merges adjacent inline scripts into one at the end of the body", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
+15 -40
View File
@@ -4,7 +4,9 @@ import {
deferScriptsUntilFonts,
UNCONDITIONAL_CSS_KEY,
headStyleRuns,
INLINED_FILE_ATTR,
inlineScriptRuns,
isJavaScriptType,
styleElementsFor,
type CompositionStyle,
} from "./scriptRuns";
@@ -28,7 +30,8 @@ import { transformSync } from "esbuild";
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
import {
RUNTIME_BOOTSTRAP_ATTR,
insertBeforeCloseTag,
escapeInlineScriptSource,
insertRuntimeTag,
parseHTMLContent,
stripEmbeddedRuntimeScripts,
} from "./htmlDocument";
@@ -62,7 +65,6 @@ function getRuntimeScriptUrl(): string {
function injectInterceptor(html: string, runtimeMode: "inline" | "placeholder" = "inline"): string {
const sanitized = stripEmbeddedRuntimeScripts(html);
if (sanitized.includes(RUNTIME_BOOTSTRAP_ATTR)) return sanitized;
// Three modes for the runtime <script>:
// 1. HYPERFRAME_RUNTIME_URL env var set → emit src="<url>" (production CDN deploy).
@@ -82,19 +84,7 @@ function injectInterceptor(html: string, runtimeMode: "inline" | "placeholder" =
const inlinedRuntime = getHyperframeRuntimeScript();
tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1">${inlinedRuntime}</script>`;
}
const withHead = insertBeforeCloseTag(sanitized, "head", `${tag}\n`);
if (withHead !== null) return withHead;
const htmlOpenMatch = sanitized.match(/<html\b[^>]*>/i);
if (htmlOpenMatch?.index != null) {
const insertPos = htmlOpenMatch.index + htmlOpenMatch[0].length;
return `${sanitized.slice(0, insertPos)}<head>${tag}</head>${sanitized.slice(insertPos)}`;
}
const doctypeIdx = sanitized.toLowerCase().indexOf("<!doctype");
if (doctypeIdx >= 0) {
const insertPos = sanitized.indexOf(">", doctypeIdx) + 1;
return sanitized.slice(0, insertPos) + tag + sanitized.slice(insertPos);
}
return tag + sanitized;
return insertRuntimeTag(sanitized, tag);
}
function isRelativeUrl(url: string): boolean {
@@ -749,7 +739,7 @@ function coalesceHeadStylesAndBodyScripts(document: Document): void {
for (const el of members) el.remove();
if (!mergedJs) continue;
const inlineScript = document.createElement("script");
inlineScript.textContent = stripJsCommentsParserSafe(mergedJs);
inlineScript.textContent = escapeInlineScriptSource(stripJsCommentsParserSafe(mergedJs));
if (anchor) anchor.before(inlineScript);
else document.body.appendChild(inlineScript);
}
@@ -1226,9 +1216,6 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
templateEl.remove();
}
// Inline local JS
const localJsChunks: string[] = [];
let jsAnchorPlaced = false;
for (const el of [...document.querySelectorAll("script[src]")]) {
const src = el.getAttribute("src");
if (!src || !isRelativeUrl(src)) continue;
@@ -1240,27 +1227,9 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
const jsPath = resolveEntryPath(src);
const js = jsPath ? safeReadFile(jsPath) : null;
if (js == null) continue;
localJsChunks.push(js);
if (!jsAnchorPlaced) {
const anchor = document.createElement("script");
anchor.setAttribute("data-hf-bundled-local-js", "1");
el.replaceWith(anchor);
jsAnchorPlaced = true;
} else {
el.remove();
}
}
if (localJsChunks.length > 0) {
const anchor = document.querySelector('script[data-hf-bundled-local-js="1"]');
const joinedJs = joinJsChunks(localJsChunks);
if (anchor) {
anchor.removeAttribute("data-hf-bundled-local-js");
anchor.textContent = joinedJs;
} else {
const script = document.createElement("script");
script.textContent = joinedJs;
document.body.appendChild(script);
}
el.setAttribute(INLINED_FILE_ATTR, src);
el.removeAttribute("src");
el.textContent = js;
}
for (const link of compExternalLinks) ensureExternalLinkTag(document, link);
@@ -1294,6 +1263,12 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
enforceCompositionPixelSizing(document);
autoHealMissingCompositionIds(document);
coalesceHeadStylesAndBodyScripts(document);
for (const el of document.querySelectorAll(`script[${INLINED_FILE_ATTR}]`)) {
const js = el.textContent ?? "";
el.textContent = escapeInlineScriptSource(
isJavaScriptType(el) ? stripJsCommentsParserSafe(js) : js,
);
}
deferScriptsUntilFonts(document, (el) => el.hasAttribute(RUNTIME_BOOTSTRAP_ATTR));
injectTextRenderingRule(document);
@@ -69,6 +69,24 @@ describe("htmlDocument helpers", () => {
expect(stripped).toContain("window.__renderReady");
});
it.each([
["reads the runtime global", "if (window.__hyperframeRuntime) window.seen = 1;"],
[
"queries the bootstrap attribute",
'document.querySelector("[data-hyperframes-preview-runtime]");',
],
["names a runtime file", 'console.log("hyperframe.runtime.iife.js");'],
["sets up window.__player", "window.__player = window.__player || {};"],
])("keeps an authored script that %s", (_, source) => {
const html = `<script>${source}</script>`;
expect(stripEmbeddedRuntimeScripts(html)).toBe(html);
});
it("strips a runtime file linked with a query string or uppercase name", () => {
const html = '<script src="/static/HYPERFRAME.RUNTIME.IIFE.JS?v=2"></script><p>kept</p>';
expect(stripEmbeddedRuntimeScripts(html)).toBe("<p>kept</p>");
});
it("does not treat non-script tags as scripts when stripping runtimes", () => {
const html = "<scripture>window.__playerReady = true;</scripture>";
@@ -99,7 +117,7 @@ describe("htmlDocument helpers", () => {
);
expect(injected).toContain("<\\/script ><script>window.pwned = true;<\\/script>");
expect(injected).toContain("<\\!-- kept as script text");
expect(injected).toContain("\\x3C!-- kept as script text");
expect(injected).not.toContain("</script ><script>window.pwned = true;");
});
@@ -148,7 +166,7 @@ describe("htmlDocument helpers", () => {
expect(stripped).toBe("<p>İİ</p><p>kept</p>");
const escaped = injectScriptsIntoHtml(page, ['x="İİ</SCRIPT>"'], []);
expect(escaped).toContain('<script>x="İİ<\\/script>"</script>');
expect(escaped).toContain('<script>x="İİ<\\/SCRIPT>"</script>');
});
it("skips a script tag written inside an attribute value", () => {
+42 -27
View File
@@ -2,19 +2,10 @@ import { parseHTML } from "linkedom";
export const RUNTIME_BOOTSTRAP_ATTR = "data-hyperframes-preview-runtime";
const RUNTIME_SRC_MARKERS = [
const RUNTIME_FILES = [
"hyperframe.runtime.iife.js",
"hyperframes-runtime.modular.inline.js",
"hyperframe-runtime.modular-runtime.inline.js",
RUNTIME_BOOTSTRAP_ATTR,
];
const RUNTIME_INLINE_MARKERS = [
"__hyperframeRuntimeBootstrapped",
"__hyperframeRuntime",
"__hyperframeRuntimeTeardown",
"__HF_EXPORT_RENDER_SEEK_CONFIG",
"window.__player =",
];
const SIMPLE_RUNTIME_FLAG_ASSIGNMENTS = [
@@ -67,7 +58,7 @@ export function stripEmbeddedRuntimeScripts(html: string): string {
const closeTagEnd = findScriptCloseTagEnd(loweredHtml, startTagEnd + 1);
const scriptEnd = closeTagEnd === -1 ? html.length : closeTagEnd;
const block = html.slice(scriptStart, scriptEnd);
if (!shouldStripRuntimeScriptBlock(block)) {
if (!shouldStripRuntimeScriptBlock(block, html.slice(scriptStart, startTagEnd + 1))) {
output += block;
}
cursor = scriptEnd;
@@ -142,19 +133,21 @@ function findScriptCloseTagBoundary(loweredHtml: string, from: number): number {
return loweredHtml[cursor] === ">" ? cursor + 1 : -1;
}
function shouldStripRuntimeScriptBlock(block: string): boolean {
const lowered = block.toLowerCase();
for (const marker of RUNTIME_SRC_MARKERS) {
if (lowered.includes(marker.toLowerCase())) return true;
}
for (const marker of RUNTIME_INLINE_MARKERS) {
if (block.includes(marker)) return true;
function shouldStripRuntimeScriptBlock(block: string, startTag: string): boolean {
const script = parseHTML(`${startTag}</script>`).document.querySelector("script");
if (
script?.hasAttribute(RUNTIME_BOOTSTRAP_ATTR) ||
isRuntimeFileUrl(script?.getAttribute("src"))
) {
return true;
}
const scriptSource = getScriptSource(block).trim();
for (const pattern of SIMPLE_RUNTIME_FLAG_ASSIGNMENTS) {
if (pattern.test(scriptSource)) return true;
}
return false;
return SIMPLE_RUNTIME_FLAG_ASSIGNMENTS.some((pattern) => pattern.test(scriptSource));
}
export function isRuntimeFileUrl(src: string | null | undefined): boolean {
const path = lowerAscii(src?.split(/[?#]/, 1)[0] ?? "");
return RUNTIME_FILES.some((file) => path === file || path.endsWith(`/${file}`));
}
function getScriptSource(block: string): string {
@@ -174,15 +167,19 @@ function isHtmlWhitespace(char: string): boolean {
return char === " " || char === "\n" || char === "\t" || char === "\r" || char === "\f";
}
function escapeInlineScriptSource(source: string): string {
export function escapeInlineScriptSource(source: string): string {
return escapeCaseInsensitiveToken(
escapeCaseInsensitiveToken(source, "</script", "<\\/script"),
escapeCaseInsensitiveToken(source, "</script", (match) => `<\\/${match.slice(2)}`),
"<!--",
"<\\!--",
() => "\\x3C!--",
);
}
function escapeCaseInsensitiveToken(source: string, token: string, replacement: string): string {
function escapeCaseInsensitiveToken(
source: string,
token: string,
replacement: (match: string) => string,
): string {
const loweredSource = lowerAscii(source);
const loweredToken = lowerAscii(token);
let output = "";
@@ -194,7 +191,9 @@ function escapeCaseInsensitiveToken(source: string, token: string, replacement:
output += source.slice(cursor);
break;
}
output += source.slice(cursor, tokenStart) + replacement;
output +=
source.slice(cursor, tokenStart) +
replacement(source.slice(tokenStart, tokenStart + token.length));
cursor = tokenStart + token.length;
}
@@ -343,6 +342,22 @@ export function insertBeforeCloseTag(
return insertBeforeDocumentTag(html, `</${name}`, markup);
}
export function insertRuntimeTag(html: string, tag: string): string {
const withHead = insertBeforeCloseTag(html, "head", `${tag}\n`);
if (withHead !== null) return withHead;
const htmlOpenMatch = html.match(/<html\b[^>]*>/i);
if (htmlOpenMatch?.index != null) {
const insertPos = htmlOpenMatch.index + htmlOpenMatch[0].length;
return `${html.slice(0, insertPos)}<head>${tag}</head>${html.slice(insertPos)}`;
}
const doctypeIdx = html.toLowerCase().indexOf("<!doctype");
if (doctypeIdx >= 0) {
const insertPos = html.indexOf(">", doctypeIdx) + 1;
return html.slice(0, insertPos) + tag + html.slice(insertPos);
}
return tag + html;
}
/**
* Insert raw tag markup at the very start of `<head>`, ahead of every author
* script (inline or external). Falls back to just before `<body>`, then to the
+2
View File
@@ -53,6 +53,8 @@ export {
injectTagsAtHeadStart,
injectScriptsIntoHtml,
insertBeforeCloseTag,
insertRuntimeTag,
isRuntimeFileUrl,
parseHTMLContent,
stripEmbeddedRuntimeScripts,
} from "./htmlDocument";
@@ -51,6 +51,23 @@ describe("inlineScriptRuns", () => {
).toEqual([{ members: ["a", "b"], anchor: null }]);
});
it.each([
"application/ecmascript",
" TEXT/JScript ",
"text/javascript1.5",
"application/x-javascript",
])("treats the legacy JavaScript type %j as a classic script", (type) => {
expect(runsOf(`<script>a</script><script type="${type}">b</script>`)).toEqual([
{ members: ["a", "b"], anchor: null },
]);
});
it("leaves a script whose type only looks like JavaScript out of the run", () => {
expect(runsOf('<script>a</script><script type="text/javascript2">b</script>')).toEqual([
{ members: ["a"], anchor: null },
]);
});
it("returns no runs when there are no inline scripts", () => {
expect(runsOf('<script src="x.js"></script>')).toEqual([]);
});
+32 -3
View File
@@ -4,14 +4,40 @@ export interface InlineScriptRun {
anchor: Element | null;
}
// The HTML spec's JavaScript MIME type essence matches: a script with any of these types runs as classic.
const JAVASCRIPT_TYPES = new Set([
"",
"application/ecmascript",
"application/javascript",
"application/x-ecmascript",
"application/x-javascript",
"text/ecmascript",
"text/javascript",
"text/javascript1.0",
"text/javascript1.1",
"text/javascript1.2",
"text/javascript1.3",
"text/javascript1.4",
"text/javascript1.5",
"text/jscript",
"text/livescript",
"text/x-ecmascript",
"text/x-javascript",
]);
export function isJavaScriptType(el: Element): boolean {
return JAVASCRIPT_TYPES.has((el.getAttribute("type") || "").trim().toLowerCase());
}
function isClassicInline(el: Element): boolean {
const type = (el.getAttribute("type") || "").trim().toLowerCase();
return !type || type === "text/javascript" || type === "application/javascript";
return !el.hasAttribute("nomodule") && isJavaScriptType(el);
}
function isSeparateExecution(el: Element, isPinned: (el: Element) => boolean): boolean {
return (
el.hasAttribute("src") ||
el.hasAttribute("defer") ||
el.hasAttribute("async") ||
isPinned(el) ||
(el.getAttribute("type") || "").trim().toLowerCase() === "module"
);
@@ -43,6 +69,9 @@ const AFTER_FONTS_MODULE_TYPE = `${AFTER_FONTS_SCRIPT_TYPE}+module`;
export const AFTER_FONTS_SCRIPTS = `script[type="${AFTER_FONTS_SCRIPT_TYPE}"], script[type="${AFTER_FONTS_MODULE_TYPE}"]`;
export const AFTER_FONTS_CLAIM = "__hfAfterFontsClaimed";
export const INLINED_FILE_ATTR = "data-hf-inlined-src";
export const DEFERRED_FILE = `[defer][src], [defer][${INLINED_FILE_ATTR}]`;
const AFTER_FONTS_FALLBACK_ATTR = "data-hf-after-fonts-fallback";
// For a runtime older than the gate: at DOMContentLoaded, before that runtime boots, run them in parser order.
@@ -52,7 +81,7 @@ const afterFontsFallback = () => `document.addEventListener("DOMContentLoaded",
var all = [].slice.call(document.querySelectorAll('${AFTER_FONTS_SCRIPTS}'));
if (!all.length) return;
console.warn("[hyperframes] the runtime has no web-font gate; composition scripts run without waiting for fonts");
var late = function (el) { return el.type !== T || (el.hasAttribute("src") && el.hasAttribute("defer")); };
var late = function (el) { return el.type !== T || el.matches('${DEFERRED_FILE}'); };
var queue = all.filter(function (el) { return !late(el); }).concat(all.filter(late));
(function next() {
var el = queue.shift();
+4 -7
View File
@@ -1,4 +1,4 @@
import { AFTER_FONTS_CLAIM, typeAfterFonts } from "../compiler/scriptRuns";
import { AFTER_FONTS_CLAIM, DEFERRED_FILE, typeAfterFonts } from "../compiler/scriptRuns";
import { postRuntimeMessage } from "./bridge";
/** Past the 3 s a font-display:block face holds text back, far under the engine's 45 s player-ready wait. */
@@ -110,17 +110,14 @@ export async function runScriptsAfterFonts(
afterRun?: () => void,
): Promise<void> {
await waitForFonts();
const isLate = (el: Element) =>
typeAfterFonts(el) === "module" || (el.hasAttribute("src") && el.hasAttribute("defer"));
const late = scripts.filter(isLate);
const isLate = (el: Element) => typeAfterFonts(el) === "module" || el.matches(DEFERRED_FILE);
const held: HeldListener[] = [];
const release = [document, window].map((target) => holdPassedLoadEvents(target, held));
for (const el of scripts.filter((el) => !isLate(el))) {
for (const el of [...scripts.filter((el) => !isLate(el)), ...scripts.filter(isLate)]) {
const script = runInPlace(el);
if (script?.hasAttribute("src") && !script.async && !script.noModule) await loaded(script);
else if (typeAfterFonts(el) === "module") await afterQueuedModules();
}
for (const el of late) runInPlace(el);
if (late.length > 0) await afterQueuedModules();
for (const undo of release) undo();
afterRun?.();
for (const { target, type, listener } of held) {
@@ -44,9 +44,9 @@ const claims = (claimed: boolean) =>
: delete (window as unknown as Record<string, unknown>)[AFTER_FONTS_CLAIM];
// A page compiled elsewhere and imported, so jsdom runs none of its scripts; the test runs the fallback.
function compilePage(): void {
function compilePage(scripts = `<script>${logs("a")}</script><script>${logs("b")}</script>`): void {
const compiled = new DOMParser().parseFromString(
`<output id="log"></output><script>${logs("a")}</script><script>${logs("b")}</script>`,
`<output id="log"></output>${scripts}`,
"text/html",
);
deferScriptsUntilFonts(compiled);
@@ -104,29 +104,32 @@ describe("runtime entry: composition scripts after web fonts", () => {
expect(log()).toBe(`${before}wrapped `);
});
it("runs an inline script after an external one only once the external one has loaded", async () => {
serveFonts(Promise.resolve(), []);
// jsdom fetches no src: the test loads the library, once the runtime has put its script in place.
const library = new MutationObserver((records) => {
for (const node of records.flatMap((record) => [...record.addedNodes])) {
if (!(node instanceof HTMLScriptElement) || !node.src || node.type) continue;
document.body.dataset.lib = "loaded";
node.dispatchEvent(new Event("load"));
}
});
library.observe(document.body, { childList: true });
document.body.innerHTML =
`<output id="log"></output>` +
`<script type="${AFTER_FONTS_SCRIPT_TYPE}" src="https://cdn.example/lib.js"></script>` +
`<script type="${AFTER_FONTS_SCRIPT_TYPE}">` +
`document.getElementById("log").textContent += "lib:" + document.body.dataset.lib;</script>`;
it.each(["", " defer"])(
"runs an inline script after an external one only once the external one has loaded (%s)",
async (when) => {
serveFonts(Promise.resolve(), []);
// jsdom fetches no src: the test loads the library, once the runtime has put its script in place.
const library = new MutationObserver((records) => {
for (const node of records.flatMap((record) => [...record.addedNodes])) {
if (!(node instanceof HTMLScriptElement) || !node.src || node.type) continue;
document.body.dataset.lib = "loaded";
node.dispatchEvent(new Event("load"));
}
});
library.observe(document.body, { childList: true });
document.body.innerHTML =
`<output id="log"></output>` +
`<script type="${AFTER_FONTS_SCRIPT_TYPE}"${when} src="https://cdn.example/lib.js"></script>` +
`<script type="${AFTER_FONTS_SCRIPT_TYPE}"${when}${when && ' data-hf-inlined-src="main.js"'}>` +
`document.getElementById("log").textContent += "lib:" + document.body.dataset.lib;</script>`;
await parseThenLoad();
await vi.waitFor(() => expect(window.__player).toBeDefined());
library.disconnect();
delete document.body.dataset.lib;
expect(log()).toBe("lib:loaded");
});
await parseThenLoad();
await vi.waitFor(() => expect(window.__player).toBeDefined());
library.disconnect();
delete document.body.dataset.lib;
expect(log()).toBe("lib:loaded");
},
);
it("runs the scripts at the font timeout and reports the families still loading", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
@@ -154,6 +157,39 @@ describe("runtime entry: composition scripts after web fonts", () => {
);
});
it.each([
[
"an inlined deferred file after the classic scripts",
' data-hf-inlined-src="main.js"',
"classic deferred ",
],
["an authored inline defer script in place, as the browser does", "", "deferred classic "],
])("runs %s", async (_, inlined, expected) => {
serveFonts(Promise.resolve(), []);
document.body.innerHTML =
`<output id="log"></output>` +
`<script type="${AFTER_FONTS_SCRIPT_TYPE}" defer${inlined}>${logs("deferred")}</script>` +
`<script type="${AFTER_FONTS_SCRIPT_TYPE}">${logs("classic")}</script>`;
await parseThenLoad();
await vi.waitFor(() => expect(window.__player).toBeDefined());
expect(log()).toBe(expected);
});
it.each([
["an inlined deferred file last", ' data-hf-inlined-src="main.js"', "classic deferred "],
["an authored inline defer script in place", "", "deferred classic "],
])("runs %s through the fallback too", (_, inlined, expected) => {
vi.spyOn(console, "warn").mockImplementation(() => {});
claims(false);
compilePage(
`<script defer${inlined}>${logs("deferred")}</script><script>${logs("classic")}</script>`,
);
document.dispatchEvent(new Event("DOMContentLoaded"));
expect(log()).toBe(expected);
});
it("runs deferred scripts once and in order through the page's fallback when the runtime predates the gate", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
claims(false);
@@ -12,6 +12,7 @@ const INTEGRATION_TEST_FILES = new Set([
"tests/distributed/_smoke/webm-concat-copy.test.ts",
"tests/playback-rate-av-parity/playback-rate-av-parity.test.ts",
"src/regression-harness-psnr.test.ts",
"src/services/bundleScriptPolicy.test.ts",
"src/services/coreRuntimeBrowser.test.ts",
"src/services/deterministicFonts-systemCapture.test.ts",
"src/services/vfxDeterminism.test.ts",
@@ -0,0 +1,117 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import puppeteer, { type Browser } from "puppeteer";
import { bundleToSingleHtml } from "@hyperframes/core/compiler";
const INLINE_ONLY_POLICY = `<meta http-equiv="Content-Security-Policy" content="script-src 'unsafe-inline'">`;
const ROOT = `<div data-composition-id="root" data-start="0" data-duration="2" data-width="320" data-height="180">`;
async function bundled(indexHtml: string, mainJs: string): Promise<string> {
const dir = mkdtempSync(join(tmpdir(), "hf-bundle-scripts-"));
try {
writeFileSync(join(dir, "index.html"), indexHtml);
writeFileSync(join(dir, "main.js"), mainJs);
return await bundleToSingleHtml(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
describe("bundled local scripts in Chrome", () => {
let browser: Browser;
beforeAll(async () => {
browser = await puppeteer.launch({
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
}, 30_000);
afterAll(async () => {
await browser?.close();
});
it("runs a local defer script after the classic scripts and animates under an inline-only policy", async () => {
const html = await bundled(
`<!doctype html><html><head>${INLINE_ONLY_POLICY}
<style>
@keyframes slide { from { transform: translateX(0); } to { transform: translateX(100px); } }
#box.go { animation: slide 2s linear both; }
</style>
</head><body>
${ROOT}<div id="box"></div></div>
<script defer src="main.js"></script>
<script>window.ORDER = ["classic"];</script>
</body></html>`,
`window.ORDER.push("deferred"); document.getElementById("box").classList.add("go");`,
);
const page = await browser.newPage();
const blocked: string[] = [];
page.on("console", (message) => {
if (/Content Security Policy/i.test(message.text())) blocked.push(message.text());
});
await page.setContent(html);
await page.waitForFunction(
() => (window as unknown as { __playerReady?: boolean }).__playerReady === true,
);
const result = await page.evaluate(() => {
const runtimeWindow = window as unknown as {
ORDER?: string[];
__player?: { renderSeek?: (timeSeconds: number) => void };
};
runtimeWindow.__player?.renderSeek?.(1);
const animation = document.getElementById("box")?.getAnimations()[0];
return { order: runtimeWindow.ORDER, animationTime: Number(animation?.currentTime) };
});
expect(blocked).toEqual([]);
expect(result).toEqual({ order: ["classic", "deferred"], animationTime: 1000 });
});
it("runs a local defer script after the inline module before it", async () => {
const html = await bundled(
`<!doctype html><html><head></head><body>
${ROOT}</div>
<script type="module">window.MODULE_RAN = true;</script>
<script defer src="main.js"></script>
</body></html>`,
"window.SAW_MODULE = window.MODULE_RAN === true;",
);
const page = await browser.newPage();
await page.setContent(html);
await page.waitForFunction(
() => (window as unknown as { __playerReady?: boolean }).__playerReady === true,
);
expect(
await page.evaluate(() => (window as unknown as { SAW_MODULE?: boolean }).SAW_MODULE),
).toBe(true);
});
it("runs a local defer script only after the deferred CDN script before it has loaded", async () => {
const html = await bundled(
`<!doctype html><html><head></head><body>
${ROOT}</div>
<script defer src="https://cdn.example/lib.js"></script>
<script defer src="main.js"></script>
</body></html>`,
"window.SEEN = window.LIB;",
);
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on("request", (request) =>
request.url() === "https://cdn.example/lib.js"
? request.respond({ contentType: "text/javascript", body: "window.LIB = 'loaded';" })
: request.continue(),
);
await page.setContent(html);
await page.waitForFunction(
() => (window as unknown as { __playerReady?: boolean }).__playerReady === true,
);
expect(await page.evaluate(() => (window as unknown as { SEEN?: string }).SEEN)).toBe("loaded");
});
});
@@ -315,6 +315,19 @@ describe("inlineExternalScripts", () => {
expect(result).toBe(html);
});
it("leaves a CDN runtime link for the file server to strip", async () => {
const originalFetch = globalThis.fetch;
const fetchMock = mock(async () => new Response("window.__hyperframeRuntime = {};"));
globalThis.fetch = fetchMock as any;
try {
const html = `<html><head><script src="https://cdn.example.com/hyperframe.runtime.iife.js"></script></head><body></body></html>`;
expect(await inlineExternalScripts(html)).toBe(html);
expect(fetchMock).not.toHaveBeenCalled();
} finally {
globalThis.fetch = originalFetch;
}
});
it("inlines a CDN script on successful fetch", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = mock(async () => new Response("var gsap = {};", { status: 200 })) as any;
@@ -53,6 +53,7 @@ import {
inlineScriptRuns,
styleElementsFor,
insertBeforeCloseTag,
isRuntimeFileUrl,
} from "@hyperframes/core/compiler";
import {
checkSubCompositionUsability,
@@ -1208,7 +1209,7 @@ export async function inlineExternalScripts(html: string): Promise<string> {
for (const el of scripts) {
const src = (el.getAttribute("src") || "").trim();
if (src && isHttpUrl(src)) {
if (src && isHttpUrl(src) && !isRuntimeFileUrl(src)) {
externalScripts.push({ el: el as unknown as Element, src });
}
}
@@ -29,6 +29,18 @@ describe("buildSubCompositionHtml", () => {
expect(html).toContain(`<script src="${gsapCdnDist()}gsap.min.js"></script>`);
});
it("adds the preview runtime even when the project head's script mentions a runtime file", () => {
const dir = makeTempProject({
"index.html": `<!doctype html><html><head><script>console.log("hyperframe.runtime.iife.js");</script></head><body></body></html>`,
"compositions/scene.html": `<div data-composition-id="scene" data-width="320" data-height="180"></div>`,
});
const html = buildSubCompositionHtml(dir, "compositions/scene.html", "/api/runtime.js");
expect(html).toContain(
'<script data-hyperframes-preview-runtime="1" src="/api/runtime.js"></script>',
);
expect(html).toContain('console.log("hyperframe.runtime.iife.js")');
});
it("adds the preview base even when the project head's script mentions a <base>", () => {
const dir = makeTempProject({
"index.html": `<!doctype html><html><head><script>if (0) document.write('<base href="../">');</script></head><body></body></html>`,
@@ -6,7 +6,11 @@ import {
rewriteCssAssetUrls,
rewriteInlineStyleAssetUrls,
} from "@hyperframes/core";
import { deferScriptsUntilFonts, stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
import {
deferScriptsUntilFonts,
RUNTIME_BOOTSTRAP_ATTR,
stripEmbeddedRuntimeScripts,
} from "@hyperframes/core/compiler";
import { isFullHtmlDocument } from "@hyperframes/core/compiler/html-document";
import { gsapCdnDist } from "@hyperframes/core/gsap-cdn";
@@ -345,13 +349,7 @@ export function buildSubCompositionHtml(
// injected tag (added next) is never removed.
headContent = stripEmbeddedRuntimeScripts(headContent);
// Ensure runtime is present (might differ from the one in index.html)
if (
!headContent.includes("hyperframe.runtime") &&
!headContent.includes("hyperframes-preview-runtime")
) {
headContent += `\n<script data-hyperframes-preview-runtime="1" src="${runtimeUrl}"></script>`;
}
headContent += `\n<script ${RUNTIME_BOOTSTRAP_ATTR}="1" src="${runtimeUrl}"></script>`;
// Fallback: if no index.html head was found, add minimal deps
if (!headContent.includes("gsap")) {
@@ -134,6 +134,69 @@ describe("registerPreviewRoutes", () => {
expect(await baseOf("/projects/a%22b/preview")).toBe("/api/projects/a%22b/preview/");
});
it.each([
["returns nothing", async () => null],
[
"returns its own page without a runtime",
async () =>
`<!doctype html><html><head></head><body><script>gsap.set("#card", { opacity: 0.5 });</script></body></html>`,
],
[
"throws",
async () => {
throw new Error("bundler unavailable");
},
],
])(
"loads the runtime before the composition's scripts when the bundler %s",
async (_, bundle) => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
`<!doctype html><html><head></head><body><div id="card"></div>
<script>gsap.set("#card", { opacity: 0.5 }); window.__timelines = { index: gsap.timeline() };</script>
</body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir, { bundle }));
const html = await (await app.request("http://localhost/projects/demo/preview")).text();
const runtimeAt = html.indexOf('src="/api/runtime.js"');
expect(runtimeAt).toBeGreaterThan(-1);
expect(runtimeAt).toBeLessThan(html.indexOf("gsap.set("));
},
);
it("keeps an authored script that reads the runtime global when the page is read from disk", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
`<!doctype html><html><head></head><body>
<script>if (window.__hyperframeRuntime) window.AUTHOR_SEEN = 1;</script>
</body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const html = await (await app.request("http://localhost/projects/demo/preview")).text();
expect(html).toContain("window.AUTHOR_SEEN = 1");
});
it("serves one preview runtime when the page on disk already links one", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
`<!doctype html><html><head>
<script src="hyperframe.runtime.iife.js"></script>
<script data-hyperframes-preview-runtime="1" src="/old-runtime.js"></script>
</head><body><script>window.AUTHOR = 1;</script></body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const html = await (await app.request("http://localhost/projects/demo/preview")).text();
expect(html.split("data-hyperframes-preview-runtime")).toHaveLength(2);
expect(html).toContain('src="/api/runtime.js"');
expect(html).not.toMatch(/hyperframe\.runtime\.iife\.js|old-runtime/);
});
it("keeps the encoded <base> when the bundler fails and the page is read from disk", async () => {
const projectDir = createProjectDir();
const app = new Hono();
+14 -16
View File
@@ -7,7 +7,9 @@ import { createHash } from "node:crypto";
import {
AFTER_FONTS_SCRIPT_TYPE,
addScenePartsManifest,
RUNTIME_BOOTSTRAP_ATTR,
insertBeforeCloseTag,
insertRuntimeTag,
stripEmbeddedRuntimeScripts,
type BundleOptions,
} from "@hyperframes/core/compiler";
@@ -108,6 +110,12 @@ function parseStudioMotionManifestContent(content: string): {
}
}
/** Swaps any runtime already in the page for the preview runtime, placed as the bundler places it. */
function withPreviewRuntime(html: string, runtimeUrl: string): string {
const tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1" src="${runtimeUrl}"></script>`;
return insertRuntimeTag(stripEmbeddedRuntimeScripts(html), tag);
}
function injectScriptTagIntoHead(html: string, scriptTag: string): string {
return insertBeforeCloseTag(html, "head", `${scriptTag}\n`) ?? `${scriptTag}\n${html}`;
}
@@ -418,7 +426,6 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
signature: string,
): Promise<string | null> {
const diskMain = resolveProjectMainHtml(project.dir, project.id);
const normalizedDisk = diskMain ? ensureHfIds(diskMain.html) : null;
try {
let bundled = await adapter.bundle(project.dir, {
@@ -428,25 +435,13 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
let mainCompositionPath = "index.html";
if (!bundled) {
if (!diskMain) return null;
// Disk HTML may carry a baked inline runtime from a prior export; strip
// it so the preview runtime injected below isn't double-loaded (the
// bundled path already strips via htmlBundler). Idempotent if absent.
bundled = stripEmbeddedRuntimeScripts(normalizedDisk ?? diskMain.html);
bundled = ensureHfIds(diskMain.html);
mainCompositionPath = diskMain.compositionPath;
}
bundled = withPreviewRuntime(bundled, adapter.runtimeUrl);
recordPreviewReferences(project.dir, bundled);
recordPreviewBuilt(project.dir);
// Inject runtime if not already present (check URL pattern and bundler attribute)
if (
!bundled.includes("hyperframe.runtime") &&
!bundled.includes("hyperframes-preview-runtime")
) {
const runtimeTag = `<script src="${adapter.runtimeUrl}"></script>`;
bundled =
insertBeforeCloseTag(bundled, "body", `${runtimeTag}\n`) ?? `${bundled}\n${runtimeTag}`;
}
// Inject <base> for relative asset resolution
bundled = withPreviewBase(bundled, project.id);
@@ -477,7 +472,10 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
// not the pre-request snapshot that may have been saved over.
const fallback = resolveProjectMainHtml(project.dir, project.id);
if (fallback) {
const fallbackHtml = withPreviewBase(ensureHfIds(fallback.html), project.id);
const fallbackHtml = withPreviewBase(
withPreviewRuntime(ensureHfIds(fallback.html), adapter.runtimeUrl),
project.id,
);
let fallbackAugmented = injectStudioPreviewAugmentations(
await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath),
adapter,
+1 -1
View File
@@ -61,7 +61,7 @@
],
"sources": {
"packages/producer/scripts/run-test-lane.mjs": "bff02680f19eebcce0df72cdeedbbc05edfdcede3f3f60658fc0cb18ed8c59f6",
"packages/producer/scripts/test-classification.mjs": "fa9452d63a9f3b76ab6be7ff001acddfbf3eb117159d932dcd1510eb88b4b5c6",
"packages/producer/scripts/test-classification.mjs": "e16151bbd3a3f9767e513fdfb1155fad286855e1b9ffb077ace80d3fe900bd16",
".github/workflows/ci.yml#job#producer-source-tests": "45da72aa6cf08cdfd5bcf5311e89f12af4842f9eebc6735df19da8c02f4d4864",
"packages/producer/package.json#script#test:unit": "ae6beda8319d166774be958ea5593cd27dbf7f6c9bce1d787a3fe28dfc3477b1",
"packages/producer/package.json#script#test:integration": "008589f37d026877595a13501cbb66abd5cbe50e21fdcac6904f45466c3f475d",