feat(studio-server): the preview records the kind and origin of each early error (#7041)

* feat(studio-server): the preview records the kind and origin of each early error

* fix(studio-server): write preview script values with U+2028/2029 and < escaped
This commit is contained in:
Miguel Ángel
2026-11-03 07:47:49 +00:00
committed by GitHub
parent 9a2cd9e61b
commit b84835c413
3 changed files with 82 additions and 5 deletions
+1
View File
@@ -16,6 +16,7 @@ export function defersMediaIn(doc: Document): boolean {
export const STUDIO_PREVIEW_UPCOMING_ATTR = "data-hf-upcoming";
export const STUDIO_PREVIEW_LAZY_ATTR = "data-hf-preview-lazy";
export const STUDIO_PREVIEW_ERRORS = "__hfPreviewErrors";
export const STUDIO_PREVIEW_ERROR_KINDS = "__hfPreviewErrorKinds";
export const refusedPreviewMessage = (status: number) =>
`The preview page could not be loaded (HTTP ${status}).`;
@@ -22,12 +22,18 @@ import { tmpdir } from "node:os";
import { join, parse } from "node:path";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import {
STUDIO_PREVIEW_ERROR_KINDS,
STUDIO_PREVIEW_ERRORS,
STUDIO_PREVIEW_CAPTURE_MODE,
STUDIO_PREVIEW_MARK_META,
} from "@hyperframes/core/studio-preview-mark";
import { AFTER_FONTS_SCRIPT_TYPE } from "@hyperframes/core/compiler";
import { PREVIEW_BUNDLE_OPTIONS, PREVIEW_CAPTURE_PARAM, registerPreviewRoutes } from "./preview";
import {
PREVIEW_BUNDLE_OPTIONS,
PREVIEW_CAPTURE_PARAM,
registerPreviewRoutes,
scriptJson,
} from "./preview";
import { registerFileRoutes } from "./files";
import { createPreviewDocumentStore } from "../helpers/previewDocumentStore";
import { MAX_OPEN_MEDIA_RANGE_BYTES } from "../helpers/mediaRange";
@@ -374,6 +380,63 @@ describe("registerPreviewRoutes", () => {
expect(previewWindow[STUDIO_PREVIEW_ERRORS]).toEqual(["Uncaught Error: GSAP could not load"]);
});
it("writes a value into a script so it cannot close the tag or end the line", () => {
const out = scriptJson("</script><!--\u2028\u2029");
expect(out).not.toMatch(/<|\u2028|\u2029/);
expect(JSON.parse(out)).toBe("</script><!--\u2028\u2029");
});
it("records the kind and origin of each early error, never its text, and leaves the messages as they were", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
"<!DOCTYPE html><html><head></head><body></body></html>",
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const html = await (await app.request("http://localhost/projects/demo/preview")).text();
const body =
new RegExp(`<script>([^<]*${STUDIO_PREVIEW_ERROR_KINDS}[^<]*)</script>`).exec(html)?.[1] ??
"";
const previewWindow: Record<string, unknown> = {
location: { href: "http://localhost/api/projects/demo/preview/", origin: "http://localhost" },
};
let raise: (event: object) => void = () => undefined;
new Function("window", "addEventListener", body)(
previewWindow,
(_type: string, listener: typeof raise) => (raise = listener),
);
class Custom extends Error {}
raise({
message: "a",
error: new SyntaxError("secret one"),
filename: "http://localhost/api/runtime.js",
});
raise({
message: "b",
error: new TypeError("secret two"),
filename: "http://localhost/api/projects/demo/preview/app.js",
});
raise({
message: "c",
error: new Custom("secret three"),
filename: "http://localhost/assets/studio.js",
});
raise({ message: "d", filename: "https://cdn.example.com/lib.js" });
raise({ message: "e", error: new ReferenceError("secret four"), filename: "" });
expect(previewWindow[STUDIO_PREVIEW_ERRORS]).toEqual(["a", "b", "c", "d", "e"]);
expect(previewWindow[STUDIO_PREVIEW_ERROR_KINDS]).toEqual([
{ name: "SyntaxError", origin: "runtime" },
{ name: "TypeError", origin: "project" },
{ name: "other", origin: "studio" },
{ name: "other", origin: "unknown" },
{ name: "ReferenceError", origin: "unknown" },
]);
expect(JSON.stringify(previewWindow[STUDIO_PREVIEW_ERROR_KINDS])).not.toContain("secret");
});
it("serves a later scene's image lazy, and captures every image eager under a capture-mode mark", async () => {
const projectDir = createProjectDir();
const later =
+17 -4
View File
@@ -17,6 +17,7 @@ import {
import {
STUDIO_PREVIEW_CAPTURE_MODE,
STUDIO_PREVIEW_MARK_META,
STUDIO_PREVIEW_ERROR_KINDS,
STUDIO_PREVIEW_ERRORS,
} from "@hyperframes/core/studio-preview-mark";
import {
@@ -273,7 +274,7 @@ const GSAP_FALLBACK_ATTR = "data-hf-gsap-fallback";
function gsapCdnFallbackScript(baseUrl: string): string {
return `<script ${GSAP_FALLBACK_ATTR}>
(function(){
var cdnBase=${JSON.stringify(gsapCdnDist(undefined, baseUrl)).replace(/</g, "\\u003c")};
var cdnBase=${scriptJson(gsapCdnDist(undefined, baseUrl))};
var loaded={};
function loadFallback(file){
if(loaded[file])return loaded[file];
@@ -349,9 +350,21 @@ function previewVariablesFromRequest(rawVariables: string | undefined):
/** Captures screenshot right after a seek, so they get every image eager; media loads near the playhead. */
export const PREVIEW_CAPTURE_PARAM = "hf-capture";
export function scriptJson(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, "\\u003c")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
// Studio's console capture attaches at the iframe's load; this keeps what was raised before it.
const EARLY_ERRORS_SCRIPT = `<script>(function(){var seen=window.${STUDIO_PREVIEW_ERRORS}=[];
addEventListener("error",function(e){seen.push(e.message||String(e))});})();</script>`;
const earlyErrorsScript = (
runtimeUrl: string,
) => `<script>(function(){var seen=window.${STUDIO_PREVIEW_ERRORS}=[],kinds=window.${STUDIO_PREVIEW_ERROR_KINDS}=[],known={SyntaxError:1,TypeError:1,ReferenceError:1,RangeError:1},runtime=${scriptJson(new URL(runtimeUrl, "http://x").pathname)};
addEventListener("error",function(e){seen.push(e.message||String(e));
var name=e.error&&e.error.constructor&&e.error.constructor.name,path="";
try{var url=e.filename&&new URL(e.filename,window.location.href);if(url&&url.origin===window.location.origin)path=url.pathname}catch(_){}
kinds.push({name:known[name]===1?name:"other",origin:!path?"unknown":path===runtime?"runtime":path.indexOf("/api/projects/")===0?"project":"studio"})});})();</script>`;
function injectStudioPreviewAugmentations(
html: string,
@@ -367,7 +380,7 @@ function injectStudioPreviewAugmentations(
)
: injectTagsAtHeadStart(
lazyPreviewImages(html),
`<meta name="${STUDIO_PREVIEW_MARK_META}">${EARLY_ERRORS_SCRIPT}`,
`<meta name="${STUDIO_PREVIEW_MARK_META}">${earlyErrorsScript(adapter.runtimeUrl)}`,
);
const withFallback = injectGsapCdnFallback(
injectProjectSignature(