diff --git a/.claude/CUSTOM-AGENTS-GUIDE.md b/.claude/CUSTOM-AGENTS-GUIDE.md index 44968cf..0e3dbd3 100644 --- a/.claude/CUSTOM-AGENTS-GUIDE.md +++ b/.claude/CUSTOM-AGENTS-GUIDE.md @@ -89,7 +89,7 @@ Integration testing, contract testing, load testing for APIs. ### devops-automator (blue) Docker, CI/CD pipeline generation, deployment automation. -- **When to use:** Setting up Docker, creating CI/CD, deploying to Cloudflare Workers or Node.js +- **When to use:** Setting up Docker, creating CI/CD, deploying to Cloudflare Workers, Node.js, or AWS Lambda - **Key capabilities:** Multi-stage Docker, GitHub Actions, wrangler deploy, PM2, health endpoints --- diff --git a/.claude/agents/devops-automator.md b/.claude/agents/devops-automator.md index 7f90e98..e227dd3 100644 --- a/.claude/agents/devops-automator.md +++ b/.claude/agents/devops-automator.md @@ -1,6 +1,6 @@ --- name: devops-automator -description: API deployment and CI/CD specialist for Docker, GitHub Actions, Cloudflare Workers, and Node.js deployments. Use when setting up deployment pipelines, configuring Docker, managing Workers deployments, or automating build processes. +description: API deployment and CI/CD specialist for Docker, GitHub Actions, Cloudflare Workers, Node.js, and AWS Lambda deployments. Use when setting up deployment pipelines, configuring Docker, managing Workers deployments, deploying with SAM, or automating build processes. color: blue tools: Bash, Read, Write, Grep, MultiEdit --- diff --git a/.claude/pipeline.config.json b/.claude/pipeline.config.json index 0b1293a..d200a11 100644 --- a/.claude/pipeline.config.json +++ b/.claude/pipeline.config.json @@ -68,7 +68,7 @@ "noAnyTypes": true }, "deployment": { - "targets": ["cloudflare-workers", "node-docker"], + "targets": ["cloudflare-workers", "node-docker", "aws-lambda"], "defaultTarget": "cloudflare-workers", "cloudflareWorkers": { "compatibilityDate": "2024-12-01", @@ -86,6 +86,16 @@ "processManager": "node", "multiStage": true }, + "awsLambda": { + "runtime": "nodejs22.x", + "architecture": "arm64", + "memorySizeMb": 512, + "timeoutSeconds": 28, + "apiGatewayType": "http-api", + "bundler": "esbuild", + "framework": "sam", + "provisionedConcurrency": 0 + }, "cicd": { "provider": "github-actions", "runTests": true, diff --git a/.claude/skills/deployment-config-generator/SKILL.md b/.claude/skills/deployment-config-generator/SKILL.md index 73bec0c..70e8bfd 100644 --- a/.claude/skills/deployment-config-generator/SKILL.md +++ b/.claude/skills/deployment-config-generator/SKILL.md @@ -3,10 +3,13 @@ name: deployment-config-generator description: > Generates deployment configuration based on the target platform specified in build-spec.json. For Cloudflare Workers, generates wrangler.toml with bindings. - For Node.js, generates Dockerfile and docker-compose.yml with PostgreSQL. Always - generates GitHub Actions CI/CD workflow and .env.example. Keywords: deploy, - deployment, wrangler, cloudflare, workers, docker, dockerfile, docker-compose, - github-actions, ci-cd, pipeline, env, configuration, infrastructure + For Node.js, generates Dockerfile and docker-compose.yml with PostgreSQL. For + AWS Lambda, generates a SAM template (API Gateway HTTP API), esbuild bundling + config, and an OIDC deploy workflow. Always generates GitHub Actions CI/CD + workflow and .env.example. Keywords: deploy, deployment, wrangler, cloudflare, + workers, docker, dockerfile, docker-compose, aws, lambda, sam, api-gateway, + serverless, esbuild, oidc, cold-start, github-actions, ci-cd, pipeline, env, + configuration, infrastructure --- # Deployment Config Generator (Phase 5) @@ -42,7 +45,7 @@ const spec = JSON.parse( await readFile('.claude/plans/build-spec.json', 'utf-8'), ); -const target = spec.deployment.target; // 'cloudflare-workers' | 'node' +const target = spec.deployment.target; // 'cloudflare-workers' | 'node' | 'aws-lambda' const authStrategy = spec.auth.strategy; ``` @@ -245,6 +248,105 @@ serve({ }); ``` +### Step 2c -- AWS Lambda Configuration + +If target is `aws-lambda`, generate a SAM template, esbuild bundling config, and +the Lambda entry point. Canonical templates live in `templates/aws-lambda/` +(`template.yaml`, `samconfig.toml`, `esbuild.config.mjs`, `deploy.yml`, +`docker-compose.yml` for the local dev database). + +The Lambda entry point uses Hono's AWS Lambda adapter. The adapter ships inside +the core `hono` package (`hono/aws-lambda`) -- there is no separate package to +install: + +```typescript +// api/src/lambda.ts +import { handle } from 'hono/aws-lambda'; +import app from './index.js'; + +export const handler = handle(app); +``` + +Key pieces of `api/template.yaml`: + +```yaml +Globals: + Function: + Runtime: nodejs22.x + Architectures: [arm64] # Graviton: cheaper, faster cold starts + MemorySize: 512 # CPU scales with memory; right-size with Power Tuning + Timeout: 28 # below API Gateway's hard 30s integration timeout + +Resources: + HttpApi: + Type: AWS::Serverless::HttpApi # HTTP API (v2), NOT REST API + Properties: + StageName: $default + + ApiFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: dist/ # prebuilt esbuild bundle + Handler: lambda.handler + Events: + Root: + Type: HttpApi # no Path/Method = $default catch-all; + Properties: # Hono does the routing + ApiId: !Ref HttpApi +``` + +Bundling: `esbuild.config.mjs` produces a single minified `dist/lambda.mjs` +(`pnpm build:lambda`). `sam deploy` zips and uploads `dist/` directly -- do not +use `sam build` (its esbuild builder runs npm and conflicts with pnpm). + +Add the build/deploy scripts to package.json: + +```json +{ + "scripts": { + "build:lambda": "node esbuild.config.mjs", + "deploy": "pnpm run build:lambda && sam deploy" + } +} +``` + +(Invoke as `pnpm run deploy` -- bare `pnpm deploy` is a pnpm built-in.) + +Cold start notes to include in the generated README: + +- Single-file esbuild bundle: no node_modules to unzip/resolve at init +- arm64 + memory sizing (CPU scales with memory; benchmark with AWS Lambda + Power Tuning before raising the 512 MB floor) +- Reuse module-scope DB connections across warm invocations; cap the pool at + `max: 1` per instance and point DATABASE_URL at RDS Proxy or a serverless + driver so Lambda concurrency cannot exhaust Postgres connections +- Provisioned concurrency for latency-critical routes (SnapStart is not + available for Node.js runtimes) +- Container-image alternative: AWS Lambda Web Adapter runs the Node.js server + build unchanged (useful past the 250 MB zip limit; slower cold starts) + +Deployment uses GitHub OIDC (no long-lived AWS keys). Generate +`.github/workflows/deploy.yml` from `templates/aws-lambda/deploy.yml`: + +```yaml +permissions: + id-token: write # required for OIDC federation + contents: read + +# ... + +- uses: aws-actions/setup-sam@v2 + with: + use-installer: true +- uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} +- run: >- + sam deploy --no-confirm-changeset --no-fail-on-empty-changeset + --parameter-overrides "DatabaseUrl=${{ secrets.DATABASE_URL }} JwtSecret=${{ secrets.JWT_SECRET }}" +``` + ### Step 3 -- Generate GitHub Actions CI/CD ```yaml @@ -339,6 +441,14 @@ jobs: # For Node.js / Docker: # - run: docker build -t myapi:${{ github.sha }} ./api # - run: docker push myapi:${{ github.sha }} + # For AWS Lambda (OIDC -- job also needs `permissions: id-token: write`, + # see Step 2c): + # - uses: aws-actions/setup-sam@v2 + # - uses: aws-actions/configure-aws-credentials@v4 + # with: + # role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + # aws-region: ${{ vars.AWS_REGION }} + # - run: cd api && pnpm run build:lambda && sam deploy --no-confirm-changeset deploy-production: runs-on: ubuntu-latest @@ -412,9 +522,13 @@ Ensure the API package.json has all necessary scripts: | Wrangler config | `api/wrangler.toml` | Cloudflare Workers deployment | | Dockerfile | `api/Dockerfile` | Node.js container build | | Docker Compose | `docker-compose.yml` | Full stack local/prod setup | +| SAM template | `api/template.yaml` | AWS Lambda + API Gateway HTTP API stack | +| SAM config | `api/samconfig.toml` | Stack name, region, deploy defaults | +| esbuild config | `api/esbuild.config.mjs` | Single-file Lambda bundle build | | CI/CD workflow | `.github/workflows/ci.yml` | GitHub Actions pipeline | +| Deploy workflow | `.github/workflows/deploy.yml` | OIDC-based AWS Lambda deploy (Lambda target) | | Env template | `.env.example` | Environment variable template | -| Entry point | `api/src/index.ts` | Platform-specific server bootstrap | +| Entry point | `api/src/index.ts` | Platform-specific server bootstrap (plus `api/src/lambda.ts` on Lambda) | ## Integration diff --git a/.claude/skills/schema-intake/SKILL.md b/.claude/skills/schema-intake/SKILL.md index 7fb138c..7089e47 100644 --- a/.claude/skills/schema-intake/SKILL.md +++ b/.claude/skills/schema-intake/SKILL.md @@ -84,7 +84,7 @@ continuing. Adapt follow-up questions based on responses. **Question 6 -- Deployment Target** > Where will this API be deployed? -> Options: Cloudflare Workers (default), Node.js / Docker +> Options: Cloudflare Workers (default), Node.js / Docker, AWS Lambda **Question 7 -- Additional Requirements** (optional) > Any special requirements? (pagination style, rate limiting, soft deletes, etc.) @@ -294,7 +294,7 @@ interface BuildSpec { sourceType: 'file' | 'interview'; }; deployment: { - target: 'cloudflare-workers' | 'node'; + target: 'cloudflare-workers' | 'node' | 'aws-lambda'; runtime: string; }; auth: { @@ -553,7 +553,7 @@ function validateBuildSpec(spec: BuildSpec): string[] { | `tdd-from-schema` (Phase 2) | Reads `endpoints` and `auth` to generate test cases | | `route-generation` (Phase 3) | Reads `endpoints` to scaffold Hono route handlers | | `api-authentication` | Reads `auth` to configure JWT/API key middleware | -| `deployment-config-generator` | Reads `deployment` to generate wrangler.toml or Dockerfile | +| `deployment-config-generator` | Reads `deployment` to generate wrangler.toml, Dockerfile, or SAM template | | `api-documentation` | Reads the generated OpenAPI spec or regenerates from build-spec | ### Triggering the Next Phase diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e91eb5c..6ab8f56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,7 +166,7 @@ jobs: fi done - for dir in templates/shared templates/cloudflare-workers templates/node-server templates/docker; do + for dir in templates/shared templates/cloudflare-workers templates/node-server templates/docker templates/aws-lambda; do if [ -d "$dir" ]; then echo " $dir: OK" else @@ -226,6 +226,10 @@ jobs: run: pnpm exec tsc --noEmit -p tsconfig.node.json working-directory: templates/snippets + - name: Type-check AWS Lambda snippets + run: pnpm exec tsc --noEmit -p tsconfig.lambda.json + working-directory: templates/snippets + check-links: name: Check Markdown Links runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 7480f0c..510e273 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is a **Claude Code-integrated API & backend development framework** providing specialized agents, skills, scripts, and schema-to-API conversion pipelines. Built with TypeScript, Hono (HTTP framework), Drizzle ORM, and PostgreSQL. Deployed to Cloudflare Workers or Node.js. +This is a **Claude Code-integrated API & backend development framework** providing specialized agents, skills, scripts, and schema-to-API conversion pipelines. Built with TypeScript, Hono (HTTP framework), Drizzle ORM, and PostgreSQL. Deployed to Cloudflare Workers, Node.js, or AWS Lambda. Nerva is the backend counterpart to **Aurelius** (frontend framework). @@ -12,7 +12,7 @@ The framework is designed for: - Schema-first API development (OpenAPI spec to working API) - OpenAPI-to-working-API conversion with TDD-mandatory development - Comprehensive testing (integration tests, contract tests, load tests with k6) -- Deployment to Cloudflare Workers or Node.js with Docker support +- Deployment to Cloudflare Workers, Node.js (Docker), or AWS Lambda (SAM) - Full product lifecycle support (engineering, database, testing, DevOps, operations) ## Project Structure @@ -38,6 +38,7 @@ project-root/ │ ├── shared/ # ESLint, Prettier, TypeScript configs │ ├── cloudflare-workers/ # Wrangler config │ ├── node-server/ # Node.js server config +│ ├── aws-lambda/ # SAM template, esbuild config, OIDC deploy workflow │ └── docker/ # Dockerfile, docker-compose ├── docs/ │ ├── schema-to-api/ # Pipeline guide @@ -57,7 +58,7 @@ project-root/ ```bash # Setup a new API project -./scripts/setup-project.sh my-api --cloudflare # or --node +./scripts/setup-project.sh my-api --cloudflare # or --node / --lambda # Run tests with coverage ./scripts/run-tests.sh @@ -232,7 +233,7 @@ Autonomous 10-phase pipeline that converts an OpenAPI specification into a worki - **Schema-first** — OpenAPI spec drives database schema, types, handlers, and tests - **Contract testing** — generated tests validate API responses against OpenAPI spec - **Load testing** — k6 scripts generated for performance-critical endpoints -- **Multi-target deployment** — Cloudflare Workers, Node.js, Docker with single config change +- **Multi-target deployment** — Cloudflare Workers, Node.js, Docker, AWS Lambda with single config change - **Type safety end-to-end** — Drizzle schema to Zod validators to TypeScript types - Quality gate: 80%+ coverage, TypeScript strict, security audit, bundle analysis - Resumable: TodoWrite tracks progress across interrupted sessions @@ -487,9 +488,10 @@ pnpm drizzle-kit studio # Open database GUI ```bash ./scripts/setup-project.sh my-api --cloudflare # New Cloudflare Workers project ./scripts/setup-project.sh my-api --node # New Node.js project +./scripts/setup-project.sh my-api --lambda # New AWS Lambda (SAM) project ``` --- -**Last Updated:** 2026-03-29 +**Last Updated:** 2026-06-11 **Architecture:** 24 agents, 12 skills, 4 plugins + gh CLI, 9 scripts \ No newline at end of file diff --git a/README.md b/README.md index dd12793..6eea2d3 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A Claude Code-integrated API & backend development framework with TypeScript, Ho - **12 Development Skills** — Automated workflows for schema parsing, TDD, route generation, authentication, validation, documentation - **10-Phase Schema-to-API Pipeline** — Convert OpenAPI specs into fully working, tested API servers with a single command - **TDD-Mandatory Development** — Integration tests written before route handlers, hard gate enforced -- **Dual Deployment Targets** — Cloudflare Workers (edge) or Node.js (Docker) with one config change +- **Three Deployment Targets** — Cloudflare Workers (edge), Node.js (Docker), or AWS Lambda (SAM) with one config change - **Testing Stack** — Vitest, supertest, contract tests against OpenAPI, k6 load tests - **Schema-First Design** — Database schemas, types, and validators all derived from your API spec @@ -37,7 +37,7 @@ cd Nerva pnpm install # Initialize a new API project -./scripts/setup-project.sh my-api --cloudflare # or --node +./scripts/setup-project.sh my-api --cloudflare # or --node / --lambda # Start development cd api && pnpm dev @@ -175,6 +175,7 @@ Full catalog: [`.claude/CUSTOM-AGENTS-GUIDE.md`](.claude/CUSTOM-AGENTS-GUIDE.md) | `templates/shared/` | ESLint, Prettier, TypeScript, Vitest configs | | `templates/cloudflare-workers/` | Wrangler config with Hyperdrive | | `templates/node-server/` | Dockerfile + docker-compose with PostgreSQL | +| `templates/aws-lambda/` | SAM template, esbuild config, OIDC deploy workflow | | `templates/docker/` | Extended Docker with Redis + pgAdmin | ## Part of the PMDS Framework Series diff --git a/docs/onboarding/architecture.md b/docs/onboarding/architecture.md index 107a568..325df88 100644 --- a/docs/onboarding/architecture.md +++ b/docs/onboarding/architecture.md @@ -61,7 +61,7 @@ Shell scripts for common development tasks: | Script | Purpose | |--------|---------| -| `setup-project.sh` | Initialize a new API project (--cloudflare or --node) | +| `setup-project.sh` | Initialize a new API project (--cloudflare, --node, or --lambda) | | `run-tests.sh` | Vitest test suite with coverage | | `check-types.sh` | TypeScript strict mode checking | | `security-scan.sh` | Dependency and code security audit | @@ -71,7 +71,7 @@ Shell scripts for common development tasks: | `generate-openapi-docs.sh` | Build API documentation | | `generate-client.sh` | Generate typed API client for frontends | -### Templates (4 directories) +### Templates (5 directories) Starter configurations for different deployment targets: @@ -80,6 +80,7 @@ Starter configurations for different deployment targets: | `templates/shared/` | ESLint, Prettier, TypeScript, Vitest configs | | `templates/cloudflare-workers/` | Wrangler config with D1/KV/Hyperdrive bindings | | `templates/node-server/` | Dockerfile + docker-compose with PostgreSQL | +| `templates/aws-lambda/` | SAM template (API Gateway HTTP API), esbuild config, OIDC deploy workflow | | `templates/docker/` | Extended Docker setup with Redis + pgAdmin | ## Pipelines diff --git a/docs/onboarding/quickstart.md b/docs/onboarding/quickstart.md index 91a1d5f..75e04e7 100644 --- a/docs/onboarding/quickstart.md +++ b/docs/onboarding/quickstart.md @@ -32,7 +32,7 @@ If you have an OpenAPI specification file: ```bash # Initialize the project structure -./scripts/setup-project.sh my-api --cloudflare # or --node +./scripts/setup-project.sh my-api --cloudflare # or --node / --lambda # Open Claude Code and run the pipeline /build-from-schema path/to/openapi-spec.yaml diff --git a/docs/schema-to-api/README.md b/docs/schema-to-api/README.md index 4cd04db..96a0f2a 100644 --- a/docs/schema-to-api/README.md +++ b/docs/schema-to-api/README.md @@ -15,7 +15,7 @@ Convert an OpenAPI specification into a fully working, tested API server through | 7 | Validation Layer | Generate Zod schemas for request/response validation | | 8 | Auth & Security | Wire authentication middleware, CORS, rate limiting | | 9 | Documentation | Generate API docs, Swagger UI config, example responses | -| 10 | Deployment Config | Produce Wrangler TOML, Dockerfile, or both | +| 10 | Deployment Config | Produce Wrangler TOML, Dockerfile, or SAM template | ## Phase-by-Phase Deep Dive @@ -102,12 +102,12 @@ Convert an OpenAPI specification into a fully working, tested API server through ### Phase 10: Deployment Config -**What it does:** Produces deployment configuration files matching the target platform. For Cloudflare Workers: `wrangler.toml` with D1 bindings. For Node.js: `Dockerfile` with multi-stage build and `docker-compose.yml`. +**What it does:** Produces deployment configuration files matching the target platform. For Cloudflare Workers: `wrangler.toml` with D1 bindings. For Node.js: `Dockerfile` with multi-stage build and `docker-compose.yml`. For AWS Lambda: `template.yaml` (SAM with an API Gateway HTTP API), `samconfig.toml`, esbuild bundle config, and an OIDC-based deploy workflow. - **Inputs:** Project structure, database config, environment variables -- **Outputs:** `wrangler.toml` and/or `Dockerfile` + `docker-compose.yml` +- **Outputs:** `wrangler.toml`, `Dockerfile` + `docker-compose.yml`, or `template.yaml` + `samconfig.toml` + `esbuild.config.mjs` - **Tools:** `deployment-config-generator` skill -- **Config:** `pipeline.config.json` -- `deployment.target` (cloudflare, node, both) +- **Config:** `pipeline.config.json` -- `deployment.target` (cloudflare, node, aws-lambda, both) ## Configuration diff --git a/package.json b/package.json index 30954b1..5777482 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "openapi", "schema-first", "tdd", - "cloudflare-workers" + "cloudflare-workers", + "aws-lambda" ], "author": "PAMulligan", "license": "MIT", diff --git a/scripts/setup-project.sh b/scripts/setup-project.sh index b06fc71..f24f34a 100644 --- a/scripts/setup-project.sh +++ b/scripts/setup-project.sh @@ -3,7 +3,7 @@ set -euo pipefail # ============================================================================ # setup-project.sh - Initialize a new Nerva API project -# Usage: ./scripts/setup-project.sh [--cloudflare|--node] [--dry-run] +# Usage: ./scripts/setup-project.sh [--cloudflare|--node|--lambda] [--dry-run] # ============================================================================ RED='\033[0;31m' @@ -26,9 +26,10 @@ TEMPLATES_DIR="$PROJECT_ROOT/templates" if [[ $# -lt 1 ]]; then error "Missing project name." - echo "Usage: $0 [--cloudflare|--node] [--dry-run]" + echo "Usage: $0 [--cloudflare|--node|--lambda] [--dry-run]" echo " --cloudflare Set up for Cloudflare Workers deployment" echo " --node Set up for Node.js / Docker deployment (default)" + echo " --lambda Set up for AWS Lambda deployment (SAM + API Gateway HTTP API)" echo " --dry-run Preview what would be created without making changes" exit 1 fi @@ -42,6 +43,7 @@ while [[ $# -gt 0 ]]; do case "$1" in --cloudflare) PLATFORM="cloudflare"; shift ;; --node) PLATFORM="node"; shift ;; + --lambda) PLATFORM="lambda"; shift ;; --dry-run) DRY_RUN=true; shift ;; *) error "Unknown option: $1"; exit 1 ;; esac @@ -129,14 +131,21 @@ if ! $DRY_RUN; then cd "$API_DIR" fi +DEV_ENTRY="src/index.ts" +LAMBDA_SCRIPTS="" +if [[ "$PLATFORM" == "lambda" ]]; then + DEV_ENTRY="src/dev.ts" + LAMBDA_SCRIPTS=$'\n "build:lambda": "node esbuild.config.mjs",\n "deploy": "pnpm run build:lambda && sam deploy",' +fi + write_file "$API_DIR/package.json" << PKGJSON { "name": "$PROJECT_NAME", "version": "0.0.1", "private": true, "type": "module", - "scripts": { - "dev": "tsx watch src/index.ts", + "scripts": {$LAMBDA_SCRIPTS + "dev": "tsx watch $DEV_ENTRY", "build": "tsc", "start": "node dist/index.js", "test": "vitest run", @@ -174,6 +183,13 @@ step "Creating initial source files..." if [[ "$PLATFORM" == "cloudflare" ]]; then copy_file "$TEMPLATES_DIR/snippets/cloudflare/src/index.ts" "$API_DIR/src/index.ts" copy_file "$TEMPLATES_DIR/snippets/cloudflare/src/config.ts" "$API_DIR/src/config.ts" +elif [[ "$PLATFORM" == "lambda" ]]; then + copy_file "$TEMPLATES_DIR/snippets/aws-lambda/src/index.ts" "$API_DIR/src/index.ts" + copy_file "$TEMPLATES_DIR/snippets/aws-lambda/src/lambda.ts" "$API_DIR/src/lambda.ts" + copy_file "$TEMPLATES_DIR/snippets/aws-lambda/src/dev.ts" "$API_DIR/src/dev.ts" + copy_file "$TEMPLATES_DIR/snippets/aws-lambda/src/config.ts" "$API_DIR/src/config.ts" + # Local dev server only; production traffic goes through src/lambda.ts + run_cmd pnpm add @hono/node-server else copy_file "$TEMPLATES_DIR/snippets/node/src/index.ts" "$API_DIR/src/index.ts" copy_file "$TEMPLATES_DIR/snippets/node/src/config.ts" "$API_DIR/src/config.ts" @@ -203,6 +219,9 @@ copy_file "$TEMPLATES_DIR/snippets/shared/src/db/seed.ts" "$API_DIR/src/db/seed. if [[ "$PLATFORM" == "cloudflare" ]]; then copy_file "$TEMPLATES_DIR/snippets/cloudflare/src/db/ping.ts" "$API_DIR/src/db/ping.ts" copy_file "$TEMPLATES_DIR/snippets/cloudflare/src/routes/health.ts" "$API_DIR/src/routes/health.ts" +elif [[ "$PLATFORM" == "lambda" ]]; then + copy_file "$TEMPLATES_DIR/snippets/aws-lambda/src/db/ping.ts" "$API_DIR/src/db/ping.ts" + copy_file "$TEMPLATES_DIR/snippets/aws-lambda/src/routes/health.ts" "$API_DIR/src/routes/health.ts" else copy_file "$TEMPLATES_DIR/snippets/node/src/db/ping.ts" "$API_DIR/src/db/ping.ts" copy_file "$TEMPLATES_DIR/snippets/node/src/routes/health.ts" "$API_DIR/src/routes/health.ts" @@ -213,6 +232,8 @@ copy_file "$TEMPLATES_DIR/snippets/shared/tests/soft-delete.test.ts" "$API_DIR/t if [[ "$PLATFORM" == "cloudflare" ]]; then copy_file "$TEMPLATES_DIR/snippets/cloudflare/tests/unit/health.test.ts" "$API_DIR/tests/unit/health.test.ts" +elif [[ "$PLATFORM" == "lambda" ]]; then + copy_file "$TEMPLATES_DIR/snippets/aws-lambda/tests/unit/health.test.ts" "$API_DIR/tests/unit/health.test.ts" else copy_file "$TEMPLATES_DIR/snippets/node/tests/unit/health.test.ts" "$API_DIR/tests/unit/health.test.ts" fi @@ -242,6 +263,36 @@ JWT_SECRET=replace-me-with-a-secure-32+-character-secret DVEOF success "Cloudflare Workers configured. Edit wrangler.toml with your resource IDs." +elif [[ "$PLATFORM" == "lambda" ]]; then + step "Setting up AWS Lambda (SAM)..." + run_cmd pnpm add -D esbuild + copy_file "$TEMPLATES_DIR/aws-lambda/template.yaml" "$API_DIR/template.yaml" + copy_file "$TEMPLATES_DIR/aws-lambda/samconfig.toml" "$API_DIR/samconfig.toml" + copy_file "$TEMPLATES_DIR/aws-lambda/esbuild.config.mjs" "$API_DIR/esbuild.config.mjs" + copy_file "$TEMPLATES_DIR/aws-lambda/docker-compose.yml" "$API_DIR/docker-compose.yml" + + make_dirs "$TARGET_DIR/.github/workflows" + copy_file "$TEMPLATES_DIR/aws-lambda/deploy.yml" "$TARGET_DIR/.github/workflows/deploy.yml" + + write_file "$API_DIR/.env.example" << 'LENVEOF' +# Local development only -- deployed functions get their environment from +# template.yaml parameters (sam deploy --parameter-overrides). +NODE_ENV=development +PORT=3000 +DATABASE_URL=postgresql://nerva:nerva_secret@localhost:5432/nerva_db +LOG_LEVEL=debug +APP_VERSION=0.0.1 +HEALTH_DB_TIMEOUT_MS=2000 +# Required in production (min 32 chars). Generate with: openssl rand -hex 32 +JWT_SECRET=dev-secret-change-me-in-production-min-32-chars +LENVEOF + + if ! command -v sam &>/dev/null; then + warn "AWS SAM CLI not found. Install it before deploying:" + warn " https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html" + fi + + success "AWS Lambda configured. Edit samconfig.toml with your stack name and region." else step "Setting up Node.js / Docker..." copy_file "$TEMPLATES_DIR/node-server/Dockerfile" "$API_DIR/Dockerfile" @@ -268,6 +319,10 @@ if [[ "$PLATFORM" == "cloudflare" ]]; then DEV_BASE_URL="http://localhost:8787" PLATFORM_LABEL="Cloudflare Workers" DEV_STEPS=$'npx wrangler dev # start the local dev server' +elif [[ "$PLATFORM" == "lambda" ]]; then + DEV_BASE_URL="http://localhost:3000" + PLATFORM_LABEL="AWS Lambda" + DEV_STEPS=$'docker compose up -d # start PostgreSQL\npnpm dev # start the dev server' else DEV_BASE_URL="http://localhost:3000" PLATFORM_LABEL="Node.js / Docker" @@ -434,6 +489,69 @@ pnpm test # run the test suite The dev server listens at $DEV_BASE_URL -- verify with a GET to /health. READMEDYN + if [[ "$PLATFORM" == "lambda" ]]; then + cat << 'READMELAMBDA' + +## Deploying to AWS Lambda + +The API deploys as a single Lambda function behind an API Gateway **HTTP API** +(payload format 2.0), defined in `api/template.yaml` (AWS SAM). Hono's +`aws-lambda` adapter (`api/src/lambda.ts`) translates Lambda events into +standard Requests, so the same app code runs locally, in tests, and on Lambda. + +```bash +cd api +pnpm build:lambda # bundle src/lambda.ts -> dist/lambda.mjs (esbuild) +sam deploy --guided # first deploy: interactive, updates samconfig.toml +pnpm run deploy # subsequent deploys: build + sam deploy +``` + +Secrets are `NoEcho` CloudFormation parameters -- pass them at deploy time +instead of committing them: + +```bash +sam deploy --parameter-overrides "DatabaseUrl=$DATABASE_URL JwtSecret=$JWT_SECRET" +``` + +CI/CD: `.github/workflows/deploy.yml` deploys on every push to `main` using +GitHub OIDC (no long-lived AWS keys). The one-time IAM setup is documented in +comments at the top of that file. + +### Cold start optimization + +The defaults in `api/template.yaml` are chosen with cold starts in mind: + +- **Single-file bundle.** esbuild emits one minified `lambda.mjs`, so there is + no `node_modules` tree to unzip and resolve during init. Keep it that way: + prefer small dependencies and let tree-shaking work. +- **arm64 (Graviton).** Cheaper per millisecond and generally faster cold + starts than x86_64. +- **Memory = CPU.** Lambda allocates CPU proportionally to memory; 512 MB is + the configured floor. If p99 latency matters, benchmark 1024-1769 MB with + [AWS Lambda Power Tuning](https://github.com/alexcasalboni/aws-lambda-power-tuning) -- + more memory often costs *less* overall because invocations finish sooner. +- **Reuse connections across invocations.** Anything created at module scope + survives warm invocations. Keep the Postgres pool small (`max: 1` per + instance) and point `DatabaseUrl` at **RDS Proxy** (or a serverless driver + such as Neon) so concurrent Lambda instances cannot exhaust database + connections. +- **Provisioned concurrency** is the lever for latency-critical endpoints -- + `template.yaml` ships a commented block. SnapStart is not available for + Node.js runtimes. +- **Avoid heavy top-level work.** Defer rarely used SDK clients and remote + config fetches until first use; top-level code runs inside the billed init + phase. + +### Container-based alternative (Lambda Web Adapter) + +To run the Node.js *server* build on Lambda without code changes, package it +as a container image with the +[AWS Lambda Web Adapter](https://github.com/awslabs/aws-lambda-web-adapter), +which proxies Lambda events to the HTTP port the server listens on. Useful for +container-only dependencies or bundles beyond the 250 MB zip limit; the +trade-off is slower cold starts than the zip deployment this project uses. +READMELAMBDA + fi cat << 'READMESTATIC' ## Postman @@ -474,6 +592,7 @@ dist/ *.log .wrangler/ .dev.vars +.aws-sam/ coverage/ .DS_Store GEOF @@ -497,6 +616,11 @@ if ! $DRY_RUN; then echo " cd $PROJECT_NAME/api" if [[ "$PLATFORM" == "cloudflare" ]]; then echo " npx wrangler dev # Start local dev server" + elif [[ "$PLATFORM" == "lambda" ]]; then + echo " docker compose up -d # Start PostgreSQL" + echo " pnpm dev # Start dev server" + echo " pnpm build:lambda # Bundle for Lambda" + echo " sam deploy --guided # First AWS deploy" else echo " docker compose up -d # Start PostgreSQL" echo " pnpm dev # Start dev server" diff --git a/templates/aws-lambda/deploy.yml b/templates/aws-lambda/deploy.yml new file mode 100644 index 0000000..c9fa6d2 --- /dev/null +++ b/templates/aws-lambda/deploy.yml @@ -0,0 +1,79 @@ +# Deploys the API to AWS Lambda via SAM using GitHub OIDC -- no long-lived +# AWS access keys are stored in the repository. +# +# One-time AWS setup: +# 1. Create the GitHub OIDC identity provider in IAM: +# Provider URL: https://token.actions.githubusercontent.com +# Audience: sts.amazonaws.com +# 2. Create an IAM role that trusts the provider, scoped to this repository: +# "token.actions.githubusercontent.com:sub": "repo:/:ref:refs/heads/main" +# Grant it permissions for CloudFormation, Lambda, API Gateway, the SAM +# artifact S3 bucket, and iam:PassRole for the function execution role. +# 3. Configure the repository: +# variable AWS_REGION e.g. us-east-1 +# secret AWS_DEPLOY_ROLE_ARN the role ARN from step 2 +# secrets DATABASE_URL, JWT_SECRET +# +# Docs: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services + +name: Deploy to AWS Lambda + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + id-token: write # required for OIDC federation + contents: read + +concurrency: + group: deploy-aws-lambda + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + defaults: + run: + working-directory: api + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: api/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + run: pnpm test + + - name: Bundle for Lambda + run: pnpm run build:lambda + + - uses: aws-actions/setup-sam@v2 + with: + use-installer: true + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Deploy + run: >- + sam deploy + --no-confirm-changeset + --no-fail-on-empty-changeset + --parameter-overrides + "DatabaseUrl=${{ secrets.DATABASE_URL }} JwtSecret=${{ secrets.JWT_SECRET }}" diff --git a/templates/aws-lambda/docker-compose.yml b/templates/aws-lambda/docker-compose.yml new file mode 100644 index 0000000..283ce18 --- /dev/null +++ b/templates/aws-lambda/docker-compose.yml @@ -0,0 +1,26 @@ +# Local development database for AWS Lambda projects. Production uses RDS or +# Aurora (via RDS Proxy); this compose file only provides PostgreSQL for +# `pnpm dev` and the test suite. +services: + postgres: + image: postgres:17-alpine + container_name: nerva-postgres + restart: unless-stopped + ports: + - '${DB_PORT:-5432}:5432' + environment: + POSTGRES_USER: ${POSTGRES_USER:-nerva} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-nerva_secret} + POSTGRES_DB: ${POSTGRES_DB:-nerva_db} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-nerva} -d ${POSTGRES_DB:-nerva_db}'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + postgres_data: + driver: local diff --git a/templates/aws-lambda/esbuild.config.mjs b/templates/aws-lambda/esbuild.config.mjs new file mode 100644 index 0000000..8f8d921 --- /dev/null +++ b/templates/aws-lambda/esbuild.config.mjs @@ -0,0 +1,21 @@ +import { build } from 'esbuild'; + +// Bundles the Lambda entry point into a single minified ESM file. One small +// artifact keeps cold starts short: there is no node_modules tree to unzip +// and resolve during the init phase. +await build({ + entryPoints: ['src/lambda.ts'], + outfile: 'dist/lambda.mjs', + bundle: true, + platform: 'node', + target: 'node22', + format: 'esm', + minify: true, + sourcemap: true, + // Shim require() for transitive CommonJS dependencies in the ESM bundle + banner: { + js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);", + }, + // The AWS SDK v3 ships with the nodejs runtime; never bundle it + external: ['@aws-sdk/*'], +}); diff --git a/templates/aws-lambda/samconfig.toml b/templates/aws-lambda/samconfig.toml new file mode 100644 index 0000000..4ea9d23 --- /dev/null +++ b/templates/aws-lambda/samconfig.toml @@ -0,0 +1,35 @@ +# Nerva API - AWS SAM deployment configuration +# https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html +# +# Build the bundle first, then deploy: +# pnpm build:lambda +# sam deploy --parameter-overrides "DatabaseUrl=$DATABASE_URL JwtSecret=$JWT_SECRET" +# +# Secrets are NoEcho template parameters passed at deploy time -- never commit +# them to this file. For production, prefer wiring AWS Secrets Manager or SSM +# Parameter Store references into template.yaml. + +version = 0.1 + +[default.global.parameters] +stack_name = "nerva-api" + +[default.deploy.parameters] +resolve_s3 = true +s3_prefix = "nerva-api" +region = "us-east-1" +capabilities = "CAPABILITY_IAM" +confirm_changeset = true +fail_on_empty_changeset = false + +# Additional environments deploy as separate stacks: +# sam deploy --config-env staging +# +# [staging.global.parameters] +# stack_name = "nerva-api-staging" +# +# [staging.deploy.parameters] +# resolve_s3 = true +# s3_prefix = "nerva-api-staging" +# region = "us-east-1" +# capabilities = "CAPABILITY_IAM" diff --git a/templates/aws-lambda/template.yaml b/templates/aws-lambda/template.yaml new file mode 100644 index 0000000..6f05ebe --- /dev/null +++ b/templates/aws-lambda/template.yaml @@ -0,0 +1,96 @@ +# Nerva API - AWS Lambda Configuration (AWS SAM) +# Deploy: pnpm build:lambda && sam deploy --guided (first deploy) +# pnpm run deploy (after samconfig.toml exists) +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Nerva API - Hono on AWS Lambda behind an API Gateway HTTP API + +Parameters: + DatabaseUrl: + Type: String + NoEcho: true + Description: >- + PostgreSQL connection string. Point this at RDS Proxy (or a serverless + driver endpoint such as Neon) so concurrent Lambda instances cannot + exhaust database connections. + JwtSecret: + Type: String + NoEcho: true + MinLength: 32 + Description: JWT signing secret (min 32 characters) + LogLevel: + Type: String + Default: info + AllowedValues: [debug, info, warn, error] + AppVersion: + Type: String + Default: 0.0.1 + +Globals: + Function: + Runtime: nodejs22.x + # arm64 (Graviton) costs less per millisecond and typically cold-starts + # faster than x86_64 + Architectures: [arm64] + # Lambda allocates CPU proportionally to memory. 512 MB is a sensible + # floor for a bundled Hono API; benchmark with AWS Lambda Power Tuning + # before raising or lowering it. + MemorySize: 512 + # Keep below API Gateway's hard 30-second integration timeout + Timeout: 28 + +Resources: + # HTTP API (v2) -- cheaper and lower-latency than REST API. It sends payload + # format 2.0 events, which Hono's aws-lambda adapter handles natively. + HttpApi: + Type: AWS::Serverless::HttpApi + Properties: + StageName: $default + # CORS is handled by Hono middleware in src/index.ts. Configure it here + # instead if API Gateway should answer preflights without invoking the + # function: + # CorsConfiguration: + # AllowOrigins: + # - https://app.example.com + # AllowMethods: [GET, POST, PUT, PATCH, DELETE, OPTIONS] + # AllowHeaders: [Content-Type, Authorization] + # MaxAge: 86400 + + ApiFunction: + Type: AWS::Serverless::Function + Properties: + # Prebuilt single-file bundle from `pnpm build:lambda` (esbuild.config.mjs) + CodeUri: dist/ + Handler: lambda.handler + Description: Hono API handler + Environment: + Variables: + NODE_ENV: production + DATABASE_URL: !Ref DatabaseUrl + JWT_SECRET: !Ref JwtSecret + LOG_LEVEL: !Ref LogLevel + APP_VERSION: !Ref AppVersion + HEALTH_DB_TIMEOUT_MS: '2000' + # Readable stack traces from the minified bundle (dist/lambda.mjs.map) + NODE_OPTIONS: --enable-source-maps + Events: + # No Path/Method means SAM creates the $default catch-all route: + # every request reaches the function and Hono does the routing. + Root: + Type: HttpApi + Properties: + ApiId: !Ref HttpApi + # Uncomment to keep instances warm for latency-critical workloads. + # SnapStart is not available for Node.js runtimes, so provisioned + # concurrency is the cold-start lever on Lambda: + # AutoPublishAlias: live + # ProvisionedConcurrencyConfig: + # ProvisionedConcurrentExecutions: 2 + +Outputs: + ApiUrl: + Description: HTTP API endpoint + Value: !Sub 'https://${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}' + ApiFunctionArn: + Description: Lambda function ARN + Value: !GetAtt ApiFunction.Arn diff --git a/templates/snippets/aws-lambda/src/config.ts b/templates/snippets/aws-lambda/src/config.ts new file mode 100644 index 0000000..08942fb --- /dev/null +++ b/templates/snippets/aws-lambda/src/config.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +const isProduction = process.env['NODE_ENV'] === 'production'; + +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.coerce.number().int().positive().default(3000), + DATABASE_URL: z.string().url(), + JWT_SECRET: isProduction + ? z.string().min(32, 'JWT_SECRET must be at least 32 characters in production') + : z.string().min(32).default('dev-secret-change-me-in-production-min-32-chars'), + LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default(isProduction ? 'info' : 'debug'), + APP_VERSION: z.string().default('unknown'), + HEALTH_DB_TIMEOUT_MS: z.coerce.number().int().positive().default(2000), +}); + +export type Config = z.infer; + +export const config: Config = envSchema.parse(process.env); diff --git a/templates/snippets/aws-lambda/src/db/ping.ts b/templates/snippets/aws-lambda/src/db/ping.ts new file mode 100644 index 0000000..46b4c62 --- /dev/null +++ b/templates/snippets/aws-lambda/src/db/ping.ts @@ -0,0 +1,10 @@ +import { client } from './client.js'; + +export async function pingDatabase(): Promise<'connected' | 'disconnected'> { + try { + await client`SELECT 1`; + return 'connected'; + } catch { + return 'disconnected'; + } +} diff --git a/templates/snippets/aws-lambda/src/dev.ts b/templates/snippets/aws-lambda/src/dev.ts new file mode 100644 index 0000000..29b8530 --- /dev/null +++ b/templates/snippets/aws-lambda/src/dev.ts @@ -0,0 +1,9 @@ +import { serve } from '@hono/node-server'; +import { config } from './config'; +import app from './index.js'; + +// Local development server only — deployed environments run src/lambda.ts on +// AWS Lambda. The same Hono app serves both, so behavior matches production. +console.log(`Dev server starting on port ${config.PORT}`); + +serve({ fetch: app.fetch, port: config.PORT }); diff --git a/templates/snippets/aws-lambda/src/index.ts b/templates/snippets/aws-lambda/src/index.ts new file mode 100644 index 0000000..03815a9 --- /dev/null +++ b/templates/snippets/aws-lambda/src/index.ts @@ -0,0 +1,26 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { etag } from 'hono/etag'; +import { logger } from 'hono/logger'; +import { requestId } from 'hono/request-id'; +import { secureHeaders } from 'hono/secure-headers'; +import { config } from './config'; +import { healthRoutes } from './routes/health'; +// Note: compress() is omitted. Compressing responses inside the function burns +// billed CPU on every invocation; put CloudFront in front of the API instead. + +const app = new Hono(); + +app.use('*', logger()); +app.use('*', cors()); +app.use('*', etag()); +app.use('*', secureHeaders()); +app.use('*', requestId()); + +app.route('/health', healthRoutes); + +app.get('/', (c) => { + return c.json({ message: 'Nerva API', version: config.APP_VERSION }); +}); + +export default app; diff --git a/templates/snippets/aws-lambda/src/lambda.ts b/templates/snippets/aws-lambda/src/lambda.ts new file mode 100644 index 0000000..96b5856 --- /dev/null +++ b/templates/snippets/aws-lambda/src/lambda.ts @@ -0,0 +1,8 @@ +import { handle } from 'hono/aws-lambda'; +import app from './index.js'; + +// AWS Lambda entry point. The adapter translates API Gateway events (payload +// format 1.0 and 2.0) and ALB events into standard Requests for Hono. +// esbuild.config.mjs bundles this file into dist/lambda.mjs; template.yaml +// points the function handler at "lambda.handler". +export const handler = handle(app); diff --git a/templates/snippets/aws-lambda/src/routes/health.ts b/templates/snippets/aws-lambda/src/routes/health.ts new file mode 100644 index 0000000..16c1e4e --- /dev/null +++ b/templates/snippets/aws-lambda/src/routes/health.ts @@ -0,0 +1,29 @@ +import { Hono } from 'hono'; +import { config } from '../config'; +import { pingDatabase } from '../db/ping'; + +const startTime = Date.now(); + +export const healthRoutes = new Hono().get('/', async (c) => { + const timeout = new Promise<'disconnected'>((resolve) => + setTimeout(() => resolve('disconnected'), config.HEALTH_DB_TIMEOUT_MS), + ); + + let database: 'connected' | 'disconnected'; + try { + database = await Promise.race([pingDatabase(), timeout]); + } catch { + database = 'disconnected'; + } + + const status = database === 'connected' ? 'healthy' : 'unhealthy'; + const body = { + status, + version: config.APP_VERSION, + uptime: Math.floor((Date.now() - startTime) / 1000), + timestamp: new Date().toISOString(), + requestId: c.get('requestId'), + checks: { database }, + }; + return c.json(body, status === 'healthy' ? 200 : 503); +}); diff --git a/templates/snippets/aws-lambda/tests/unit/health.test.ts b/templates/snippets/aws-lambda/tests/unit/health.test.ts new file mode 100644 index 0000000..d52e51b --- /dev/null +++ b/templates/snippets/aws-lambda/tests/unit/health.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Hono } from 'hono'; +import { requestId } from 'hono/request-id'; +import { pingDatabase } from '../../src/db/ping'; +import { healthRoutes } from '../../src/routes/health'; + +vi.mock('../../src/db/ping', () => ({ + pingDatabase: vi.fn(), +})); + +interface HealthBody { + status: 'healthy' | 'unhealthy'; + version: string; + uptime: number; + timestamp: string; + requestId: string; + checks: { database: 'connected' | 'disconnected' }; +} + +const makeApp = () => { + const app = new Hono(); + app.use('*', requestId()); + app.route('/health', healthRoutes); + return app; +}; + +const mockedPing = vi.mocked(pingDatabase); + +describe('Health endpoint', () => { + beforeEach(() => { + mockedPing.mockReset(); + }); + + it('returns 200 + healthy when DB is connected', async () => { + mockedPing.mockResolvedValue('connected'); + const res = await makeApp().request('/health'); + expect(res.status).toBe(200); + const body = (await res.json()) as HealthBody; + expect(body.status).toBe('healthy'); + expect(body.checks.database).toBe('connected'); + }); + + it('returns version from APP_VERSION env', async () => { + mockedPing.mockResolvedValue('connected'); + const res = await makeApp().request('/health'); + const body = (await res.json()) as HealthBody; + expect(body.version).toBe('0.0.1'); + }); + + it('returns numeric uptime >= 0', async () => { + mockedPing.mockResolvedValue('connected'); + const res = await makeApp().request('/health'); + const body = (await res.json()) as HealthBody; + expect(typeof body.uptime).toBe('number'); + expect(body.uptime).toBeGreaterThanOrEqual(0); + }); + + it('returns 503 + unhealthy when DB is disconnected', async () => { + mockedPing.mockResolvedValue('disconnected'); + const res = await makeApp().request('/health'); + expect(res.status).toBe(503); + const body = (await res.json()) as HealthBody; + expect(body.status).toBe('unhealthy'); + expect(body.checks.database).toBe('disconnected'); + }); + + it('returns 503 when ping exceeds timeout', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + mockedPing.mockImplementation(() => new Promise(() => {})); + const reqPromise = makeApp().request('/health'); + await vi.advanceTimersByTimeAsync(2001); + const res = await reqPromise; + expect(res.status).toBe(503); + vi.useRealTimers(); + }); + + it('returns 503 when ping throws (never 500)', async () => { + mockedPing.mockRejectedValue(new Error('boom')); + const res = await makeApp().request('/health'); + expect(res.status).toBe(503); + }); + + it('preserves requestId in body and X-Request-Id header', async () => { + mockedPing.mockResolvedValue('connected'); + const res = await makeApp().request('/health'); + const body = (await res.json()) as HealthBody; + expect(typeof body.requestId).toBe('string'); + expect(body.requestId.length).toBeGreaterThan(0); + expect(res.headers.get('X-Request-Id')).not.toBeNull(); + }); +}); diff --git a/templates/snippets/package.json b/templates/snippets/package.json index e3a9842..511619f 100644 --- a/templates/snippets/package.json +++ b/templates/snippets/package.json @@ -6,7 +6,8 @@ "scripts": { "typecheck:cf": "tsc --noEmit -p tsconfig.cloudflare.json", "typecheck:node": "tsc --noEmit -p tsconfig.node.json", - "typecheck": "pnpm run typecheck:cf && pnpm run typecheck:node" + "typecheck:lambda": "tsc --noEmit -p tsconfig.lambda.json", + "typecheck": "pnpm run typecheck:cf && pnpm run typecheck:node && pnpm run typecheck:lambda" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260415.1", diff --git a/templates/snippets/tsconfig.lambda.json b/templates/snippets/tsconfig.lambda.json new file mode 100644 index 0000000..a9c627e --- /dev/null +++ b/templates/snippets/tsconfig.lambda.json @@ -0,0 +1,21 @@ +{ + "extends": "../shared/tsconfig.json", + "compilerOptions": { + "noEmit": true, + "moduleResolution": "bundler", + "types": ["node"], + "rootDir": ".", + "rootDirs": ["shared/src", "aws-lambda/src"], + "paths": {} + }, + "include": [ + "shared/src/**/*.ts", + "aws-lambda/src/**/*.ts" + ], + "exclude": [ + "node_modules", + "**/tests/**", + "cloudflare/**", + "node/**" + ] +}