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..a31f4261b --- /dev/null +++ b/packages/cli/src/commands/lambda.ts @@ -0,0 +1,317 @@ +/** + * `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) ?? + // 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; + + // 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"); + await runDeploy({ + stackName, + region: args.region as string | undefined, + awsProfile: args.profile as string | undefined, + reservedConcurrency: parsePositiveInt(args.concurrency, "--concurrency"), + chromeSource: parseChromeSource(args["chrome-source"]), + lambdaMemoryMb: parsePositiveInt(args.memory, "--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 = 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); + } + 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: 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: parsePositiveInt(args["wait-interval-ms"], "--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; +} + +/** + * 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 + * 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 ((allowed as readonly string[]).includes(s)) return s as T; + throw new Error(`${errorPrefix} must be ${allowed.join("|")}; 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; + +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/deploy.ts b/packages/cli/src/commands/lambda/deploy.ts new file mode 100644 index 000000000..d6276c323 --- /dev/null +++ b/packages/cli/src/commands/lambda/deploy.ts @@ -0,0 +1,114 @@ +/** + * `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 ?? 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, + lambdaMemoryMb: resolved.lambdaMemoryMb, + 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..9295e438a --- /dev/null +++ b/packages/cli/src/commands/lambda/destroy.ts @@ -0,0 +1,42 @@ +/** + * `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})`)); + // 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 ?? process.env.AWS_PROFILE, + }); + 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..233777723 --- /dev/null +++ b/packages/cli/src/commands/lambda/progress.ts @@ -0,0 +1,84 @@ +/** + * `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 { 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); + + // 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, + 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..34edc505f --- /dev/null +++ b/packages/cli/src/commands/lambda/render.ts @@ -0,0 +1,160 @@ +/** + * `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 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; + 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", + }; + + // 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: "", + uploaded: false, + } + : undefined; + + const { renderToLambda } = await loadSDK(); + 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) { + // --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; + } + + 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 loadSDK(); + 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..a1baf8497 --- /dev/null +++ b/packages/cli/src/commands/lambda/sam.ts @@ -0,0 +1,183 @@ +/** + * 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; + /** 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"; +} + +/** + * 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 paramOverrides = [ + `ChromeSource=${opts.chromeSource ?? "sparticuz"}`, + `ReservedConcurrency=${opts.reservedConcurrency ?? -1}`, + ]; + if (opts.lambdaMemoryMb !== undefined) { + paramOverrides.push(`LambdaMemoryMb=${opts.lambdaMemoryMb}`); + } + 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", + ...paramOverrides, + ]; + 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..3f949afa4 --- /dev/null +++ b/packages/cli/src/commands/lambda/sites.ts @@ -0,0 +1,64 @@ +/** + * `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 { 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; + 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 { deploySite } = await loadSDK(); + 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 === 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..ccece6a0d --- /dev/null +++ b/packages/cli/src/commands/lambda/state.ts @@ -0,0 +1,99 @@ +/** + * 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-"; +/** + * 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, + 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; +} 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, 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.