Files
hyperframes/packages/cli/src/utils/staticProjectServer.ts
T
Xuanru LiandClaude Opus 4.8 906c8d04f8 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>
2026-07-06 17:20:18 -07:00

124 lines
4.3 KiB
TypeScript

import { createServer, type ServerResponse } from "node:http";
import { createReadStream, existsSync, statSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";
import { getMimeType } from "@hyperframes/core/studio-api";
export interface StaticProjectServer {
url: string;
port: number;
close: () => Promise<void>;
}
/**
* Serve a file with HTTP Range support. Chromium needs byte-range seekability
* to determine the duration of formats that carry it in a trailing/implicit
* position (notably WAV, which otherwise reports `.duration` as `Infinity`
* however long it buffers). A plain 200 with no `Accept-Ranges` makes the
* media element non-seekable, so `hyperframes validate` would spuriously warn
* that a perfectly valid local WAV's duration "could not be read".
*/
function serveFileWithRange(
filePath: string,
rangeHeader: string | undefined,
res: ServerResponse,
) {
const size = statSync(filePath).size;
const headers: Record<string, string> = {
"Content-Type": getMimeType(filePath),
"Accept-Ranges": "bytes",
};
// Resolve the requested byte window. Absent/malformed Range serves the
// whole file (200); a valid `bytes=start-end` (including the open-ended
// `start-` and suffix `-N` forms) serves a 206 slice.
const last = size - 1;
let start = 0;
let end = last;
let status = 200;
const match = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()) : null;
if (match) {
const hasStart = match[1] !== "";
start = hasStart ? Number(match[1]) : Math.max(0, size - Number(match[2]));
end = !hasStart ? last : match[2] !== "" ? Math.min(Number(match[2]), last) : last;
if (start > end || start > last) {
res.writeHead(416, { ...headers, "Content-Range": `bytes */${size}` });
res.end();
return;
}
status = 206;
headers["Content-Range"] = `bytes ${start}-${end}/${size}`;
}
headers["Content-Length"] = String(end - start + 1);
// Stream only the requested window instead of buffering the whole file: a
// 1KB Range of a 50MB asset must not allocate 50MB. createReadStream reads
// just `[start, end]` and closes its own fd on end/error. writeHead is
// deferred to `open` so a failed open can still answer 500.
const stream = createReadStream(filePath, { start, end });
stream.on("open", () => {
res.writeHead(status, headers);
stream.pipe(res);
});
stream.on("error", () => {
if (!res.headersSent) res.writeHead(500);
res.end();
stream.destroy();
});
}
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 ?? "/";
if (url === "/" || url === "/index.html") {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(html);
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();
});
const port = await new Promise<number>((resolvePort, rejectPort) => {
server.on("error", rejectPort);
// Bind loopback only (SECURITY F-001): a bare listen(0) binds 0.0.0.0/::,
// which an IDE's port auto-forward surfaces as a transient "preview". The
// snapshot browser is co-located (url below is already 127.0.0.1).
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
const resolvedPort = typeof addr === "object" && addr ? addr.port : 0;
if (!resolvedPort) rejectPort(new Error(bindErrorMessage));
else resolvePort(resolvedPort);
});
});
return {
url: `http://127.0.0.1:${port}/`,
port,
close: () =>
new Promise<void>((resolveClose) => {
server.close(() => resolveClose());
}),
};
}