diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cf02b84efc..e3dbef36ca 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,7 @@ jobs: publish: name: Publish runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 environment: npm-publish permissions: contents: write @@ -120,8 +120,59 @@ jobs: - run: corepack prepare pnpm@10.17.1 --activate - run: bun install --frozen-lockfile - run: bun run build + + # Before `npm publish`, so @hyperframes/aws-lambda pins this exact zip. + - name: Build Lambda handler zip + id: lambda_zip + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + REPO: ${{ github.repository }} + run: | + # A retried run reuses the zip already on the release, so the pin still matches it. + if gh release download "v${VERSION}" --repo "$REPO" --pattern hyperframes-lambda-handler.zip --dir dist; then + echo "attached=true" >> "$GITHUB_OUTPUT" + else + bun run --cwd packages/aws-lambda build:zip + fi + bun packages/aws-lambda/scripts/pin-handler-zip.ts + - run: bun run verify:packed-manifests + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + PRERELEASE: ${{ steps.version.outputs.prerelease }} + REPO: ${{ github.repository }} + run: | + NOTES_FILE="releases/v${VERSION}.md" + + # Skip if release already exists (idempotent re-runs) + if gh release view "v${VERSION}" --repo "$REPO" >/dev/null 2>&1; then + echo "Release v${VERSION} already exists — skipping" + else + FLAGS=(--repo "$REPO" --title "v${VERSION}") + if [ -f "$NOTES_FILE" ]; then + FLAGS+=(--notes-file "$NOTES_FILE") + else + echo "No reviewed release notes found at $NOTES_FILE — using GitHub generated notes" + FLAGS+=(--generate-notes) + fi + if [ "$PRERELEASE" = "true" ]; then + FLAGS+=(--prerelease) + fi + gh release create "v${VERSION}" "${FLAGS[@]}" + fi + + - name: Attach Lambda handler zip to release + if: steps.lambda_zip.outputs.attached != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + REPO: ${{ github.repository }} + run: gh release upload "v${VERSION}" dist/hyperframes-lambda-handler.zip dist/hyperframes-lambda-handler.sha256 --repo "$REPO" + - name: Publish packages env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -190,32 +241,6 @@ jobs: exit 1 fi - - name: Create GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ steps.version.outputs.version }} - PRERELEASE: ${{ steps.version.outputs.prerelease }} - REPO: ${{ github.repository }} - run: | - NOTES_FILE="releases/v${VERSION}.md" - - # Skip if release already exists (idempotent re-runs) - if gh release view "v${VERSION}" --repo "$REPO" >/dev/null 2>&1; then - echo "Release v${VERSION} already exists — skipping" - else - FLAGS=(--repo "$REPO" --title "v${VERSION}") - if [ -f "$NOTES_FILE" ]; then - FLAGS+=(--notes-file "$NOTES_FILE") - else - echo "No reviewed release notes found at $NOTES_FILE — using GitHub generated notes" - FLAGS+=(--generate-notes) - fi - if [ "$PRERELEASE" = "true" ]; then - FLAGS+=(--prerelease) - fi - gh release create "v${VERSION}" "${FLAGS[@]}" - fi - - name: Build portable agent plugin run: node scripts/package-agent-plugin.mjs diff --git a/.gitignore b/.gitignore index 4b2672de0f..13491b9750 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # Build output dist/ +packages/aws-lambda/prebuilt/ *.tsbuildinfo # Environment diff --git a/docs/deploy/aws-lambda.mdx b/docs/deploy/aws-lambda.mdx index e379ef3ebc..31fbf07390 100644 --- a/docs/deploy/aws-lambda.mdx +++ b/docs/deploy/aws-lambda.mdx @@ -46,9 +46,8 @@ The Lambda handler is a thin dispatch: parse the Step Functions event, download |------|-----|---------| | AWS credentials | The CLI and the deploy step both call AWS APIs. | Env vars, `~/.aws/credentials`, SSO, or IMDS — any chain the AWS SDK for JavaScript v3 would resolve. | | AWS SAM CLI | `hyperframes lambda deploy/destroy` shells out to `sam deploy`/`sam delete`. | [Install guide](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) | -| `bun` | Used to build `packages/aws-lambda/dist/handler.zip` at deploy time. | `npm install -g bun` or [bun.sh](https://bun.sh) | -| Lambda adapter | The published CLI loads the AWS adapter only for Lambda commands. | `npm install -g @hyperframes/aws-lambda` alongside the CLI | -| HyperFrames repo checkout | `lambda deploy` builds the Lambda handler ZIP from source. Adopters who deploy outside a checkout can set `HYPERFRAMES_REPO_ROOT` to point at one. | `git clone https://github.com/heygen-com/hyperframes` | +| Lambda adapter | The published CLI loads the AWS adapter only for Lambda commands. Each release pins a prebuilt Lambda handler ZIP (a GitHub release asset); `lambda deploy` downloads it, checks its SHA-256 and deploys it with the adapter's SAM template. No repo checkout needed. | `npm install -g @hyperframes/aws-lambda` alongside the CLI, at the same version | +| HyperFrames repo checkout (optional) | Inside a checkout, or with `HYPERFRAMES_REPO_ROOT` pointing at one, `lambda deploy` builds the handler ZIP from source instead (needs `bun`). Use it to deploy unreleased changes, `--chrome-source=chrome-headless-shell`, or an adapter release older than the prebuilt handler. | `git clone https://github.com/heygen-com/hyperframes`, and `npm install -g bun` or [bun.sh](https://bun.sh) | ## Three deployment paths diff --git a/packages/aws-lambda/package.json b/packages/aws-lambda/package.json index f5bec3e941..29dcd84d21 100644 --- a/packages/aws-lambda/package.json +++ b/packages/aws-lambda/package.json @@ -9,6 +9,7 @@ }, "files": [ "dist/", + "prebuilt/", "scripts/", "README.md" ], diff --git a/packages/aws-lambda/scripts/build-zip.ts b/packages/aws-lambda/scripts/build-zip.ts index 2856c6fb09..572507fc4f 100644 --- a/packages/aws-lambda/scripts/build-zip.ts +++ b/packages/aws-lambda/scripts/build-zip.ts @@ -111,10 +111,10 @@ async function main(): Promise { const opts = parseArgs(process.argv); const start = Date.now(); - rmSync(distDir, { recursive: true, force: true }); - mkdirSync(distDir, { recursive: true }); - + // Leave the package build in dist/: the release job zips after `bun run build`. const stagingDir = join(distDir, "staging"); + rmSync(stagingDir, { recursive: true, force: true }); + rmSync(join(distDir, "handler.zip"), { force: true }); mkdirSync(stagingDir, { recursive: true }); console.log(`[build-zip] source=${opts.source}`); diff --git a/packages/aws-lambda/scripts/pin-handler-zip.test.ts b/packages/aws-lambda/scripts/pin-handler-zip.test.ts new file mode 100644 index 0000000000..15d16dd1e8 --- /dev/null +++ b/packages/aws-lambda/scripts/pin-handler-zip.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { adapterPackageRoot, prebuiltHandler } from "../src/sdk/prebuiltHandler.js"; +import { pinHandlerZip } from "./pin-handler-zip.js"; + +const realTemplate = readFileSync( + join(import.meta.dir, "..", "..", "..", "examples", "aws-lambda", "template.yaml"), + "utf-8", +); +const dirs: string[] = []; + +function fixtureRepo(): { root: string; pkgDir: string } { + const root = mkdtempSync(join(tmpdir(), "hf-pin-zip-")); + dirs.push(root); + const pkgDir = join(root, "packages", "aws-lambda"); + mkdirSync(join(pkgDir, "dist"), { recursive: true }); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "@hyperframes/aws-lambda", + version: "9.8.7", + repository: { url: "https://github.com/heygen-com/hyperframes" }, + }), + ); + mkdirSync(join(root, "examples", "aws-lambda"), { recursive: true }); + writeFileSync(join(root, "examples", "aws-lambda", "template.yaml"), realTemplate); + return { root, pkgDir }; +} + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("pinHandlerZip", () => { + it("moves the built zip out of the package and pins its URL and sha256 for this version", () => { + const { root, pkgDir } = fixtureRepo(); + const zip = Buffer.from("zip bytes"); + writeFileSync(join(pkgDir, "dist", "handler.zip"), zip); + writeFileSync(join(pkgDir, "dist", "handler.zip.manifest.json"), "{}"); + const sha256 = createHash("sha256").update(zip).digest("hex"); + + const manifest = pinHandlerZip(root); + + expect(manifest).toEqual({ + version: "9.8.7", + url: "https://github.com/heygen-com/hyperframes/releases/download/v9.8.7/hyperframes-lambda-handler.zip", + sha256, + bytes: zip.length, + }); + // Nothing large is left for npm to pack. + expect(existsSync(join(pkgDir, "dist", "handler.zip"))).toBe(false); + expect(existsSync(join(pkgDir, "dist", "handler.zip.manifest.json"))).toBe(false); + expect(readFileSync(join(root, "dist", "hyperframes-lambda-handler.zip"))).toEqual(zip); + expect(readFileSync(join(root, "dist", "hyperframes-lambda-handler.sha256"), "utf-8")).toBe( + `${sha256} hyperframes-lambda-handler.zip\n`, + ); + + const pinned = prebuiltHandler(pkgDir); + expect(pinned).toEqual({ + ...manifest, + templatePath: join(pkgDir, "prebuilt", "template.yaml"), + }); + const template = readFileSync(pinned!.templatePath, "utf-8"); + expect(template).toContain(" CodeUri: handler.zip\n"); + expect(template).not.toContain("packages/aws-lambda/dist/handler.zip\n"); + }); + + it("pins a zip a previous run already attached to the release", () => { + const { root, pkgDir } = fixtureRepo(); + mkdirSync(join(root, "dist")); + writeFileSync(join(root, "dist", "hyperframes-lambda-handler.zip"), "attached"); + + expect(pinHandlerZip(root).sha256).toBe(createHash("sha256").update("attached").digest("hex")); + expect(prebuiltHandler(pkgDir)?.sha256).toBe(pinHandlerZip(root).sha256); + }); + + it("refuses a template whose CodeUri it cannot rewrite", () => { + const { root, pkgDir } = fixtureRepo(); + writeFileSync(join(pkgDir, "dist", "handler.zip"), "zip"); + writeFileSync( + join(root, "examples", "aws-lambda", "template.yaml"), + "CodeUri: elsewhere.zip\n", + ); + + expect(() => pinHandlerZip(root)).toThrow(/expected exactly one/); + }); +}); + +describe("prebuiltHandler", () => { + it("reads from its own package root", () => { + expect(adapterPackageRoot()).toBe(join(import.meta.dir, "..")); + }); + + it("is undefined when the package carries no pinned zip", () => { + const { pkgDir } = fixtureRepo(); + expect(prebuiltHandler(pkgDir)).toBeUndefined(); + }); +}); diff --git a/packages/aws-lambda/scripts/pin-handler-zip.ts b/packages/aws-lambda/scripts/pin-handler-zip.ts new file mode 100644 index 0000000000..eee4ab4a84 --- /dev/null +++ b/packages/aws-lambda/scripts/pin-handler-zip.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env bun +// Release step: writes the handler ZIP's pin (asset URL + sha256) and the SAM template into the +// package. A retried release run reuses the ZIP already on the release. +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + HANDLER_MANIFEST_FILE, + HANDLER_ZIP_ASSET, + type HandlerZipManifest, + PREBUILT_DIR, +} from "../src/sdk/prebuiltHandler.js"; + +const CHECKOUT_CODE_URI = "CodeUri: ../../packages/aws-lambda/dist/handler.zip"; + +export function pinHandlerZip(repoRoot: string): HandlerZipManifest { + const pkgDir = join(repoRoot, "packages", "aws-lambda"); + const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf-8")) as { + version: string; + repository: { url: string }; + }; + const asset = join(repoRoot, "dist", HANDLER_ZIP_ASSET); + const built = join(pkgDir, "dist", "handler.zip"); + if (existsSync(built)) { + mkdirSync(dirname(asset), { recursive: true }); + renameSync(built, asset); + rmSync(`${built}.manifest.json`, { force: true }); + } + const bytes = readFileSync(asset); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + writeFileSync(asset.replace(/\.zip$/, ".sha256"), `${sha256} ${HANDLER_ZIP_ASSET}\n`); + + const template = readFileSync(join(repoRoot, "examples", "aws-lambda", "template.yaml"), "utf-8"); + if (template.split(CHECKOUT_CODE_URI).length !== 2) { + throw new Error( + `[pin-handler-zip] expected exactly one "${CHECKOUT_CODE_URI}" in the template`, + ); + } + const outDir = join(pkgDir, PREBUILT_DIR); + mkdirSync(outDir, { recursive: true }); + writeFileSync( + join(outDir, "template.yaml"), + template.replace(CHECKOUT_CODE_URI, "CodeUri: handler.zip"), + ); + const manifest: HandlerZipManifest = { + version: pkg.version, + url: `${pkg.repository.url}/releases/download/v${pkg.version}/${HANDLER_ZIP_ASSET}`, + sha256, + bytes: bytes.length, + }; + writeFileSync(join(outDir, HANDLER_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`); + return manifest; +} + +if (import.meta.main) { + const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); + console.log(JSON.stringify(pinHandlerZip(repoRoot), null, 2)); +} diff --git a/packages/aws-lambda/src/sdk/index.ts b/packages/aws-lambda/src/sdk/index.ts index 23775a0a80..c03f7bbfbd 100644 --- a/packages/aws-lambda/src/sdk/index.ts +++ b/packages/aws-lambda/src/sdk/index.ts @@ -39,5 +39,6 @@ export { validateStepFunctionsInputSize, validateVariablesPayload, } from "./validateConfig.js"; +export { type PrebuiltHandler, prebuiltHandler } from "./prebuiltHandler.js"; export type { SerializableDistributedRenderConfig } from "../events.js"; export type { DistributedFormat } from "../formatExtension.js"; diff --git a/packages/aws-lambda/src/sdk/prebuiltHandler.ts b/packages/aws-lambda/src/sdk/prebuiltHandler.ts new file mode 100644 index 0000000000..6808530503 --- /dev/null +++ b/packages/aws-lambda/src/sdk/prebuiltHandler.ts @@ -0,0 +1,44 @@ +// The handler ZIP pinned to this package version; the release workflow writes `prebuilt/`. + +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const HANDLER_ZIP_ASSET = "hyperframes-lambda-handler.zip"; +export const PREBUILT_DIR = "prebuilt"; +export const HANDLER_MANIFEST_FILE = "handler-zip.json"; + +export interface HandlerZipManifest { + version: string; + url: string; + sha256: string; + bytes: number; +} + +export interface PrebuiltHandler extends HandlerZipManifest { + /** SAM template whose `CodeUri` is `handler.zip` in the same directory. */ + templatePath: string; +} + +/** This package's root, found by walking up from the running module (source or bundle). */ +export function adapterPackageRoot(): string { + const from = fileURLToPath(import.meta.url); + for (let dir = dirname(from); dir !== dirname(dir); dir = dirname(dir)) { + const pkg = join(dir, "package.json"); + if ( + existsSync(pkg) && + JSON.parse(readFileSync(pkg, "utf-8")).name === "@hyperframes/aws-lambda" + ) + return dir; + } + throw new Error(`[aws-lambda] no @hyperframes/aws-lambda package.json above ${from}`); +} + +/** The pinned handler for this install, or undefined when this build carries none. */ +export function prebuiltHandler(root: string = adapterPackageRoot()): PrebuiltHandler | undefined { + const dir = join(root, PREBUILT_DIR); + const manifestPath = join(dir, HANDLER_MANIFEST_FILE); + if (!existsSync(manifestPath)) return undefined; + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as HandlerZipManifest; + return { ...manifest, templatePath: join(dir, "template.yaml") }; +} diff --git a/packages/cli/src/commands/lambda.ts b/packages/cli/src/commands/lambda.ts index fdfb9d230c..7e02a7dcb8 100644 --- a/packages/cli/src/commands/lambda.ts +++ b/packages/cli/src/commands/lambda.ts @@ -230,16 +230,9 @@ export default defineCommand({ // 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", - "render-batch", - "progress", - "destroy", - ]); + // deploy to Lambda. These subverbs need it and get a friendly hint when it + // is missing; `deploy` reports that itself and `destroy` needs only SAM. + const verbsNeedingSDK = new Set(["sites", "render", "render-batch", "progress"]); if (verbsNeedingSDK.has(subcommand)) { try { await import("@hyperframes/aws-lambda/sdk"); diff --git a/packages/cli/src/commands/lambda/deploy.test.ts b/packages/cli/src/commands/lambda/deploy.test.ts new file mode 100644 index 0000000000..fa8579a36b --- /dev/null +++ b/packages/cli/src/commands/lambda/deploy.test.ts @@ -0,0 +1,61 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const root = vi.hoisted(() => ({ value: undefined as string | undefined })); +vi.mock("./repoRoot.js", () => ({ repoRoot: () => root.value })); +vi.mock("@hyperframes/aws-lambda/sdk", () => ({ prebuiltHandler: () => undefined })); +vi.mock("./sam.js", async (importOriginal) => ({ + ...(await importOriginal()), + samDeploy: vi.fn(), + fetchStackOutputs: vi.fn(() => ({ bucketName: "b", functionName: "f", stateMachineArn: "s" })), +})); +vi.mock("./state.js", () => ({ DEFAULT_STACK_NAME: "hf", writeStackOutputs: () => "state.json" })); + +const { runDeploy } = await import("./deploy.js"); +const { samDeploy } = await import("./sam.js"); + +let dir: string | undefined; +let quiet: { mockRestore(): void }; + +beforeEach(() => { + vi.mocked(samDeploy).mockClear(); + quiet = vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + quiet.mockRestore(); + root.value = undefined; + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = undefined; +}); + +describe("runDeploy", () => { + it("outside a checkout with no pinned handler, fails with the one message before running SAM", async () => { + await expect(runDeploy()).rejects.toThrow( + /no prebuilt Lambda handler is available[\s\S]*Docs:/, + ); + expect(samDeploy).not.toHaveBeenCalled(); + }); + + it("outside a checkout, refuses a Chrome source the prebuilt handler does not bundle", async () => { + await expect(runDeploy({ chromeSource: "chrome-headless-shell" })).rejects.toThrow( + /needs a hyperframes checkout/, + ); + expect(samDeploy).not.toHaveBeenCalled(); + }); + + it("inside a checkout, deploys the checkout's template as before", async () => { + dir = mkdtempSync(join(tmpdir(), "hf-deploy-checkout-")); + const samDir = join(dir, "examples", "aws-lambda"); + mkdirSync(samDir, { recursive: true }); + writeFileSync(join(samDir, "template.yaml"), "Resources: {}\n"); + mkdirSync(join(dir, "packages", "aws-lambda", "dist"), { recursive: true }); + writeFileSync(join(dir, "packages", "aws-lambda", "dist", "handler.zip"), "zip"); + root.value = dir; + + await runDeploy({ skipBuild: true }); + expect(vi.mocked(samDeploy).mock.calls[0]?.[0]).toMatchObject({ samDir }); + }); +}); diff --git a/packages/cli/src/commands/lambda/deploy.ts b/packages/cli/src/commands/lambda/deploy.ts index f6d342c025..823fdbaa5b 100644 --- a/packages/cli/src/commands/lambda/deploy.ts +++ b/packages/cli/src/commands/lambda/deploy.ts @@ -1,5 +1,6 @@ /** - * `hyperframes lambda deploy` — build the handler ZIP, sam-deploy the + * `hyperframes lambda deploy` — build the handler ZIP (or, outside a + * checkout, fetch the adapter's pinned prebuilt one), sam-deploy the * Phase 6a SAM template, and persist the stack outputs locally so the * other lambda subcommands don't need re-derive them. * @@ -9,8 +10,9 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { c } from "../../ui/colors.js"; +import { stagePrebuiltHandler } from "./prebuiltHandler.js"; import { fetchStackOutputs, locateSamTemplate, samDeploy } from "./sam.js"; import { repoRoot } from "./repoRoot.js"; import { DEFAULT_STACK_NAME, writeStackOutputs } from "./state.js"; @@ -41,26 +43,11 @@ export async function runDeploy(args: Partial = {}): Promise { 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.`, - ); - } - } + const samDir = await prepareSamDir(repoRoot(), resolved); console.log(c.dim(`→ sam deploy (stack=${resolved.stackName} region=${resolved.region})`)); samDeploy({ - repoRoot: root, + samDir, stackName: resolved.stackName, region: resolved.region, awsProfile: resolved.awsProfile, @@ -114,6 +101,31 @@ export async function runDeploy(args: Partial = {}): Promise { console.log(c.dim(`Render with: hyperframes lambda render `)); } +/** Checkout: build the ZIP from source. Otherwise: stage the adapter's pinned prebuilt ZIP. */ +async function prepareSamDir(root: string | undefined, args: DeployArgs): Promise { + if (!root) { + if (args.chromeSource !== "sparticuz") { + throw new Error( + "[lambda deploy] The prebuilt handler bundles sparticuz Chrome; --chrome-source=chrome-headless-shell needs a hyperframes checkout.", + ); + } + return stagePrebuiltHandler(); + } + const samDir = dirname(locateSamTemplate(root)); + if (!args.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.`, + ); + } + } + return samDir; +} + function buildHandlerZip(root: string): void { // bun run --cwd packages/aws-lambda build:zip const result = spawnSync( diff --git a/packages/cli/src/commands/lambda/destroy.ts b/packages/cli/src/commands/lambda/destroy.ts index 9295e438a5..0e2d94cacc 100644 --- a/packages/cli/src/commands/lambda/destroy.ts +++ b/packages/cli/src/commands/lambda/destroy.ts @@ -9,6 +9,8 @@ * rather than re-implementing the empty-and-delete dance here. */ +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { c } from "../../ui/colors.js"; import { deleteStackOutputs, requireStack } from "./state.js"; import { samDelete } from "./sam.js"; @@ -22,11 +24,12 @@ export interface DestroyArgs { export async function runDestroy(args: DestroyArgs): Promise { const stack = requireStack(args.stackName); console.log(c.dim(`→ sam delete (stack=${stack.stackName} region=${stack.region})`)); + const root = repoRoot(); // 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(), + samDir: root ? join(root, "examples", "aws-lambda") : tmpdir(), stackName: stack.stackName, region: stack.region, awsProfile: args.awsProfile ?? process.env.AWS_PROFILE, diff --git a/packages/cli/src/commands/lambda/prebuiltHandler.test.ts b/packages/cli/src/commands/lambda/prebuiltHandler.test.ts new file mode 100644 index 0000000000..9bf966a8d2 --- /dev/null +++ b/packages/cli/src/commands/lambda/prebuiltHandler.test.ts @@ -0,0 +1,122 @@ +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { noHandlerMessage, type StageDeps, stagePrebuiltHandler } from "./prebuiltHandler.js"; + +const ZIP = Buffer.from("prebuilt handler zip"); +const SHA = createHash("sha256").update(ZIP).digest("hex"); +const URL = "https://github.com/heygen-com/hyperframes/releases/download/v9.8.7/x.zip"; +const dirs: string[] = []; + +function setup(body: Buffer = ZIP) { + const root = mkdtempSync(join(tmpdir(), "hf-prebuilt-")); + dirs.push(root); + const templatePath = join(root, "template.yaml"); + writeFileSync(templatePath, "CodeUri: handler.zip\n"); + const downloads: string[] = []; + const logs: string[] = []; + const deps: StageDeps = { + loadAdapter: async () => ({ + prebuiltHandler: () => ({ + version: "9.8.7", + url: URL, + sha256: SHA, + bytes: ZIP.length, + templatePath, + }), + }), + download: async (url, dest) => { + downloads.push(url); + writeFileSync(dest, body); + }, + cacheDir: join(root, "cache"), + log: (line) => logs.push(line), + }; + return { deps, downloads, logs }; +} + +function missingModule(): Error { + return Object.assign(new Error("Cannot find package '@hyperframes/aws-lambda' imported from x"), { + code: "ERR_MODULE_NOT_FOUND", + }); +} + +afterEach(() => { + vi.unstubAllEnvs(); + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("stagePrebuiltHandler", () => { + it("downloads the pinned zip once, then reuses the verified copy beside the template", async () => { + const { deps, downloads, logs } = setup(); + + const dir = await stagePrebuiltHandler(deps); + expect(logs).toContain(`→ Verified sha256 ${SHA}`); + expect(dir).toBe(join(deps.cacheDir, `9.8.7-${SHA}`)); + expect(readFileSync(join(dir, "handler.zip"))).toEqual(ZIP); + expect(readFileSync(join(dir, "template.yaml"), "utf-8")).toBe("CodeUri: handler.zip\n"); + + expect(await stagePrebuiltHandler(deps)).toBe(dir); + expect(downloads).toEqual([URL]); + }); + + it("re-downloads a cached copy that no longer matches the pin", async () => { + const { deps, downloads } = setup(); + const dir = await stagePrebuiltHandler(deps); + writeFileSync(join(dir, "handler.zip"), "tampered"); + + await stagePrebuiltHandler(deps); + expect(readFileSync(join(dir, "handler.zip"))).toEqual(ZIP); + expect(downloads).toHaveLength(2); + }); + + it("refuses a download whose sha256 does not match and keeps nothing to deploy", async () => { + vi.stubEnv("HYPERFRAMES_LAMBDA_HANDLER_URL", "https://b.example/h.zip?X-Amz-Signature=secret"); + const { deps } = setup(Buffer.from("something else")); + + const refusal = stagePrebuiltHandler(deps); + await expect(refusal).rejects.toThrow( + /Refusing the handler ZIP from https:\/\/b\.example\/h\.zip: sha256/, + ); + await expect(refusal).rejects.not.toThrow(/secret/); + const dir = join(deps.cacheDir, `9.8.7-${SHA}`); + expect(existsSync(join(dir, "handler.zip"))).toBe(false); + expect(existsSync(join(dir, "handler.zip.download"))).toBe(false); + expect(existsSync(join(dir, "template.yaml"))).toBe(false); + }); + + it("fetches from HYPERFRAMES_LAMBDA_HANDLER_URL when set, still against the pinned sha256", async () => { + vi.stubEnv("HYPERFRAMES_LAMBDA_HANDLER_URL", "https://mirror.example/handler.zip"); + const { deps, downloads } = setup(); + + await stagePrebuiltHandler(deps); + expect(downloads).toEqual(["https://mirror.example/handler.zip"]); + }); +}); + +describe("stagePrebuiltHandler with nothing to deploy", () => { + it("names the adapter install, the checkout alternative and the docs when the adapter is missing", async () => { + const { deps, downloads } = setup(); + deps.loadAdapter = async () => { + throw missingModule(); + }; + + await expect(stagePrebuiltHandler(deps)).rejects.toThrow(noHandlerMessage(false)); + const message = noHandlerMessage(false); + expect(message).toMatch(/npm install -g @hyperframes\/aws-lambda@/); + expect(message).toMatch(/HYPERFRAMES_REPO_ROOT/); + expect(message).toMatch(/^Docs: https:\/\/hyperframes\.heygen\.com\/deploy\/aws-lambda$/m); + expect(downloads).toEqual([]); + }); + + it("says the installed adapter has no prebuilt handler when it pins none", async () => { + const { deps, downloads } = setup(); + deps.loadAdapter = async () => ({}); + + await expect(stagePrebuiltHandler(deps)).rejects.toThrow(noHandlerMessage(true)); + expect(noHandlerMessage(true)).toMatch(/has no prebuilt handler for its version/); + expect(downloads).toEqual([]); + }); +}); diff --git a/packages/cli/src/commands/lambda/prebuiltHandler.ts b/packages/cli/src/commands/lambda/prebuiltHandler.ts new file mode 100644 index 0000000000..5849ba1f68 --- /dev/null +++ b/packages/cli/src/commands/lambda/prebuiltHandler.ts @@ -0,0 +1,88 @@ +// `lambda deploy` without a repo checkout: fetch the pinned handler ZIP, verify its sha256 and +// stage it beside the packaged SAM template. + +import { createHash } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { downloadFile } from "../../utils/download.js"; +import { isMissingPackageError } from "../../utils/missingPackage.js"; +import { VERSION } from "../../version.js"; + +const DOCS_URL = "https://hyperframes.heygen.com/deploy/aws-lambda"; +const HANDLER_CACHE_DIR = join(homedir(), ".cache", "hyperframes", "lambda-handler"); + +type AdapterSdk = typeof import("@hyperframes/aws-lambda/sdk"); + +export interface StageDeps { + loadAdapter: () => Promise>; + download: (url: string, dest: string, options: { maxBytes: number }) => Promise; + cacheDir: string; + log: (line: string) => void; +} + +const defaultDeps: StageDeps = { + loadAdapter: () => import("@hyperframes/aws-lambda/sdk"), + download: downloadFile, + cacheDir: HANDLER_CACHE_DIR, + log: (line) => console.log(line), +}; + +/** The one message for "no checkout and no pinned handler": everything the user needs to fix it. */ +export function noHandlerMessage(adapterInstalled: boolean): string { + const adapter = adapterInstalled + ? " - The installed @hyperframes/aws-lambda has no prebuilt handler for its version. Install one that does:\n" + : " - Install the Lambda adapter; its release pins the prebuilt handler:\n"; + return ( + "[lambda deploy] This is not a HyperFrames checkout, and no prebuilt Lambda handler is available.\n" + + adapter + + ` npm install -g @hyperframes/aws-lambda@${VERSION}\n` + + " - Or run from a hyperframes checkout, or set HYPERFRAMES_REPO_ROOT to one.\n" + + `Docs: ${DOCS_URL}` + ); +} + +function sha256File(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +/** Returns the directory holding the verified `handler.zip` and its `template.yaml`. */ +export async function stagePrebuiltHandler(deps: StageDeps = defaultDeps): Promise { + let sdk: Partial; + try { + sdk = await deps.loadAdapter(); + } catch (err) { + if (isMissingPackageError(err, "@hyperframes/aws-lambda")) + throw new Error(noHandlerMessage(false)); + throw err; + } + // Adapters released before handler zips were published lack prebuiltHandler. + const pinned = sdk.prebuiltHandler?.(); + if (!pinned) throw new Error(noHandlerMessage(true)); + + const dir = join(deps.cacheDir, `${pinned.version}-${pinned.sha256}`); + const zip = join(dir, "handler.zip"); + if (existsSync(zip) && sha256File(zip) === pinned.sha256) { + deps.log(`→ Using verified handler ZIP ${pinned.version} from ${dir}`); + } else { + // Override for mirrors and pre-release testing; the pinned sha256 still decides. + const url = process.env.HYPERFRAMES_LAMBDA_HANDLER_URL ?? pinned.url; + deps.log(`→ Downloading handler ZIP ${pinned.version} (${pinned.bytes} bytes)`); + mkdirSync(dir, { recursive: true }); + const partial = `${zip}.download`; + await deps.download(url, partial, { maxBytes: pinned.bytes }); + const actual = sha256File(partial); + if (actual !== pinned.sha256) { + unlinkSync(partial); + const urlWithoutCredentials = url.split("?")[0]; + throw new Error( + `[lambda deploy] Refusing the handler ZIP from ${urlWithoutCredentials}: sha256 ${actual} does not match ` + + `the ${pinned.sha256} pinned by @hyperframes/aws-lambda@${pinned.version}.`, + ); + } + renameSync(partial, zip); + deps.log(`→ Verified sha256 ${pinned.sha256}`); + } + copyFileSync(pinned.templatePath, join(dir, "template.yaml")); + return dir; +} diff --git a/packages/cli/src/commands/lambda/repoRoot.ts b/packages/cli/src/commands/lambda/repoRoot.ts index 93268b31d2..13de3aac17 100644 --- a/packages/cli/src/commands/lambda/repoRoot.ts +++ b/packages/cli/src/commands/lambda/repoRoot.ts @@ -4,15 +4,15 @@ * 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. + * contains `packages/aws-lambda/`. Undefined outside a checkout (the + * published CLI), where deploy uses the adapter's prebuilt handler. */ import { existsSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -export function repoRoot(): string { +export function repoRoot(): string | undefined { const override = process.env.HYPERFRAMES_REPO_ROOT; if (override) { if (existsSync(resolve(override, "packages", "aws-lambda", "package.json"))) return override; @@ -30,8 +30,5 @@ export function repoRoot(): string { 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.", - ); + return undefined; } diff --git a/packages/cli/src/commands/lambda/sam.test.ts b/packages/cli/src/commands/lambda/sam.test.ts index fca06f73be..9b9f177236 100644 --- a/packages/cli/src/commands/lambda/sam.test.ts +++ b/packages/cli/src/commands/lambda/sam.test.ts @@ -47,12 +47,12 @@ describe("samDeploy", () => { // An omitted override makes SAM send UsePreviousValue, so a redeploy keeps an existing cap. it("sends no ReservedConcurrency when --concurrency is omitted", () => { - samDeploy({ repoRoot: "/repo", stackName: "s", region: "us-east-2" }); + samDeploy({ samDir: "/repo", stackName: "s", region: "us-east-2" }); expect(overrides()).not.toContain("ReservedConcurrency"); }); it("forwards an explicit --concurrency", () => { - samDeploy({ repoRoot: "/repo", stackName: "s", region: "us-east-2", reservedConcurrency: 8 }); + samDeploy({ samDir: "/repo", stackName: "s", region: "us-east-2", reservedConcurrency: 8 }); expect(overrides()).toContain("ReservedConcurrency=8"); }); }); @@ -84,7 +84,7 @@ describe("parseReservedConcurrency", () => { for (const bad of ["abc", "0", "-1", "2.5"]) { expect(() => parseReservedConcurrency(bad)).toThrow('positive integer or "none"'); } - samDeploy({ repoRoot: "/repo", stackName: "s", region: "r", reservedConcurrency: -1 }); + samDeploy({ samDir: "/repo", stackName: "s", region: "r", reservedConcurrency: -1 }); expect(vi.mocked(spawnSync).mock.lastCall?.[1]).toContain("ReservedConcurrency=-1"); }); }); diff --git a/packages/cli/src/commands/lambda/sam.ts b/packages/cli/src/commands/lambda/sam.ts index 4804e93494..0020dd6f55 100644 --- a/packages/cli/src/commands/lambda/sam.ts +++ b/packages/cli/src/commands/lambda/sam.ts @@ -45,8 +45,8 @@ function assertAwsCliAvailable(): void { } export interface DeployOptions { - /** Repository root — the SAM template lives at `examples/aws-lambda/template.yaml` underneath. */ - repoRoot: string; + /** Directory holding the `template.yaml` to deploy; SAM runs from here. */ + samDir: string; /** CloudFormation stack name. */ stackName: string; region: string; @@ -60,8 +60,8 @@ export interface DeployOptions { } /** - * Resolve the SAM template path relative to `repoRoot`. No installed-package fallback: the - * template's `CodeUri` is relative to the monorepo, so it only works inside a checkout. + * Resolve the checkout's SAM template. Its `CodeUri` is relative to the monorepo; outside a + * checkout deploy uses the adapter's packaged template instead (see prebuiltHandler.ts). */ export function locateSamTemplate(repoRoot: string): string { const candidate = join(repoRoot, "examples", "aws-lambda", "template.yaml"); @@ -116,8 +116,7 @@ export function samDeploy(opts: DeployOptions): void { 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" }); + const result = spawnSync("sam", args, { cwd: opts.samDir, stdio: opts.stdio ?? "inherit" }); if (result.status !== 0) { throw new Error( `[lambda] sam deploy exited with code ${result.status ?? "unknown"}\n` + @@ -132,7 +131,7 @@ export function samDeploy(opts: DeployOptions): void { /** Run `sam delete` non-interactively. */ export function samDelete(opts: { - repoRoot: string; + samDir: string; stackName: string; region: string; awsProfile?: string; @@ -143,8 +142,7 @@ export function samDelete(opts: { 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" }); + const result = spawnSync("sam", args, { cwd: opts.samDir, stdio: opts.stdio ?? "inherit" }); if (result.status !== 0) { throw new Error(`[lambda] sam delete exited with code ${result.status ?? "unknown"}`); } diff --git a/packages/cli/src/utils/download.test.ts b/packages/cli/src/utils/download.test.ts index 1214210a0f..2aca1fcc18 100644 --- a/packages/cli/src/utils/download.test.ts +++ b/packages/cli/src/utils/download.test.ts @@ -270,6 +270,24 @@ describe("redirect handling", () => { expect(readdirSync(dir)).toEqual([]); }); + it("keeps a signed query out of the redirect-limit error", async () => { + mockGet.mockImplementation(httpsResponse(302, { location: "/loop" })); + const dir = mkdtempSync(join(tmpdir(), "hyperframes-download-redirect-")); + tempDirs.push(dir); + + const error = await downloadFile( + "https://example.test/handler.zip?X-Amz-Signature=secret", + join(dir, "handler.zip"), + ).then( + () => { + throw new Error("expected the download to fail"); + }, + (caught: unknown) => caught as Error, + ); + expect(error.message).toContain("https://example.test/handler.zip"); + expect(error.message).not.toContain("secret"); + }); + it.each([302, 404, 503])("closes rejected HTTP %s responses", async (status) => { const responses: IncomingMessage[] = []; mockGet.mockImplementation( diff --git a/packages/cli/src/utils/download.ts b/packages/cli/src/utils/download.ts index b8911085b4..60df467445 100644 --- a/packages/cli/src/utils/download.ts +++ b/packages/cli/src/utils/download.ts @@ -113,7 +113,9 @@ export function downloadFile( if (hops >= MAX_REDIRECTS) { removePartialFile(tmp); reject( - new Error(`Download failed: more than ${MAX_REDIRECTS} redirects from ${url}`), + new Error( + `Download failed: more than ${MAX_REDIRECTS} redirects from ${url.split("?")[0]}`, + ), ); return; } diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index d0af3d6dda..448b146ef2 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -1,5 +1,5 @@ { - "@hyperframes/aws-lambda": { "packed": 60823, "unpacked": 209843 }, + "@hyperframes/aws-lambda": { "packed": 70000, "unpacked": 244000 }, "@hyperframes/cli": { "packed": 7517641, "unpacked": 22401400 }, "@hyperframes/core": { "packed": 1636442, "unpacked": 3903211 }, "@hyperframes/engine": { "packed": 428347, "unpacked": 1436293 }, diff --git a/scripts/publish-workflow.test.mjs b/scripts/publish-workflow.test.mjs index 051543daff..252d1955c3 100644 --- a/scripts/publish-workflow.test.mjs +++ b/scripts/publish-workflow.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -131,3 +131,68 @@ test("stable release tag creation survives retries and rejects a mismatched comm rmSync(root, { recursive: true, force: true }); } }); + +test("the Lambda handler zip is pinned and attached to the release before npm publish", () => { + const names = publish.steps.map((step) => step.name); + const at = (name) => { + assert.ok(names.includes(name), `missing step ${name}`); + return names.indexOf(name); + }; + assert.ok(at("Build Lambda handler zip") < at("Create GitHub Release")); + assert.ok(at("Create GitHub Release") < at("Attach Lambda handler zip to release")); + assert.ok(at("Attach Lambda handler zip to release") < at("Publish packages")); + const attach = publish.steps[at("Attach Lambda handler zip to release")]; + assert.equal(attach.if, "steps.lambda_zip.outputs.attached != 'true'"); + assert.doesNotMatch(attach.run, /--clobber/); +}); + +test("a retried release reuses the attached handler zip instead of building a new one", () => { + const step = publish.steps.find((s) => s.name === "Build Lambda handler zip"); + const run = (ghExit) => { + const root = mkdtempSync(join(tmpdir(), "hyperframes-lambda-zip-step-")); + try { + const bin = join(root, "bin"); + mkdirSync(bin); + const log = join(root, "calls.log"); + for (const [name, exit] of [ + ["gh", ghExit], + ["bun", 0], + ]) { + writeFileSync(join(bin, name), `#!/bin/sh\necho "${name} $*" >> "${log}"\nexit ${exit}\n`); + chmodSync(join(bin, name), 0o755); + } + const output = join(root, "github-output"); + writeFileSync(output, ""); + const result = spawnSync("bash", ["-euo", "pipefail", "-c", step.run], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + GITHUB_OUTPUT: output, + VERSION: "9.8.7", + REPO: "o/r", + }, + timeout: 5_000, + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + return { calls: readFileSync(log, "utf8"), output: readFileSync(output, "utf8") }; + } finally { + rmSync(root, { recursive: true, force: true }); + } + }; + + const retried = run(0); + assert.match( + retried.calls, + /^gh release download v9\.8\.7 --repo o\/r --pattern hyperframes-lambda-handler\.zip --dir dist$/m, + ); + assert.doesNotMatch(retried.calls, /build:zip/); + assert.match(retried.calls, /^bun packages\/aws-lambda\/scripts\/pin-handler-zip\.ts$/m); + assert.equal(retried.output, "attached=true\n"); + + const fresh = run(1); + assert.match(fresh.calls, /^bun run --cwd packages\/aws-lambda build:zip$/m); + assert.match(fresh.calls, /^bun packages\/aws-lambda\/scripts\/pin-handler-zip\.ts$/m); + assert.equal(fresh.output, ""); +});