fix(cli): reject directory --composition and add --browser-timeout (#1199) (#1200)

* fix(cli): reject directory --composition and add --browser-timeout (#1199)

Two unrelated symptoms from issue #1199, fixed together:

1. `--composition .` (or any directory path) used to slip past the
   existsSync check in render.ts and explode downstream as
   `EISDIR: illegal operation on a directory, read` when the producer
   readFileSync'd the entry. The CLI now treats `.` / `""` as "omit
   the flag" (falls back to index.html) and rejects other directory
   paths with an actionable error pointing at the .html shape.

2. The 60s Puppeteer page.goto timeout in frameCapture.ts was hard-
   coded, so heavy compositions (many videos / fonts / asset requests)
   could not complete `domcontentloaded` in time. Add a configurable
   `pageNavigationTimeout` to EngineConfig (default 60_000, env
   fallback PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS) and expose it as
   `--browser-timeout <seconds>` on `hyperframes render`. The flag
   threads through both renderLocal (via resolveConfig) and the
   docker bridge (via buildDockerRunArgs).

Tests:
- render.test.ts: forwards/omits pageNavigationTimeout into resolveConfig
- dockerRunArgs.test.ts: forwards/omits --browser-timeout (seconds)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): address PR #1200 review — extract validators, tighten bounds

Addresses Vai's blockers and Miguel's nits on PR #1200:

- Vai blocker 1 (fallow CRAP) + blocker 3 (no argv tests):
  Extract --browser-timeout and --composition validators into pure
  helpers in utils/renderArgs.ts with a structured-result discriminant.
  Drops ~45 lines of inline validation from run(), reducing its CRAP
  score 1290→978 and cyclomatic 75→65. 19 new unit tests cover the
  parse branches (sub-ms, overflow, NaN, Infinity, empty, negative,
  ".", "./", whitespace, directory, missing, ../escape, sibling-prefix).

- Vai blocker 2 (sub-ms → timeout:0 = "no timeout"): reject inputs
  that round to <1 ms. Puppeteer treats page.goto({timeout:0}) as
  wait-forever, so --browser-timeout 0.0004 silently flipped the
  semantics. Now rejected with an explicit "rounds to 0 ms" error.

- Vai important 5 (1e10 accepted → setTimeout overflow): cap at
  86_400s (24h). Above Node's TIMEOUT_MAX ≈ 2^31-1 ms setTimeout
  fires immediately, the opposite of "long timeout."

- Vai important 4 (related timeouts unmentioned): CLI help and docs
  now flag PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS and the 45s
  playerReadyTimeout as the other knobs heavy compositions may need.

- Vai nit 7 (s/ms unit mismatch): help text and docs row both call
  out the SECONDS-vs-MILLISECONDS difference between flag and env.

- Vai nit 8 / Miguel nit (composition flag discoverability): the
  --composition description now says "Pass `.` (or omit the flag)
  to render the project's index.html."

- Miguel nit (dead branch): the entryFile === "" unreachable branch
  is gone. New helper uses `if (!trimmed || trimmed === ".")`.

Also adds a trailing-separator guard on the project-containment check
(sibling-prefix bypass: /proj-evil/x.html no longer slips past
startsWith('/proj')) — flagged by the code review.

The three remaining fallow complexity findings on render.ts (run,
renderDocker, trackRenderMetrics) are inherited from main; this PR
reduces run() but does not refactor it. Suppressed with
fallow-ignore-next-line markers and inline rationale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): diverge --browser-timeout error messages per Vai nit 5

The `not-a-number` and `not-positive` branches in browserTimeoutErrorMessage
shared the generic "Must be a positive number of seconds" message even
though the discriminant carried distinct kinds. Diverge them so users see
the specific failure mode:

  --browser-timeout abc   →  "Got \"abc\", which is not a number."
  --browser-timeout -5    →  "Got \"-5\" seconds, which is not positive."

