fix(studio-server): inject page tags at the document's own head and body, not inside a script (#4484)

This commit is contained in:
Miguel Ángel
2026-09-25 12:05:06 +00:00
committed by GitHub
parent 5f95787cce
commit f1f370e96c
10 changed files with 292 additions and 65 deletions
+3 -10
View File
@@ -17,6 +17,7 @@ import { transformSync } from "esbuild";
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
import {
RUNTIME_BOOTSTRAP_ATTR,
insertBeforeCloseTag,
parseHTMLContent,
stripEmbeddedRuntimeScripts,
} from "./htmlDocument";
@@ -65,16 +66,8 @@ function injectInterceptor(html: string, runtimeMode: "inline" | "placeholder" =
const inlinedRuntime = getHyperframeRuntimeScript();
tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1">${inlinedRuntime}</script>`;
}
if (sanitized.includes("</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 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;
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import {
injectScriptsAtHeadStart,
injectScriptsIntoHtml,
injectTagsAtHeadStart,
insertBeforeCloseTag,
parseHTMLContent,
stripEmbeddedRuntimeScripts,
} from "./htmlDocument.js";
@@ -80,4 +82,123 @@ describe("htmlDocument helpers", () => {
expect(injected).toContain("<\\!-- kept as script text");
expect(injected).not.toContain("</script ><script>window.pwned = true;");
});
it("injects at the document's own </head> and </body>, not at those strings inside script, style or comment text", () => {
const vendor = 'w.print("<head>"),w.print("</head>"),w.print("<body>"),w.print("</body>")';
const html = `<html><head><script>${vendor}</script><style>a::after{content:"</head>"}</style><!-- </body> --></HEAD ><body><script>${vendor}</script></body></html>`;
const injected = injectScriptsIntoHtml(html, ["window.h = 1;"], ["window.b = 1;"]);
expect(injected.split(`<script>${vendor}</script>`)).toHaveLength(3);
expect(injected).toContain("<script>window.h = 1;</script>\n</HEAD >");
expect(injected).toContain("<script>window.b = 1;</script>\n</body></html>");
});
it("falls back to the document's own <body> when </head> is omitted", () => {
const vendor = 'w.print("<head>"),w.print("<body>")';
const html = `<html><head><script>${vendor}</script><body><p>x</p></body></html>`;
const injected = injectScriptsIntoHtml(html, ["window.h = 1;"], []);
expect(injected).toContain(`<script>${vendor}</script><script>window.h = 1;</script>\n<body>`);
});
it("injects at head start past a script that prints <head>, and before <body> without a head", () => {
const vendor = 'w.print("<head>"),w.print("<body>")';
const noHead = `<html><body><script>${vendor}</script></body></html>`;
expect(injectTagsAtHeadStart(noHead, "<meta x>")).toBe(`<html><meta x>\n${noHead.slice(6)}`);
});
it("skips title text and an empty comment", () => {
const html = "<html><head><title>Intro to <script></title><!--></head><body></body></html>";
expect(insertBeforeCloseTag(html, "head", "X")).toBe(
"<html><head><title>Intro to <script></title><!-->X</head><body></body></html>",
);
});
it("keeps indexes right after a character that lowercases to two (İ)", () => {
const page = "<html><head><title>İzmir</title></head><body><h1>İstanbul</h1></body></html>";
const injected = injectScriptsIntoHtml(page, ["a=1"], ["b=2"]);
expect(injected).toContain("<script>a=1</script>\n</head>");
expect(injected).toContain("<script>b=2</script>\n</body></html>");
const stripped = stripEmbeddedRuntimeScripts(
'<p>İİ</p><script src="hyperframe.runtime.iife.js"></script><p>kept</p>',
);
expect(stripped).toBe("<p>İİ</p><p>kept</p>");
const escaped = injectScriptsIntoHtml(page, ['x="İİ</SCRIPT>"'], []);
expect(escaped).toContain('<script>x="İİ<\\/script>"</script>');
});
it("skips a script tag written inside an attribute value", () => {
const html =
'<html><head><meta content="<script>"></head><body><script>a</script></body></html>';
expect(insertBeforeCloseTag(html, "head", "X")).toBe(
'<html><head><meta content="<script>">X</head><body><script>a</script></body></html>',
);
});
it("reads past stray quotes, a self-closing SVG title and a look-alike close tag", () => {
expect(insertBeforeCloseTag("<body><img alt=it's><p>hi</p></body>", "body", "X")).toBe(
"<body><img alt=it's><p>hi</p>X</body>",
);
expect(insertBeforeCloseTag("<body><svg><title/></svg></body>", "body", "X")).toBe(
"<body><svg><title/></svg>X</body>",
);
const lookAlike = '<head><script>x="</scripts>";y="</head>"</script></head><body></body>';
expect(insertBeforeCloseTag(lookAlike, "head", "X")).toBe(
'<head><script>x="</scripts>";y="</head>"</script>X</head><body></body>',
);
});
it("treats a quote as a value only after =, like the browser", () => {
const page = "<html><head><meta name=it's></head><body><p>don't</p></body></html>";
expect(injectScriptsIntoHtml(page, ["H"], [])).toContain(
"<meta name=it's><script>H</script>\n</head>",
);
for (const meta of [
'<meta content=a=" x>',
'<meta b="c"="d>',
'<meta ="x>',
'<meta="b>',
'<meta b/="x>',
'</ a="x>',
]) {
const odd = `<html><head>${meta}</head><body class="y"></body></html>`;
expect(insertBeforeCloseTag(odd, "head", "X")).toBe(odd.replace("</head>", "X</head>"));
}
const headAttr = "<html><head data-x=it's></head><body>it's</body></html>";
expect(injectTagsAtHeadStart(headAttr, "T")).toBe(
"<html><head data-x=it's>\nT</head><body>it's</body></html>",
);
});
it("ends a comment at --!>, but not on its own opening dashes", () => {
expect(insertBeforeCloseTag("<head><!-- a --!></head>", "head", "X")).toBe(
"<head><!-- a --!>X</head>",
);
expect(insertBeforeCloseTag("<head><!--!></head>-->", "head", "X")).toBeNull();
expect(insertBeforeCloseTag("<head><!---></head>", "head", "X")).toBe("<head><!--->X</head>");
});
it("stays linear over many unclosed raw-text tags and an unfinished quoted attribute", () => {
const many = `<body>${"<script/>".repeat(40_000)}</body>`;
const unfinished = `<body>${'<p data-if="a>b" '.repeat(20_000)}`;
const started = performance.now();
expect(insertBeforeCloseTag(many, "body", "X")).toBe(many.replace("</body>", "X</body>"));
expect(insertBeforeCloseTag(unfinished, "body", "X")).toBeNull();
expect(performance.now() - started).toBeLessThan(500);
});
it("finds no head end past an unclosed script", () => {
expect(insertBeforeCloseTag("<html><head><script>a</head>", "head", "X")).toBeNull();
});
it("finds no close tag in a fragment", () => {
expect(insertBeforeCloseTag('<div><script>"</head>"</script></div>', "head", "x")).toBeNull();
});
});
+125 -29
View File
@@ -34,9 +34,14 @@ export function parseHTMLContent(html: string): Document {
return parseHTML(`<!DOCTYPE html><html><head></head><body>${html}</body></html>`).document;
}
/** Lowercases A-Z only, so indexes found in the result are valid in the input ("İ" lowercases to two chars). */
function lowerAscii(text: string): string {
return text.replace(/[A-Z]+/g, (letters) => letters.toLowerCase());
}
export function stripEmbeddedRuntimeScripts(html: string): string {
if (!html) return html;
const loweredHtml = html.toLowerCase();
const loweredHtml = lowerAscii(html);
let output = "";
let cursor = 0;
@@ -76,23 +81,44 @@ function findScriptStart(loweredHtml: string, from: number): number {
return -1;
}
type TagState = "tagName" | "between" | "name" | "equals" | "value";
function findTagEnd(html: string, from: number): number {
let quote: string | undefined;
let state: TagState = "tagName";
for (let index = from; index < html.length; index += 1) {
const char = html[index];
const char = html.charAt(index);
if (quote) {
if (char === quote) quote = undefined;
continue;
}
if (char === '"' || char === "'") {
quote = char;
quote = char === quote ? undefined : quote;
continue;
}
if (char === ">") return index;
if (state === "equals" && (char === '"' || char === "'")) {
quote = char;
state = "between";
continue;
}
state = nextTagState(state, char, index === from);
}
return -1;
}
function nextTagState(state: TagState, char: string, first: boolean): TagState {
if (isHtmlWhitespace(char)) return stateAfterWhitespace(state);
if (char === "/" && !first) return stateAfterSlash(state);
if (char === "=" && state === "name") return "equals";
if (state === "equals") return "value";
return state === "between" ? "name" : state;
}
function stateAfterWhitespace(state: TagState): TagState {
return state === "tagName" || state === "value" ? "between" : state;
}
function stateAfterSlash(state: TagState): TagState {
return state === "equals" || state === "value" ? "value" : "between";
}
function findScriptCloseTagEnd(loweredHtml: string, from: number): number {
let index = loweredHtml.indexOf("</script", from);
while (index !== -1) {
@@ -129,7 +155,7 @@ function shouldStripRuntimeScriptBlock(block: string): boolean {
function getScriptSource(block: string): string {
const startTagEnd = findTagEnd(block, 1);
if (startTagEnd === -1) return "";
const loweredBlock = block.toLowerCase();
const loweredBlock = lowerAscii(block);
const closeTagStart = loweredBlock.lastIndexOf("</script");
const end = closeTagStart === -1 ? block.length : closeTagStart;
return block.slice(startTagEnd + 1, end);
@@ -152,8 +178,8 @@ function escapeInlineScriptSource(source: string): string {
}
function escapeCaseInsensitiveToken(source: string, token: string, replacement: string): string {
const loweredSource = source.toLowerCase();
const loweredToken = token.toLowerCase();
const loweredSource = lowerAscii(source);
const loweredToken = lowerAscii(token);
let output = "";
let cursor = 0;
@@ -174,19 +200,98 @@ function inlineScriptTags(scripts: readonly string[]): string {
return scripts.map((source) => `<script>${escapeInlineScriptSource(source)}</script>`).join("\n");
}
const RAW_TEXT_TAGS = ["script", "style", "title", "textarea"] as const;
type DocumentTag = "<head" | "</head" | "<body" | "</body";
const COMMENT_END = /--!?>/g;
function findDocumentTag(html: string, tag: DocumentTag): number {
const lowered = lowerAscii(html);
const unclosedRawText = new Set<string>();
let cursor = 0;
while (cursor !== -1) {
const open = lowered.indexOf("<", cursor);
if (open === -1) return -1;
if (isTagAt(lowered, open, tag)) return open;
cursor = skipMarkup(lowered, open, unclosedRawText);
}
return -1;
}
function isTagAt(lowered: string, at: number, token: string): boolean {
return lowered.startsWith(token, at) && isTagBoundary(lowered.charAt(at + token.length));
}
/** Cursor just past the markup that starts at `open`, or -1 when the document ends inside it. */
function skipMarkup(lowered: string, open: number, unclosedRawText: Set<string>): number {
if (lowered.startsWith("<!--", open)) return skipComment(lowered, open);
const next = lowered.charAt(open + 1);
if (next === "/" && !/[a-z]/.test(lowered.charAt(open + 2))) {
const bogusEnd = lowered.indexOf(">", open);
return bogusEnd === -1 ? -1 : bogusEnd + 1;
}
if (!/[a-z/]/.test(next)) return open + 1;
const tagEnd = findTagEnd(lowered, open + 1);
return tagEnd === -1 ? -1 : skipRawText(lowered, open, tagEnd, unclosedRawText);
}
function skipComment(lowered: string, open: number): number {
if (lowered.startsWith("<!-->", open) || lowered.startsWith("<!--->", open)) {
return lowered.indexOf(">", open) + 1;
}
COMMENT_END.lastIndex = open + 4;
const end = COMMENT_END.exec(lowered);
return end ? end.index + end[0].length : -1;
}
function skipRawText(
lowered: string,
open: number,
tagEnd: number,
unclosedRawText: Set<string>,
): number {
const rawText = RAW_TEXT_TAGS.find((name) => isTagAt(lowered, open, `<${name}`));
if (!rawText) return tagEnd + 1;
const close = unclosedRawText.has(rawText) ? -1 : findRawTextClose(lowered, rawText, tagEnd + 1);
if (close !== -1) return close;
unclosedRawText.add(rawText);
return lowered.charAt(tagEnd - 1) === "/" ? tagEnd + 1 : -1;
}
function findRawTextClose(lowered: string, name: string, from: number): number {
const close = `</${name}`;
for (let at = lowered.indexOf(close, from); at !== -1; at = lowered.indexOf(close, at + 1)) {
if (isTagAt(lowered, at, close)) return at;
}
return -1;
}
function insertBeforeDocumentTag(html: string, tag: DocumentTag, markup: string): string | null {
const at = findDocumentTag(html, tag);
return at === -1 ? null : html.slice(0, at) + markup + html.slice(at);
}
/** Insert markup just before the document's own `</head>` or `</body>`; null when it has none. */
export function insertBeforeCloseTag(
html: string,
name: "head" | "body",
markup: string,
): string | null {
return insertBeforeDocumentTag(html, `</${name}`, markup);
}
/**
* 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
* top of the document, for fragments that carry neither.
*/
export function injectTagsAtHeadStart(html: string, tags: string): string {
if (html.includes("<head")) {
return html.replace(/<head\b[^>]*>/i, (match) => `${match}\n${tags}`);
const headOpen = findDocumentTag(html, "<head");
const headOpenEnd = headOpen === -1 ? -1 : findTagEnd(html, headOpen + 1);
if (headOpenEnd !== -1) {
return `${html.slice(0, headOpenEnd + 1)}\n${tags}${html.slice(headOpenEnd + 1)}`;
}
if (html.includes("<body")) {
return html.replace("<body", () => `${tags}\n<body`);
}
return `${tags}\n${html}`;
return insertBeforeDocumentTag(html, "<body", `${tags}\n`) ?? `${tags}\n${html}`;
}
export function injectScriptsAtHeadStart(html: string, scripts: readonly string[]): string {
@@ -206,23 +311,14 @@ export function injectScriptsIntoHtml(
if (headScripts.length > 0) {
const headTags = inlineScriptTags(headScripts);
if (html.includes("</head>")) {
// Function replacement avoids `$&` interpolation in runtime source.
html = html.replace("</head>", () => `${headTags}\n</head>`);
} else if (html.includes("<body")) {
html = html.replace("<body", () => `${headTags}\n<body`);
} else {
html = `${headTags}\n${html}`;
}
const withHead = insertBeforeCloseTag(html, "head", `${headTags}\n`);
html =
withHead ?? insertBeforeDocumentTag(html, "<body", `${headTags}\n`) ?? `${headTags}\n${html}`;
}
if (bodyScripts.length > 0) {
const bodyTags = inlineScriptTags(bodyScripts);
if (html.includes("</body>")) {
html = html.replace("</body>", () => `${bodyTags}\n</body>`);
} else {
html = `${html}\n${bodyTags}`;
}
html = insertBeforeCloseTag(html, "body", `${bodyTags}\n`) ?? `${html}\n${bodyTags}`;
}
return html;
+1
View File
@@ -52,6 +52,7 @@ export {
injectScriptsAtHeadStart,
injectTagsAtHeadStart,
injectScriptsIntoHtml,
insertBeforeCloseTag,
parseHTMLContent,
stripEmbeddedRuntimeScripts,
} from "./htmlDocument";
@@ -166,6 +166,14 @@ describe("injectSdkPositionEditsRenderScript", () => {
expect(out).toContain("data-hf-edit-base-x");
});
it("injects before the document's own </body>, not inside an inlined script that prints one", () => {
const vendor = 'p.print("</body>")';
const html = `<html><body><h1 data-hf-edit-base-x="0">Hi</h1><script>${vendor}</script></body></html>`;
const out = injectSdkPositionEditsRenderScript(html);
expect(out).toContain(`<script>${vendor}</script><script>`);
expect(out.endsWith("</script></body></html>")).toBe(true);
});
it("appends the script when there is no </body> tag", () => {
const out = injectSdkPositionEditsRenderScript('<div data-hf-edit-base-y="0"></div>');
expect(out.startsWith('<div data-hf-edit-base-y="0"></div>')).toBe(true);
@@ -47,6 +47,7 @@ import {
readDeclaredDefaults,
parseHostVariableValues,
inlineScriptRuns,
insertBeforeCloseTag,
} from "@hyperframes/core/compiler";
import {
checkSubCompositionUsability,
@@ -125,9 +126,7 @@ export function injectSdkPositionEditsRenderScript(html: string): string {
}
const scriptBody = getPositionEditsRenderScript().replace(/<\/script/gi, "<\\/script");
const script = `<script>${scriptBody}</script>`;
const bodyClose = html.search(/<\/body\s*>/i);
if (bodyClose < 0) return `${html}${script}`;
return `${html.slice(0, bodyClose)}${script}${html.slice(bodyClose)}`;
return insertBeforeCloseTag(html, "body", script) ?? `${html}${script}`;
}
/**
@@ -2035,10 +2034,11 @@ export async function compileForRender(
];
const hasPositionEdits = HF_POSITION_ATTRS.some((attr) => assembledHtml.includes(attr));
const htmlWithPositionScript = hasPositionEdits
? assembledHtml.replace(
/<\/body>/i,
`<script>${createStudioPositionSeekReapplyScript()}</script></body>`,
)
? (insertBeforeCloseTag(
assembledHtml,
"body",
`<script>${createStudioPositionSeekReapplyScript()}</script>`,
) ?? assembledHtml)
: assembledHtml;
const htmlWithSdkPositionScript = injectSdkPositionEditsRenderScript(htmlWithPositionScript);
@@ -111,3 +111,21 @@ describe("injectMediaCodecMapIntoHtml pre-warm", () => {
expect(resolveProxy).toHaveBeenCalledTimes(1);
});
});
describe("injectMediaCodecMapIntoHtml placement", () => {
it("puts the map before the document's own </head>, not inside an inlined script that prints one", async () => {
const { injectMediaCodecMapIntoHtml } = await loadHelper({
"/audio/seg01.webm": hostile("vp9", 'video/webm; codecs="vp09.00.10.08"'),
});
const vendor = 'p.print("<head>"),p.print("</head>"),p.print("<body>")';
const html = await injectMediaCodecMapIntoHtml(
`<html><head><script>${vendor}</script></head><body></body></html>`,
tmpProject(),
[{ html: "<html></html>" }],
);
expect(html).toContain(`<script>${vendor}</script>`);
expect(html.indexOf("data-hf-media-codec-map")).toBeGreaterThan(html.indexOf(vendor));
});
});
@@ -1,4 +1,5 @@
import { resolve } from "node:path";
import { insertBeforeCloseTag } from "@hyperframes/core/compiler/html-document";
import type { StudioApiAdapter } from "../types.js";
import {
createMediaCodecProbeCache,
@@ -66,14 +67,6 @@ export function proxyEtagSalt(raw: string | undefined): string {
return `:proxy:${raw}:${PROXY_PARAMS_VERSION}`;
}
// Mirrors `injectScriptTagIntoHead` in routes/preview.ts (kept local rather
// than imported to avoid a helpers → routes dependency edge for one
// two-line utility).
function injectScriptTagIntoHead(html: string, scriptTag: string): string {
if (html.includes("</head>")) return html.replace("</head>", `${scriptTag}\n</head>`);
return `${scriptTag}\n${html}`;
}
/**
* Injects `window.__HF_MEDIA_CODEC_MAP__` (the U1 codec-facts scan) into
* served composition HTML, and fire-and-forget pre-warms `resolveProxy` for
@@ -130,7 +123,7 @@ export async function injectMediaCodecMapIntoHtml(
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
const tag = `<script data-hf-media-codec-map>window.__HF_MEDIA_CODEC_MAP__=${json};</script>`;
return injectScriptTagIntoHead(html, tag);
return insertBeforeCloseTag(html, "head", `${tag}\n`) ?? `${tag}\n${html}`;
}
/**
+2 -3
View File
@@ -91,6 +91,7 @@ import {
} from "../helpers/compositionInsertion.js";
import { resolveGsapWriter } from "./gsapMutationCapabilities.js";
import { requestSubPath } from "../helpers/requestSubPath.js";
import { insertBeforeCloseTag } from "@hyperframes/core/compiler/html-document";
// ── Server cutover flag ─────────────────────────────────────────────────────
@@ -1276,9 +1277,7 @@ async function prepareGsapMutationScript(
`window.__timelines["${compId}"] = tl;`,
"</script>",
].join("\n");
html = html.includes("</body>")
? html.replace("</body>", `${bootstrap}\n</body>`)
: `${html}\n${bootstrap}`;
html = insertBeforeCloseTag(html, "body", `${bootstrap}\n`) ?? `${html}\n${bootstrap}`;
block = extractGsapScriptBlock(html);
}
if (
+5 -7
View File
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
import { createHash } from "node:crypto";
import {
injectScriptsIntoHtml,
insertBeforeCloseTag,
stripEmbeddedRuntimeScripts,
type BundleOptions,
} from "@hyperframes/core/compiler";
@@ -62,8 +63,7 @@ function injectProjectSignature(html: string, signature: string): string {
tag,
);
}
if (html.includes("</head>")) return html.replace("</head>", `${tag}\n</head>`);
return `${tag}\n${html}`;
return insertBeforeCloseTag(html, "head", `${tag}\n`) ?? `${tag}\n${html}`;
}
function readStudioMotionManifestContent(projectDir: string): string {
@@ -95,8 +95,7 @@ function parseStudioMotionManifestContent(content: string): {
}
function injectScriptTagIntoHead(html: string, scriptTag: string): string {
if (html.includes("</head>")) return html.replace("</head>", `${scriptTag}\n</head>`);
return `${scriptTag}\n${html}`;
return insertBeforeCloseTag(html, "head", `${scriptTag}\n`) ?? `${scriptTag}\n${html}`;
}
function htmlHasGsap(html: string): boolean {
@@ -371,9 +370,8 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
!bundled.includes("hyperframes-preview-runtime")
) {
const runtimeTag = `<script src="${adapter.runtimeUrl}"></script>`;
bundled = bundled.includes("</body>")
? bundled.replace("</body>", `${runtimeTag}\n</body>`)
: bundled + `\n${runtimeTag}`;
bundled =
insertBeforeCloseTag(bundled, "body", `${runtimeTag}\n`) ?? `${bundled}\n${runtimeTag}`;
}
// Inject <base> for relative asset resolution