mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(cli): check and snapshot run where a sandbox refuses to listen on localhost (#5618)
This commit is contained in:
@@ -5,7 +5,7 @@ const mocks = vi.hoisted(() => ({
|
||||
ensureBrowser: vi.fn(),
|
||||
findSystemBrowser: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
buildChromeArgs: vi.fn(() => []),
|
||||
buildChromeArgs: vi.fn((): string[] => []),
|
||||
resolveBrowserGpuMode: vi.fn(async () => "software" as const),
|
||||
compositionRequiresWebGpu: vi.fn(() => false),
|
||||
assertWebGpuAdapterAvailable: vi.fn(async () => undefined),
|
||||
@@ -33,6 +33,11 @@ vi.mock("@hyperframes/engine", () => ({
|
||||
}));
|
||||
|
||||
import { openSettledCompositionPage } from "./captureCompositionFrame.js";
|
||||
import { Server } from "node:http";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
|
||||
|
||||
const HTML = '<div data-composition-id="main" data-width="1920" data-height="1080"></div>';
|
||||
const OPTIONS = {
|
||||
@@ -54,6 +59,8 @@ function fakeBrowser() {
|
||||
goto: vi.fn(async () => undefined),
|
||||
waitForFunction: vi.fn(async () => undefined),
|
||||
evaluate: vi.fn(async () => undefined),
|
||||
setRequestInterception: vi.fn(async (_on: boolean) => undefined),
|
||||
on: vi.fn((_event: string, _handler: (request: unknown) => void) => undefined),
|
||||
};
|
||||
return {
|
||||
browser: {
|
||||
@@ -178,3 +185,69 @@ describe("openSettledCompositionPage Windows bundled-browser recovery", () => {
|
||||
expect(mocks.launch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openSettledCompositionPage when nothing may listen", () => {
|
||||
const launched = () =>
|
||||
mocks.launch.mock.calls.at(-1)?.[0] as unknown as { pipe?: boolean; args: string[] };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.ensureBrowser.mockResolvedValue({ executablePath: BUNDLED, source: "cache" });
|
||||
mocks.buildChromeArgs.mockReturnValue(["--headless"]);
|
||||
mocks.resolveBrowserGpuMode.mockResolvedValue("software");
|
||||
mocks.resolveConfig.mockReturnValue({ allowSoftwareWebGpu: false });
|
||||
mocks.usesSoftwareWebGpu.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("drives Chrome over a pipe in one process and answers the page from the project", async () => {
|
||||
vi.spyOn(Server.prototype, "listen").mockImplementation(function (this: Server) {
|
||||
process.nextTick(() =>
|
||||
this.emit("error", Object.assign(new Error("listen EPERM"), { code: "EPERM" })),
|
||||
);
|
||||
return this;
|
||||
});
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-nolisten-"));
|
||||
const server = await serveStaticProjectHtml(dir, "<html><body>composition</body></html>");
|
||||
const opened = fakeBrowser();
|
||||
mocks.launch.mockResolvedValue(opened.browser);
|
||||
try {
|
||||
await openSettledCompositionPage(HTML, server.url, OPTIONS);
|
||||
|
||||
expect(launched().pipe).toBe(true);
|
||||
expect(launched().args).toEqual(["--headless", "--single-process"]);
|
||||
expect(opened.page.setRequestInterception).toHaveBeenCalledWith(true);
|
||||
const onRequest = opened.page.on.mock.calls.find(([event]) => event === "request")![1];
|
||||
const respond = vi.fn(async (_response: { status: number; body: Buffer }) => undefined);
|
||||
const proceed = vi.fn(async () => undefined);
|
||||
const request = (url: string) => ({
|
||||
url: () => url,
|
||||
method: () => "GET",
|
||||
headers: () => ({}),
|
||||
isInterceptResolutionHandled: () => false,
|
||||
respond,
|
||||
continue: proceed,
|
||||
abort: vi.fn(async () => undefined),
|
||||
});
|
||||
onRequest(request(`${server.url}index.html`));
|
||||
await vi.waitFor(() => expect(respond).toHaveBeenCalledOnce());
|
||||
expect(respond.mock.calls[0]![0]).toMatchObject({ status: 200 });
|
||||
expect(String(respond.mock.calls[0]![0].body)).toContain("composition");
|
||||
onRequest(request("https://cdn.example/gsap.js"));
|
||||
expect(proceed).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await server.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the usual launch for a project served on a port", async () => {
|
||||
const opened = fakeBrowser();
|
||||
mocks.launch.mockResolvedValue(opened.browser);
|
||||
await openSettledCompositionPage(HTML, "http://127.0.0.1:3000", OPTIONS);
|
||||
expect(launched().pipe).toBeUndefined();
|
||||
expect(launched().args).toEqual(["--headless"]);
|
||||
expect(opened.page.setRequestInterception).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "../browser/launch.js";
|
||||
import { spawn } from "node:child_process";
|
||||
import type { Browser, Page } from "puppeteer-core";
|
||||
import type { InProcessRequest, InProcessResponse } from "../utils/staticProjectServer.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import {
|
||||
assertWebGpuAdapterAvailable,
|
||||
@@ -201,6 +202,8 @@ export async function openSettledCompositionPage(
|
||||
const puppeteer = await import("puppeteer-core");
|
||||
const { buildChromeArgs } = await import("@hyperframes/engine");
|
||||
const requestedGpuMode = options.browserGpuMode ?? resolveCliChromeGpuMode();
|
||||
// Nothing may listen (a sandbox refused it): DevTools over a pipe, one process, pages answered in-process.
|
||||
const answer = (await import("../utils/staticProjectServer.js")).inProcessAnswer(url);
|
||||
const requiresWebGpu = compositionRequiresWebGpu(html);
|
||||
let softwareWebGpu = false;
|
||||
const launch = async (executablePath: string): Promise<Browser> => {
|
||||
@@ -212,14 +215,15 @@ export async function openSettledCompositionPage(
|
||||
options.signal?.throwIfAborted();
|
||||
const webGpu = resolveLocalWebGpu(resolvedGpuMode, requiresWebGpu);
|
||||
softwareWebGpu = webGpu.softwareWebGpu;
|
||||
const args = buildChromeArgs(
|
||||
{ ...viewport, captureMode: "screenshot", requiresWebGpu },
|
||||
webGpu.gpuConfig,
|
||||
);
|
||||
return launchManagedBrowser(puppeteer.default, {
|
||||
headless: true,
|
||||
signal: options.signal,
|
||||
executablePath,
|
||||
args: buildChromeArgs(
|
||||
{ ...viewport, captureMode: "screenshot", requiresWebGpu },
|
||||
webGpu.gpuConfig,
|
||||
),
|
||||
...(answer ? { pipe: true, args: [...args, "--single-process"] } : { args }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -258,6 +262,7 @@ export async function openSettledCompositionPage(
|
||||
const page = await chromeBrowser.newPage();
|
||||
await installPageFunctionGuard(page);
|
||||
await page.setViewport(viewport);
|
||||
if (answer) await answerPageInProcess(page, url, answer);
|
||||
await options.beforeNavigate?.(page);
|
||||
await page.goto(url, {
|
||||
waitUntil: "domcontentloaded",
|
||||
@@ -598,3 +603,25 @@ export async function runFfmpegOnce(
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Requests to the in-process origin are answered here; any other request goes on as usual. */
|
||||
async function answerPageInProcess(
|
||||
page: Page,
|
||||
url: string,
|
||||
answer: (request: InProcessRequest) => Promise<InProcessResponse>,
|
||||
): Promise<void> {
|
||||
const origin = new URL(url).origin;
|
||||
await page.setRequestInterception(true);
|
||||
page.on("request", (request) => {
|
||||
if (request.isInterceptResolutionHandled()) return;
|
||||
const target = new URL(request.url());
|
||||
if (target.origin !== origin) return void request.continue().catch(() => {});
|
||||
answer({
|
||||
method: request.method(),
|
||||
path: target.pathname + target.search,
|
||||
headers: request.headers(),
|
||||
})
|
||||
.then((response) => request.respond(response))
|
||||
.catch(() => request.abort("failed").catch(() => {}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
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";
|
||||
import { Server } from "node:http";
|
||||
import {
|
||||
inProcessAnswer,
|
||||
serveStaticProjectHtml,
|
||||
type StaticProjectServer,
|
||||
} from "./staticProjectServer.js";
|
||||
|
||||
// `serveStaticProjectHtml` reaches these two studio-server helpers via a
|
||||
// absolute module regardless of which file's relative specifier reaches it).
|
||||
@@ -413,3 +418,53 @@ describe("serveStaticProjectHtml transparent media proxies", () => {
|
||||
expect(mocks.resolveProxy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("serveStaticProjectHtml when a sandbox refuses listen()", () => {
|
||||
const refuse = (code: string) =>
|
||||
vi.spyOn(Server.prototype, "listen").mockImplementation(function (this: Server) {
|
||||
process.nextTick(() =>
|
||||
this.emit("error", Object.assign(new Error(`listen ${code}`), { code })),
|
||||
);
|
||||
return this;
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("answers the project in this process, Range included, with nothing listening", async () => {
|
||||
const listen = refuse("EPERM");
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-static-"));
|
||||
writeFileSync(join(dir, "tone.wav"), Buffer.from("0123456789", "utf-8"));
|
||||
server = await serveStaticProjectHtml(dir, "<html><body>composition</body></html>");
|
||||
|
||||
expect(listen).toHaveBeenCalledOnce();
|
||||
expect(server.port).toBe(0);
|
||||
expect(inProcessAnswer(server.url)).toBe(server.answer);
|
||||
const page = await server.answer!({ method: "GET", path: "/", headers: {} });
|
||||
expect(page.status).toBe(200);
|
||||
expect(page.body.toString()).toContain("composition");
|
||||
const slice = await server.answer!({
|
||||
method: "GET",
|
||||
path: "/tone.wav",
|
||||
headers: { range: "bytes=2-5" },
|
||||
});
|
||||
expect(slice.status).toBe(206);
|
||||
expect(slice.headers["content-range"]).toBe("bytes 2-5/10");
|
||||
expect(slice.body.toString()).toBe("2345");
|
||||
expect(
|
||||
(await server.answer!({ method: "GET", path: "/missing.png", headers: {} })).status,
|
||||
).toBe(404);
|
||||
|
||||
const url = server.url;
|
||||
await server.close();
|
||||
server = undefined;
|
||||
expect(inProcessAnswer(url)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still fails on a bind error that is not a refusal", async () => {
|
||||
refuse("EADDRINUSE");
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-static-"));
|
||||
await expect(serveStaticProjectHtml(dir, "<html></html>")).rejects.toMatchObject({
|
||||
code: "EADDRINUSE",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createServer, type ServerResponse } from "node:http";
|
||||
import { createServer, request as httpRequest, type Server, type ServerResponse } from "node:http";
|
||||
import { Duplex } from "node:stream";
|
||||
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
import { getMimeType } from "@hyperframes/core/studio-api";
|
||||
@@ -22,8 +23,92 @@ import {
|
||||
|
||||
export interface StaticProjectServer {
|
||||
url: string;
|
||||
/** 0 when nothing listens: the server answers in this process only (see `answer`). */
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
/** Set when a sandbox refused listen(): the browser's requests to `url` come here instead of a socket. */
|
||||
answer?: (request: InProcessRequest) => Promise<InProcessResponse>;
|
||||
}
|
||||
|
||||
export interface InProcessRequest {
|
||||
method: string;
|
||||
/** Path and query, e.g. `/assets/a.mp4?hf-proxy=alpha`. */
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface InProcessResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: Buffer;
|
||||
}
|
||||
|
||||
/** A sandbox refuses a loopback listen() with one of these; any other bind error stays fatal. */
|
||||
const REFUSED_LISTEN = new Set(["EPERM", "EACCES"]);
|
||||
|
||||
/** Two connected in-memory sockets: what one side writes, the other reads. */
|
||||
function socketPair(): [Duplex, Duplex] {
|
||||
const sides: Duplex[] = [];
|
||||
const side = (other: () => Duplex) =>
|
||||
new Duplex({
|
||||
read() {},
|
||||
write(chunk, _encoding, done) {
|
||||
other().push(chunk);
|
||||
done();
|
||||
},
|
||||
final(done) {
|
||||
other().push(null);
|
||||
done();
|
||||
},
|
||||
});
|
||||
sides.push(
|
||||
side(() => sides[1]!),
|
||||
side(() => sides[0]!),
|
||||
);
|
||||
return [sides[0]!, sides[1]!];
|
||||
}
|
||||
|
||||
/** Hands one request to `server` over an in-memory socket, so its handler (Range, proxies) answers unchanged. */
|
||||
function answerInProcess(server: Server, request: InProcessRequest): Promise<InProcessResponse> {
|
||||
const [client, served] = socketPair();
|
||||
server.emit("connection", served);
|
||||
return new Promise((resolveAnswer, rejectAnswer) => {
|
||||
const req = httpRequest(
|
||||
{
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
headers: { ...request.headers, connection: "close" },
|
||||
// No `agent`: Node uses createConnection only when no agent is given.
|
||||
createConnection: () => client as never,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("error", rejectAnswer);
|
||||
res.on("end", () => {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(res.headers)) {
|
||||
if (value !== undefined && name !== "connection" && name !== "transfer-encoding")
|
||||
headers[name] = Array.isArray(value) ? value.join(", ") : value;
|
||||
}
|
||||
resolveAnswer({ status: res.statusCode ?? 500, headers, body: Buffer.concat(chunks) });
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", rejectAnswer);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
let inProcessServers = 0;
|
||||
const inProcessAnswers = new Map<
|
||||
string,
|
||||
(request: InProcessRequest) => Promise<InProcessResponse>
|
||||
>();
|
||||
|
||||
/** The in-process answer for a URL served by `serveStaticProjectHtml` when listen() was refused, else undefined. */
|
||||
export function inProcessAnswer(url: string) {
|
||||
return inProcessAnswers.get(new URL(url).origin);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -229,8 +314,27 @@ export async function serveStaticProjectHtml(
|
||||
if (!resolvedPort) rejectPort(new Error(bindErrorMessage));
|
||||
else resolvePort(resolvedPort);
|
||||
});
|
||||
}).catch((error: unknown) => {
|
||||
if (REFUSED_LISTEN.has((error as NodeJS.ErrnoException).code ?? "")) return 0;
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (port === 0) {
|
||||
server.removeAllListeners("error");
|
||||
// A sandbox (e.g. an agent's) may refuse listen(); the browser asks this origin and the process answers.
|
||||
const url = `http://hyperframes-${++inProcessServers}.localhost/`;
|
||||
const answer = (request: InProcessRequest) => answerInProcess(server, request);
|
||||
inProcessAnswers.set(new URL(url).origin, answer);
|
||||
return {
|
||||
url,
|
||||
port: 0,
|
||||
close: async () => {
|
||||
inProcessAnswers.delete(new URL(url).origin);
|
||||
},
|
||||
answer,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}/`,
|
||||
port,
|
||||
|
||||
Reference in New Issue
Block a user