The shared hint ("pass a positive number of seconds, e.g. 180") is
preserved on both branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-06-04 16:47:10 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 8228932e17
commit 6affe2d212
9 changed files with 528 additions and 26 deletions
+1
View File
@@ -623,6 +623,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| `--variables` | JSON object | — | Variable overrides merged over `data-composition-variables` defaults. Read via `window.__hyperframes.getVariables()` |
| `--variables-file` | path | — | Path to a JSON file with variable overrides (alternative to `--variables`) |
| `--strict-variables` | — | off | Fail render if any `--variables` key is undeclared or has a wrong type vs the composition's `data-composition-variables`. Without this flag, mismatches print as warnings and the render continues. |
| `--browser-timeout` | seconds (0.00186400) | 60 | Puppeteer page-navigation timeout for the entry HTML. Increase when heavy compositions (many videos, fonts, or asset requests) cannot reach `domcontentloaded` within the default 60 s. The flag takes **seconds**; the env fallback `PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS` takes **milliseconds**. This controls `page.goto` only — very heavy compositions may also need `PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS` and/or `PRODUCER_PLAYER_READY_TIMEOUT_MS` bumped (post-navigation `window.__hf` readiness has its own 45 s budget). |
CRF and target bitrate default to the `--quality` preset. Use `--crf` or `--video-bitrate` for fine-grained overrides; `RenderConfig.crf` and `RenderConfig.videoBitrate` accept the same overrides programmatically.
+35
View File
@@ -212,6 +212,41 @@ describe("renderLocal browser GPU config", () => {
expect(producerState.createdJobs[0]?.entryFile).toBeUndefined();
});
it("forwards --browser-timeout into resolveConfig as pageNavigationTimeout (ms)", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
pageNavigationTimeoutMs: 180_000,
});
expect(producerState.resolveConfigCalls[0]).toMatchObject({
pageNavigationTimeout: 180_000,
});
});
it("omits pageNavigationTimeout from resolveConfig when --browser-timeout is not set", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
});
// Issue #1199: when the flag is omitted, the engine's DEFAULT_CONFIG must
// own the navigation timeout. Forwarding `undefined` would override
// `pageNavigationTimeout: 60_000` to `undefined` and re-introduce the
// bug in a different shape.
expect(producerState.resolveConfigCalls[0]).not.toHaveProperty("pageNavigationTimeout");
});
it("forwards outputResolution to createRenderJob when --resolution is set", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
+47 -24
View File
@@ -6,6 +6,7 @@ import {
resolveVariablesArg,
validateVariablesAgainstProject,
} from "../utils/variables.js";
import { resolveBrowserTimeoutMsArg, resolveCompositionEntryArg } from "../utils/renderArgs.js";
export const examples: Example[] = [
["Render to MP4", "hyperframes render --output output.mp4"],
@@ -119,7 +120,8 @@ export default defineCommand({
alias: "c",
description:
"Render a specific composition file instead of index.html (e.g. compositions/intro.html). " +
"Sub-compositions using <template> wrappers must be referenced from index.html via data-composition-src.",
"Sub-compositions using <template> wrappers must be referenced from index.html via data-composition-src. " +
"Pass `.` (or omit the flag) to render the project's index.html.",
},
output: {
type: "string",
@@ -234,7 +236,26 @@ export default defineCommand({
"Use --no-page-side-compositing to force the layered path.",
default: true,
},
"browser-timeout": {
type: "string",
description:
"Puppeteer page-navigation timeout in SECONDS for the entry HTML. " +
"Increase when heavy compositions (many videos / fonts / asset " +
"requests) cannot reach domcontentloaded within the 60s default " +
"(see issue #1199). Accepts 0.001-86400 (24h cap). " +
"Note: this controls page.goto only — very heavy compositions may " +
"also need PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS / " +
"PRODUCER_PLAYER_READY_TIMEOUT_MS bumped (the post-goto window.__hf " +
"readiness poll has its own 45s budget). " +
"Env fallback: PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS (MILLISECONDS).",
},
},
// `run` is the citty handler for `hyperframes render` — sequential flag
// validation + render dispatch. Inherited CRITICAL on main (CRAP 1290);
// this PR extracted --browser-timeout + --composition validators into
// `utils/renderArgs.ts`, reducing cyclomatic 75→65 and CRAP 1290→978.
// Full decomposition is tracked separately and out of scope for #1199.
// fallow-ignore-next-line complexity
async run({ args }) {
// ── Resolve project ────────────────────────────────────────────────────
const project = resolveProject(args.dir);
@@ -378,29 +399,12 @@ export default defineCommand({
process.exit(1);
}
// ── Validate composition entry file ──────────────────────────────────
const entryFile = args.composition?.trim().replace(/^\.\//, "") || undefined;
if (entryFile) {
const absProjectDir = resolve(project.dir);
const entryPath = resolve(absProjectDir, entryFile);
if (!entryPath.startsWith(absProjectDir)) {
errorBox(
"Invalid composition path",
`Entry file must stay inside the project directory: ${entryFile}`,
);
process.exit(1);
}
try {
statSync(entryPath);
} catch {
errorBox(
"Composition not found",
`"${entryFile}" does not exist in the project directory.`,
"Pass a path to a .html file relative to the project root (e.g. compositions/intro.html).",
);
process.exit(1);
}
}
// ── Validate browser-timeout (seconds) and composition entry file ────
// Both validators live in `utils/renderArgs.ts` so the parse/reject
// branches are unit-testable without `process.exit`. See issue #1199
// for the original EISDIR / silent-timeout-0 footguns this guards.
const pageNavigationTimeoutMs = resolveBrowserTimeoutMsArg(args["browser-timeout"]);
const entryFile = resolveCompositionEntryArg(args.composition, project.dir, statSync);
// ── Print render plan ─────────────────────────────────────────────────
if (!quiet) {
@@ -523,6 +527,7 @@ export default defineCommand({
entryFile,
outputResolution,
pageSideCompositing: args["page-side-compositing"] !== false,
pageNavigationTimeoutMs,
exitAfterComplete: true,
});
} else {
@@ -541,6 +546,7 @@ export default defineCommand({
variables,
entryFile,
outputResolution,
pageNavigationTimeoutMs,
exitAfterComplete: true,
});
}
@@ -570,6 +576,13 @@ interface RenderOptions {
/** Output resolution preset; see `resolveDeviceScaleFactor` for constraints. */
outputResolution?: CanvasResolution;
pageSideCompositing?: boolean;
/**
* Puppeteer `page.goto()` timeout for the entry HTML, in milliseconds.
* When omitted, the engine default (60s) applies. Surfaced as
* `--browser-timeout <seconds>` at the CLI and threaded through to the
* producer's EngineConfig override.
*/
pageNavigationTimeoutMs?: number;
}
/**
@@ -736,6 +749,9 @@ function resolveDockerHostPlatform(options: RenderOptions): string {
return platform;
}
// Inherited minor finding (CRAP 37.1, cyclomatic 11). This PR only added
// `pageNavigationTimeoutMs` to the options forwarded to `buildDockerRunArgs`.
// fallow-ignore-next-line complexity
async function renderDocker(
projectDir: string,
outputPath: string,
@@ -790,6 +806,7 @@ async function renderDocker(
entryFile: options.entryFile,
outputResolution: options.outputResolution,
pageSideCompositing: options.pageSideCompositing,
pageNavigationTimeoutMs: options.pageNavigationTimeoutMs,
},
});
@@ -865,6 +882,9 @@ export async function renderLocal(
useGpu: options.gpu,
producerConfig: producer.resolveConfig({
browserGpuMode: options.browserGpuMode ?? "software",
...(options.pageNavigationTimeoutMs != null
? { pageNavigationTimeout: options.pageNavigationTimeoutMs }
: {}),
}),
hdrMode: options.hdrMode,
crf: options.crf,
@@ -958,6 +978,9 @@ function handleRenderError(
* Extract rich metrics from the completed render job and send to telemetry.
* speed_ratio = composition_duration / render_time — higher is better, >1 means faster than realtime.
*/
// Inherited CRITICAL (CRAP 148.4, cyclomatic 24): exhaustive nullish-fallback
// chain across 30+ telemetry fields. Not touched by this PR.
// fallow-ignore-next-line complexity
function trackRenderMetrics(
job: RenderJob,
elapsedMs: number,
@@ -248,6 +248,23 @@ describe("buildDockerRunArgs", () => {
expect(args).not.toContain("--composition");
});
it("forwards --browser-timeout in seconds when pageNavigationTimeoutMs is set", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, pageNavigationTimeoutMs: 180_000 },
});
const idx = args.indexOf("--browser-timeout");
expect(idx).toBeGreaterThan(-1);
// CLI flag takes seconds; engine takes ms — the docker bridge converts
// back to seconds so the in-container CLI re-parses it consistently.
expect(args[idx + 1]).toBe("180");
});
it("omits --browser-timeout when pageNavigationTimeoutMs is not set", () => {
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(args).not.toContain("--browser-timeout");
});
it("forwards rational --fps verbatim (NTSC 30000/1001)", () => {
// Regression for the fps fraction-syntax feature: the rational form must
// survive the host → container hop as a single `30000/1001` argument so
+15
View File
@@ -52,6 +52,13 @@ export interface DockerRenderOptions {
/** Output resolution preset (e.g. "landscape-4k"). Forwarded as `--resolution`. */
outputResolution?: string;
pageSideCompositing?: boolean;
/**
* Puppeteer page-navigation timeout, in milliseconds. Forwarded to the
* in-container CLI as `--browser-timeout <seconds>` (the CLI takes
* seconds; the engine takes ms — kept consistent with the host-side
* `--browser-timeout` flag).
*/
pageNavigationTimeoutMs?: number;
}
/**
@@ -79,6 +86,11 @@ export function resolveDockerPlatform(
return arch === "arm64" ? "linux/arm64" : "linux/amd64";
}
// Pure argv builder — the cognitive count tracks the number of optional CLI
// flags it forwards, not branching depth. Each conditional spread is one
// option = O(1) to read. Inherited from main (#1196 added platform handling);
// this PR added one more conditional for --browser-timeout.
// fallow-ignore-next-line complexity
export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
const { imageTag, projectDir, outputDir, outputFilename, options } = input;
const platform = input.platform ?? resolveDockerPlatform();
@@ -118,5 +130,8 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
...(options.entryFile ? ["--composition", options.entryFile] : []),
...(options.outputResolution ? ["--resolution", options.outputResolution] : []),
...(options.pageSideCompositing === false ? ["--no-page-side-compositing"] : []),
...(options.pageNavigationTimeoutMs != null
? ["--browser-timeout", String(options.pageNavigationTimeoutMs / 1000)]
: []),
];
}
+173
View File
@@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import type { Stats } from "node:fs";
import { resolve } from "node:path";
import {
MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS,
parseBrowserTimeoutMsArg,
parseCompositionEntryArg,
type BrowserTimeoutParseResult,
type CompositionEntryParseResult,
} from "./renderArgs.js";
function expectBrowserTimeoutErr(result: BrowserTimeoutParseResult): { kind: string; raw: string } {
if (result.ok) throw new Error(`expected error, got value=${result.value}`);
return result.error;
}
function expectCompositionErr(result: CompositionEntryParseResult): {
kind: string;
entryFile: string;
} {
if (result.ok) throw new Error(`expected error, got value=${result.value}`);
return result.error;
}
/** Build a fake `Stats` for the in-memory stat adapter. */
function fakeStats(kind: "file" | "directory"): Stats {
return {
isFile: () => kind === "file",
isDirectory: () => kind === "directory",
} as Stats;
}
/**
* Build a stat adapter from a path → kind map. Throws ENOENT for any
* path not in the map, matching `statSync` behaviour.
*/
function makeStat(entries: Record<string, "file" | "directory">): (path: string) => Stats {
return (path) => {
const kind = entries[path];
if (!kind) {
const err = new Error(`ENOENT: no such file or directory, stat '${path}'`);
(err as NodeJS.ErrnoException).code = "ENOENT";
throw err;
}
return fakeStats(kind);
};
}
describe("parseBrowserTimeoutMsArg", () => {
it("returns undefined when the flag is absent", () => {
expect(parseBrowserTimeoutMsArg(undefined)).toEqual({ ok: true, value: undefined });
});
it("converts whole seconds to milliseconds", () => {
expect(parseBrowserTimeoutMsArg("180")).toEqual({ ok: true, value: 180_000 });
});
it("accepts fractional seconds and rounds to integer ms", () => {
expect(parseBrowserTimeoutMsArg("90.5")).toEqual({ ok: true, value: 90_500 });
expect(parseBrowserTimeoutMsArg("0.001")).toEqual({ ok: true, value: 1 });
});
it("rejects non-numeric input", () => {
const err = expectBrowserTimeoutErr(parseBrowserTimeoutMsArg("abc"));
expect(err.kind).toBe("not-a-number");
});
it("rejects Infinity and NaN", () => {
expect(expectBrowserTimeoutErr(parseBrowserTimeoutMsArg("Infinity")).kind).toBe("not-a-number");
expect(expectBrowserTimeoutErr(parseBrowserTimeoutMsArg("NaN")).kind).toBe("not-a-number");
});
it("rejects zero and negative values", () => {
expect(expectBrowserTimeoutErr(parseBrowserTimeoutMsArg("0")).kind).toBe("not-positive");
expect(expectBrowserTimeoutErr(parseBrowserTimeoutMsArg("-5")).kind).toBe("not-positive");
expect(expectBrowserTimeoutErr(parseBrowserTimeoutMsArg("")).kind).toBe("not-positive");
});
it("rejects sub-millisecond inputs that would round to timeout: 0 (Puppeteer 'no timeout' sentinel)", () => {
// Regression for issue #1199 follow-up: --browser-timeout 0.0004 passes
// > 0 in seconds but Math.round(0.4) = 0 ms, which Puppeteer interprets
// as "wait forever". The validator must reject before the multiply.
expect(expectBrowserTimeoutErr(parseBrowserTimeoutMsArg("0.0004")).kind).toBe("too-small");
});
it("rejects values above the 24h cap to prevent setTimeout overflow", () => {
expect(expectBrowserTimeoutErr(parseBrowserTimeoutMsArg("1e10")).kind).toBe("too-large");
expect(
expectBrowserTimeoutErr(
parseBrowserTimeoutMsArg(String(MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS + 1)),
).kind,
).toBe("too-large");
});
it("accepts values right at the 24h cap", () => {
expect(parseBrowserTimeoutMsArg(String(MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS))).toEqual({
ok: true,
value: MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS * 1000,
});
});
});
describe("parseCompositionEntryArg", () => {
const PROJECT = "/proj";
const stat = makeStat({
[resolve(PROJECT, "index.html")]: "file",
[resolve(PROJECT, "compositions/intro.html")]: "file",
[resolve(PROJECT, "compositions")]: "directory",
[PROJECT]: "directory",
});
it("returns undefined when the flag is absent", () => {
expect(parseCompositionEntryArg(undefined, PROJECT, stat)).toEqual({
ok: true,
value: undefined,
});
});
it("normalizes '.' to undefined so the producer falls back to index.html (issue #1199)", () => {
expect(parseCompositionEntryArg(".", PROJECT, stat)).toEqual({ ok: true, value: undefined });
});
it("normalizes './' to undefined", () => {
expect(parseCompositionEntryArg("./", PROJECT, stat)).toEqual({ ok: true, value: undefined });
});
it("normalizes empty / whitespace-only to undefined", () => {
expect(parseCompositionEntryArg("", PROJECT, stat)).toEqual({ ok: true, value: undefined });
expect(parseCompositionEntryArg(" ", PROJECT, stat)).toEqual({ ok: true, value: undefined });
});
it("passes through a valid .html file path", () => {
expect(parseCompositionEntryArg("compositions/intro.html", PROJECT, stat)).toEqual({
ok: true,
value: "compositions/intro.html",
});
});
it("strips a leading ./ before resolution", () => {
expect(parseCompositionEntryArg("./compositions/intro.html", PROJECT, stat)).toEqual({
ok: true,
value: "compositions/intro.html",
});
});
it("rejects a directory path with 'not-a-file' (the EISDIR cause)", () => {
const err = expectCompositionErr(parseCompositionEntryArg("compositions", PROJECT, stat));
expect(err).toEqual({ kind: "not-a-file", entryFile: "compositions" });
});
it("rejects a non-existent file with 'not-found'", () => {
const err = expectCompositionErr(parseCompositionEntryArg("missing.html", PROJECT, stat));
expect(err).toEqual({ kind: "not-found", entryFile: "missing.html" });
});
it("rejects a path that escapes the project directory", () => {
const err = expectCompositionErr(parseCompositionEntryArg("../escape.html", PROJECT, stat));
expect(err.kind).toBe("outside-project");
});
it("rejects a sibling-prefix path (trailing-separator guard)", () => {
// Without the trailing-separator guard, `/proj-evil/x.html`.startsWith('/proj')
// returns true and the sibling-directory escape passes validation.
const siblingStat = makeStat({
"/proj-evil/x.html": "file",
[PROJECT]: "directory",
});
const err = expectCompositionErr(
parseCompositionEntryArg("../proj-evil/x.html", PROJECT, siblingStat),
);
expect(err.kind).toBe("outside-project");
});
});
+221
View File
@@ -0,0 +1,221 @@
/**
* Pure parsers for `hyperframes render` argv that aren't already shared
* (fps, quality, format, variables live elsewhere). Lives separately so
* the validation branches are unit-testable without `process.exit` — the
* side-effecting wrappers (`resolve*`) own the `errorBox + exit(1)` UI.
*
* Issue #1199 motivated the extraction: the original inline validators
* in `render.ts` were untestable and re-introduced the EISDIR / silent
* `timeout: 0` footguns at the rate of "one per missing branch".
*/
import { resolve, sep } from "node:path";
import { type Stats } from "node:fs";
import { errorBox } from "../ui/format.js";
// ── --browser-timeout ──────────────────────────────────────────────────
/**
* Lower bound on `pageNavigationTimeout` after the seconds→ms multiply.
* Puppeteer treats `page.goto({ timeout: 0 })` as "no timeout / wait
* forever", so a positive-looking input like `--browser-timeout 0.0004`
* (rounds to 0 ms) must NOT silently flip the semantics. 1 ms is the
* smallest value that survives `Math.round` without becoming the
* disabled sentinel.
*/
const MIN_PAGE_NAVIGATION_TIMEOUT_MS = 1;
/**
* Upper bound on `--browser-timeout` in seconds. Above ~24 days Node's
* `setTimeout` overflows TIMEOUT_MAX (`2^31 - 1` ms ≈ 24.8 days) and
* fires immediately, which is the opposite of "long timeout." Cap at
* 24h so a typo (`1e10` for `1e1`) errors out instead of silently
* disabling the budget.
*/
export const MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS = 86_400;
export type BrowserTimeoutParseError =
| { kind: "not-a-number"; raw: string }
| { kind: "not-positive"; raw: string }
| { kind: "too-small"; raw: string }
| { kind: "too-large"; raw: string };
export type BrowserTimeoutParseResult =
| { ok: true; value: number | undefined }
| { ok: false; error: BrowserTimeoutParseError };
/**
* Parse and validate `--browser-timeout <seconds>` into milliseconds.
* Returns `{ ok: true, value: undefined }` when the flag is absent so
* callers can spread the result without clobbering the engine default.
*/
export function parseBrowserTimeoutMsArg(raw: string | undefined): BrowserTimeoutParseResult {
if (raw == null) return { ok: true, value: undefined };
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
return { ok: false, error: { kind: "not-a-number", raw } };
}
if (parsed <= 0) {
return { ok: false, error: { kind: "not-positive", raw } };
}
if (parsed > MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS) {
return { ok: false, error: { kind: "too-large", raw } };
}
const ms = Math.round(parsed * 1000);
if (ms < MIN_PAGE_NAVIGATION_TIMEOUT_MS) {
// Sub-millisecond inputs (e.g. 0.0004 s) round to 0 ms, which
// Puppeteer treats as "no timeout" — the opposite of the user's
// intent. Reject explicitly.
return { ok: false, error: { kind: "too-small", raw } };
}
return { ok: true, value: ms };
}
function browserTimeoutErrorMessage(error: BrowserTimeoutParseError): {
title: string;
message: string;
hint?: string;
} {
const title = "Invalid browser-timeout";
switch (error.kind) {
case "not-a-number":
return {
title,
message: `Got "${error.raw}", which is not a number. Pass a positive number of seconds (e.g. 180).`,
};
case "not-positive":
return {
title,
message: `Got "${error.raw}" seconds, which is not positive. Pass a positive number of seconds (e.g. 180).`,
};
case "too-small":
return {
title,
message: `Got "${error.raw}" seconds, which rounds to 0 ms. Puppeteer treats 0 as 'no timeout' — pass a value that rounds to at least 1 ms.`,
};
case "too-large":
return {
title,
message: `Got "${error.raw}" seconds, which exceeds the ${MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS}s (24h) cap. Node's setTimeout overflows for larger values.`,
};
}
}
/**
* Side-effecting wrapper around `parseBrowserTimeoutMsArg`. Exits the
* process with a friendly error box on validation failure.
*/
export function resolveBrowserTimeoutMsArg(raw: string | undefined): number | undefined {
const result = parseBrowserTimeoutMsArg(raw);
if (!result.ok) {
const { title, message, hint } = browserTimeoutErrorMessage(result.error);
errorBox(title, message, hint);
process.exit(1);
}
return result.value;
}
// ── --composition ──────────────────────────────────────────────────────
export type CompositionEntryParseError =
| { kind: "outside-project"; entryFile: string }
| { kind: "not-found"; entryFile: string }
| { kind: "not-a-file"; entryFile: string };
export type CompositionEntryParseResult =
| { ok: true; value: string | undefined }
| { ok: false; error: CompositionEntryParseError };
/**
* Parse and validate `--composition <path>` into a project-relative
* entry file (or `undefined` for the index.html default).
*
* - `undefined` / `""` / `"."` / `"./"` → undefined (defaults to
* index.html). Issue #1199: the prior code threaded `.` straight
* through and the producer's `readFileSync` blew up with
* `EISDIR: illegal operation on a directory, read`.
* - Other strings are resolved against `projectDir`, checked for
* containment, existence, and isFile() via the injected `stat`
* adapter. The adapter shape lets unit tests inject fixtures
* without touching the filesystem.
*/
export function parseCompositionEntryArg(
raw: string | undefined,
projectDir: string,
stat: (path: string) => Stats,
): CompositionEntryParseResult {
const trimmed = raw?.trim().replace(/^\.\//, "") || undefined;
// Normalize the project-root shorthands to "no entry override" so the
// producer falls back to index.html instead of statSync-ing the dir
// and later blowing up with EISDIR inside readFileSync().
if (!trimmed || trimmed === ".") return { ok: true, value: undefined };
const absProjectDir = resolve(projectDir);
const entryPath = resolve(absProjectDir, trimmed);
// Trailing-separator guard: `startsWith` alone treats `/proj` as a
// prefix of `/proj-evil`, letting a sibling-directory escape through.
// Allow the resolved path to BE the project dir (already covered by
// the trimmed === "." branch above) or to live beneath it with a
// path separator.
if (entryPath !== absProjectDir && !entryPath.startsWith(absProjectDir + sep)) {
return { ok: false, error: { kind: "outside-project", entryFile: trimmed } };
}
let entryStat: Stats;
try {
entryStat = stat(entryPath);
} catch {
return { ok: false, error: { kind: "not-found", entryFile: trimmed } };
}
if (!entryStat.isFile()) {
// Directory paths slip past existsSync downstream and explode with
// `EISDIR: illegal operation on a directory, read` inside the
// producer's readFileSync. Reject here with an actionable message.
return { ok: false, error: { kind: "not-a-file", entryFile: trimmed } };
}
return { ok: true, value: trimmed };
}
function compositionEntryErrorMessage(error: CompositionEntryParseError): {
title: string;
message: string;
hint?: string;
} {
switch (error.kind) {
case "outside-project":
return {
title: "Invalid composition path",
message: `Entry file must stay inside the project directory: ${error.entryFile}`,
};
case "not-found":
return {
title: "Composition not found",
message: `"${error.entryFile}" does not exist in the project directory.`,
hint: "Pass a path to a .html file relative to the project root (e.g. compositions/intro.html).",
};
case "not-a-file":
return {
title: "Invalid composition path",
message: `"${error.entryFile}" is a directory, not an .html file.`,
hint: "Pass a path to a .html file (e.g. compositions/intro.html), or omit --composition to render index.html.",
};
}
}
/**
* Side-effecting wrapper around `parseCompositionEntryArg`. Exits the
* process with a friendly error box on validation failure.
*/
export function resolveCompositionEntryArg(
raw: string | undefined,
projectDir: string,
stat: (path: string) => Stats,
): string | undefined {
const result = parseCompositionEntryArg(raw, projectDir, stat);
if (!result.ok) {
const { title, message, hint } = compositionEntryErrorMessage(result.error);
errorBox(title, message, hint);
process.exit(1);
}
return result.value;
}
+15
View File
@@ -131,6 +131,16 @@ export interface EngineConfig {
// ── Timeouts ─────────────────────────────────────────────────────────
playerReadyTimeout: number;
renderReadyTimeout: number;
/**
* Puppeteer `page.goto()` navigation timeout for the entry HTML, in ms.
* The browser must reach `domcontentloaded` within this budget — heavy
* compositions (many videos, large fonts, hundreds of asset requests)
* can blow past the default 60s on cold cache. Default: 60_000.
*
* Env fallback: `PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS`.
* CLI flag: `--browser-timeout <seconds>`.
*/
pageNavigationTimeout: number;
// ── Runtime ──────────────────────────────────────────────────────────
/** Verify Hyperframe runtime SHA256 checksums. */
@@ -204,6 +214,7 @@ export const DEFAULT_CONFIG: EngineConfig = {
playerReadyTimeout: 45_000,
renderReadyTimeout: 15_000,
pageNavigationTimeout: 60_000,
verifyRuntime: true,
@@ -333,6 +344,10 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
"PRODUCER_RENDER_READY_TIMEOUT_MS",
DEFAULT_CONFIG.renderReadyTimeout,
),
pageNavigationTimeout: envNum(
"PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS",
DEFAULT_CONFIG.pageNavigationTimeout,
),
verifyRuntime: env("PRODUCER_VERIFY_HYPERFRAME_RUNTIME") !== "false",
runtimeManifestPath: env("PRODUCER_HYPERFRAME_MANIFEST_PATH"),
+4 -2
View File
@@ -746,9 +746,11 @@ export async function initializeSession(session: CaptureSession): Promise<void>
// Navigate to the file server
const url = `${serverUrl}/index.html`;
const pageNavigationTimeout =
session.config?.pageNavigationTimeout ?? DEFAULT_CONFIG.pageNavigationTimeout;
if (session.captureMode === "screenshot") {
// Screenshot mode: standard navigation, rAF works normally
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
await page.goto(url, { waitUntil: "domcontentloaded", timeout: pageNavigationTimeout });
const pageReadyTimeout =
session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
@@ -878,7 +880,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
})();
warmupLoopPromise.catch(() => {});
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
await page.goto(url, { waitUntil: "domcontentloaded", timeout: pageNavigationTimeout });
// Poll for window.__hf readiness using manual evaluate loop (waitForFunction
// uses rAF polling internally, which won't fire in beginFrame mode).