mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(cli): add hyperframes lambda deploy/render/progress/destroy (#910)
* feat(cli): add hyperframes lambda deploy/render/progress/destroy
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 <cwd>/.hyperframes/lambda-stack-<name>.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).
* 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.
* fix(cli): keep @hyperframes/aws-lambda external in the tsup bundle
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.
* fix(cli): address PR review on lambda CLI
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.
* fix(cli): keep aws-lambda out of CLI runtime deps
The "Smoke: global install" CI step packs the CLI via `npm pack` and
installs it globally via `npm install -g <tgz>`. 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).
This commit is contained in:
@@ -42,6 +42,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^1.1.0",
|
||||
"@hyperframes/aws-lambda": "workspace:*",
|
||||
"@hyperframes/core": "workspace:*",
|
||||
"@hyperframes/engine": "workspace:*",
|
||||
"@hyperframes/producer": "workspace:*",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* `hyperframes lambda` — top-level dispatcher for AWS Lambda subcommands.
|
||||
*
|
||||
* Each subverb lives in `./lambda/<name>.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("<subcommand> [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 <projectDir>`)",
|
||||
},
|
||||
|
||||
// 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-<uuid>)",
|
||||
},
|
||||
"output-key": {
|
||||
type: "string",
|
||||
description: "Final output S3 key (default: renders/<exec>/output.<ext>)",
|
||||
},
|
||||
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 <projectDir>",
|
||||
);
|
||||
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 <projectDir> --width <px> --height <px>",
|
||||
);
|
||||
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 <renderId | executionArn>",
|
||||
);
|
||||
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<T extends string>(
|
||||
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")!;
|
||||
@@ -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<DeployArgs> = {}): Promise<void> {
|
||||
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 <project-dir>`));
|
||||
}
|
||||
|
||||
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"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* `hyperframes lambda progress <executionArn>` — 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<void> {
|
||||
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:<region>:<account>:stateMachine:<sm-name>` →
|
||||
// `arn:aws:states:<region>:<account>:execution:<sm-name>:<exec-name>`
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* `hyperframes lambda render <projectDir>` — 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<typeof import("@hyperframes/aws-lambda/sdk")> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
return new Promise((res) => setTimeout(res, ms));
|
||||
}
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* `hyperframes lambda sites create <projectDir>` — 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<typeof import("@hyperframes/aws-lambda/sdk")> {
|
||||
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<void> {
|
||||
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}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 `<cwd>/.hyperframes/lambda-stack-<stackName>.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;
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user