From 902e094ebddcb39745a4a02e60e207343cf72cff Mon Sep 17 00:00:00 2001 From: James Date: Sat, 16 May 2026 23:50:21 +0000 Subject: [PATCH 1/5] feat(cli): add hyperframes lambda deploy/render/progress/destroy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the @hyperframes/aws-lambda SDK + the Phase 6a SAM template behind a single CLI surface so an end-to-end render is three commands instead of the ~8 manual bun+sam+aws steps the smoke script does today: hyperframes lambda deploy hyperframes lambda render ./my-project --width 1920 --height 1080 --wait hyperframes lambda destroy Subcommands: - deploy: build handler.zip + sam-deploy + persist stack outputs to /.hyperframes/lambda-stack-.json - sites create: pre-upload a project to S3 with a stable content hash so re-renders skip the tar+PUT pass - render: start a Step Functions execution; --wait blocks and streams per-chunk progress + accrued cost - progress: one-shot snapshot — status, frames, cost breakdown, errors. Accepts renderId or executionArn - destroy: sam-delete + drop the local state file (S3 bucket is Retain'd by the template; documented in --help and in docs/packages/cli.mdx) To keep @sparticuz/chromium out of the CLI's transitive deps, this also adds a dedicated ./sdk subpath export to @hyperframes/aws-lambda; the CLI imports from @hyperframes/aws-lambda/sdk exclusively. The existing . barrel still re-exports both handler + SDK for adopters who want one entry point. Defaults are deliberately cost-conservative for first-time users: --concurrency=8 (low enough to never surprise) and --memory=10240 (the common case; documented for adopters who want to tune down). Tests: 5 unit tests on the state-file round-trip. CLI integration against sam local invoke is part of the upcoming PR 6.6 (lambda-local regression harness). --- bun.lock | 15 +- docs/packages/cli.mdx | 74 +++++ packages/aws-lambda/package.json | 1 + packages/aws-lambda/src/sdk/index.ts | 26 ++ packages/cli/package.json | 1 + packages/cli/src/cli.ts | 1 + packages/cli/src/commands/lambda.ts | 260 ++++++++++++++++++ packages/cli/src/commands/lambda/deploy.ts | 113 ++++++++ packages/cli/src/commands/lambda/destroy.ts | 39 +++ packages/cli/src/commands/lambda/progress.ts | 82 ++++++ packages/cli/src/commands/lambda/render.ts | 145 ++++++++++ packages/cli/src/commands/lambda/repoRoot.ts | 33 +++ packages/cli/src/commands/lambda/sam.ts | 175 ++++++++++++ packages/cli/src/commands/lambda/sites.ts | 57 ++++ .../cli/src/commands/lambda/state.test.ts | 67 +++++ packages/cli/src/commands/lambda/state.ts | 96 +++++++ packages/cli/src/help.ts | 4 + packages/cli/tsconfig.json | 4 +- 18 files changed, 1185 insertions(+), 8 deletions(-) create mode 100644 packages/aws-lambda/src/sdk/index.ts create mode 100644 packages/cli/src/commands/lambda.ts create mode 100644 packages/cli/src/commands/lambda/deploy.ts create mode 100644 packages/cli/src/commands/lambda/destroy.ts create mode 100644 packages/cli/src/commands/lambda/progress.ts create mode 100644 packages/cli/src/commands/lambda/render.ts create mode 100644 packages/cli/src/commands/lambda/repoRoot.ts create mode 100644 packages/cli/src/commands/lambda/sam.ts create mode 100644 packages/cli/src/commands/lambda/sites.ts create mode 100644 packages/cli/src/commands/lambda/state.test.ts create mode 100644 packages/cli/src/commands/lambda/state.ts diff --git a/bun.lock b/bun.lock index e39963586..bc2ebca20 100644 --- a/bun.lock +++ b/bun.lock @@ -53,7 +53,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.6.14", + "version": "0.6.15", "bin": { "hyperframes": "./dist/cli.js", }, @@ -76,6 +76,7 @@ }, "devDependencies": { "@clack/prompts": "^1.1.0", + "@hyperframes/aws-lambda": "workspace:*", "@hyperframes/core": "workspace:*", "@hyperframes/engine": "workspace:*", "@hyperframes/producer": "workspace:*", @@ -96,7 +97,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.6.14", + "version": "0.6.15", "dependencies": { "@chenglou/pretext": "^0.0.5", "postcss": "^8.5.8", @@ -123,7 +124,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.6.14", + "version": "0.6.15", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -141,7 +142,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.6.14", + "version": "0.6.15", "devDependencies": { "@types/bun": "^1.1.0", "gsap": "^3.12.5", @@ -153,7 +154,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.6.14", + "version": "0.6.15", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -192,7 +193,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.6.14", + "version": "0.6.15", "dependencies": { "html2canvas": "^1.4.1", }, @@ -204,7 +205,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.6.14", + "version": "0.6.15", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 943abfcc0..d716821d7 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -860,6 +860,80 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ +## hyperframes lambda + +Deploy HyperFrames distributed rendering to AWS Lambda and drive renders from your laptop or CI. + +The `hyperframes lambda` command group wraps the `@hyperframes/aws-lambda` SDK plus AWS SAM so an end-to-end render is three commands: + +```bash +hyperframes lambda deploy +hyperframes lambda render ./my-project --width 1920 --height 1080 --wait +hyperframes lambda destroy # when you're done +``` + +### Prerequisites + +- AWS credentials configured (env vars, `~/.aws/credentials`, SSO, or IMDS). +- [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) on `PATH`. +- `bun` on `PATH` (used to build the Lambda handler ZIP). + +### Subcommands + +#### `lambda deploy` + +Builds `packages/aws-lambda/dist/handler.zip` and SAM-deploys the stack at `examples/aws-lambda/template.yaml`. On success, writes `/.hyperframes/lambda-stack-.json` so the other subcommands don't need to re-derive the bucket / state-machine ARN. + +```bash +hyperframes lambda deploy \ + --stack-name=hyperframes-prod \ + --region=us-east-1 \ + --concurrency=8 \ + --memory=10240 +``` + +Idempotent — re-running on the same `--stack-name` resolves to a no-op when nothing changed. + +#### `lambda sites create ` + +Tars + uploads `` to S3 with a content-addressed key. Returns a `siteId` you can reuse across multiple renders so a re-render of the same tree skips the upload. + +```bash +hyperframes lambda sites create ./my-project +# → siteId: abc1234deadbeef0 (stable across re-runs of the same tree) + +hyperframes lambda render ./my-project --site-id=abc1234deadbeef0 --width 1920 --height 1080 +``` + +#### `lambda render ` + +Starts a Step Functions execution. Returns immediately with a `renderId` (use `lambda progress` to poll) unless `--wait` is set, in which case the CLI blocks until the render finishes and streams per-chunk progress lines. + +```bash +hyperframes lambda render ./my-project \ + --width=1920 --height=1080 --fps=30 --format=mp4 \ + --chunk-size=240 --max-parallel-chunks=16 \ + --wait +``` + +`--json` swaps the human-readable output for a machine-parseable JSON snapshot. + +#### `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. + +```bash +hyperframes lambda progress hf-render-abcd1234 +``` + +#### `lambda destroy` + +Calls `sam delete --no-prompts` and drops the local state file. The render S3 bucket is configured with CloudFormation `Retain` so it survives destruction — empty and delete it via the AWS console / CLI if you want the storage back. + +### State files + +`hyperframes lambda` keeps per-stack metadata under `/.hyperframes/lambda-stack-.json` so the verbs don't need to call `describe-stacks` every time. Commit the file to a repo or `.gitignore` it depending on your workflow — it contains the bucket name, state-machine ARN, and region, none of which are secrets but all of which are AWS-account-identifying. + ## hyperframes.json `hyperframes init` writes a `hyperframes.json` file at the root of every new project. `hyperframes add` reads it to know which registry to pull items from and where to drop them. Edit the file (or delete it to fall back to defaults) to reshape your project layout or point at a custom registry. diff --git a/packages/aws-lambda/package.json b/packages/aws-lambda/package.json index 66215fdf1..a94e09624 100644 --- a/packages/aws-lambda/package.json +++ b/packages/aws-lambda/package.json @@ -18,6 +18,7 @@ "exports": { ".": "./src/index.ts", "./handler": "./src/handler.ts", + "./sdk": "./src/sdk/index.ts", "./cdk": "./src/cdk/index.ts" }, "publishConfig": { diff --git a/packages/aws-lambda/src/sdk/index.ts b/packages/aws-lambda/src/sdk/index.ts new file mode 100644 index 000000000..a45928d53 --- /dev/null +++ b/packages/aws-lambda/src/sdk/index.ts @@ -0,0 +1,26 @@ +/** + * SDK subpath export — `@hyperframes/aws-lambda/sdk`. + * + * Pulled into its own subpath so consumers that only drive Lambda renders + * (CLI, CI scripts, adopter tooling) don't pay the cost of importing + * `./handler.js`, which transitively pulls `@sparticuz/chromium` + + * `puppeteer-core` into the module graph. The SDK files here are + * AWS-SDK only — safe to load in any Node environment. + */ + +export { deploySite, type DeploySiteOptions, type SiteHandle } from "./deploySite.js"; +export { renderToLambda, type RenderHandle, type RenderToLambdaOptions } from "./renderToLambda.js"; +export { + getRenderProgress, + type GetRenderProgressOptions, + type RenderError, + type RenderProgress, + type RenderStatus, +} from "./getRenderProgress.js"; +export { + type BilledLambdaInvocation, + computeRenderCost, + type RenderCost, +} from "./costAccounting.js"; +export { InvalidConfigError, validateDistributedRenderConfig } from "./validateConfig.js"; +export type { SerializableDistributedRenderConfig } from "../events.js"; diff --git a/packages/cli/package.json b/packages/cli/package.json index 12d3d578b..105199018 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -42,6 +42,7 @@ }, "devDependencies": { "@clack/prompts": "^1.1.0", + "@hyperframes/aws-lambda": "workspace:*", "@hyperframes/core": "workspace:*", "@hyperframes/engine": "workspace:*", "@hyperframes/producer": "workspace:*", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index df64a104d..06a748a46 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -86,6 +86,7 @@ const subCommands = { validate: () => import("./commands/validate.js").then((m) => m.default), snapshot: () => import("./commands/snapshot.js").then((m) => m.default), capture: () => import("./commands/capture.js").then((m) => m.default), + lambda: () => import("./commands/lambda.js").then((m) => m.default), }; const main = defineCommand({ diff --git a/packages/cli/src/commands/lambda.ts b/packages/cli/src/commands/lambda.ts new file mode 100644 index 000000000..fcf0e256d --- /dev/null +++ b/packages/cli/src/commands/lambda.ts @@ -0,0 +1,260 @@ +/** + * `hyperframes lambda` — top-level dispatcher for AWS Lambda subcommands. + * + * Each subverb lives in `./lambda/.ts` and exports a single + * `runXxx(args)` async function. The subcommand surface is intentionally + * thin glue: argument parsing + help text here; the actual work + * (`renderToLambda` / `getRenderProgress` / `deploySite` / SAM driver) + * lives in `@hyperframes/aws-lambda/sdk`. + */ + +import { defineCommand } from "citty"; +import type { Example } from "./_examples.js"; +import { c } from "../ui/colors.js"; + +export const examples: Example[] = [ + ["Deploy the Lambda render stack to AWS", "hyperframes lambda deploy"], + [ + "Render a composition on the deployed stack", + "hyperframes lambda render ./my-project --width 1920 --height 1080", + ], + [ + "Render and stream progress until done", + "hyperframes lambda render ./my-project --width 1920 --height 1080 --wait", + ], + ["Check progress for a started render", "hyperframes lambda progress hf-render-abcd1234"], + [ + "Pre-upload a project so multiple renders share the upload", + "hyperframes lambda sites create ./my-project", + ], + ["Tear the stack down", "hyperframes lambda destroy"], +]; + +const HELP = ` +${c.bold("hyperframes lambda")} ${c.dim(" [args]")} + +Deploy + drive distributed video renders on AWS Lambda. + +${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("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.bold("FIRST RUN:")} + ${c.accent("hyperframes lambda deploy")} + ${c.accent("hyperframes lambda render ./my-project --width 1920 --height 1080 --wait")} + +${c.bold("REQUIREMENTS:")} + • AWS CLI configured (env vars, ~/.aws/credentials, or SSO) + • AWS SAM CLI installed (https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) + • bun on PATH (used to build the handler ZIP) +`; + +export default defineCommand({ + meta: { name: "lambda", description: "Deploy and drive renders on AWS Lambda" }, + args: { + subcommand: { + type: "positional", + required: false, + description: "deploy | sites | render | progress | destroy", + }, + target: { + type: "positional", + required: false, + description: "Subcommand-specific positional (project dir, render id, etc.)", + }, + extra: { + type: "positional", + required: false, + description: "Extra positional (e.g. `sites create `)", + }, + + // Stack identity + "stack-name": { + type: "string", + description: "CloudFormation stack name (default: hyperframes-default)", + }, + region: { type: "string", description: "AWS region (default: AWS_REGION env or us-east-1)" }, + profile: { type: "string", description: "AWS profile name (default: AWS_PROFILE env)" }, + + // deploy + concurrency: { type: "string", description: "Lambda reserved concurrency (default: 8)" }, + "chrome-source": { + type: "string", + description: "sparticuz | chrome-headless-shell (default: sparticuz)", + }, + memory: { type: "string", description: "Lambda memory MB (default: 10240)" }, + "skip-build": { type: "boolean", description: "Reuse existing handler.zip (deploy)" }, + + // sites / render + "site-id": { type: "string", description: "Explicit site id (overrides content hash)" }, + width: { type: "string", description: "Render width in pixels" }, + height: { type: "string", description: "Render height in pixels" }, + fps: { type: "string", description: "Render fps (24 | 30 | 60)" }, + format: { type: "string", description: "mp4 | mov | png-sequence (default: mp4)" }, + codec: { type: "string", description: "h264 | h265 (mp4 only)" }, + quality: { type: "string", description: "draft | standard | high" }, + "chunk-size": { type: "string", description: "Frames per chunk (default: 240)" }, + "max-parallel-chunks": { type: "string", description: "Max concurrent chunks (default: 16)" }, + "execution-name": { + type: "string", + description: "Step Functions execution name (default: hf-render-)", + }, + "output-key": { + type: "string", + description: "Final output S3 key (default: renders//output.)", + }, + wait: { type: "boolean", description: "Block until the render finishes" }, + "wait-interval-ms": { + type: "string", + description: "Poll cadence in ms when --wait is set (default: 5000)", + }, + + // shared + json: { type: "boolean", description: "Emit machine-readable JSON" }, + }, + async run({ args }) { + const subcommand = args.subcommand; + if (!subcommand) { + console.log(HELP); + return; + } + + const stackName = (args["stack-name"] as string | undefined) ?? "hyperframes-default"; + + switch (subcommand) { + case "deploy": { + const { runDeploy } = await import("./lambda/deploy.js"); + await runDeploy({ + stackName, + region: args.region as string | undefined, + awsProfile: args.profile as string | undefined, + reservedConcurrency: parseIntFlag(args.concurrency), + chromeSource: parseChromeSource(args["chrome-source"]), + lambdaMemoryMb: parseIntFlag(args.memory), + skipBuild: Boolean(args["skip-build"]), + }); + return; + } + case "sites": { + if (args.target !== "create") { + console.error( + `[lambda sites] unknown verb "${String(args.target)}". Only "create" is supported.`, + ); + process.exit(1); + } + const projectDir = args.extra as string | undefined; + if (!projectDir) { + console.error( + "[lambda sites create] usage: hyperframes lambda sites create ", + ); + process.exit(1); + } + const { runSitesCreate } = await import("./lambda/sites.js"); + await runSitesCreate({ + projectDir, + stackName, + siteId: args["site-id"] as string | undefined, + json: Boolean(args.json), + }); + return; + } + case "render": { + const projectDir = args.target as string | undefined; + if (!projectDir) { + console.error( + "[lambda render] usage: hyperframes lambda render --width --height ", + ); + process.exit(1); + } + const width = parseIntFlag(args.width); + const height = parseIntFlag(args.height); + if (!width || !height) { + console.error("[lambda render] --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] --fps must be 24, 30, or 60; got ${fpsRaw}.`); + process.exit(1); + } + const { runRender } = await import("./lambda/render.js"); + await runRender({ + projectDir, + stackName, + siteId: args["site-id"] as string | undefined, + fps: fpsRaw, + width, + height, + format: parseFormat(args.format), + codec: parseCodec(args.codec), + quality: parseQuality(args.quality), + chunkSize: parseIntFlag(args["chunk-size"]), + maxParallelChunks: parseIntFlag(args["max-parallel-chunks"]), + executionName: args["execution-name"] as string | undefined, + outputKey: args["output-key"] as string | undefined, + json: Boolean(args.json), + wait: Boolean(args.wait), + waitIntervalMs: parseIntFlag(args["wait-interval-ms"]) ?? 5000, + }); + return; + } + case "progress": { + const target = args.target as string | undefined; + if (!target) { + console.error( + "[lambda progress] usage: hyperframes lambda progress ", + ); + process.exit(1); + } + const { runProgress } = await import("./lambda/progress.js"); + await runProgress({ target, stackName, json: Boolean(args.json) }); + return; + } + case "destroy": { + const { runDestroy } = await import("./lambda/destroy.js"); + await runDestroy({ stackName, awsProfile: args.profile as string | undefined }); + return; + } + default: + console.error(`${c.error("Unknown subcommand:")} ${subcommand}\n${HELP}`); + process.exit(1); + } + }, +}); + +function parseIntFlag(raw: unknown): number | undefined { + if (raw === undefined || raw === null || raw === "") return undefined; + const n = Number.parseInt(String(raw), 10); + return Number.isFinite(n) ? n : undefined; +} + +function parseFormat(raw: unknown): "mp4" | "mov" | "png-sequence" { + const s = String(raw ?? "mp4"); + if (s === "mp4" || s === "mov" || s === "png-sequence") return s; + throw new Error(`[lambda render] --format must be mp4|mov|png-sequence; got ${s}`); +} + +function parseCodec(raw: unknown): "h264" | "h265" | undefined { + if (raw === undefined || raw === null || raw === "") return undefined; + const s = String(raw); + if (s === "h264" || s === "h265") return s; + throw new Error(`[lambda render] --codec must be h264|h265; got ${s}`); +} + +function parseQuality(raw: unknown): "draft" | "standard" | "high" | undefined { + if (raw === undefined || raw === null || raw === "") return undefined; + const s = String(raw); + if (s === "draft" || s === "standard" || s === "high") return s; + throw new Error(`[lambda render] --quality must be draft|standard|high; got ${s}`); +} + +function parseChromeSource(raw: unknown): "sparticuz" | "chrome-headless-shell" { + const s = String(raw ?? "sparticuz"); + if (s === "sparticuz" || s === "chrome-headless-shell") return s; + throw new Error( + `[lambda deploy] --chrome-source must be sparticuz|chrome-headless-shell; got ${s}`, + ); +} diff --git a/packages/cli/src/commands/lambda/deploy.ts b/packages/cli/src/commands/lambda/deploy.ts new file mode 100644 index 000000000..814cdc984 --- /dev/null +++ b/packages/cli/src/commands/lambda/deploy.ts @@ -0,0 +1,113 @@ +/** + * `hyperframes lambda deploy` — build the handler ZIP, sam-deploy the + * Phase 6a SAM template, and persist the stack outputs locally so the + * other lambda subcommands don't need re-derive them. + * + * Idempotent: re-running points at the same stack name and SAM resolves + * the changeset to a no-op when nothing changed. + */ + +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { c } from "../../ui/colors.js"; +import { fetchStackOutputs, locateSamTemplate, samDeploy } from "./sam.js"; +import { repoRoot } from "./repoRoot.js"; +import { DEFAULT_STACK_NAME, writeStackOutputs } from "./state.js"; + +export interface DeployArgs { + stackName: string; + region: string; + awsProfile?: string; + reservedConcurrency: number; + chromeSource: "sparticuz" | "chrome-headless-shell"; + lambdaMemoryMb: number; + /** Override the handler-ZIP rebuild step. Useful for CI runs that already built it. */ + skipBuild: boolean; +} + +const DEFAULT_REGION = "us-east-1"; +const DEFAULT_MEMORY_MB = 10240; +// Low default reserved-concurrency so a first-time user doesn't get +// surprise-billed by a runaway Map state. Adopters with their own quota +// raise this explicitly via `--concurrency`. +const DEFAULT_CONCURRENCY = 8; + +export async function runDeploy(args: Partial = {}): Promise { + const resolved: DeployArgs = { + stackName: args.stackName ?? `hyperframes-${DEFAULT_STACK_NAME}`, + region: args.region ?? process.env.AWS_REGION ?? DEFAULT_REGION, + awsProfile: args.awsProfile ?? process.env.AWS_PROFILE, + reservedConcurrency: args.reservedConcurrency ?? DEFAULT_CONCURRENCY, + chromeSource: args.chromeSource ?? "sparticuz", + lambdaMemoryMb: args.lambdaMemoryMb ?? DEFAULT_MEMORY_MB, + skipBuild: args.skipBuild ?? false, + }; + + const root = repoRoot(); + // Locate the SAM template up-front so users get a fast, clear error + // (not an opaque `sam deploy` failure) when this isn't a checkout. + locateSamTemplate(root); + + if (!resolved.skipBuild) { + console.log(c.dim("→ Building handler ZIP")); + buildHandlerZip(root); + } else { + const zip = join(root, "packages", "aws-lambda", "dist", "handler.zip"); + if (!existsSync(zip)) { + throw new Error( + `--skip-build set but ${zip} does not exist. Run \`bun run --cwd packages/aws-lambda build:zip\` first or drop --skip-build.`, + ); + } + } + + console.log(c.dim(`→ sam deploy (stack=${resolved.stackName} region=${resolved.region})`)); + samDeploy({ + repoRoot: root, + stackName: resolved.stackName, + region: resolved.region, + awsProfile: resolved.awsProfile, + reservedConcurrency: resolved.reservedConcurrency, + chromeSource: resolved.chromeSource, + }); + + console.log(c.dim("→ Reading stack outputs")); + const outputs = fetchStackOutputs({ + stackName: resolved.stackName, + region: resolved.region, + awsProfile: resolved.awsProfile, + }); + + const statePath = writeStackOutputs({ + stackName: resolved.stackName, + region: resolved.region, + bucketName: outputs.bucketName, + stateMachineArn: outputs.stateMachineArn, + functionName: outputs.functionName, + lambdaMemoryMb: resolved.lambdaMemoryMb, + deployedAt: new Date().toISOString(), + }); + + console.log(); + console.log(c.success("Stack deployed.")); + console.log(` ${c.dim("Bucket:")} ${outputs.bucketName}`); + console.log(` ${c.dim("State machine:")} ${outputs.stateMachineArn}`); + console.log(` ${c.dim("Function:")} ${outputs.functionName}`); + console.log(` ${c.dim("State file:")} ${resolve(statePath)}`); + console.log(); + console.log(c.dim(`Render with: hyperframes lambda render `)); +} + +function buildHandlerZip(root: string): void { + // bun run --cwd packages/aws-lambda build:zip + const result = spawnSync( + "bun", + ["run", "--cwd", join(root, "packages", "aws-lambda"), "build:zip"], + { stdio: "inherit" }, + ); + if (result.status !== 0) { + throw new Error( + `[lambda deploy] handler ZIP build exited with code ${result.status ?? "unknown"}`, + ); + } +} diff --git a/packages/cli/src/commands/lambda/destroy.ts b/packages/cli/src/commands/lambda/destroy.ts new file mode 100644 index 000000000..65f789629 --- /dev/null +++ b/packages/cli/src/commands/lambda/destroy.ts @@ -0,0 +1,39 @@ +/** + * `hyperframes lambda destroy` — tear the CloudFormation stack down and + * drop the locally-cached stack outputs. Wraps `sam delete`. + * + * The render bucket is created with `Retain` deletion policy in the SAM + * template, so the underlying S3 bucket survives destruction. Adopters + * who want to fully delete it must do so via the AWS console / CLI + * after this command completes; we document that in the deploy guide + * rather than re-implementing the empty-and-delete dance here. + */ + +import { c } from "../../ui/colors.js"; +import { deleteStackOutputs, requireStack } from "./state.js"; +import { samDelete } from "./sam.js"; +import { repoRoot } from "./repoRoot.js"; + +export interface DestroyArgs { + stackName: string; + awsProfile?: string; +} + +export async function runDestroy(args: DestroyArgs): Promise { + const stack = requireStack(args.stackName); + console.log(c.dim(`→ sam delete (stack=${stack.stackName} region=${stack.region})`)); + samDelete({ + repoRoot: repoRoot(), + stackName: stack.stackName, + region: stack.region, + awsProfile: args.awsProfile, + }); + deleteStackOutputs(args.stackName); + console.log(); + console.log(c.success("Stack torn down.")); + console.log( + c.dim( + `Note: the render bucket "${stack.bucketName}" was deployed with Retain — empty + delete it via the AWS console or CLI if you want to fully reclaim storage.`, + ), + ); +} diff --git a/packages/cli/src/commands/lambda/progress.ts b/packages/cli/src/commands/lambda/progress.ts new file mode 100644 index 000000000..6bfe9f2d0 --- /dev/null +++ b/packages/cli/src/commands/lambda/progress.ts @@ -0,0 +1,82 @@ +/** + * `hyperframes lambda progress ` — print a single progress + * snapshot for a render. Wraps {@link getRenderProgress}. Accepts a + * full executionArn or a renderId (in which case we resolve the arn + * from the stack's region + state-machine name). + */ + +import { getRenderProgress } from "@hyperframes/aws-lambda/sdk"; +import { c } from "../../ui/colors.js"; +import { requireStack } from "./state.js"; + +export interface ProgressArgs { + /** Full SFN execution ARN OR the renderId (== execution name). */ + target: string; + stackName: string; + json: boolean; +} + +export async function runProgress(args: ProgressArgs): Promise { + const stack = requireStack(args.stackName); + + // Allow callers to pass either the full ARN or the renderId/exec name. + // The state machine ARN is on the stack, so deriving the execution ARN + // from a bare name is a deterministic suffix swap. + const executionArn = args.target.startsWith("arn:") + ? args.target + : executionArnFromName(stack.stateMachineArn, args.target); + + const progress = await getRenderProgress({ + executionArn, + region: stack.region, + defaultMemorySizeMb: stack.lambdaMemoryMb, + }); + + if (args.json) { + console.log(JSON.stringify(progress, null, 2)); + return; + } + + const pct = Math.round(progress.overallProgress * 100); + console.log(`${c.dim("Status:")} ${statusColor(progress.status)}`); + console.log(`${c.dim("Progress:")} ${pct}%`); + console.log( + `${c.dim("Frames:")} ${progress.framesRendered}${progress.totalFrames === null ? "" : ` / ${progress.totalFrames}`}`, + ); + console.log(`${c.dim("Lambdas:")} ${progress.lambdasInvoked}`); + console.log( + `${c.dim("Cost:")} ${progress.costs.displayCost} (Lambda $${progress.costs.breakdown.lambdaUsd.toFixed(4)} + SFN $${progress.costs.breakdown.stepFunctionsUsd.toFixed(4)})`, + ); + if (progress.outputFile) { + console.log(`${c.dim("Output:")} ${progress.outputFile.s3Uri}`); + } + if (progress.errors.length > 0) { + console.log(); + console.log(c.error("Errors:")); + for (const err of progress.errors) { + console.log(` ${c.dim(err.state)}: ${err.error} — ${err.cause}`); + } + } + if (progress.fatalErrorEncountered) { + process.exitCode = 1; + } +} + +function executionArnFromName(stateMachineArn: string, name: string): string { + // `arn:aws:states:::stateMachine:` → + // `arn:aws:states:::execution::` + return stateMachineArn.replace(":stateMachine:", ":execution:") + `:${name}`; +} + +function statusColor(status: string): string { + switch (status) { + case "SUCCEEDED": + return c.success(status); + case "FAILED": + case "TIMED_OUT": + case "ABORTED": + return c.error(status); + default: + return status; + } +} diff --git a/packages/cli/src/commands/lambda/render.ts b/packages/cli/src/commands/lambda/render.ts new file mode 100644 index 000000000..fb879fede --- /dev/null +++ b/packages/cli/src/commands/lambda/render.ts @@ -0,0 +1,145 @@ +/** + * `hyperframes lambda render ` — start a distributed render + * against the deployed stack. Wraps {@link renderToLambda}. Does NOT + * poll — use `hyperframes lambda progress` for that. + */ + +import { resolve as resolvePath } from "node:path"; +import { + renderToLambda, + type SerializableDistributedRenderConfig, +} from "@hyperframes/aws-lambda/sdk"; +import { c } from "../../ui/colors.js"; +import { requireStack, stateFilePath } from "./state.js"; + +export interface RenderArgs { + projectDir: string; + stackName: string; + siteId?: string; + /** Composition config — fps/width/height/format required, rest optional. */ + fps: 24 | 30 | 60; + width: number; + height: number; + format: "mp4" | "mov" | "png-sequence"; + codec?: "h264" | "h265"; + quality?: "draft" | "standard" | "high"; + chunkSize?: number; + maxParallelChunks?: number; + executionName?: string; + outputKey?: string; + /** Print machine-readable JSON instead of the human-friendly summary. */ + json: boolean; + /** Block until the render finishes. Polls `progress` until SUCCEEDED/FAILED. */ + wait: boolean; + /** Poll cadence in ms when `--wait` is set. */ + waitIntervalMs: number; +} + +export async function runRender(args: RenderArgs): Promise { + const stack = requireStack(args.stackName); + const projectDir = resolvePath(args.projectDir); + + 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", + }; + + const siteHandle = args.siteId + ? { + siteId: args.siteId, + projectS3Uri: `s3://${stack.bucketName}/sites/${args.siteId}/project.tar.gz`, + bytes: 0, + uploadedAt: "", + uploaded: false, + } + : undefined; + + const handle = await renderToLambda({ + projectDir: siteHandle ? undefined : projectDir, + siteHandle, + bucketName: stack.bucketName, + stateMachineArn: stack.stateMachineArn, + region: stack.region, + config, + executionName: args.executionName, + outputKey: args.outputKey, + }); + + if (args.json) { + console.log(JSON.stringify(handle, null, 2)); + if (args.wait) { + await waitForCompletion(handle.executionArn, stack, args.waitIntervalMs, args.json); + } + return; + } + + console.log(c.success("Render started.")); + console.log(` ${c.dim("Render ID:")} ${handle.renderId}`); + console.log(` ${c.dim("Execution ARN:")} ${handle.executionArn}`); + console.log(` ${c.dim("Output S3 URI:")} ${handle.outputS3Uri}`); + console.log(` ${c.dim("Project S3:")} ${handle.projectS3Uri}`); + console.log(` ${c.dim("Stack state:")} ${stateFilePath(args.stackName)}`); + console.log(); + if (args.wait) { + await waitForCompletion(handle.executionArn, stack, args.waitIntervalMs, args.json); + return; + } + console.log(c.dim(`Poll with: hyperframes lambda progress ${handle.renderId}`)); +} + +async function waitForCompletion( + executionArn: string, + stack: { region: string; functionName: string; lambdaMemoryMb: number }, + intervalMs: number, + json: boolean, +): Promise { + // Lazy import to avoid pulling SFN client when only `render --no-wait` is used. + const { getRenderProgress } = await import("@hyperframes/aws-lambda/sdk"); + let lastRendered = -1; + while (true) { + const progress = await getRenderProgress({ + executionArn, + region: stack.region, + defaultMemorySizeMb: stack.lambdaMemoryMb, + }); + if (!json && progress.framesRendered !== lastRendered) { + lastRendered = progress.framesRendered; + const total = progress.totalFrames ?? "?"; + const pct = Math.round(progress.overallProgress * 100); + console.log( + ` ${c.dim(`[${progress.status}]`)} ${pct}% • ${progress.framesRendered}/${total} frames • ${progress.costs.displayCost}`, + ); + } + if (progress.status !== "RUNNING") { + if (json) { + console.log(JSON.stringify(progress, null, 2)); + } else if (progress.status === "SUCCEEDED" && progress.outputFile) { + console.log(); + console.log(c.success("Render complete.")); + console.log(` ${c.dim("Output:")} ${progress.outputFile.s3Uri}`); + console.log(` ${c.dim("Size:")} ${progress.outputFile.bytes ?? "?"} bytes`); + console.log(` ${c.dim("Total cost:")} ${progress.costs.displayCost}`); + } else { + console.log(); + console.log(c.error(`Render ended with status ${progress.status}.`)); + for (const err of progress.errors) { + console.log(` ${c.dim(err.state)}: ${err.error} — ${err.cause}`); + } + process.exitCode = 1; + } + return; + } + await sleep(intervalMs); + } +} + +function sleep(ms: number): Promise { + return new Promise((res) => setTimeout(res, ms)); +} diff --git a/packages/cli/src/commands/lambda/repoRoot.ts b/packages/cli/src/commands/lambda/repoRoot.ts new file mode 100644 index 000000000..b7f6628db --- /dev/null +++ b/packages/cli/src/commands/lambda/repoRoot.ts @@ -0,0 +1,33 @@ +/** + * Resolve the HyperFrames repo root. Used by `lambda deploy`/`destroy` + * to find `examples/aws-lambda/template.yaml` and the handler ZIP build + * script. + * + * Walks up from this file's location until it finds a directory that + * contains `packages/aws-lambda/`. Caching is unnecessary — this runs + * once per CLI invocation. + */ + +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export function repoRoot(): string { + const override = process.env.HYPERFRAMES_REPO_ROOT; + if (override && existsSync(resolve(override, "packages", "aws-lambda", "package.json"))) { + return override; + } + let dir = dirname(fileURLToPath(import.meta.url)); + for (let depth = 0; depth < 12; depth++) { + if (existsSync(resolve(dir, "packages", "aws-lambda", "package.json"))) { + return dir; + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error( + "[hyperframes lambda] could not find the repo root (no packages/aws-lambda/ above this CLI's source). " + + "Run `hyperframes lambda` from within a hyperframes checkout, or set HYPERFRAMES_REPO_ROOT explicitly.", + ); +} diff --git a/packages/cli/src/commands/lambda/sam.ts b/packages/cli/src/commands/lambda/sam.ts new file mode 100644 index 000000000..b5fad810d --- /dev/null +++ b/packages/cli/src/commands/lambda/sam.ts @@ -0,0 +1,175 @@ +/** + * Thin wrappers around the AWS SAM CLI used by `hyperframes lambda deploy` + * and `hyperframes lambda destroy`. + * + * We shell out instead of programmatically driving the CloudFormation API + * because: + * 1. SAM handles the rollback-on-failure semantics correctly, including + * stuck-rollback recovery. Re-implementing that in TypeScript would + * duplicate a non-trivial chunk of the SAM CLI. + * 2. The SAM template at `examples/aws-lambda/template.yaml` already + * describes the topology; adopters who customize the template + * shouldn't have to maintain it twice. + * 3. SAM's `--resolve-s3` auto-creates an artifact bucket for the + * handler ZIP upload, which we'd otherwise have to re-implement. + * + * CDK adopters use `HyperframesRenderStack` directly from their own + * CDK app — this CLI path is for users who don't want to write a CDK + * project. + */ + +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +/** Throws with a clear hint when the SAM CLI is not on PATH. */ +export function assertSamAvailable(): void { + try { + execFileSync("sam", ["--version"], { stdio: "ignore" }); + } catch { + throw new Error( + "`sam` CLI not found on PATH. Install AWS SAM CLI from https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html and retry.", + ); + } +} + +/** Throws with a clear hint when the `aws` CLI is not on PATH. */ +export function assertAwsCliAvailable(): void { + try { + execFileSync("aws", ["--version"], { stdio: "ignore" }); + } catch { + throw new Error( + "`aws` CLI not found on PATH. Install from https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html and configure credentials before retrying.", + ); + } +} + +export interface DeployOptions { + /** Repository root — the SAM template lives at `examples/aws-lambda/template.yaml` underneath. */ + repoRoot: string; + /** CloudFormation stack name. */ + stackName: string; + region: string; + awsProfile?: string; + reservedConcurrency?: number; + chromeSource?: "sparticuz" | "chrome-headless-shell"; + /** Pass-through stdio. Defaults to "inherit" so SAM's progress lines stream live. */ + stdio?: "inherit" | "pipe"; +} + +/** + * Resolve the SAM template path relative to `repoRoot`. We look for the + * `examples/aws-lambda/template.yaml` first (development checkout) and + * fall back to the installed-package layout when running from a globally + * installed `hyperframes` CLI. + */ +export function locateSamTemplate(repoRoot: string): string { + const candidate = join(repoRoot, "examples", "aws-lambda", "template.yaml"); + if (!existsSync(candidate)) { + throw new Error( + `[lambda] SAM template not found at ${candidate}. ` + + `If you're running from an installed package, point --sam-template at your local copy of examples/aws-lambda/template.yaml.`, + ); + } + return candidate; +} + +/** Run `sam deploy` non-interactively. Returns when SAM exits 0; throws on non-zero. */ +export function samDeploy(opts: DeployOptions): void { + assertSamAvailable(); + const args = [ + "deploy", + "--stack-name", + opts.stackName, + "--region", + opts.region, + "--resolve-s3", + "--capabilities", + "CAPABILITY_IAM", + "--no-confirm-changeset", + "--no-fail-on-empty-changeset", + "--parameter-overrides", + `ChromeSource=${opts.chromeSource ?? "sparticuz"}`, + `ReservedConcurrency=${opts.reservedConcurrency ?? -1}`, + ]; + if (opts.awsProfile) { + args.push("--profile", opts.awsProfile); + } + const samDir = join(opts.repoRoot, "examples", "aws-lambda"); + const result = spawnSync("sam", args, { cwd: samDir, stdio: opts.stdio ?? "inherit" }); + if (result.status !== 0) { + throw new Error(`[lambda] sam deploy exited with code ${result.status ?? "unknown"}`); + } +} + +/** Run `sam delete` non-interactively. */ +export function samDelete(opts: { + repoRoot: string; + stackName: string; + region: string; + awsProfile?: string; + stdio?: "inherit" | "pipe"; +}): void { + assertSamAvailable(); + const args = ["delete", "--stack-name", opts.stackName, "--region", opts.region, "--no-prompts"]; + if (opts.awsProfile) { + args.push("--profile", opts.awsProfile); + } + const samDir = join(opts.repoRoot, "examples", "aws-lambda"); + const result = spawnSync("sam", args, { cwd: samDir, stdio: opts.stdio ?? "inherit" }); + if (result.status !== 0) { + throw new Error(`[lambda] sam delete exited with code ${result.status ?? "unknown"}`); + } +} + +export interface StackOutputBag { + bucketName: string; + functionName: string; + stateMachineArn: string; +} + +/** + * Query CloudFormation for the stack outputs the SAM template exports. + * Used after `samDeploy` to populate the local state file. + */ +export function fetchStackOutputs(opts: { + stackName: string; + region: string; + awsProfile?: string; +}): StackOutputBag { + assertAwsCliAvailable(); + const args = [ + "cloudformation", + "describe-stacks", + "--stack-name", + opts.stackName, + "--region", + opts.region, + "--query", + "Stacks[0].Outputs", + "--output", + "json", + ]; + if (opts.awsProfile) { + args.unshift("--profile", opts.awsProfile); + } + const out = execFileSync("aws", args, { encoding: "utf-8" }); + const parsed = JSON.parse(out) as { OutputKey: string; OutputValue: string }[]; + const byKey = new Map(parsed.map((o) => [o.OutputKey, o.OutputValue])); + const bucketName = byKey.get("RenderBucketName"); + const functionName = byKey.get("RenderFunctionArn"); + const stateMachineArn = byKey.get("RenderStateMachineArn"); + if (!bucketName || !functionName || !stateMachineArn) { + throw new Error( + `[lambda] stack ${opts.stackName} is missing one of RenderBucketName/RenderFunctionArn/RenderStateMachineArn. Got keys: ${[...byKey.keys()].join(", ")}`, + ); + } + return { + bucketName, + // RenderFunctionArn is the full ARN; the Lambda function name is the + // last colon-segment, which downstream `getRenderProgress` calls use + // for cost math + CloudWatch lookups. + functionName: functionName.split(":").pop() ?? functionName, + stateMachineArn, + }; +} diff --git a/packages/cli/src/commands/lambda/sites.ts b/packages/cli/src/commands/lambda/sites.ts new file mode 100644 index 000000000..8a631024f --- /dev/null +++ b/packages/cli/src/commands/lambda/sites.ts @@ -0,0 +1,57 @@ +/** + * `hyperframes lambda sites create ` — tar + upload a project + * to the deployed render bucket at a content-addressed S3 prefix. Used + * by adopters who want to pre-stage a project so multiple subsequent + * renders share the upload. + * + * Without this verb, every `render` call re-tars the same tree on every + * invocation. With it, the same `siteId` resolves to a `HeadObject`-204 + * short-circuit inside `deploySite`. + */ + +import { resolve as resolvePath } from "node:path"; +import { deploySite } from "@hyperframes/aws-lambda/sdk"; +import { c } from "../../ui/colors.js"; +import { DEFAULT_STACK_NAME, requireStack } from "./state.js"; + +export interface SitesCreateArgs { + projectDir: string; + stackName: string; + siteId?: string; + /** Print machine-readable JSON instead of the human-friendly summary. */ + json: boolean; +} + +export async function runSitesCreate(args: SitesCreateArgs): Promise { + const stack = requireStack(args.stackName); + const projectDir = resolvePath(args.projectDir); + + const handle = await deploySite({ + projectDir, + bucketName: stack.bucketName, + region: stack.region, + siteId: args.siteId, + }); + + if (args.json) { + console.log(JSON.stringify(handle, null, 2)); + return; + } + + console.log( + c.success(handle.uploaded ? "Site uploaded." : "Site already up to date (skipped upload)."), + ); + console.log(` ${c.dim("Site ID:")} ${handle.siteId}`); + console.log(` ${c.dim("S3 URI:")} ${handle.projectS3Uri}`); + console.log(` ${c.dim("Bytes:")} ${handle.bytes}`); + console.log(` ${c.dim("Uploaded at:")} ${handle.uploadedAt}`); + console.log(); + console.log( + c.dim( + `Render with: hyperframes lambda render ${args.projectDir} --site-id=${handle.siteId}` + + (args.stackName === `hyperframes-${DEFAULT_STACK_NAME}` + ? "" + : ` --stack-name=${args.stackName}`), + ), + ); +} diff --git a/packages/cli/src/commands/lambda/state.test.ts b/packages/cli/src/commands/lambda/state.test.ts new file mode 100644 index 000000000..08792ac9f --- /dev/null +++ b/packages/cli/src/commands/lambda/state.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + deleteStackOutputs, + listStackNames, + readStackOutputs, + stateFilePath, + type StackOutputs, + writeStackOutputs, +} from "./state.js"; + +let workdir: string; + +beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), "hf-lambda-cli-state-")); +}); + +afterEach(() => { + rmSync(workdir, { recursive: true, force: true }); +}); + +const sample: StackOutputs = { + stackName: "test", + region: "us-east-1", + bucketName: "bucket-x", + stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf", + functionName: "hf-render", + lambdaMemoryMb: 10240, + deployedAt: "2026-05-16T00:00:00Z", +}; + +describe("lambda state file", () => { + it("round-trips outputs through disk", () => { + const path = writeStackOutputs(sample, workdir); + expect(path).toBe(stateFilePath("test", workdir)); + const read = readStackOutputs("test", workdir); + expect(read).toEqual(sample); + }); + + it("returns null when the stack file is missing", () => { + expect(readStackOutputs("absent", workdir)).toBeNull(); + }); + + it("lists stack names by parsing the filename prefix", () => { + writeStackOutputs({ ...sample, stackName: "alpha" }, workdir); + writeStackOutputs({ ...sample, stackName: "beta" }, workdir); + expect(listStackNames(workdir).sort()).toEqual(["alpha", "beta"]); + }); + + it("deleteStackOutputs removes the file (no error if absent)", () => { + writeStackOutputs(sample, workdir); + expect(readStackOutputs("test", workdir)).not.toBeNull(); + deleteStackOutputs("test", workdir); + expect(readStackOutputs("test", workdir)).toBeNull(); + // Re-delete is a no-op. + deleteStackOutputs("test", workdir); + }); + + it("handles malformed JSON by returning null instead of throwing", () => { + const path = stateFilePath("bad", workdir); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "{ not json"); + expect(readStackOutputs("bad", workdir)).toBeNull(); + }); +}); diff --git a/packages/cli/src/commands/lambda/state.ts b/packages/cli/src/commands/lambda/state.ts new file mode 100644 index 000000000..6307d79f6 --- /dev/null +++ b/packages/cli/src/commands/lambda/state.ts @@ -0,0 +1,96 @@ +/** + * Persists `hyperframes lambda` stack outputs (bucket, state-machine ARN, + * region) so `render` / `progress` / `destroy` don't need to re-derive + * them from CloudFormation on every call. + * + * Stored at `/.hyperframes/lambda-stack-.json`. Project- + * local on purpose: a developer who runs `hyperframes lambda deploy` in + * two different worktrees will get two distinct stack files, which is + * the right default. If the user wants a shared default location, they + * can symlink the directory. + */ + +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +export interface StackOutputs { + /** Project-name prefix passed to `deploy`. Used as CloudFormation stack-name suffix. */ + stackName: string; + region: string; + bucketName: string; + stateMachineArn: string; + functionName: string; + /** Lambda memory MB used during deploy; carried so cost math doesn't have to re-derive it. */ + lambdaMemoryMb: number; + deployedAt: string; +} + +const STATE_DIR_NAME = ".hyperframes"; +const STATE_FILE_PREFIX = "lambda-stack-"; +const DEFAULT_STACK_NAME = "default"; + +export function stateFilePath( + stackName: string = DEFAULT_STACK_NAME, + cwd: string = process.cwd(), +): string { + return join(cwd, STATE_DIR_NAME, `${STATE_FILE_PREFIX}${stackName}.json`); +} + +export function writeStackOutputs(outputs: StackOutputs, cwd: string = process.cwd()): string { + const path = stateFilePath(outputs.stackName, cwd); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(outputs, null, 2) + "\n"); + return path; +} + +export function readStackOutputs( + stackName: string = DEFAULT_STACK_NAME, + cwd: string = process.cwd(), +): StackOutputs | null { + const path = stateFilePath(stackName, cwd); + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")) as StackOutputs; + } catch { + return null; + } +} + +export function deleteStackOutputs( + stackName: string = DEFAULT_STACK_NAME, + cwd: string = process.cwd(), +): void { + const path = stateFilePath(stackName, cwd); + if (existsSync(path)) rmSync(path); +} + +export function listStackNames(cwd: string = process.cwd()): string[] { + const dir = join(cwd, STATE_DIR_NAME); + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((f) => f.startsWith(STATE_FILE_PREFIX) && f.endsWith(".json")) + .map((f) => f.slice(STATE_FILE_PREFIX.length, -".json".length)); +} + +/** + * Read stack outputs or print a helpful error and exit. Shared between + * `render`, `progress`, and `destroy` so the "did you run `deploy` first?" + * hint is consistent. + */ +export function requireStack(stackName: string, cwd: string = process.cwd()): StackOutputs { + const stack = readStackOutputs(stackName, cwd); + if (!stack) { + const known = listStackNames(cwd); + let hint = `Run \`hyperframes lambda deploy${stackName === DEFAULT_STACK_NAME ? "" : ` --stack-name=${stackName}`}\` first.`; + if (known.length) { + hint += ` Known stacks here: ${known.join(", ")}.`; + } + console.error( + `[hyperframes lambda] no stack state for "${stackName}" at ${stateFilePath(stackName, cwd)}. ${hint}`, + ); + process.exit(1); + } + return stack; +} + +export { DEFAULT_STACK_NAME }; diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 8dd52c2cc..d9d0f7606 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -51,6 +51,10 @@ const GROUPS: Group[] = [ ["upgrade", "Check for updates and show upgrade instructions"], ], }, + { + title: "Deploy", + commands: [["lambda", "Deploy and drive distributed renders on AWS Lambda"]], + }, { title: "AI & Integrations", commands: [ diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 855ecc53f..1185f6900 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -5,7 +5,9 @@ "moduleResolution": "bundler", "baseUrl": ".", "paths": { - "@hyperframes/producer": ["../producer/src/index.ts"] + "@hyperframes/producer": ["../producer/src/index.ts"], + "@hyperframes/producer/distributed": ["../producer/src/distributed.ts"], + "@hyperframes/aws-lambda/sdk": ["../aws-lambda/src/sdk/index.ts"] }, "strict": true, "noUncheckedIndexedAccess": true, From fba014f1781281b1131d33b0f950533730ef1228 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 17 May 2026 00:11:11 +0000 Subject: [PATCH 2/5] refactor(cli): /simplify pass on the lambda command group Two small cleanups on top of the lambda CLI: - Replace parseFormat / parseCodec / parseQuality / parseChromeSource (four near-identical helpers) with a single generic parseEnum() + typed const-tuple lookups. The four callers now read as one-line arrow functions that lift the allowed values out of the function body so they're easy to extend. - DEFAULT_STACK_NAME was const-declared then re-exported at the bottom of state.ts; just mark the const export inline. No behavior changes. All CLI tests still pass. --- packages/cli/src/commands/lambda.ts | 50 ++++++++++++----------- packages/cli/src/commands/lambda/state.ts | 4 +- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/commands/lambda.ts b/packages/cli/src/commands/lambda.ts index fcf0e256d..a1da23a2d 100644 --- a/packages/cli/src/commands/lambda.ts +++ b/packages/cli/src/commands/lambda.ts @@ -231,30 +231,34 @@ function parseIntFlag(raw: unknown): number | undefined { return Number.isFinite(n) ? n : undefined; } -function parseFormat(raw: unknown): "mp4" | "mov" | "png-sequence" { - const s = String(raw ?? "mp4"); - if (s === "mp4" || s === "mov" || s === "png-sequence") return s; - throw new Error(`[lambda render] --format must be mp4|mov|png-sequence; got ${s}`); -} - -function parseCodec(raw: unknown): "h264" | "h265" | undefined { - if (raw === undefined || raw === null || raw === "") return undefined; +/** + * Parse a string-union flag against a closed set of allowed values. + * Returns `defaultValue` (which may be `undefined`) when the input is + * empty; throws with a flag-specific message when the value is set + * but unrecognised. + */ +function parseEnum( + raw: unknown, + allowed: readonly T[], + errorPrefix: string, + defaultValue: T | undefined, +): T | undefined { + if (raw === undefined || raw === null || raw === "") return defaultValue; const s = String(raw); - if (s === "h264" || s === "h265") return s; - throw new Error(`[lambda render] --codec must be h264|h265; got ${s}`); + if ((allowed as readonly string[]).includes(s)) return s as T; + throw new Error(`${errorPrefix} must be ${allowed.join("|")}; got ${s}`); } -function parseQuality(raw: unknown): "draft" | "standard" | "high" | undefined { - if (raw === undefined || raw === null || raw === "") return undefined; - const s = String(raw); - if (s === "draft" || s === "standard" || s === "high") return s; - throw new Error(`[lambda render] --quality must be draft|standard|high; got ${s}`); -} +const FORMATS = ["mp4", "mov", "png-sequence"] as const; +const CODECS = ["h264", "h265"] as const; +const QUALITIES = ["draft", "standard", "high"] as const; +const CHROME_SOURCES = ["sparticuz", "chrome-headless-shell"] as const; -function parseChromeSource(raw: unknown): "sparticuz" | "chrome-headless-shell" { - const s = String(raw ?? "sparticuz"); - if (s === "sparticuz" || s === "chrome-headless-shell") return s; - throw new Error( - `[lambda deploy] --chrome-source must be sparticuz|chrome-headless-shell; got ${s}`, - ); -} +const parseFormat = (raw: unknown): (typeof FORMATS)[number] => + parseEnum(raw, FORMATS, "[lambda render] --format", "mp4")!; +const parseCodec = (raw: unknown): (typeof CODECS)[number] | undefined => + parseEnum(raw, CODECS, "[lambda render] --codec", undefined); +const parseQuality = (raw: unknown): (typeof QUALITIES)[number] | undefined => + parseEnum(raw, QUALITIES, "[lambda render] --quality", undefined); +const parseChromeSource = (raw: unknown): (typeof CHROME_SOURCES)[number] => + parseEnum(raw, CHROME_SOURCES, "[lambda deploy] --chrome-source", "sparticuz")!; diff --git a/packages/cli/src/commands/lambda/state.ts b/packages/cli/src/commands/lambda/state.ts index 6307d79f6..36bb7a41e 100644 --- a/packages/cli/src/commands/lambda/state.ts +++ b/packages/cli/src/commands/lambda/state.ts @@ -27,7 +27,7 @@ export interface StackOutputs { const STATE_DIR_NAME = ".hyperframes"; const STATE_FILE_PREFIX = "lambda-stack-"; -const DEFAULT_STACK_NAME = "default"; +export const DEFAULT_STACK_NAME = "default"; export function stateFilePath( stackName: string = DEFAULT_STACK_NAME, @@ -92,5 +92,3 @@ export function requireStack(stackName: string, cwd: string = process.cwd()): St } return stack; } - -export { DEFAULT_STACK_NAME }; From ff18709043b9ceb4d811bfcf56c4c12d0e162d43 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 17 May 2026 00:29:42 +0000 Subject: [PATCH 3/5] fix(cli): keep @hyperframes/aws-lambda external in the tsup bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit esbuild can't bundle @hyperframes/aws-lambda's transitive AWS SDK deps (@aws-sdk/* + @smithy/*) cleanly into a node binary — the SDK's .browser.js conditional re-exports break the resolver: ESM Build failed No matching export in "splitStream.browser.js" for import "splitStream" (and ~10 similar errors) Mark aws-lambda as `external` so esbuild doesn't follow it, and move it from devDependencies to dependencies so the published CLI can resolve it from node_modules at runtime. The lambda subverb files dynamic-import only on `hyperframes lambda *` invocation, so the CLI cold-start cost is unchanged. The install-size hit (AWS SDK + @sparticuz/chromium ≈ 200 MiB) is documented as a v1 tradeoff; a future split into a lambda-sdk-only subpackage can pare this back. --- packages/cli/package.json | 2 +- packages/cli/tsup.config.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 105199018..5e68e1a57 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@hono/node-server": "^1.8.0", + "@hyperframes/aws-lambda": "workspace:*", "@puppeteer/browsers": "^2.13.0", "adm-zip": "^0.5.16", "citty": "^0.2.1", @@ -42,7 +43,6 @@ }, "devDependencies": { "@clack/prompts": "^1.1.0", - "@hyperframes/aws-lambda": "workspace:*", "@hyperframes/core": "workspace:*", "@hyperframes/engine": "workspace:*", "@hyperframes/producer": "workspace:*", diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index d64484797..bec25fc9c 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -52,6 +52,14 @@ var __dirname = __hf_dirname(__filename);`, "esbuild", "giget", "postcss", + // aws-lambda transitively pulls @aws-sdk/* + @smithy/* which include + // .browser.js conditional exports esbuild can't bundle cleanly into + // a node binary. Keep it external; the lambda subverb files dynamic- + // import it only when the user runs `hyperframes lambda *`, so the + // CLI's cold start doesn't load it. Runtime resolution comes from + // @hyperframes/aws-lambda being a `dependencies` entry in package.json. + "@hyperframes/aws-lambda", + "@hyperframes/aws-lambda/sdk", ], noExternal: [ "@hyperframes/core", @@ -71,6 +79,16 @@ var __dirname = __hf_dirname(__filename);`, esbuildOptions(options) { options.alias = { "@hyperframes/producer": resolve(__dirname, "../producer/src/index.ts"), + // esbuild's alias map treats `@hyperframes/producer` as a file path + // and would otherwise resolve `@hyperframes/producer/distributed` + // to `../producer/src/index.ts/distributed` (treating the file as a + // directory). Adding an explicit alias for every subpath we import + // avoids the prefix-substitution misfire. + "@hyperframes/producer/distributed": resolve(__dirname, "../producer/src/distributed.ts"), + // Same reason: the lambda CLI imports `@hyperframes/aws-lambda/sdk`, + // which would resolve to `../aws-lambda/src/index.ts/sdk` without + // an explicit subpath alias. The SDK subpath has its own barrel. + "@hyperframes/aws-lambda/sdk": resolve(__dirname, "../aws-lambda/src/sdk/index.ts"), // hf#732 lever-4: alias for the PNG decode+blit worker's import. // `alphaBlit.ts` is import-free (only zlib) so the worker survives // the worker_thread loader boundary directly via this TS source. From 8ff0fc26579232af1af6dbb77d0dd16e580775cb Mon Sep 17 00:00:00 2001 From: James Date: Sun, 17 May 2026 00:42:33 +0000 Subject: [PATCH 4/5] fix(cli): address PR review on lambda CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blockers + four important items from Vai's review: - `--memory` was parsed and recorded in the local state file but never forwarded to `sam deploy` as a parameter override. Worse, `progress.ts` then read the *recorded* value for cost math, so `--memory 5120` produced wrong cost numbers downstream. Thread `LambdaMemoryMb` through samDeploy's --parameter-overrides. - `--profile` was only consumed by deploy / destroy. render and progress fell back to the default credentials chain — a user with `--profile prod` would silently render against their default account (wrong-account billing footgun). Set `process.env.AWS_PROFILE` (and `AWS_REGION`) in the dispatcher before any subverb runs; the AWS SDK reads them natively, so render / progress / sites all benefit without each subverb threading the flag through the SDK call. - `--profile` + destroy now also reads `process.env.AWS_PROFILE` as a fallback (matching deploy's existing env fallback). - `--wait --json` printed both the start handle AND the final progress snapshot, producing two concatenated JSON blobs that `jq` rejected. Now emits a single document: handle (without --wait) OR final progress (with --wait). - Negative integers on `--width` / `--height` / `--chunk-size` / `--max-parallel-chunks` / `--memory` / `--concurrency` now fail loudly via a new `parsePositiveInt` wrapper instead of flowing into the SDK and producing opaque AWS validation errors mid- render. - `DEFAULT_STACK_NAME` is now centralized to the literal `"hyperframes-default"` and consumed from one place. Previously the value was assembled as `hyperframes-${"default"}` in three sites and hardcoded as `"hyperframes-default"` in a fourth. `requireStack`'s hint now matches the dispatcher's default. The faked `SiteHandle` for `--site-id` keeps the documented placeholder fields but also surfaces `bucketName` (from PR 909's extended SiteHandle interface), matching the SDK contract. All CLI unit tests + the full bundler build still pass. --- packages/cli/src/commands/lambda.ts | 47 +++++++++++++++++---- packages/cli/src/commands/lambda/deploy.ts | 3 +- packages/cli/src/commands/lambda/destroy.ts | 5 ++- packages/cli/src/commands/lambda/render.ts | 13 +++++- packages/cli/src/commands/lambda/sam.ts | 12 +++++- packages/cli/src/commands/lambda/sites.ts | 4 +- packages/cli/src/commands/lambda/state.ts | 7 ++- 7 files changed, 73 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/commands/lambda.ts b/packages/cli/src/commands/lambda.ts index a1da23a2d..50f620285 100644 --- a/packages/cli/src/commands/lambda.ts +++ b/packages/cli/src/commands/lambda.ts @@ -122,7 +122,22 @@ export default defineCommand({ return; } - const stackName = (args["stack-name"] as string | undefined) ?? "hyperframes-default"; + const stackName = + (args["stack-name"] as string | undefined) ?? + // Lazy-imported so the dispatcher doesn't pull state.ts (and its + // node:fs deps) on every CLI invocation — only on lambda runs. + (await import("./lambda/state.js")).DEFAULT_STACK_NAME; + + // Apply --profile globally before any AWS-SDK / `aws` / `sam` call runs. + // The AWS SDK + the SAM CLI both read AWS_PROFILE from the environment, + // so setting it here threads the value through render / progress / sites + // (which don't take an explicit awsProfile arg) without each subverb + // having to know about it. Region gets the same treatment so the SDK + // clients constructed inside the SDK pick it up too. + const profileFlag = args.profile as string | undefined; + if (profileFlag) process.env.AWS_PROFILE = profileFlag; + const regionFlag = args.region as string | undefined; + if (regionFlag) process.env.AWS_REGION = regionFlag; switch (subcommand) { case "deploy": { @@ -131,9 +146,9 @@ export default defineCommand({ stackName, region: args.region as string | undefined, awsProfile: args.profile as string | undefined, - reservedConcurrency: parseIntFlag(args.concurrency), + reservedConcurrency: parsePositiveInt(args.concurrency, "--concurrency"), chromeSource: parseChromeSource(args["chrome-source"]), - lambdaMemoryMb: parseIntFlag(args.memory), + lambdaMemoryMb: parsePositiveInt(args.memory, "--memory"), skipBuild: Boolean(args["skip-build"]), }); return; @@ -169,9 +184,9 @@ export default defineCommand({ ); process.exit(1); } - const width = parseIntFlag(args.width); - const height = parseIntFlag(args.height); - if (!width || !height) { + const width = parsePositiveInt(args.width, "--width"); + const height = parsePositiveInt(args.height, "--height"); + if (width === undefined || height === undefined) { console.error("[lambda render] --width and --height are required."); process.exit(1); } @@ -191,13 +206,13 @@ export default defineCommand({ format: parseFormat(args.format), codec: parseCodec(args.codec), quality: parseQuality(args.quality), - chunkSize: parseIntFlag(args["chunk-size"]), - maxParallelChunks: parseIntFlag(args["max-parallel-chunks"]), + chunkSize: parsePositiveInt(args["chunk-size"], "--chunk-size"), + maxParallelChunks: parsePositiveInt(args["max-parallel-chunks"], "--max-parallel-chunks"), executionName: args["execution-name"] as string | undefined, outputKey: args["output-key"] as string | undefined, json: Boolean(args.json), wait: Boolean(args.wait), - waitIntervalMs: parseIntFlag(args["wait-interval-ms"]) ?? 5000, + waitIntervalMs: parsePositiveInt(args["wait-interval-ms"], "--wait-interval-ms") ?? 5000, }); return; } @@ -231,6 +246,20 @@ function parseIntFlag(raw: unknown): number | undefined { return Number.isFinite(n) ? n : undefined; } +/** + * Parse a flag that must be a positive integer (>= 1) when supplied. + * Negative values or non-integers fail loudly instead of flowing into + * the SDK and producing opaque AWS validation errors mid-render. + */ +function parsePositiveInt(raw: unknown, flagName: string): number | undefined { + const n = parseIntFlag(raw); + if (n === undefined) return undefined; + if (!Number.isInteger(n) || n < 1) { + throw new Error(`[lambda] ${flagName} must be a positive integer; got ${n}`); + } + return n; +} + /** * Parse a string-union flag against a closed set of allowed values. * Returns `defaultValue` (which may be `undefined`) when the input is diff --git a/packages/cli/src/commands/lambda/deploy.ts b/packages/cli/src/commands/lambda/deploy.ts index 814cdc984..d6276c323 100644 --- a/packages/cli/src/commands/lambda/deploy.ts +++ b/packages/cli/src/commands/lambda/deploy.ts @@ -35,7 +35,7 @@ const DEFAULT_CONCURRENCY = 8; export async function runDeploy(args: Partial = {}): Promise { const resolved: DeployArgs = { - stackName: args.stackName ?? `hyperframes-${DEFAULT_STACK_NAME}`, + stackName: args.stackName ?? DEFAULT_STACK_NAME, region: args.region ?? process.env.AWS_REGION ?? DEFAULT_REGION, awsProfile: args.awsProfile ?? process.env.AWS_PROFILE, reservedConcurrency: args.reservedConcurrency ?? DEFAULT_CONCURRENCY, @@ -68,6 +68,7 @@ export async function runDeploy(args: Partial = {}): Promise { region: resolved.region, awsProfile: resolved.awsProfile, reservedConcurrency: resolved.reservedConcurrency, + lambdaMemoryMb: resolved.lambdaMemoryMb, chromeSource: resolved.chromeSource, }); diff --git a/packages/cli/src/commands/lambda/destroy.ts b/packages/cli/src/commands/lambda/destroy.ts index 65f789629..9295e438a 100644 --- a/packages/cli/src/commands/lambda/destroy.ts +++ b/packages/cli/src/commands/lambda/destroy.ts @@ -22,11 +22,14 @@ export interface DestroyArgs { export async function runDestroy(args: DestroyArgs): Promise { const stack = requireStack(args.stackName); console.log(c.dim(`→ sam delete (stack=${stack.stackName} region=${stack.region})`)); + // Mirror deploy.ts's AWS_PROFILE env fallback — `AWS_PROFILE=prod + // hyperframes lambda destroy` should hit the same account `deploy` + // did, not the default credentials chain. samDelete({ repoRoot: repoRoot(), stackName: stack.stackName, region: stack.region, - awsProfile: args.awsProfile, + awsProfile: args.awsProfile ?? process.env.AWS_PROFILE, }); deleteStackOutputs(args.stackName); console.log(); diff --git a/packages/cli/src/commands/lambda/render.ts b/packages/cli/src/commands/lambda/render.ts index fb879fede..e924ac2c8 100644 --- a/packages/cli/src/commands/lambda/render.ts +++ b/packages/cli/src/commands/lambda/render.ts @@ -51,9 +51,14 @@ export async function runRender(args: RenderArgs): Promise { runtimeCap: "lambda", }; + // When the caller passes only --site-id, synthesise the minimum-shape + // SiteHandle pointing at the deterministic content-addressed key. The + // `bytes` / `uploadedAt` fields are intentionally placeholders — the + // SDK reads only `siteId` + `projectS3Uri` when `uploaded: false`. const siteHandle = args.siteId ? { siteId: args.siteId, + bucketName: stack.bucketName, projectS3Uri: `s3://${stack.bucketName}/sites/${args.siteId}/project.tar.gz`, bytes: 0, uploadedAt: "", @@ -73,9 +78,15 @@ export async function runRender(args: RenderArgs): Promise { }); if (args.json) { - console.log(JSON.stringify(handle, null, 2)); + // --wait + --json should emit a single parseable JSON document: the + // final progress snapshot. Without --wait, emit the handle (the + // caller will poll progress separately). Previously this printed + // both, producing two concatenated JSON blobs that `jq -r` would + // misparse. if (args.wait) { await waitForCompletion(handle.executionArn, stack, args.waitIntervalMs, args.json); + } else { + console.log(JSON.stringify(handle, null, 2)); } return; } diff --git a/packages/cli/src/commands/lambda/sam.ts b/packages/cli/src/commands/lambda/sam.ts index b5fad810d..a1baf8497 100644 --- a/packages/cli/src/commands/lambda/sam.ts +++ b/packages/cli/src/commands/lambda/sam.ts @@ -52,6 +52,8 @@ export interface DeployOptions { region: string; awsProfile?: string; reservedConcurrency?: number; + /** Lambda memory in MB. Forwarded as the `LambdaMemoryMb` parameter override. */ + lambdaMemoryMb?: number; chromeSource?: "sparticuz" | "chrome-headless-shell"; /** Pass-through stdio. Defaults to "inherit" so SAM's progress lines stream live. */ stdio?: "inherit" | "pipe"; @@ -77,6 +79,13 @@ export function locateSamTemplate(repoRoot: string): string { /** Run `sam deploy` non-interactively. Returns when SAM exits 0; throws on non-zero. */ export function samDeploy(opts: DeployOptions): void { assertSamAvailable(); + const paramOverrides = [ + `ChromeSource=${opts.chromeSource ?? "sparticuz"}`, + `ReservedConcurrency=${opts.reservedConcurrency ?? -1}`, + ]; + if (opts.lambdaMemoryMb !== undefined) { + paramOverrides.push(`LambdaMemoryMb=${opts.lambdaMemoryMb}`); + } const args = [ "deploy", "--stack-name", @@ -89,8 +98,7 @@ export function samDeploy(opts: DeployOptions): void { "--no-confirm-changeset", "--no-fail-on-empty-changeset", "--parameter-overrides", - `ChromeSource=${opts.chromeSource ?? "sparticuz"}`, - `ReservedConcurrency=${opts.reservedConcurrency ?? -1}`, + ...paramOverrides, ]; if (opts.awsProfile) { args.push("--profile", opts.awsProfile); diff --git a/packages/cli/src/commands/lambda/sites.ts b/packages/cli/src/commands/lambda/sites.ts index 8a631024f..6bdcc93ab 100644 --- a/packages/cli/src/commands/lambda/sites.ts +++ b/packages/cli/src/commands/lambda/sites.ts @@ -49,9 +49,7 @@ export async function runSitesCreate(args: SitesCreateArgs): Promise { console.log( c.dim( `Render with: hyperframes lambda render ${args.projectDir} --site-id=${handle.siteId}` + - (args.stackName === `hyperframes-${DEFAULT_STACK_NAME}` - ? "" - : ` --stack-name=${args.stackName}`), + (args.stackName === DEFAULT_STACK_NAME ? "" : ` --stack-name=${args.stackName}`), ), ); } diff --git a/packages/cli/src/commands/lambda/state.ts b/packages/cli/src/commands/lambda/state.ts index 36bb7a41e..ccece6a0d 100644 --- a/packages/cli/src/commands/lambda/state.ts +++ b/packages/cli/src/commands/lambda/state.ts @@ -27,7 +27,12 @@ export interface StackOutputs { const STATE_DIR_NAME = ".hyperframes"; const STATE_FILE_PREFIX = "lambda-stack-"; -export const DEFAULT_STACK_NAME = "default"; +/** + * Default CloudFormation stack name used when the caller doesn't pass + * `--stack-name`. Centralised so deploy/destroy/sites/dispatcher all + * agree on the literal `"hyperframes-default"`. + */ +export const DEFAULT_STACK_NAME = "hyperframes-default"; export function stateFilePath( stackName: string = DEFAULT_STACK_NAME, From 51556b5f603c08d52d351789dc7133b1124eb347 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 17 May 2026 07:30:31 +0000 Subject: [PATCH 5/5] fix(cli): keep aws-lambda out of CLI runtime deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Smoke: global install" CI step packs the CLI via `npm pack` and installs it globally via `npm install -g `. npm doesn't understand the workspace: protocol, so a runtime `dependencies` entry of `@hyperframes/aws-lambda: workspace:*` blows up with: npm error code EUNSUPPORTEDPROTOCOL npm error Unsupported URL Type "workspace:": workspace:* (pnpm rewrites workspace:* on publish; npm pack doesn't.) Three changes to unblock the smoke + keep the published CLI install small for users who don't deploy to Lambda: - Move `@hyperframes/aws-lambda` from CLI's `dependencies` back to `devDependencies`. It's already external in tsup.config.ts; the bundle references it via runtime resolution only. - Convert the static `import { … } from "@hyperframes/aws-lambda/sdk"` in sites.ts / render.ts / progress.ts to `await import()` inside each function. tsup with `splitting: false` was inlining those static imports at the top of the bundle, which made Node eagerly resolve them at CLI startup (MODULE_NOT_FOUND before any lambda subcommand even runs). Dynamic imports stay dynamic in the bundle. - Add a friendly missing-module check in the lambda dispatcher. When a user runs `hyperframes lambda deploy / render / sites / progress / destroy` without aws-lambda installed, they now see: @hyperframes/aws-lambda is not installed. The `hyperframes lambda deploy` command needs it at runtime. Install it alongside the CLI: npm install -g @hyperframes/aws-lambda Verified locally: pack + global install + `hyperframes init --example blank` now succeeds end-to-end (was the same scenario the CI smoke job runs). --- packages/cli/package.json | 2 +- packages/cli/src/commands/lambda.ts | 24 ++++++++++++++++++++ packages/cli/src/commands/lambda/progress.ts | 4 +++- packages/cli/src/commands/lambda/render.ts | 14 ++++++++---- packages/cli/src/commands/lambda/sites.ts | 11 ++++++++- 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 5e68e1a57..105199018 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,7 +25,6 @@ }, "dependencies": { "@hono/node-server": "^1.8.0", - "@hyperframes/aws-lambda": "workspace:*", "@puppeteer/browsers": "^2.13.0", "adm-zip": "^0.5.16", "citty": "^0.2.1", @@ -43,6 +42,7 @@ }, "devDependencies": { "@clack/prompts": "^1.1.0", + "@hyperframes/aws-lambda": "workspace:*", "@hyperframes/core": "workspace:*", "@hyperframes/engine": "workspace:*", "@hyperframes/producer": "workspace:*", diff --git a/packages/cli/src/commands/lambda.ts b/packages/cli/src/commands/lambda.ts index 50f620285..a31f4261b 100644 --- a/packages/cli/src/commands/lambda.ts +++ b/packages/cli/src/commands/lambda.ts @@ -139,6 +139,30 @@ export default defineCommand({ const regionFlag = args.region as string | undefined; if (regionFlag) process.env.AWS_REGION = regionFlag; + // The lambda subverbs dynamic-import `@hyperframes/aws-lambda` at call + // time. We keep aws-lambda as a workspace devDependency (not a runtime + // 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"]); + if (verbsNeedingSDK.has(subcommand)) { + try { + await import("@hyperframes/aws-lambda/sdk"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ERR_MODULE_NOT_FOUND") { + console.error( + `${c.error("@hyperframes/aws-lambda is not installed.")} The ${c.accent(`hyperframes lambda ${subcommand}`)} command needs it at runtime.\n` + + `Install it alongside the CLI:\n` + + ` ${c.accent("npm install -g @hyperframes/aws-lambda")}\n` + + `Or, for an opt-in dev setup:\n` + + ` ${c.accent("npm install @hyperframes/aws-lambda")}`, + ); + process.exit(1); + } + throw err; + } + } + switch (subcommand) { case "deploy": { const { runDeploy } = await import("./lambda/deploy.js"); diff --git a/packages/cli/src/commands/lambda/progress.ts b/packages/cli/src/commands/lambda/progress.ts index 6bfe9f2d0..233777723 100644 --- a/packages/cli/src/commands/lambda/progress.ts +++ b/packages/cli/src/commands/lambda/progress.ts @@ -5,7 +5,6 @@ * from the stack's region + state-machine name). */ -import { getRenderProgress } from "@hyperframes/aws-lambda/sdk"; import { c } from "../../ui/colors.js"; import { requireStack } from "./state.js"; @@ -26,6 +25,9 @@ export async function runProgress(args: ProgressArgs): Promise { ? args.target : executionArnFromName(stack.stateMachineArn, args.target); + // 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. + const { getRenderProgress } = await import("@hyperframes/aws-lambda/sdk"); const progress = await getRenderProgress({ executionArn, region: stack.region, diff --git a/packages/cli/src/commands/lambda/render.ts b/packages/cli/src/commands/lambda/render.ts index e924ac2c8..34edc505f 100644 --- a/packages/cli/src/commands/lambda/render.ts +++ b/packages/cli/src/commands/lambda/render.ts @@ -5,13 +5,16 @@ */ import { resolve as resolvePath } from "node:path"; -import { - renderToLambda, - type SerializableDistributedRenderConfig, -} from "@hyperframes/aws-lambda/sdk"; +import type { SerializableDistributedRenderConfig } from "@hyperframes/aws-lambda/sdk"; import { c } from "../../ui/colors.js"; import { requireStack, stateFilePath } 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"); +} + export interface RenderArgs { projectDir: string; stackName: string; @@ -66,6 +69,7 @@ export async function runRender(args: RenderArgs): Promise { } : undefined; + const { renderToLambda } = await loadSDK(); const handle = await renderToLambda({ projectDir: siteHandle ? undefined : projectDir, siteHandle, @@ -112,7 +116,7 @@ async function waitForCompletion( json: boolean, ): Promise { // Lazy import to avoid pulling SFN client when only `render --no-wait` is used. - const { getRenderProgress } = await import("@hyperframes/aws-lambda/sdk"); + const { getRenderProgress } = await loadSDK(); let lastRendered = -1; while (true) { const progress = await getRenderProgress({ diff --git a/packages/cli/src/commands/lambda/sites.ts b/packages/cli/src/commands/lambda/sites.ts index 6bdcc93ab..3f949afa4 100644 --- a/packages/cli/src/commands/lambda/sites.ts +++ b/packages/cli/src/commands/lambda/sites.ts @@ -10,10 +10,18 @@ */ import { resolve as resolvePath } from "node:path"; -import { deploySite } from "@hyperframes/aws-lambda/sdk"; import { c } from "../../ui/colors.js"; import { DEFAULT_STACK_NAME, requireStack } from "./state.js"; +// `@hyperframes/aws-lambda` is a workspace devDependency in `packages/cli` +// so the published CLI install stays small for users who don't deploy to +// Lambda. The lambda subverbs dynamic-import it on call. The dispatcher in +// `commands/lambda.ts` checks the import resolves before any subverb runs +// and prints a friendly install hint on `ERR_MODULE_NOT_FOUND`. +async function loadSDK(): Promise { + return import("@hyperframes/aws-lambda/sdk"); +} + export interface SitesCreateArgs { projectDir: string; stackName: string; @@ -26,6 +34,7 @@ export async function runSitesCreate(args: SitesCreateArgs): Promise { const stack = requireStack(args.stackName); const projectDir = resolvePath(args.projectDir); + const { deploySite } = await loadSDK(); const handle = await deploySite({ projectDir, bucketName: stack.bucketName,