diff --git a/packages/studio-server/src/helpers/mediaProxyPreview.ts b/packages/studio-server/src/helpers/mediaProxyPreview.ts
index 9ff53c1a73..f59b43585b 100644
--- a/packages/studio-server/src/helpers/mediaProxyPreview.ts
+++ b/packages/studio-server/src/helpers/mediaProxyPreview.ts
@@ -111,7 +111,7 @@ export async function injectMediaCodecMapIntoHtml(
});
}
// <-escape prevents a src path containing "" 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(/window.__hfVariables={"a":1};';
+
+describe("injectPreviewVariables", () => {
+ it.each([
+ [
+ "
x",
+ "",
+ "x",
+ ],
+ [
+ "x",
+ "",
+ "x",
+ ],
+ ["x", "", "x"],
+ [" \nx", " \n", "x"],
+ ["fragment", "", "fragment"],
+ ["xy", "", "xy"],
+ ["x", "", "x"],
+ ["x", "x"],
+ ["", "x", "", "x"],
+ ])("preserves insertion boundaries (case %#)", (html, before, after) => {
+ expect(injectPreviewVariables(html, { a: 1 })).toBe(before + SCRIPT + after);
+ });
+
+ it.each([" {
+ 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("", { a: "" })).toBe(
+ '',
+ );
+ });
+});
diff --git a/packages/studio-server/src/helpers/previewVariables.ts b/packages/studio-server/src/helpers/previewVariables.ts
new file mode 100644
index 0000000000..79574e09b0
--- /dev/null
+++ b/packages/studio-server/src/helpers/previewVariables.ts
@@ -0,0 +1,28 @@
+/**
+ * Inject preview variable overrides: `?variables=` 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 {
+ // <-escape prevents a string value containing "" from
+ // breaking out of the injected tag.
+ const json = JSON.stringify(values).replace(/window.__hfVariables=${json};`;
+ // Insert as early as possible without ever landing before the doctype —
+ // content before flips the document into quirks mode, so the
+ // fallback chain is → → after the doctype → prepend.
+ for (const pattern of [/, 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;
+}
diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts
index f23b8c1130..ef807bf1c5 100644
--- a/packages/studio-server/src/routes/preview.ts
+++ b/packages/studio-server/src/routes/preview.ts
@@ -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,
@@ -203,32 +204,6 @@ function injectGsapCdnFallback(html: string): string {
return GSAP_CDN_FALLBACK_SCRIPT + html;
}
-/**
- * Inject preview variable overrides: `?variables=` 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 {
- // <-escape prevents a string value containing "" from
- // breaking out of the injected tag.
- const json = JSON.stringify(values).replace(/window.__hfVariables=${json};`;
- // Insert as early as possible without ever landing before the doctype —
- // content before flips the document into quirks mode, so the
- // fallback chain is → → after the doctype → prepend.
- for (const pattern of [/]*>/i, /]*>/i, /^\s*]*>/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