fix(cli): localize remote assets before validate so it matches render (#2001)

validate served the composition over a loopback origin and let headless
Chrome fetch remote <img crossorigin>/@font-face assets cross-origin, while
the render pipeline downloads them to disk first. Buckets whose CORS
allowlist omits the loopback origin then failed the CORS-mode request with a
false net::ERR_FAILED that never occurs in the real render, pushing authors
(and agent pipelines) to delete crossorigin — which disables WebGL
color-grading/shaders for that asset.

Reuse producer's localizeRemote{Media,Image,FontFace}Sources in validate,
downloading into a temp dir served as an extra static-server asset root
(project dir untouched, cleaned up after). validate now matches render.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Xuanru Li
2026-07-06 17:20:18 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 89e36da13d
commit 906c8d04f8
5 changed files with 130 additions and 13 deletions
@@ -9,6 +9,25 @@ import {
} from "./validate.js";
import type { ProjectLintResult } from "../utils/lintProject.js";
// validateInBrowser lazy-loads the producer localize helpers via loadProducer;
// mock it so these unit tests never resolve @hyperframes/producer's built dist.
vi.mock("../utils/producer.js", () => ({
loadProducer: vi.fn(async () => ({
localizeRemoteMediaSources: vi.fn(async (html: string) => ({
html,
remoteMediaAssets: new Map(),
})),
localizeRemoteImageSources: vi.fn(async (html: string) => ({
html,
remoteMediaAssets: new Map(),
})),
localizeRemoteFontFaces: vi.fn(async (html: string) => ({
html,
remoteMediaAssets: new Map(),
})),
})),
}));
// Regression for the validate audio-duration-probe timeout: a slow-loading
// media element's duration was snapshotted once, at a fixed point in time,
// and any element still mid-load was permanently misreported as unreadable.
+45 -2
View File
@@ -1,5 +1,6 @@
import { defineCommand } from "citty";
import { existsSync, readFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveProject, type ProjectDir } from "../utils/project.js";
@@ -334,6 +335,37 @@ export function extractCompositionErrorsFromLint(
.map((f) => ({ level: "error" as const, text: f.message }));
}
// Match the render pipeline: localize remote <img>/<video>/<audio>/@font-face
// into a temp dir (served as an extra asset root) so validate resolves them
// same-origin and doesn't false-fail on cross-origin (crossorigin/CORS) fetches
// the real render never makes. Best-effort: no-op on any failure.
async function localizeRemoteAssets(
html: string,
): Promise<{ html: string; assetRoots: string[]; cleanup: () => void }> {
let dir: string | undefined;
try {
const { loadProducer } = await import("../utils/producer.js");
const { localizeRemoteMediaSources, localizeRemoteImageSources, localizeRemoteFontFaces } =
await loadProducer();
dir = mkdtempSync(join(tmpdir(), "hf-validate-assets-"));
const assetDir = dir;
const media = await localizeRemoteMediaSources(html, assetDir);
const images = await localizeRemoteImageSources(media.html, assetDir);
const fonts = await localizeRemoteFontFaces(images.html, assetDir);
const count =
media.remoteMediaAssets.size + images.remoteMediaAssets.size + fonts.remoteMediaAssets.size;
return {
html: fonts.html,
assetRoots: count > 0 ? [assetDir] : [],
cleanup: () => rmSync(assetDir, { recursive: true, force: true }),
};
} catch {
// Best-effort: drop any partial temp dir before falling back to remote URLs.
if (dir) rmSync(dir, { recursive: true, force: true });
return { html, assetRoots: [], cleanup: () => {} };
}
}
async function validateInBrowser(
project: ProjectDir,
opts: { timeout?: number; contrast?: boolean },
@@ -359,7 +391,17 @@ async function validateInBrowser(
// runtime tag) is no longer needed — there's no `src` attribute to match.
const html = await bundleToSingleHtml(projectDir);
const server = await serveStaticProjectHtml(projectDir, html);
const localized = await localizeRemoteAssets(html);
const server = await serveStaticProjectHtml(
projectDir,
localized.html,
undefined,
localized.assetRoots,
).catch((err) => {
// Server never started — the finally below won't run, so clean up here.
localized.cleanup();
throw err;
});
const errors: ConsoleEntry[] = [...compositionErrors];
const warnings: ConsoleEntry[] = [];
@@ -445,6 +487,7 @@ async function validateInBrowser(
await chromeBrowser.close();
} finally {
await server.close();
localized.cleanup();
}
return { errors, warnings, contrast };
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { serveStaticProjectHtml, type StaticProjectServer } from "./staticProjectServer.js";
@@ -66,3 +66,46 @@ describe("serveStaticProjectHtml range support", () => {
expect(res.headers.get("content-range")).toBe(`bytes */${body.length}`);
});
});
describe("serveStaticProjectHtml asset roots", () => {
const extraDirs: string[] = [];
const mk = (): string => {
const d = mkdtempSync(join(tmpdir(), "hf-static-root-"));
extraDirs.push(d);
return d;
};
afterEach(() => {
for (const d of extraDirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
it("serves files from an extra asset root when the project dir lacks them", async () => {
// extra root (e.g. localized-assets temp dir) resolves same-origin
const projectDir = mk();
const assetDir = mk();
mkdirSync(join(assetDir, "_remote_media"), { recursive: true });
writeFileSync(join(assetDir, "_remote_media", "img.jpg"), "PIXELS");
server = await serveStaticProjectHtml(projectDir, "<html></html>", undefined, [assetDir]);
const res = await fetch(`${server.url}_remote_media/img.jpg`);
expect(res.status).toBe(200);
expect(await res.text()).toBe("PIXELS");
});
it("prefers the project dir over an asset root for the same path", async () => {
const projectDir = mk();
const assetDir = mk();
writeFileSync(join(projectDir, "a.txt"), "PROJECT");
writeFileSync(join(assetDir, "a.txt"), "ASSET");
server = await serveStaticProjectHtml(projectDir, "<html></html>", undefined, [assetDir]);
const res = await fetch(`${server.url}a.txt`);
expect(await res.text()).toBe("PROJECT");
});
it("404s a path present in no root", async () => {
const projectDir = mk();
server = await serveStaticProjectHtml(projectDir, "<html></html>", undefined, [mk()]);
const res = await fetch(`${server.url}nope.txt`);
expect(res.status).toBe(404);
});
});
+13 -10
View File
@@ -71,7 +71,11 @@ export async function serveStaticProjectHtml(
projectDir: string,
html: string,
bindErrorMessage = "Failed to bind local HTTP server",
// Extra dirs to resolve non-index requests against, after projectDir (e.g. a
// temp dir of localized remote assets).
assetRoots: readonly string[] = [],
): Promise<StaticProjectServer> {
const roots = [projectDir, ...assetRoots];
// fallow-ignore-next-line complexity
const server = createServer((req, res) => {
const url = req.url ?? "/";
@@ -81,16 +85,15 @@ export async function serveStaticProjectHtml(
return;
}
const filePath = resolve(projectDir, decodeURIComponent(url).replace(/^\//, ""));
const rel = relative(projectDir, filePath);
if (rel.startsWith("..") || isAbsolute(rel)) {
res.writeHead(403);
res.end();
return;
}
if (existsSync(filePath)) {
serveFileWithRange(filePath, req.headers.range, res);
return;
const requestPath = decodeURIComponent(url).replace(/^\//, "");
for (const root of roots) {
const filePath = resolve(root, requestPath);
const rel = relative(root, filePath);
if (rel.startsWith("..") || isAbsolute(rel)) continue; // traversal guard; try next root
if (existsSync(filePath)) {
serveFileWithRange(filePath, req.headers.range, res);
return;
}
}
res.writeHead(404);
res.end();
+9
View File
@@ -27,6 +27,15 @@ export {
type RenderObservationStatus,
} from "./services/render/observability.js";
// ── HTML asset localization ─────────────────────────────────────────────────
// Rewrite remote <img>/<video>/<audio>/@font-face to same-origin local paths
// before capture. Shared by render and `validate` so both resolve assets alike.
export {
localizeRemoteMediaSources,
localizeRemoteImageSources,
localizeRemoteFontFaces,
} from "./services/htmlCompiler.js";
// ── Frame capture (lower-level) ─────────────────────────────────────────────
export {
createCaptureSession,