diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 18193617d..435a20edd 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -918,6 +918,62 @@ hyperframes lambda render ./my-project \ `--json` swaps the human-readable output for a machine-parseable JSON snapshot. +The composition can be parameterised with `--variables` / `--variables-file`, mirroring the local `hyperframes render` flags. Variables flow into the Step Functions execution input and reach every chunk worker as `window.__hfVariables`. Mismatches against the composition's `data-composition-variables` declaration print as warnings; pass `--strict-variables` to fail the command instead. + +```bash +hyperframes lambda render ./my-template --site-id=abc1234deadbeef0 \ + --width=1920 --height=1080 \ + --variables '{"title":"Hello Alice","accent":"#ff0000"}' +``` + +```bash +hyperframes lambda render ./my-template --site-id=abc1234deadbeef0 \ + --width=1920 --height=1080 \ + --variables-file ./alice.json --strict-variables +``` + +Variables travel inside the Step Functions Standard execution input, which AWS caps at 256 KiB for the entire payload. Pass typed data (strings, numbers, structured records) through variables; URL-reference media assets (images, audio, video) the composition resolves at render time rather than inlining bytes. The SDK validates the size client-side and rejects oversize inputs with a clear error before any AWS call runs — see the [templates-on-lambda guide](/deploy/templates-on-lambda) for the URL-your-assets convention. + +#### `lambda render-batch ` + +Fans out N personalised renders from a JSONL batch file — the headline ergonomic for automated template-rendering pipelines. Deploys the site once (or skips with `--site-id`), then invokes `renderToLambda` per batch row with per-entry `variables` and `outputKey`. Concurrent Step Functions starts are capped at `--max-concurrent` (default 50) so a 10 000-entry batch doesn't try to spawn 10 000 executions at once and trip AWS account limits. + +Batch file format (JSONL — one JSON object per line): + +```jsonl +{"outputKey": "renders/alice.mp4", "variables": {"name": "Alice", "accent": "#ff0000"}} +{"outputKey": "renders/bob.mp4", "variables": {"name": "Bob", "accent": "#0000ff"}} +{"outputKey": "renders/carol.mp4", "variables": {"name": "Carol"}, "executionName": "hf-carol-001"} +``` + +```bash +hyperframes lambda render-batch ./my-template \ + --batch ./users.jsonl \ + --width 1920 --height 1080 \ + --max-concurrent 10 +``` + +The verb prints a manifest — one row per input line — with `executionArn` + status: + +``` +Batch dispatched: 3 started, 0 failed-to-start. + + ✓ line 1 renders/alice.mp4 arn:aws:states:us-east-1:1234:execution:hf:hf-render-... + ✓ line 2 renders/bob.mp4 arn:aws:states:us-east-1:1234:execution:hf:hf-render-... + ✓ line 3 renders/carol.mp4 arn:aws:states:us-east-1:1234:execution:hf:hf-carol-001 +``` + +Pass `--json` for the machine-readable form. Poll each execution via `hyperframes lambda progress ` (or use the returned `executionArn`). + +`--dry-run` skips the AWS calls and prints the manifest with `status: "would-invoke"` for every entry — use it to lint the batch file before committing to N billable executions: + +```bash +hyperframes lambda render-batch ./my-template --batch ./users.jsonl \ + --width 1920 --height 1080 --dry-run --json +``` + +`--max-concurrent` is orchestrator-side only: it caps how many `StartExecution` calls run simultaneously, not how many Lambda invocations the account can run. AWS account-level Lambda concurrency limits live one level up and `render-batch` cannot enforce them; pick `--max-concurrent` based on your account's `concurrent-execution` quota and the Lambda reserved concurrency you provisioned via `lambda deploy --concurrency=`. + #### `lambda progress ` Prints one progress snapshot — overall percent, frames rendered, Lambda invocations, accrued cost, and any errors. Accepts either a bare `renderId` (resolved against the stack's state-machine ARN) or a full SFN execution ARN. diff --git a/packages/cli/src/commands/lambda.ts b/packages/cli/src/commands/lambda.ts index be1a7d192..75004fabd 100644 --- a/packages/cli/src/commands/lambda.ts +++ b/packages/cli/src/commands/lambda.ts @@ -23,6 +23,18 @@ export const examples: Example[] = [ "Render and stream progress until done", "hyperframes lambda render ./my-project --width 1920 --height 1080 --wait", ], + [ + "Render with composition variables (personalised template)", + 'hyperframes lambda render ./my-template --site-id abc1234deadbeef0 --width 1920 --height 1080 --variables \'{"title":"Hello Alice","accent":"#ff0000"}\'', + ], + [ + "Render with variables from a JSON file", + "hyperframes lambda render ./my-template --site-id abc1234deadbeef0 --width 1920 --height 1080 --variables-file ./alice.json", + ], + [ + "Batch-render N personalised videos from a JSONL file (deploys the site once)", + "hyperframes lambda render-batch ./my-template --batch ./users.jsonl --width 1920 --height 1080 --max-concurrent 10", + ], ["Check progress for a started render", "hyperframes lambda progress hf-render-abcd1234"], [ "Pre-upload a project so multiple renders share the upload", @@ -45,6 +57,7 @@ ${c.bold("SUBCOMMANDS:")} ${c.accent("deploy")} ${c.dim("Provision the Lambda + Step Functions + S3 stack via SAM")} ${c.accent("sites create")} ${c.dim("Tar + upload a project to S3 (reusable across renders)")} ${c.accent("render")} ${c.dim("Start a distributed render (returns a renderId)")} + ${c.accent("render-batch")} ${c.dim("Fan out N personalised renders from a JSONL batch file")} ${c.accent("progress")} ${c.dim("Print progress + cost for an in-flight or finished render")} ${c.accent("destroy")} ${c.dim("Tear the stack down (S3 bucket is retained)")} ${c.accent("policies")} ${c.dim("Print or validate the IAM permissions the CLI needs")} @@ -114,6 +127,42 @@ export default defineCommand({ type: "string", description: "Final output S3 key (default: renders//output.)", }, + // Variables — mirrors the local `hyperframes render` UX. Inline JSON or + // file path, plus --strict-variables for type-checked validation against + // the composition's `data-composition-variables` declaration. + variables: { + type: "string", + description: + 'JSON object of variable values for the composition. Example: --variables \'{"title":"Hello"}\'. Values flow into window.__hfVariables on the Lambda chunk workers.', + }, + "variables-file": { + type: "string", + description: + "Path to a JSON file with variable values (alternative to --variables). The file must contain a single JSON object.", + }, + "strict-variables": { + type: "boolean", + description: + "Fail the render command if any --variables key is undeclared or has a wrong type vs the composition's data-composition-variables. Without this flag, mismatches are warnings.", + default: false, + }, + // render-batch + batch: { + type: "string", + description: + 'Path to a JSONL batch file for `render-batch`. Each line: {"outputKey":"...","variables":{...}}', + }, + "max-concurrent": { + type: "string", + description: + "Max in-flight Step Functions executions for `render-batch` (default: 50). Distinct from --max-parallel-chunks (which caps chunks per render).", + }, + "dry-run": { + type: "boolean", + description: + "For `render-batch`: parse the batch file and print the manifest without invoking AWS. Every entry's status becomes `would-invoke`.", + default: false, + }, wait: { type: "boolean", description: "Block until the render finishes" }, "wait-interval-ms": { type: "string", @@ -152,7 +201,14 @@ export default defineCommand({ // dep) so the published CLI install stays small for users who don't // deploy to Lambda. Subverbs other than `policies` need aws-lambda; // catch the missing-module error here and turn it into a friendly hint. - const verbsNeedingSDK = new Set(["deploy", "sites", "render", "progress", "destroy"]); + const verbsNeedingSDK = new Set([ + "deploy", + "sites", + "render", + "render-batch", + "progress", + "destroy", + ]); if (verbsNeedingSDK.has(subcommand)) { try { await import("@hyperframes/aws-lambda/sdk"); @@ -242,12 +298,62 @@ export default defineCommand({ maxParallelChunks: parsePositiveInt(args["max-parallel-chunks"], "--max-parallel-chunks"), executionName: args["execution-name"] as string | undefined, outputKey: args["output-key"] as string | undefined, + variables: args.variables as string | undefined, + variablesFile: args["variables-file"] as string | undefined, + strictVariables: Boolean(args["strict-variables"]), json: Boolean(args.json), wait: Boolean(args.wait), waitIntervalMs: parsePositiveInt(args["wait-interval-ms"], "--wait-interval-ms") ?? 5000, }); return; } + case "render-batch": { + const projectDir = args.target as string | undefined; + if (!projectDir) { + console.error( + "[lambda render-batch] usage: hyperframes lambda render-batch --batch --width --height ", + ); + process.exit(1); + } + const batch = args.batch as string | undefined; + if (!batch) { + console.error( + "[lambda render-batch] --batch is required. Each line is a JSON object with at least { outputKey: '...' }.", + ); + process.exit(1); + } + const width = parsePositiveInt(args.width, "--width"); + const height = parsePositiveInt(args.height, "--height"); + if (width === undefined || height === undefined) { + console.error("[lambda render-batch] --width and --height are required."); + process.exit(1); + } + const fpsRaw = parseIntFlag(args.fps) ?? 30; + if (fpsRaw !== 24 && fpsRaw !== 30 && fpsRaw !== 60) { + console.error(`[lambda render-batch] --fps must be 24, 30, or 60; got ${fpsRaw}.`); + process.exit(1); + } + const { runRenderBatch } = await import("./lambda/render-batch.js"); + await runRenderBatch({ + projectDir, + stackName, + batch, + siteId: args["site-id"] as string | undefined, + fps: fpsRaw, + width, + height, + format: parseFormat(args.format), + codec: parseCodec(args.codec), + quality: parseQuality(args.quality), + chunkSize: parsePositiveInt(args["chunk-size"], "--chunk-size"), + maxParallelChunks: parsePositiveInt(args["max-parallel-chunks"], "--max-parallel-chunks"), + maxConcurrent: parsePositiveInt(args["max-concurrent"], "--max-concurrent"), + strictVariables: Boolean(args["strict-variables"]), + dryRun: Boolean(args["dry-run"]), + json: Boolean(args.json), + }); + return; + } case "progress": { const target = args.target as string | undefined; if (!target) { diff --git a/packages/cli/src/commands/lambda/render-batch.test.ts b/packages/cli/src/commands/lambda/render-batch.test.ts new file mode 100644 index 000000000..283ca0088 --- /dev/null +++ b/packages/cli/src/commands/lambda/render-batch.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseBatchFile, runWithConcurrencyLimit } from "./render-batch.js"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "hf-render-batch-")); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeBatch(content: string): string { + const p = join(tmpDir, "batch.jsonl"); + writeFileSync(p, content, "utf8"); + return p; +} + +describe("runWithConcurrencyLimit", () => { + it("preserves input order in the output array regardless of completion order", async () => { + // First input takes longest to resolve; output array still positional. + const delays = [40, 10, 20]; + const out = await runWithConcurrencyLimit(delays, 3, async (ms, i) => { + await new Promise((r) => setTimeout(r, ms)); + return `done-${i}`; + }); + expect(out).toEqual(["done-0", "done-1", "done-2"]); + }); + + it("caps simultaneous in-flight work to the limit", async () => { + let inFlight = 0; + let peak = 0; + const inputs = Array.from({ length: 12 }, (_, i) => i); + const worker = async (i: number): Promise => { + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((r) => setTimeout(r, 5)); + inFlight--; + return i; + }; + await runWithConcurrencyLimit(inputs, 3, worker); + expect(peak).toBe(3); + }); + + it("does not exceed the input length even when limit > inputs.length", async () => { + let inFlight = 0; + let peak = 0; + const inputs = [1, 2]; + await runWithConcurrencyLimit(inputs, 50, async (n) => { + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((r) => setTimeout(r, 2)); + inFlight--; + return n; + }); + // Only 2 inputs → only 2 concurrent workers, even with limit=50. + expect(peak).toBe(2); + }); + + it("rejects a limit < 1", async () => { + await expect(runWithConcurrencyLimit([1, 2], 0, async (n) => n)).rejects.toThrow( + /limit must be/, + ); + }); + + it("returns immediately for an empty input array", async () => { + const out = await runWithConcurrencyLimit([], 10, async (n: number) => n * 2); + expect(out).toEqual([]); + }); + + it("propagates the first worker rejection", async () => { + await expect( + runWithConcurrencyLimit([1, 2, 3], 2, async (n) => { + if (n === 2) throw new Error("boom"); + return n; + }), + ).rejects.toThrow(/boom/); + }); +}); + +describe("parseBatchFile", () => { + it("parses a JSONL file into ordered entries (line numbers preserve source order)", () => { + const path = writeBatch( + [ + '{"outputKey":"renders/alice.mp4","variables":{"name":"Alice"}}', + '{"outputKey":"renders/bob.mp4","variables":{"name":"Bob"},"executionName":"hf-bob-001"}', + ].join("\n") + "\n", + ); + const out = parseBatchFile(path); + expect(out).toHaveLength(2); + expect(out[0]?.entry.outputKey).toBe("renders/alice.mp4"); + expect(out[0]?.entry.variables).toEqual({ name: "Alice" }); + expect(out[0]?.lineNumber).toBe(1); + expect(out[1]?.entry.executionName).toBe("hf-bob-001"); + expect(out[1]?.lineNumber).toBe(2); + }); + + it("skips blank lines and preserves line numbers", () => { + const path = writeBatch( + ["", '{"outputKey":"renders/a.mp4"}', "", "", '{"outputKey":"renders/b.mp4"}'].join("\n") + + "\n", + ); + const out = parseBatchFile(path); + expect(out).toHaveLength(2); + expect(out[0]?.lineNumber).toBe(2); + expect(out[1]?.lineNumber).toBe(5); + }); + + // Helper: stub `process.exit` to throw a sentinel, run the parser, and + // verify it called exit(1). Dedupes the 3 error-path tests so each one + // is a single readable assertion. + function expectExitOne(content: string): void { + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("EXIT_CALLED"); + }); + try { + expect(() => parseBatchFile(writeBatch(content))).toThrow(/EXIT_CALLED/); + expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + exitSpy.mockRestore(); + } + } + + it("exits with a clear message on malformed JSON, naming the offending line", () => { + expectExitOne(['{"outputKey":"renders/a.mp4"}', "{not json"].join("\n")); + }); + + it("rejects entries missing outputKey", () => { + expectExitOne('{"variables":{"name":"Alice"}}\n'); + }); + + it("rejects variables that's not a plain object", () => { + expectExitOne('{"outputKey":"renders/a.mp4","variables":[1,2,3]}\n'); + }); +}); diff --git a/packages/cli/src/commands/lambda/render-batch.ts b/packages/cli/src/commands/lambda/render-batch.ts new file mode 100644 index 000000000..b5b99a236 --- /dev/null +++ b/packages/cli/src/commands/lambda/render-batch.ts @@ -0,0 +1,446 @@ +/** + * `hyperframes lambda render-batch --batch ` — + * fan out N personalised renders of the same project, one per JSONL line. + * + * The headline ergonomic for automated template-rendering pipelines on + * Lambda: deploy the site once (or accept a `--site-id` to skip), then + * call `renderToLambda` for each batch entry with per-entry `variables` + * and `outputKey`. Concurrent Step Functions executions are capped at + * `--max-concurrent` (default 50) via a semaphore so a 10 000-entry batch + * file doesn't try to start 10 000 executions simultaneously and trip the + * AWS account's concurrent-execution limit. + * + * Per-entry results land in a manifest: one row per input line with the + * `executionArn` + status. `--dry-run` skips the AWS calls and prints the + * manifest with `status: "would-invoke"` for each entry so callers can + * lint their batch file without paying for any executions. + * + * JSONL format (one JSON object per line): + * + * {"outputKey": "renders/alice.mp4", "variables": {"name": "Alice"}} + * {"outputKey": "renders/bob.mp4", "variables": {"name": "Bob"}} + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join, resolve as resolvePath } from "node:path"; +import type { + DistributedFormat, + SerializableDistributedRenderConfig, + SiteHandle, +} from "@hyperframes/aws-lambda/sdk"; +import { c } from "../../ui/colors.js"; +import { errorBox } from "../../ui/format.js"; +import { + loadProjectVariableSchema, + reportVariableIssues, + validateVariablesAgainstSchema, +} from "../../utils/variables.js"; +import { requireStack } from "./state.js"; + +// Dynamic-import the SDK so tsup keeps it out of the static-import head of +// the CLI bundle. See sites.ts loadSDK() for the full rationale. +async function loadSDK(): Promise { + return import("@hyperframes/aws-lambda/sdk"); +} + +/** Arguments accepted by `hyperframes lambda render-batch`. */ +export interface RenderBatchArgs { + projectDir: string; + stackName: string; + /** Path to the JSONL batch file. Each line is a {@link BatchEntry}. */ + batch: string; + /** + * Skip the project upload and re-use an existing pre-deployed site. The + * batch verb deploys the site once and reuses it across renders by + * default — this flag is for cases where the site was uploaded by a + * separate `sites create` step (CI / cross-machine). + */ + siteId?: string; + /** Composition config — fps/width/height/format required, rest optional. */ + fps: 24 | 30 | 60; + width: number; + height: number; + format: DistributedFormat; + codec?: "h264" | "h265"; + quality?: "draft" | "standard" | "high"; + chunkSize?: number; + maxParallelChunks?: number; + /** + * Maximum in-flight Step Functions starts at any moment. Caps fan-out + * so a 10 000-entry batch doesn't try to spawn 10 000 executions + * simultaneously. Defaults to 50. + * + * Distinct from `maxParallelChunks` (which caps chunks PER render). + * Lambda concurrent-execution limits live one level up at the AWS + * account level and this CLI cannot enforce those; the cap here is + * purely orchestrator-side. + */ + maxConcurrent?: number; + /** + * `--strict-variables` applies to every batch entry's pre-validation. + * Mismatches print as warnings; in strict mode the first failing entry + * aborts the run before any AWS call. + */ + strictVariables?: boolean; + /** + * Don't actually invoke `renderToLambda`. Print the manifest with + * `status: "would-invoke"` for every entry. Used to lint the batch + * file before committing to N billable executions. + */ + dryRun?: boolean; + /** Print machine-readable JSON instead of the human-friendly summary. */ + json: boolean; +} + +/** + * A single line in the JSONL batch file. Each entry produces one Step + * Functions execution with the supplied `variables` injected into the + * composition's `window.__hfVariables`. + */ +export interface BatchEntry { + /** + * Final output S3 key for this entry's render. Per-entry so the caller + * controls the output layout (e.g. `renders/users/alice.mp4`). Without + * an explicit value the SDK falls back to its + * `renders//output.` default, which makes a 100-row + * batch unreadable. + */ + outputKey: string; + /** + * Variable overrides for this entry. Merged over the composition's + * declared defaults inside the chunk worker (via + * `window.__hfVariables`). Optional — pass `{}` if a row needs the + * composition's defaults verbatim. + */ + variables?: Record; + /** + * Optional explicit Step Functions execution name. Defaults to + * `hf-render-` (generated by the SDK). Useful when the caller + * wants to correlate batch rows with downstream systems. + */ + executionName?: string; +} + +/** Single row of the manifest emitted by `render-batch`. */ +interface BatchManifestEntry { + /** 1-based index of the source JSONL line (after blank-line stripping). */ + inputLine: number; + /** Output S3 key the SDK was asked to produce. */ + outputKey: string; + /** + * SFN execution ARN, or `null` for entries that failed-to-start or were + * `--dry-run`-skipped. The latter case has `status: "would-invoke"`. + */ + executionArn: string | null; + /** Stable status discriminator the caller's manifest consumer can switch on. */ + status: "started" | "would-invoke" | "failed-to-start"; + /** Error message when `status === "failed-to-start"`. */ + error?: string; +} + +const DEFAULT_MAX_CONCURRENT = 50; + +/** + * Run the batch render. Throws on usage errors (bad CLI flags, missing + * project dir, malformed batch file); per-entry failures are captured in + * the manifest with `status: "failed-to-start"` rather than aborting the + * whole batch. + */ +// fallow-ignore-next-line complexity +export async function runRenderBatch(args: RenderBatchArgs): Promise { + const projectDir = resolvePath(args.projectDir); + const stack = requireStack(args.stackName); + + const batchPath = resolvePath(args.batch); + if (!existsSync(batchPath)) { + errorBox("Batch file not found", `No such file: ${batchPath}`); + process.exit(1); + } + const entries = parseBatchFile(batchPath); + if (entries.length === 0) { + errorBox("Empty batch", `${batchPath} contains zero entries (every line was blank).`); + process.exit(1); + } + + // Pre-validate every entry's variables against the composition's + // schema. Mismatches print as warnings; strict mode aborts before any + // AWS call. Schema is loaded once and reused across entries — a 10k-row + // batch with the per-entry parser would do 10k readFile + DOM parses. + // + // In strict mode we accumulate every failing entry first, then exit + // once with the full list. The naive "exit on first failure" pattern + // would force the caller to fix-one → re-run × N times on a batch with + // N broken rows. + const schema = loadProjectVariableSchema(join(projectDir, "index.html")); + const strict = args.strictVariables ?? false; + let hadStrictIssue = false; + for (const { entry, lineNumber } of entries) { + if (!entry.variables || Object.keys(entry.variables).length === 0) continue; + const issues = validateVariablesAgainstSchema(entry.variables, schema); + if (issues.length === 0) continue; + if (!args.json) { + console.log(""); + console.log(c.dim(`Batch entry on line ${lineNumber}:`)); + } + // Pass strict: false here so the helper just prints; we own the + // single exit-at-end below. + reportVariableIssues(issues, { strict: false, quiet: args.json }); + if (strict) hadStrictIssue = true; + } + if (hadStrictIssue) { + errorBox( + "Variable validation failed", + "Aborting batch due to variable issues in one or more entries (--strict-variables mode).", + ); + process.exit(1); + } + + const config: SerializableDistributedRenderConfig = { + fps: args.fps, + width: args.width, + height: args.height, + format: args.format, + codec: args.codec, + quality: args.quality, + chunkSize: args.chunkSize, + maxParallelChunks: args.maxParallelChunks, + runtimeCap: "lambda", + }; + + // Deploy the site once and reuse it across every entry. --site-id and + // --dry-run both skip the deploy via a synthesised handle. + let siteHandle: SiteHandle; + if (args.siteId) { + siteHandle = makePlaceholderSiteHandle(args.siteId, stack.bucketName); + } else if (args.dryRun) { + siteHandle = makePlaceholderSiteHandle("dry-run-site", stack.bucketName); + } else { + const { deploySite } = await loadSDK(); + siteHandle = await deploySite({ + projectDir, + bucketName: stack.bucketName, + region: stack.region, + }); + if (!args.json) { + console.log( + c.success( + siteHandle.uploaded + ? `Site uploaded once for the batch: ${siteHandle.siteId}` + : `Site already up to date (skipped upload): ${siteHandle.siteId}`, + ), + ); + console.log(); + } + } + + const maxConcurrent = args.maxConcurrent ?? DEFAULT_MAX_CONCURRENT; + // Skip the SDK import entirely on --dry-run; the startEntry closure + // short-circuits before touching `renderToLambda` so it can stay + // undefined. + const renderToLambda = args.dryRun ? undefined : (await loadSDK()).renderToLambda; + + const startEntry = async (item: { + entry: BatchEntry; + lineNumber: number; + }): Promise => { + const { entry, lineNumber } = item; + if (args.dryRun) { + return { + inputLine: lineNumber, + outputKey: entry.outputKey, + executionArn: null, + status: "would-invoke", + }; + } + if (!renderToLambda) { + // Unreachable: dryRun returns above; this branch is for the TS + // narrower since `renderToLambda` is undefined under dryRun. + throw new Error("[render-batch] renderToLambda is undefined outside --dry-run"); + } + try { + const handle = await renderToLambda({ + siteHandle, + bucketName: stack.bucketName, + stateMachineArn: stack.stateMachineArn, + region: stack.region, + config: { ...config, variables: entry.variables }, + executionName: entry.executionName, + outputKey: entry.outputKey, + }); + return { + inputLine: lineNumber, + outputKey: entry.outputKey, + executionArn: handle.executionArn, + status: "started", + }; + } catch (err) { + return { + inputLine: lineNumber, + outputKey: entry.outputKey, + executionArn: null, + status: "failed-to-start", + error: err instanceof Error ? err.message : String(err), + }; + } + }; + + const manifest = await runWithConcurrencyLimit(entries, maxConcurrent, startEntry); + + if (args.json) { + console.log(JSON.stringify(manifest, null, 2)); + return; + } + + const started = manifest.filter((m) => m.status === "started").length; + const failed = manifest.filter((m) => m.status === "failed-to-start").length; + const wouldInvoke = manifest.filter((m) => m.status === "would-invoke").length; + + if (args.dryRun) { + console.log(c.success(`Dry-run complete: ${wouldInvoke} entries would invoke.`)); + } else { + console.log(c.success(`Batch dispatched: ${started} started, ${failed} failed-to-start.`)); + } + console.log(); + for (const row of manifest) { + const tag = + row.status === "started" + ? c.success("✓") + : row.status === "would-invoke" + ? c.dim("·") + : c.error("✗"); + const detail = + row.status === "failed-to-start" + ? c.error(row.error ?? "unknown error") + : (row.executionArn ?? c.dim("(no execution)")); + console.log(` ${tag} line ${row.inputLine} ${c.dim(row.outputKey)} ${detail}`); + } + if (failed > 0) process.exitCode = 1; +} + +/** + * Synthesise a SiteHandle from a known siteId for paths that skip the + * `deploySite` upload (`--site-id` and `--dry-run`). The SDK reads only + * `siteId` + `projectS3Uri` when `uploaded: false`, so the byte / time + * fields are intentional placeholders. + */ +function makePlaceholderSiteHandle(siteId: string, bucketName: string): SiteHandle { + return { + siteId, + bucketName, + projectS3Uri: `s3://${bucketName}/sites/${siteId}/project.tar.gz`, + bytes: 0, + uploadedAt: "", + uploaded: false, + }; +} + +/** + * Read the JSONL batch file and return one parsed entry per non-blank + * line. Reads the whole file into memory — fine for typical batch sizes, + * an in-memory bound the caller can size around. Calls `errorBox` and + * `process.exit(1)` on the first malformed line so the caller doesn't + * have to sift through thousands of rows looking for the typo. + * + * Exported for unit-test coverage; production callers go through + * {@link runRenderBatch} which handles the file-not-found case. + */ +// fallow-ignore-next-line complexity +export function parseBatchFile(path: string): Array<{ entry: BatchEntry; lineNumber: number }> { + const raw = readFileSync(path, "utf8"); + const lines = raw.split(/\r?\n/); + const out: Array<{ entry: BatchEntry; lineNumber: number }> = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!.trim(); + if (line === "") continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (err) { + errorBox( + `Invalid JSON in batch file on line ${i + 1}`, + err instanceof Error ? err.message : String(err), + ); + process.exit(1); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + errorBox( + `Invalid batch entry on line ${i + 1}`, + 'Each line must be a JSON object with at least { "outputKey": "..." }.', + ); + process.exit(1); + } + const obj = parsed as Record; + const outputKey = obj.outputKey; + if (typeof outputKey !== "string" || outputKey.length === 0) { + errorBox( + `Missing outputKey on line ${i + 1}`, + 'Each batch entry needs a non-empty "outputKey" string (e.g. "renders/alice.mp4").', + ); + process.exit(1); + } + if (obj.variables !== undefined) { + if ( + obj.variables === null || + typeof obj.variables !== "object" || + Array.isArray(obj.variables) + ) { + errorBox( + `Invalid variables on line ${i + 1}`, + '"variables" must be a JSON object (or omitted).', + ); + process.exit(1); + } + } + if (obj.executionName !== undefined && typeof obj.executionName !== "string") { + errorBox( + `Invalid executionName on line ${i + 1}`, + '"executionName" must be a string (or omitted).', + ); + process.exit(1); + } + out.push({ + entry: { + outputKey, + variables: obj.variables as Record | undefined, + executionName: obj.executionName as string | undefined, + }, + lineNumber: i + 1, + }); + } + return out; +} + +/** + * Run `worker` against every input with at most `limit` concurrent + * invocations. Preserves input order in the returned array; each output + * is positional with its input. + * + * Implementation: index-cursor + N concurrent producers each picking the + * next index until the input is drained. Uses `Promise.all` and + * propagates the first worker rejection — partial-failure isolation is + * the caller's responsibility (in this file, `startEntry` wraps the + * per-entry render in try/catch so a single failure surfaces in the + * manifest rather than aborting the batch). + * + * Exported so unit tests can pin the concurrency cap independently of + * the AWS-dependent fan-out path. + */ +export async function runWithConcurrencyLimit( + inputs: readonly T[], + limit: number, + worker: (input: T, index: number) => Promise, +): Promise { + if (limit < 1) throw new Error(`runWithConcurrencyLimit: limit must be ≥ 1, got ${limit}`); + const results = new Array(inputs.length); + let cursor = 0; + const workerCount = Math.min(limit, inputs.length); + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (cursor < inputs.length) { + const idx = cursor++; + results[idx] = await worker(inputs[idx]!, idx); + } + }), + ); + return results; +} diff --git a/packages/cli/src/commands/lambda/render.ts b/packages/cli/src/commands/lambda/render.ts index 9531a5e9a..4b6a88721 100644 --- a/packages/cli/src/commands/lambda/render.ts +++ b/packages/cli/src/commands/lambda/render.ts @@ -4,12 +4,18 @@ * poll — use `hyperframes lambda progress` for that. */ -import { resolve as resolvePath } from "node:path"; +import { existsSync } from "node:fs"; +import { join, resolve as resolvePath } from "node:path"; import type { DistributedFormat, SerializableDistributedRenderConfig, } from "@hyperframes/aws-lambda/sdk"; import { c } from "../../ui/colors.js"; +import { + reportVariableIssues, + resolveVariablesArg, + validateVariablesAgainstProject, +} from "../../utils/variables.js"; import { requireStack, stateFilePath } from "./state.js"; // Dynamic-import the SDK so tsup keeps it out of the static-import head of @@ -33,6 +39,17 @@ export interface RenderArgs { maxParallelChunks?: number; executionName?: string; outputKey?: string; + /** Inline JSON for `--variables '{...}'`. Mutually exclusive with `variablesFile`. */ + variables?: string; + /** Path to a JSON file for `--variables-file ./vars.json`. */ + variablesFile?: string; + /** + * Fail the command if any `--variables` key is undeclared or has a wrong + * type vs the composition's `data-composition-variables`. Without this + * flag, mismatches are warnings (matches the local `hyperframes render` + * behavior). + */ + strictVariables?: boolean; /** Print machine-readable JSON instead of the human-friendly summary. */ json: boolean; /** Block until the render finishes. Polls `progress` until SUCCEEDED/FAILED. */ @@ -41,10 +58,43 @@ export interface RenderArgs { waitIntervalMs: number; } +// fallow-ignore-next-line complexity export async function runRender(args: RenderArgs): Promise { const stack = requireStack(args.stackName); const projectDir = resolvePath(args.projectDir); + // Resolve --variables / --variables-file using the same parser the local + // `hyperframes render` uses. `resolveVariablesArg` exits(1) with a friendly + // errorBox on parse errors so callers don't have to. + const variables = resolveVariablesArg(args.variables, args.variablesFile); + + // Validate against the composition's `data-composition-variables` + // declaration when present. The local CLI silently treats unreadable + // index.html as "no declarations" — mirror that. Skip validation + // entirely when the project dir is missing on disk (e.g. `--site-id` + // pointing at a pre-uploaded site that was packaged on another machine). + if (variables && Object.keys(variables).length > 0) { + const indexPath = join(projectDir, "index.html"); + if (existsSync(indexPath)) { + const issues = validateVariablesAgainstProject(indexPath, variables); + // Suppress the warning block when --json is set; stdout is reserved + // for the manifest. The strict-mode errorBox still prints to stderr + // and exits, so machine consumers still get a non-zero exit. + reportVariableIssues(issues, { strict: args.strictVariables ?? false, quiet: args.json }); + } else if (args.strictVariables && !args.json) { + // --strict-variables asks for typed checking but there's no + // index.html to check against (typical with --site-id pointing at a + // pre-uploaded site). Make that silent skip visible so the flag + // doesn't quietly become a no-op. + console.warn( + c.warn( + `--strict-variables: no ${indexPath} on disk — schema validation skipped. ` + + "Variables flow through unchecked. To enable strict checking, run from a project dir that contains the composition.", + ), + ); + } + } + const config: SerializableDistributedRenderConfig = { fps: args.fps, width: args.width, @@ -55,6 +105,7 @@ export async function runRender(args: RenderArgs): Promise { chunkSize: args.chunkSize, maxParallelChunks: args.maxParallelChunks, runtimeCap: "lambda", + variables, }; // When the caller passes only --site-id, synthesise the minimum-shape diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index 57cc05425..a6dbb2c43 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -261,143 +261,4 @@ describe("renderLocal browser GPU config", () => { }); }); -describe("parseVariablesArg", () => { - let parseVariablesArg: typeof import("./render.js").parseVariablesArg; - - beforeAll(async () => { - ({ parseVariablesArg } = await import("./render.js")); - }); - - function expectErr( - result: import("./render.js").VariablesParseResult, - ): T { - if (result.ok) throw new Error(`expected error, got ${JSON.stringify(result.value)}`); - return result.error as T; - } - - it("returns undefined when neither flag is set", () => { - expect(parseVariablesArg(undefined, undefined)).toEqual({ ok: true, value: undefined }); - }); - - it("parses inline JSON object", () => { - expect(parseVariablesArg('{"title":"Hello","n":3}', undefined)).toEqual({ - ok: true, - value: { title: "Hello", n: 3 }, - }); - }); - - it("parses file JSON via injected reader", () => { - const fakeReader = (path: string) => { - if (path === "vars.json") return '{"theme":"dark"}'; - throw new Error("unexpected path"); - }; - expect(parseVariablesArg(undefined, "vars.json", fakeReader)).toEqual({ - ok: true, - value: { theme: "dark" }, - }); - }); - - it("rejects when both flags are set", () => { - const err = expectErr(parseVariablesArg('{"a":1}', "vars.json")); - expect(err).toEqual({ kind: "conflict" }); - }); - - it("rejects unparseable JSON with a source-aware kind", () => { - expect(expectErr(parseVariablesArg("{not json", undefined))).toMatchObject({ - kind: "parse-error", - source: "inline", - }); - expect(expectErr(parseVariablesArg(undefined, "x", () => "{not json"))).toMatchObject({ - kind: "parse-error", - source: "file", - }); - }); - - it("rejects non-object payloads (array, string, null, number)", () => { - for (const payload of ["[1,2]", '"hello"', "null", "42"]) { - expect(expectErr(parseVariablesArg(payload, undefined))).toEqual({ kind: "shape-error" }); - } - }); - - it("surfaces filesystem errors from --variables-file", () => { - const err = expectErr<{ - kind: "read-error"; - path: string; - cause: string; - }>( - parseVariablesArg(undefined, "missing.json", () => { - throw new Error("ENOENT: no such file"); - }), - ); - expect(err.kind).toBe("read-error"); - expect(err.path).toBe("missing.json"); - expect(err.cause).toMatch(/ENOENT/); - }); -}); - -describe("validateVariablesAgainstProject", () => { - let validateVariablesAgainstProject: typeof import("./render.js").validateVariablesAgainstProject; - let tmpDir: string; - let mkdtempSync: typeof import("node:fs").mkdtempSync; - let writeFileSync: typeof import("node:fs").writeFileSync; - let rmSync: typeof import("node:fs").rmSync; - let join: typeof import("node:path").join; - let tmpdir: typeof import("node:os").tmpdir; - - beforeAll(async () => { - ({ validateVariablesAgainstProject } = await import("./render.js")); - ({ mkdtempSync, writeFileSync, rmSync } = await import("node:fs")); - ({ join } = await import("node:path")); - ({ tmpdir } = await import("node:os")); - }); - - beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), "hf-validate-vars-")); - }); - - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); - }); - - function writeIndex(html: string): string { - const path = join(tmpDir, "index.html"); - writeFileSync(path, html); - return path; - } - - it("returns [] when the project has no data-composition-variables declarations", () => { - const indexPath = writeIndex(`
`); - expect(validateVariablesAgainstProject(indexPath, { title: "Hello" })).toEqual([]); - }); - - it("returns [] when every value matches its declaration", () => { - const indexPath = writeIndex( - `
`, - ); - expect(validateVariablesAgainstProject(indexPath, { title: "Hello" })).toEqual([]); - }); - - it("flags undeclared keys", () => { - const indexPath = writeIndex( - `
`, - ); - expect(validateVariablesAgainstProject(indexPath, { title: "Hello", extra: 1 })).toEqual([ - { kind: "undeclared", variableId: "extra" }, - ]); - }); - - it("flags type mismatches", () => { - const indexPath = writeIndex( - `
`, - ); - expect(validateVariablesAgainstProject(indexPath, { count: "three" })).toEqual([ - { kind: "type-mismatch", variableId: "count", expected: "number", actual: "string" }, - ]); - }); - - it("returns [] when the index file cannot be read (lint owns that diagnostic)", () => { - expect( - validateVariablesAgainstProject(join(tmpDir, "missing.html"), { title: "Hello" }), - ).toEqual([]); - }); -}); +// Variables-helper tests live in `../utils/variables.test.ts`. diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 015559674..1916c0975 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -1,6 +1,11 @@ import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs"; +import { + reportVariableIssues, + resolveVariablesArg, + validateVariablesAgainstProject, +} from "../utils/variables.js"; export const examples: Example[] = [ ["Render to MP4", "hyperframes render --output output.mp4"], @@ -44,17 +49,12 @@ import { bytesToMb } from "../telemetry/system.js"; import { VERSION } from "../version.js"; import { isDevMode } from "../utils/env.js"; import { buildDockerRunArgs } from "../utils/dockerRunArgs.js"; -import { ensureDOMParser } from "../utils/dom.js"; import type { RenderJob } from "@hyperframes/producer"; import { - extractCompositionMetadata, - validateVariables, - formatVariableValidationIssue, normalizeResolutionFlag, parseFps, fpsToNumber, fpsToFfmpegArg, - type VariableValidationIssue, type CanvasResolution, type Fps, type FpsParseResult, @@ -513,27 +513,7 @@ export default defineCommand({ const strictVariables = args["strict-variables"] ?? false; if (variables && Object.keys(variables).length > 0) { const issues = validateVariablesAgainstProject(project.indexPath, variables); - if (issues.length > 0) { - if (!quiet) { - console.log(""); - console.log( - c.warn( - `Variable ${issues.length === 1 ? "issue" : "issues"} (${issues.length}) — values may not render as expected:`, - ), - ); - for (const issue of issues) { - console.log(" " + c.dim(formatVariableValidationIssue(issue))); - } - console.log(""); - } - if (strictVariables) { - console.log( - c.error(" Aborting render due to variable issues (--strict-variables mode)."), - ); - console.log(""); - process.exit(1); - } - } + reportVariableIssues(issues, { strict: strictVariables, quiet }); } // ── Render ──────────────────────────────────────────────────────────── @@ -602,144 +582,6 @@ interface RenderOptions { pageSideCompositing?: boolean; } -export type VariablesParseError = - | { kind: "conflict" } - | { kind: "read-error"; path: string; cause: string } - | { kind: "parse-error"; source: "inline" | "file"; cause: string } - | { kind: "shape-error" }; - -export type VariablesParseResult = - | { ok: true; value: Record | undefined } - | { ok: false; error: VariablesParseError }; - -/** - * Pure parser for `--variables` / `--variables-file` flag pair. Splits out - * from `resolveVariablesArg` so validation paths are unit-testable without - * triggering `process.exit`. Reports failures via a structured `kind` - * discriminant so the side-effecting wrapper owns all UI strings. - */ -export function parseVariablesArg( - inline: string | undefined, - filePath: string | undefined, - readFile: (path: string) => string = (p) => readFileSync(resolve(p), "utf8"), -): VariablesParseResult { - if (inline != null && filePath != null) { - return { ok: false, error: { kind: "conflict" } }; - } - let raw: string | undefined; - let source: "inline" | "file" | undefined; - if (inline != null) { - raw = inline; - source = "inline"; - } else if (filePath != null) { - try { - raw = readFile(filePath); - source = "file"; - } catch (error: unknown) { - return { - ok: false, - error: { - kind: "read-error", - path: filePath, - cause: error instanceof Error ? error.message : String(error), - }, - }; - } - } - if (raw == null) return { ok: true, value: undefined }; - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (error: unknown) { - return { - ok: false, - error: { - kind: "parse-error", - source: source ?? "inline", - cause: error instanceof Error ? error.message : String(error), - }, - }; - } - if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { - return { ok: false, error: { kind: "shape-error" } }; - } - return { ok: true, value: parsed as Record }; -} - -function variablesErrorMessage(error: VariablesParseError): { title: string; message: string } { - switch (error.kind) { - case "conflict": - return { - title: "Conflicting variables flags", - message: "Use either --variables or --variables-file, not both.", - }; - case "read-error": - return { - title: "Could not read --variables-file", - message: `${error.path}: ${error.cause}`, - }; - case "parse-error": - return { - title: - error.source === "file" - ? "Invalid JSON in --variables-file" - : "Invalid JSON in --variables", - message: error.cause, - }; - case "shape-error": - return { - title: "Invalid variables payload", - message: 'Variables must be a JSON object (e.g. {"title":"Hello"}).', - }; - } -} - -/** - * Resolve `--variables` / `--variables-file` into a plain object, or - * `undefined` when neither flag is set. Exits the process with a friendly - * error box on any validation failure. - */ -export function resolveVariablesArg( - inline: string | undefined, - filePath: string | undefined, -): Record | undefined { - const result = parseVariablesArg(inline, filePath); - if (!result.ok) { - const { title, message } = variablesErrorMessage(result.error); - errorBox(title, message); - process.exit(1); - } - return result.value; -} - -/** - * Validate `--variables` values against the project's top-level - * `data-composition-variables` declarations. Returns an empty array when - * the index has no declarations or when every key is declared with a - * matching type. Errors reading the index are silently treated as "no - * declarations" — the lint pass owns malformed-HTML diagnostics, render - * shouldn't fail just because the schema is unreadable. - */ -export function validateVariablesAgainstProject( - indexPath: string, - values: Record, -): VariableValidationIssue[] { - let html: string; - try { - html = readFileSync(indexPath, "utf8"); - } catch { - return []; - } - // extractCompositionMetadata uses DOMParser, which Node doesn't ship. - // Same pattern as `compositions.ts` and other CLI commands that touch - // @hyperframes/core's HTML parsers. - ensureDOMParser(); - const meta = extractCompositionMetadata(html); - if (meta.variables.length === 0) return []; - return validateVariables(values, meta.variables); -} - /** * Resolve the browser-GPU mode for a CLI render invocation. * diff --git a/packages/cli/src/utils/variables.test.ts b/packages/cli/src/utils/variables.test.ts new file mode 100644 index 000000000..5961fd348 --- /dev/null +++ b/packages/cli/src/utils/variables.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("parseVariablesArg", () => { + let parseVariablesArg: typeof import("./variables.js").parseVariablesArg; + + beforeAll(async () => { + ({ parseVariablesArg } = await import("./variables.js")); + }); + + function expectErr( + result: import("./variables.js").VariablesParseResult, + ): T { + if (result.ok) throw new Error(`expected error, got ${JSON.stringify(result.value)}`); + return result.error as T; + } + + it("returns undefined when neither flag is set", () => { + expect(parseVariablesArg(undefined, undefined)).toEqual({ ok: true, value: undefined }); + }); + + it("parses inline JSON object", () => { + expect(parseVariablesArg('{"title":"Hello","n":3}', undefined)).toEqual({ + ok: true, + value: { title: "Hello", n: 3 }, + }); + }); + + it("parses file JSON via injected reader", () => { + const fakeReader = (path: string) => { + if (path === "vars.json") return '{"theme":"dark"}'; + throw new Error("unexpected path"); + }; + expect(parseVariablesArg(undefined, "vars.json", fakeReader)).toEqual({ + ok: true, + value: { theme: "dark" }, + }); + }); + + it("rejects when both flags are set", () => { + const err = expectErr(parseVariablesArg('{"a":1}', "vars.json")); + expect(err).toEqual({ kind: "conflict" }); + }); + + it("rejects unparseable JSON with a source-aware kind", () => { + expect(expectErr(parseVariablesArg("{not json", undefined))).toMatchObject({ + kind: "parse-error", + source: "inline", + }); + expect(expectErr(parseVariablesArg(undefined, "x", () => "{not json"))).toMatchObject({ + kind: "parse-error", + source: "file", + }); + }); + + it("rejects non-object payloads (array, string, null, number)", () => { + for (const payload of ["[1,2]", '"hello"', "null", "42"]) { + expect(expectErr(parseVariablesArg(payload, undefined))).toEqual({ kind: "shape-error" }); + } + }); + + it("surfaces filesystem errors from --variables-file", () => { + const err = expectErr<{ + kind: "read-error"; + path: string; + cause: string; + }>( + parseVariablesArg(undefined, "missing.json", () => { + throw new Error("ENOENT: no such file"); + }), + ); + expect(err.kind).toBe("read-error"); + expect(err.path).toBe("missing.json"); + expect(err.cause).toMatch(/ENOENT/); + }); +}); + +describe("validateVariablesAgainstProject", () => { + let validateVariablesAgainstProject: typeof import("./variables.js").validateVariablesAgainstProject; + let tmpDir: string; + let mkdtempSync: typeof import("node:fs").mkdtempSync; + let writeFileSync: typeof import("node:fs").writeFileSync; + let rmSync: typeof import("node:fs").rmSync; + let join: typeof import("node:path").join; + let tmpdir: typeof import("node:os").tmpdir; + + beforeAll(async () => { + ({ validateVariablesAgainstProject } = await import("./variables.js")); + ({ mkdtempSync, writeFileSync, rmSync } = await import("node:fs")); + ({ join } = await import("node:path")); + ({ tmpdir } = await import("node:os")); + }); + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "hf-validate-vars-")); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeIndex(html: string): string { + const path = join(tmpDir, "index.html"); + writeFileSync(path, html); + return path; + } + + it("returns [] when the project has no data-composition-variables declarations", () => { + const indexPath = writeIndex(`
`); + expect(validateVariablesAgainstProject(indexPath, { title: "Hello" })).toEqual([]); + }); + + it("returns [] when every value matches its declaration", () => { + const indexPath = writeIndex( + `
`, + ); + expect(validateVariablesAgainstProject(indexPath, { title: "Hello" })).toEqual([]); + }); + + it("flags undeclared keys", () => { + const indexPath = writeIndex( + `
`, + ); + expect(validateVariablesAgainstProject(indexPath, { title: "Hello", extra: 1 })).toEqual([ + { kind: "undeclared", variableId: "extra" }, + ]); + }); + + it("flags type mismatches", () => { + const indexPath = writeIndex( + `
`, + ); + expect(validateVariablesAgainstProject(indexPath, { count: "three" })).toEqual([ + { kind: "type-mismatch", variableId: "count", expected: "number", actual: "string" }, + ]); + }); + + it("returns [] when the index file cannot be read (lint owns that diagnostic)", () => { + expect( + validateVariablesAgainstProject(join(tmpDir, "missing.html"), { title: "Hello" }), + ).toEqual([]); + }); +}); diff --git a/packages/cli/src/utils/variables.ts b/packages/cli/src/utils/variables.ts new file mode 100644 index 000000000..c9dcbb00d --- /dev/null +++ b/packages/cli/src/utils/variables.ts @@ -0,0 +1,232 @@ +/** + * Shared `--variables` / `--variables-file` / `--strict-variables` parsing + * and validation helpers used by both `hyperframes render` (in-process) and + * `hyperframes lambda render` (distributed). The Lambda CLI mirrors the + * local UX exactly — same flag names, same parse-error messages, same + * strict-mode behavior — so users who learned the local flow can drive + * Lambda renders without re-learning the surface. + * + * Side-effecting wrappers (`resolveVariablesArg`) call `process.exit(1)` + * on validation failure after rendering an `errorBox`; the pure parsers + * (`parseVariablesArg`) return a discriminated result so unit tests can + * exercise the validation paths without process termination. + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + extractCompositionMetadata, + formatVariableValidationIssue, + validateVariables, + type VariableValidationIssue, +} from "@hyperframes/core"; +import { ensureDOMParser } from "./dom.js"; +import { c } from "../ui/colors.js"; +import { errorBox } from "../ui/format.js"; + +export type VariablesParseError = + | { kind: "conflict" } + | { kind: "read-error"; path: string; cause: string } + | { kind: "parse-error"; source: "inline" | "file"; cause: string } + | { kind: "shape-error" }; + +export type VariablesParseResult = + | { ok: true; value: Record | undefined } + | { ok: false; error: VariablesParseError }; + +/** + * Pure parser for the `--variables` / `--variables-file` flag pair. Splits + * out from `resolveVariablesArg` so validation paths are unit-testable + * without triggering `process.exit`. Reports failures via a structured + * `kind` discriminant so the side-effecting wrapper owns all UI strings. + */ +// Exported for tests in `./variables.test.ts`; not consumed outside the +// package. Suppressed so fallow's unused-exports audit doesn't flag a +// type-discriminated parser whose value is exactly testability. +// fallow-ignore-next-line unused-export complexity +export function parseVariablesArg( + inline: string | undefined, + filePath: string | undefined, + readFile: (path: string) => string = (p) => readFileSync(resolve(p), "utf8"), +): VariablesParseResult { + if (inline != null && filePath != null) { + return { ok: false, error: { kind: "conflict" } }; + } + let raw: string | undefined; + let source: "inline" | "file" | undefined; + if (inline != null) { + raw = inline; + source = "inline"; + } else if (filePath != null) { + try { + raw = readFile(filePath); + source = "file"; + } catch (error: unknown) { + return { + ok: false, + error: { + kind: "read-error", + path: filePath, + cause: error instanceof Error ? error.message : String(error), + }, + }; + } + } + if (raw == null) return { ok: true, value: undefined }; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error: unknown) { + return { + ok: false, + error: { + kind: "parse-error", + source: source ?? "inline", + cause: error instanceof Error ? error.message : String(error), + }, + }; + } + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { ok: false, error: { kind: "shape-error" } }; + } + return { ok: true, value: parsed as Record }; +} + +function variablesErrorMessage(error: VariablesParseError): { title: string; message: string } { + switch (error.kind) { + case "conflict": + return { + title: "Conflicting variables flags", + message: "Use either --variables or --variables-file, not both.", + }; + case "read-error": + return { + title: "Could not read --variables-file", + message: `${error.path}: ${error.cause}`, + }; + case "parse-error": + return { + title: + error.source === "file" + ? "Invalid JSON in --variables-file" + : "Invalid JSON in --variables", + message: error.cause, + }; + case "shape-error": + return { + title: "Invalid variables payload", + message: 'Variables must be a JSON object (e.g. {"title":"Hello"}).', + }; + } +} + +/** + * Resolve `--variables` / `--variables-file` into a plain object, or + * `undefined` when neither flag is set. Exits the process with a friendly + * error box on any validation failure. + */ +export function resolveVariablesArg( + inline: string | undefined, + filePath: string | undefined, +): Record | undefined { + const result = parseVariablesArg(inline, filePath); + if (!result.ok) { + const { title, message } = variablesErrorMessage(result.error); + errorBox(title, message); + process.exit(1); + } + return result.value; +} + +/** + * Validate `--variables` values against the project's top-level + * `data-composition-variables` declarations. Returns an empty array when + * the index has no declarations or when every key is declared with a + * matching type. Errors reading the index are silently treated as "no + * declarations" — the lint pass owns malformed-HTML diagnostics, render + * shouldn't fail just because the schema is unreadable. + * + * One-shot variant: parses the index every call. Batch callers that + * pre-validate N entries against the same project should reuse + * {@link loadProjectVariableSchema} + {@link validateVariablesAgainstSchema} + * to amortise the read + DOM parse. + */ +export function validateVariablesAgainstProject( + indexPath: string, + values: Record, +): VariableValidationIssue[] { + const schema = loadProjectVariableSchema(indexPath); + return validateVariablesAgainstSchema(values, schema); +} + +/** Cached schema returned by {@link loadProjectVariableSchema}. */ +export type ProjectVariableSchema = ReturnType["variables"]; + +/** + * Read + parse the composition's `data-composition-variables` declaration + * once. Returns an empty array on missing/unreadable index — the lint + * pass owns malformed-HTML diagnostics. + * + * Batch callers pair this with {@link validateVariablesAgainstSchema} to + * avoid the per-entry file read + DOMParser cost. + */ +export function loadProjectVariableSchema(indexPath: string): ProjectVariableSchema { + let html: string; + try { + html = readFileSync(indexPath, "utf8"); + } catch { + return []; + } + // extractCompositionMetadata uses DOMParser, which Node doesn't ship. + // Same pattern as `compositions.ts` and other CLI commands that touch + // @hyperframes/core's HTML parsers. + ensureDOMParser(); + return extractCompositionMetadata(html).variables; +} + +/** + * Validate `values` against a pre-loaded schema. Empty schema means the + * project didn't declare variables — return no issues. + */ +export function validateVariablesAgainstSchema( + values: Record, + schema: ProjectVariableSchema, +): VariableValidationIssue[] { + if (schema.length === 0) return []; + return validateVariables(values, schema); +} + +/** + * Print a uniform warning block for variable validation issues; in + * `strict` mode, render an errorBox and exit(1). Used by both + * `hyperframes render` and `hyperframes lambda render` so the UX is + * identical across the two surfaces. Pass `quiet: true` to suppress the + * warning block (the errorBox in strict mode still prints). + */ +export function reportVariableIssues( + issues: readonly VariableValidationIssue[], + options: { strict: boolean; quiet?: boolean }, +): void { + if (issues.length === 0) return; + const { strict, quiet } = options; + if (!quiet) { + console.log(""); + console.log( + c.warn( + `Variable ${issues.length === 1 ? "issue" : "issues"} (${issues.length}) — values may not render as expected:`, + ), + ); + for (const issue of issues) { + console.log(" " + c.dim(formatVariableValidationIssue(issue))); + } + console.log(""); + } + if (strict) { + errorBox( + "Variable validation failed", + "Aborting render due to variable issues (--strict-variables mode).", + ); + process.exit(1); + } +}