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
2 changes: 1 addition & 1 deletion packages/studio-server/src/helpers/mediaProxyPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export async function injectMediaCodecMapIntoHtml(
});
}
// <-escape prevents a src path containing "</script>" from breaking out of
// the injected tag, mirroring injectPreviewVariables in routes/preview.ts.
// the injected tag, mirroring injectPreviewVariables in helpers/previewVariables.ts.
const json = JSON.stringify(map)
.replace(/</g, "\\u003c")
.replace(/\u2028/g, "\\u2028")
Expand Down
43 changes: 43 additions & 0 deletions packages/studio-server/src/helpers/previewVariables.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { injectPreviewVariables } from "./previewVariables";

const SCRIPT = '<script data-hf-preview-variables>window.__hfVariables={"a":1};</script>';

describe("injectPreviewVariables", () => {
it.each([
[
"<!doctype html><html><head>x</head></html>",
"<!doctype html><html><head>",
"x</head></html>",
],
[
"<!DOCTYPE html><HTML lang='en'><HEAD id='h'>x",
"<!DOCTYPE html><HTML lang='en'><HEAD id='h'>",
"x",
],
["<!doctype html><html lang='en'>x", "<!doctype html><html lang='en'>", "x"],
[" \n<!DOCTYPE html>x", " \n<!DOCTYPE html>", "x"],
["fragment", "", "fragment"],
["x<!doctype html>y", "", "x<!doctype html>y"],
["<headless>x", "<headless>", "x"],
["<head attr='>'>x", "<head attr='>", "'>x"],
["<html><head", "<html>", "<head"],
["<head<head>x", "<head<head>", "x"],
])("preserves insertion boundaries (case %#)", (html, before, after) => {
expect(injectPreviewVariables(html, { a: 1 })).toBe(before + SCRIPT + after);
});

it.each(["<head", "<html"])("handles repeated unterminated %s prefixes", (prefix) => {
const malformed = prefix.repeat(20_000);
expect(injectPreviewVariables(malformed, { a: 1 })).toBe(SCRIPT + malformed);
expect(injectPreviewVariables(malformed + ">tail", { a: 1 })).toBe(
malformed + ">" + SCRIPT + "tail",
);
});

it("escapes script-breaking input before inserting it", () => {
expect(injectPreviewVariables("<head>", { a: "</script>" })).toBe(
'<head><script data-hf-preview-variables>window.__hfVariables={"a":"\\u003c/script>"};</script>',
);
});
});
28 changes: 28 additions & 0 deletions packages/studio-server/src/helpers/previewVariables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Inject preview variable overrides: `?variables=<json>` becomes
* `window.__hfVariables` set before any composition script runs — the exact
* global the engine sets via evaluateOnNewDocument at render time
* (engine/src/services/frameCapture.ts), so preview-with-values cannot
* diverge from render behavior. The runtime's getVariables() merges these
* overrides over the declared defaults.
*/
export function injectPreviewVariables(html: string, values: Record<string, unknown>): string {
// <-escape prevents a string value containing "</script>" from
// breaking out of the injected tag.
const json = JSON.stringify(values).replace(/</g, "\\u003c");
const tag = `<script data-hf-preview-variables>window.__hfVariables=${json};</script>`;
// Insert as early as possible without ever landing before the doctype —
// content before <!doctype> flips the document into quirks mode, so the
// fallback chain is <head…> → <html…> → after the doctype → prepend.
for (const pattern of [/<head/i, /<html/i, /^\s*<!doctype/i]) {
const match = pattern.exec(html);
if (match) {
// If the first prefix has no closing >, no later prefix can close either.
const end = html.indexOf(">", match.index + match[0].length);
if (end < 0) continue;
const at = end + 1;
return html.slice(0, at) + tag + html.slice(at);
}
}
return tag + html;
}
27 changes: 1 addition & 26 deletions packages/studio-server/src/routes/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { persistHfIdsIfNeeded, stampFileHfIds } from "../helpers/hfIdPersist.js";
import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js";
import { injectPreviewVariables } from "../helpers/previewVariables.js";
import {
resolveProxy,
ProxyCapacityError,
Expand Down Expand Up @@ -203,32 +204,6 @@ function injectGsapCdnFallback(html: string): string {
return GSAP_CDN_FALLBACK_SCRIPT + html;
}

/**
* Inject preview variable overrides: `?variables=<json>` becomes
* `window.__hfVariables` set before any composition script runs — the exact
* global the engine sets via evaluateOnNewDocument at render time
* (engine/src/services/frameCapture.ts), so preview-with-values cannot
* diverge from render behavior. The runtime's getVariables() merges these
* overrides over the declared defaults.
*/
function injectPreviewVariables(html: string, values: Record<string, unknown>): string {
// <-escape prevents a string value containing "</script>" from
// breaking out of the injected tag.
const json = JSON.stringify(values).replace(/</g, "\\u003c");
const tag = `<script data-hf-preview-variables>window.__hfVariables=${json};</script>`;
// Insert as early as possible without ever landing before the doctype —
// content before <!doctype> flips the document into quirks mode, so the
// fallback chain is <head…> → <html…> → after the doctype → prepend.
for (const pattern of [/<head[^>]*>/i, /<html[^>]*>/i, /^\s*<!doctype[^>]*>/i]) {
const match = pattern.exec(html);
if (match) {
const at = match.index + match[0].length;
return html.slice(0, at) + tag + html.slice(at);
}
}
return tag + html;
}

/**
* Parse the `?variables=` query param. Absent/empty → null (no injection).
* Invalid JSON or a non-object payload is a caller error — surfaced as a 400
Expand Down
Loading