mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +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:
@@ -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")!;
|
||||
Reference in New Issue
Block a user