mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
* fix(player): stop re-encoding the composition query Every src the player sets goes through withShaderQueryParams, which parsed the author's whole query with URLSearchParams and re-serialised it with toString(). That is a form encoder: it writes a space as +, while callers percent-encode and read back with decodeURIComponent. Those two codecs are not inverses, so any space in any query value arrived corrupted. It ran even when there was nothing to inject. With no shader attributes both params are deleted, so the round-trip was pure loss, on every src, for every consumer. Append the two params to the raw query instead of re-serialising it. The player now hands a composition its query back byte-identical. Empirically space was the only casualty: plus, ampersand, equals, hash, percent, question mark, quotes and non-ASCII all survived a URLSearchParams round-trip. That is narrow, but a space in a headline or in SVG path data is the common case, and invalid path data renders nothing at all. Latent until now: no shipped consumer depended on query preservation, so this surfaced only once compositions began carrying variable payloads. * fix(cli): serve the runtime ahead of every author script injectRuntime appended its script before </body>, so it landed after any inline script the composition carried. At the moment a composition's own script ran, window.__hyperframes was undefined and getVariables() was unreachable: our documented API did not exist at the point authors are told to call it. Served order was gsap at line 6, the composition's init script at 20, the runtime at 37. A probe inside the composition's IIFE recorded hfTypeAtInit undefined with no variable keys, and the element rendered its hardcoded fallback rather than the declared value. The runtime is designed to load early. Its entry assigns __timelines, installs the authored-opacity capture (whose own comment says it must run while the document is still parsing), and exposes __hyperframes synchronously, deferring real work to DOMContentLoaded. End-of-body injection defeated all three, and nothing in it needs a parsed DOM, so no defer is wanted. Injects at head start instead, reusing the placement cascade injectScriptsAtHeadStart already implemented rather than adding a fourth copy of it. Head start rather than the closing tag so the runtime also precedes author scripts inside head. injectRuntime has exactly one consumer, the play server's composition route. Every other surface reaches the runtime through the bundler, which already injects into head, or deliberately serves raw. Two registry blocks had independently worked around this by parsing the authored attribute themselves. Those stay, but the workaround is no longer the only way to read a variable at init.
230 lines
6.9 KiB
TypeScript
230 lines
6.9 KiB
TypeScript
import { parseHTML } from "linkedom";
|
|
|
|
export const RUNTIME_BOOTSTRAP_ATTR = "data-hyperframes-preview-runtime";
|
|
|
|
const RUNTIME_SRC_MARKERS = [
|
|
"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 = [
|
|
/^window\.__playerReady\s*=\s*(?:true|false)\s*;?$/,
|
|
/^window\.__renderReady\s*=\s*(?:true|false)\s*;?$/,
|
|
];
|
|
|
|
/**
|
|
* Parse a full HTML document or wrap a fragment so linkedom consistently puts
|
|
* fragment content under document.body.
|
|
*/
|
|
export function parseHTMLContent(html: string): Document {
|
|
const trimmed = html.trimStart().toLowerCase();
|
|
if (trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")) {
|
|
return parseHTML(html).document;
|
|
}
|
|
return parseHTML(`<!DOCTYPE html><html><head></head><body>${html}</body></html>`).document;
|
|
}
|
|
|
|
export function stripEmbeddedRuntimeScripts(html: string): string {
|
|
if (!html) return html;
|
|
const loweredHtml = html.toLowerCase();
|
|
let output = "";
|
|
let cursor = 0;
|
|
|
|
while (cursor < html.length) {
|
|
const scriptStart = findScriptStart(loweredHtml, cursor);
|
|
if (scriptStart === -1) {
|
|
output += html.slice(cursor);
|
|
break;
|
|
}
|
|
|
|
output += html.slice(cursor, scriptStart);
|
|
const startTagEnd = findTagEnd(html, scriptStart + 1);
|
|
if (startTagEnd === -1) {
|
|
output += html.slice(scriptStart);
|
|
break;
|
|
}
|
|
|
|
const closeTagEnd = findScriptCloseTagEnd(loweredHtml, startTagEnd + 1);
|
|
const scriptEnd = closeTagEnd === -1 ? html.length : closeTagEnd;
|
|
const block = html.slice(scriptStart, scriptEnd);
|
|
if (!shouldStripRuntimeScriptBlock(block)) {
|
|
output += block;
|
|
}
|
|
cursor = scriptEnd;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
function findScriptStart(loweredHtml: string, from: number): number {
|
|
let index = loweredHtml.indexOf("<script", from);
|
|
while (index !== -1) {
|
|
const next = loweredHtml[index + "<script".length] ?? "";
|
|
if (isTagBoundary(next)) return index;
|
|
index = loweredHtml.indexOf("<script", index + 1);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function findTagEnd(html: string, from: number): number {
|
|
let quote: string | undefined;
|
|
for (let index = from; index < html.length; index += 1) {
|
|
const char = html[index];
|
|
if (quote) {
|
|
if (char === quote) quote = undefined;
|
|
continue;
|
|
}
|
|
if (char === '"' || char === "'") {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
if (char === ">") return index;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function findScriptCloseTagEnd(loweredHtml: string, from: number): number {
|
|
let index = loweredHtml.indexOf("</script", from);
|
|
while (index !== -1) {
|
|
const closeTagEnd = findScriptCloseTagBoundary(loweredHtml, index + "</script".length);
|
|
if (closeTagEnd !== -1) return closeTagEnd;
|
|
index = loweredHtml.indexOf("</script", index + 1);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function findScriptCloseTagBoundary(loweredHtml: string, from: number): number {
|
|
let cursor = from;
|
|
while (cursor < loweredHtml.length && isHtmlWhitespace(loweredHtml[cursor] ?? "")) {
|
|
cursor += 1;
|
|
}
|
|
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;
|
|
}
|
|
const scriptSource = getScriptSource(block).trim();
|
|
for (const pattern of SIMPLE_RUNTIME_FLAG_ASSIGNMENTS) {
|
|
if (pattern.test(scriptSource)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function getScriptSource(block: string): string {
|
|
const startTagEnd = findTagEnd(block, 1);
|
|
if (startTagEnd === -1) return "";
|
|
const loweredBlock = block.toLowerCase();
|
|
const closeTagStart = loweredBlock.lastIndexOf("</script");
|
|
const end = closeTagStart === -1 ? block.length : closeTagStart;
|
|
return block.slice(startTagEnd + 1, end);
|
|
}
|
|
|
|
function isTagBoundary(char: string): boolean {
|
|
return char === "" || char === ">" || char === "/" || isHtmlWhitespace(char);
|
|
}
|
|
|
|
function isHtmlWhitespace(char: string): boolean {
|
|
return char === " " || char === "\n" || char === "\t" || char === "\r" || char === "\f";
|
|
}
|
|
|
|
function escapeInlineScriptSource(source: string): string {
|
|
return escapeCaseInsensitiveToken(
|
|
escapeCaseInsensitiveToken(source, "</script", "<\\/script"),
|
|
"<!--",
|
|
"<\\!--",
|
|
);
|
|
}
|
|
|
|
function escapeCaseInsensitiveToken(source: string, token: string, replacement: string): string {
|
|
const loweredSource = source.toLowerCase();
|
|
const loweredToken = token.toLowerCase();
|
|
let output = "";
|
|
let cursor = 0;
|
|
|
|
while (cursor < source.length) {
|
|
const tokenStart = loweredSource.indexOf(loweredToken, cursor);
|
|
if (tokenStart === -1) {
|
|
output += source.slice(cursor);
|
|
break;
|
|
}
|
|
output += source.slice(cursor, tokenStart) + replacement;
|
|
cursor = tokenStart + token.length;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
function inlineScriptTags(scripts: readonly string[]): string {
|
|
return scripts.map((source) => `<script>${escapeInlineScriptSource(source)}</script>`).join("\n");
|
|
}
|
|
|
|
/**
|
|
* 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}`);
|
|
}
|
|
if (html.includes("<body")) {
|
|
return html.replace("<body", () => `${tags}\n<body`);
|
|
}
|
|
return `${tags}\n${html}`;
|
|
}
|
|
|
|
export function injectScriptsAtHeadStart(html: string, scripts: readonly string[]): string {
|
|
if (scripts.length === 0) return html;
|
|
return injectTagsAtHeadStart(html, inlineScriptTags(scripts));
|
|
}
|
|
|
|
export function injectScriptsIntoHtml(
|
|
html: string,
|
|
headScripts: readonly string[],
|
|
bodyScripts: readonly string[],
|
|
stripEmbeddedRuntime = true,
|
|
): string {
|
|
if (stripEmbeddedRuntime) {
|
|
html = stripEmbeddedRuntimeScripts(html);
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
}
|
|
|
|
if (bodyScripts.length > 0) {
|
|
const bodyTags = inlineScriptTags(bodyScripts);
|
|
if (html.includes("</body>")) {
|
|
html = html.replace("</body>", () => `${bodyTags}\n</body>`);
|
|
} else {
|
|
html = `${html}\n${bodyTags}`;
|
|
}
|
|
}
|
|
|
|
return html;
|
|
}
|