diff --git a/packages/aws-cdk-local-lambda/CHANGELOG.md b/packages/aws-cdk-local-lambda/CHANGELOG.md deleted file mode 100644 index 6e10b2d..0000000 --- a/packages/aws-cdk-local-lambda/CHANGELOG.md +++ /dev/null @@ -1,25 +0,0 @@ -# aws-cdk-local-lambda - -## 1.0.1 - -### Patch Changes - -- 6bc06f1: Release 1.0.1 - -## 1.0.0 - -### Major Changes - -- bd2a78a: First Release - -## 1.0.1 - -### Patch Changes - -- 2e71f63: Fix README and init CLI - -## 1.0.0 - -### Major Changes - -- ab12f8f: first release yay diff --git a/packages/aws-cdk-local-lambda/README.md b/packages/aws-cdk-local-lambda/README.md deleted file mode 100644 index a337268..0000000 --- a/packages/aws-cdk-local-lambda/README.md +++ /dev/null @@ -1,177 +0,0 @@ -
- -aws-cdk-local-lambda - -# aws-cdk-local-lambda - -**Run an API Gateway + Lambda app locally over HTTP, driven entirely by `cdk synth` output.** -No handler registry. No mocks. Hot reload on save. - -[![CI](https://img.shields.io/github/actions/workflow/status/tiny-build/aws-cdk-local-lambda/release.yml?branch=main&style=for-the-badge&logo=githubactions&logoColor=white&label=CI&labelColor=7fc5e1&color=fed11e)](https://github.com/tiny-build/aws-cdk-local-lambda/actions/workflows/ci.yml) -[![npm version](https://img.shields.io/npm/v/aws-cdk-local-lambda?style=for-the-badge&logo=npm&logoColor=white&label=npm&labelColor=7fc5e1&color=f99933)](https://www.npmjs.com/package/aws-cdk-local-lambda) -[![npm downloads](https://img.shields.io/npm/dm/aws-cdk-local-lambda?style=for-the-badge&logo=npm&logoColor=white&label=downloads&labelColor=7fc5e1&color=fed11e)](https://www.npmjs.com/package/aws-cdk-local-lambda) -[![GitHub stars](https://img.shields.io/github/stars/tiny-build/aws-cdk-local-lambda?style=for-the-badge&logo=github&logoColor=white&labelColor=7fc5e1&color=f99933)](https://github.com/tiny-build/aws-cdk-local-lambda/stargazers) - -### Built with - -![TypeScript](https://img.shields.io/badge/TypeScript-7fc5e1?style=for-the-badge&logo=typescript&logoColor=white) -![Node.js](https://img.shields.io/badge/Node.js_22+-7fc5e1?style=for-the-badge&logo=nodedotjs&logoColor=white) -![AWS CDK](https://img.shields.io/badge/AWS_CDK-f99933?style=for-the-badge&logo=amazonaws&logoColor=white) -![AWS Lambda](https://img.shields.io/badge/AWS_Lambda-f99933?style=for-the-badge&logo=awslambda&logoColor=white) -![Express](https://img.shields.io/badge/Express-7fc5e1?style=for-the-badge&logo=express&logoColor=white) -![esbuild](https://img.shields.io/badge/esbuild-fed11e?style=for-the-badge&logo=esbuild&logoColor=black) -![Commander](https://img.shields.io/badge/Commander-fed11e?style=for-the-badge&logoColor=black) -![chokidar](https://img.shields.io/badge/chokidar-7fc5e1?style=for-the-badge&logoColor=white) -![Vitest](https://img.shields.io/badge/Vitest-fed11e?style=for-the-badge&logo=vitest&logoColor=black) - -
- ---- - -## Install - -```bash -npm install aws-cdk-local-lambda -``` -or -```bash -pnpm add aws-cdk-local-lambda -``` - -> Requires **Node.js ≥ 22.14**. - -## Quickstart - -The recommended setup is a few scripts in your `package.json`. See the [simple-crud example](https://github.com/tiny-build/aws-cdk-local-lambda/tree/main/examples/simple-crud) for a complete working reference. - -```json -{ - "scripts": { - "synth": "cdk synth --app 'tsx cdk/app.ts'", - "extract": "cdk-local extract --cdk-out cdk.out --stack MyStack --stage dev --out .cdk-local/manifest.json", - "manifest": "npm run synth && npm run extract", - "serve": "cdk-local serve --manifest .cdk-local/manifest.json --port 3001 --watch", - "dev": "npm run manifest && npm run serve" - } -} -``` - -Then: - -```bash -npm run dev -``` - -> Add `.cdk-local/` or whatever path you choose to your `.gitignore` - it holds the generated manifest and would be machine-specific. - -`cdk synth` shells out to the AWS CDK CLI. Make sure `cdk` is available on your `PATH` (see the [AWS CDK Getting Started guide](https://docs.aws.amazon.com/cdk/v2/guide/getting-started.html)). - -## CLI reference - -``` -cdk-local dev --cdk-out --stack [--stage ] [--port 3001] [--no-watch] [--repo-root ] [--quiet] -cdk-local extract --cdk-out --stack [--stage ] [--out ] [--synth] [--repo-root ] [--quiet] -cdk-local serve --manifest [--port 3001] [--watch] [--quiet] -``` - -| Command | Purpose | Watch default | -|-----------|-----------------------------------------------------------|--------------------| -| `dev` | `extract` + `serve` in one step. | on (`--no-watch`) | -| `extract` | Writes a `LocalManifest` JSON to `--out` (or stdout). | n/a | -| `serve` | Reads a pre-extracted manifest and starts the server. | opt-in (`--watch`) | - -Pass `--synth` to `extract` to run `cdk synth` first (useful after cloning, since esbuild embeds absolute source paths in bundles). - -### `extract` options - -| Flag | Required | Description | -|------|----------|-------------| -| `--cdk-out ` | yes | Path to the `cdk.out` directory | -| `--stack ` | yes | CloudFormation stack name | -| `--stage ` | no | Stage suffix used to strip prefixes from Lambda function names (e.g. `dev`). Omit if your function names have no stage prefix. | -| `--out ` | no | Output path for the manifest JSON (default: stdout) | -| `--synth` | no | Run `cdk synth` before extracting | -| `--repo-root ` | no | Repo root for resolving handler source paths (default: cwd) | -| `--quiet` | no | Suppress framework log output (file changes, module invalidations, etc.) | - -### `serve` options - -| Flag | Required | Description | -|------|----------|-------------| -| `--manifest ` | yes | Path to a manifest JSON produced by `extract` | -| `--port ` | no | Port to listen on (default: `3001`) | -| `--watch` | no | Enable hot reload on handler file changes | -| `--quiet` | no | Suppress framework log output | - -### `dev` options - -Accepts all `extract` options plus `--port`, `--no-watch`, and `--quiet`. - -## Hot reload - -On any file change under the watched paths (default: derived from each Lambda's manifest `entry`, walked up to the nearest `src/` ancestor), the loader invalidates its handler cache. - -No process restart. Recovery from bundle markers happens at `extract` time only. - -## Programmatic usage - -```ts -import { extractManifest } from "aws-cdk-local-lambda/extract"; -import { createLocalApp } from "aws-cdk-local-lambda/server"; - -const manifest = await extractManifest({ - cdkOut: "cdk.out", // path to cdk.out directory - stack: "MyStack", - stage: "dev", - repoRoot: process.cwd(), // optional: root for resolving handler paths - onWarning: (msg) => console.warn(msg), -}); - -const { app, routes, stop } = await createLocalApp({ - manifest, - watch: true, - corsOptions: { origin: "*" }, // optional: passed directly to the cors middleware - bodyLimit: "10mb", // optional: express body-parser limit (default: "1mb") - healthPath: "/_health", // optional: adds a 200 OK health endpoint - onReload: (path, n) => console.log(`reloaded ${n} handlers after ${path}`), - onError: (err, req) => console.error(req.path, err), -}); - -app.listen(3001); -// routes is a readonly string[] of all registered paths, e.g. ["GET /items", "POST /items"] -// call stop() to drain the watcher and release resources -``` - -`createLocalApp` does not call `app.listen` - the caller owns the port and server lifecycle. - -### Controlling framework log output - -By default the framework logs file change detections, module invalidations, and other internal events to stderr. Pass `onFrameworkLog` to redirect or silence them. The "listening on port" line and your Lambda handler logs are always printed regardless. - -```ts -// silence all framework logs -createLocalApp({ manifest, onFrameworkLog: () => {} }); - -// pipe to your own logger -createLocalApp({ manifest, onFrameworkLog: (msg) => myLogger.debug(msg) }); -``` - -The same option is available on `extractManifest` to suppress entry-recovery logs during the extract phase. - -## Resources -- [Example stack](https://github.com/tiny-build/aws-cdk-local-lambda/tree/main/examples/simple-crud) - -## What this package does NOT do - -- Load `.env` files or set `AWS_REGION` defaults - that's the consumer's job. -- Call `app.listen` from `createLocalApp` - the consumer owns the port and lifecycle. -- Know about any repo-specific naming conventions (function prefixes, authorizer keys, etc.). - - -## License - -[MIT](https://github.com/tiny-build/aws-cdk-local-lambda/blob/main/LICENSE) © tiny-build - ---- - -> This project is not affiliated with, endorsed by, or sponsored by Amazon Web Services (AWS) in any way. AWS, CDK, Lambda, and API Gateway are trademarks of Amazon.com, Inc. or its affiliates. All trademarks and copyrights referenced in this project belong to their respective owners. diff --git a/packages/aws-cdk-local-lambda/src/bin/cdk-local.ts b/packages/aws-cdk-local-lambda/src/bin/cdk-local.ts index d72975c..7747e69 100644 --- a/packages/aws-cdk-local-lambda/src/bin/cdk-local.ts +++ b/packages/aws-cdk-local-lambda/src/bin/cdk-local.ts @@ -1,225 +1,247 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, isAbsolute, resolve } from "node:path"; -import { Command } from "commander"; -import type { Application } from "express"; -import { extractManifest } from "../extract/build-manifest"; -import { createLocalApp } from "../server/create-app"; -import type { LocalManifest } from "../types"; +import type { LocalManifest } from '../types'; +import type { Application } from 'express'; + +import { Command } from 'commander'; +import { spawn } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, isAbsolute, resolve } from 'node:path'; + +import { extractManifest } from '../extract/build-manifest'; +import { createLocalApp } from '../server/create-app'; function abs(p: string): string { - return isAbsolute(p) ? p : resolve(process.cwd(), p); + return isAbsolute(p) ? p : resolve(process.cwd(), p); } function logSuccess(line: string): void { - if (process.env.NO_COLOR) { - console.log(line); - return; - } - console.log(`\x1b[32m${line}\x1b[0m`); + if (process.env.NO_COLOR) { + console.log(line); + return; + } + console.log(`\x1b[32m${line}\x1b[0m`); } function listenUntilSignal(opts: { - app: Application; - port: number; - onListening?: () => void; - onStop: () => Promise; + app: Application; + port: number; + onListening?: () => void; + onStop: () => Promise; }): Promise { - return new Promise((resolve, reject) => { - const server = opts.app.listen(opts.port, () => { - opts.onListening?.(); - }); - server.on("error", reject); - const shutdown = (): void => { - server.close((err) => { - opts.onStop().finally(() => (err ? reject(err) : resolve())); - }); - }; - process.once("SIGINT", shutdown); - process.once("SIGTERM", shutdown); - }); + return new Promise((resolve, reject) => { + const server = opts.app.listen(opts.port, () => { + opts.onListening?.(); + }); + server.on('error', reject); + const shutdown = (): void => { + server.close(err => { + opts.onStop().finally(() => (err ? reject(err) : resolve())); + }); + }; + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + }); } -function runSynth(cdkOut: string, frameworkLog: (msg: string) => void): Promise { - return new Promise((resolve, reject) => { - frameworkLog(`[cdk-local] running cdk synth in ${dirname(cdkOut)}...`); - const child = spawn("cdk", ["synth", "--output", cdkOut], { - cwd: dirname(cdkOut), - stdio: "inherit", - env: process.env, - shell: true, - }); - child.on("close", (code) => { - if (code === 0) resolve(); - else reject(new Error(`cdk synth exited with code ${String(code)}`)); - }); - child.on("error", reject); - }); +function runSynth( + cdkOut: string, + frameworkLog: (msg: string) => void +): Promise { + return new Promise((resolve, reject) => { + frameworkLog(`[cdk-local] running cdk synth in ${dirname(cdkOut)}...`); + const child = spawn('cdk', ['synth', '--output', cdkOut], { + cwd: dirname(cdkOut), + stdio: 'inherit', + env: process.env, + shell: true + }); + child.on('close', code => { + if (code === 0) resolve(); + else reject(new Error(`cdk synth exited with code ${String(code)}`)); + }); + child.on('error', reject); + }); } interface ExtractArgs { - cdkOut: string; - stack: string; - stage?: string; - out?: string; - synth: boolean; - repoRoot?: string; - quiet: boolean; + cdkOut: string; + stack: string; + stage?: string; + out?: string; + synth: boolean; + repoRoot?: string; + quiet: boolean; } async function cmdExtract(opts: ExtractArgs): Promise { - const frameworkLog = opts.quiet ? () => {} : (msg: string) => console.error(msg); - const cdkOut = abs(opts.cdkOut); - if (opts.synth) await runSynth(cdkOut, frameworkLog); - - const m = await extractManifest({ - cdkOut: cdkOut, - stack: opts.stack, - stage: opts.stage, - repoRoot: opts.repoRoot ? abs(opts.repoRoot) : process.cwd(), - onWarning: (w) => console.warn(`[cdk-local] ${w}`), - onFrameworkLog: frameworkLog, - }); - const json = `${JSON.stringify(m, null, 2)}\n`; - if (opts.out) { - const outPath = abs(opts.out); - mkdirSync(dirname(outPath), { recursive: true }); - writeFileSync(outPath, json, "utf8"); - logSuccess( - `[cdk-local] wrote ${outPath} (${Object.keys(m.routes).length} routes, ${Object.keys(m.lambdas).length} lambdas)`, - ); - } else { - process.stdout.write(json); - } + const frameworkLog = opts.quiet + ? () => {} + : (msg: string) => console.error(msg); + const cdkOut = abs(opts.cdkOut); + if (opts.synth) await runSynth(cdkOut, frameworkLog); + + const m = await extractManifest({ + cdkOut: cdkOut, + stack: opts.stack, + stage: opts.stage, + repoRoot: opts.repoRoot ? abs(opts.repoRoot) : process.cwd(), + onWarning: w => console.warn(`[cdk-local] ${w}`), + onFrameworkLog: frameworkLog + }); + const json = `${JSON.stringify(m, null, 2)}\n`; + if (opts.out) { + const outPath = abs(opts.out); + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, json, 'utf8'); + logSuccess( + `[cdk-local] wrote ${outPath} (${Object.keys(m.routes).length} routes, ${Object.keys(m.lambdas).length} lambdas)` + ); + } else { + process.stdout.write(json); + } } interface ServeArgs { - manifest: string; - port: string; - watch: boolean; - quiet: boolean; + manifest: string; + port: string; + watch: boolean; + quiet: boolean; } async function cmdServe(opts: ServeArgs): Promise { - const frameworkLog = opts.quiet ? () => {} : (msg: string) => console.error(msg); - const manifestPath = abs(opts.manifest); - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as LocalManifest; - const handle = await createLocalApp({ - manifest, - manifestPath, - watch: opts.watch, - onReload: (p, n) => frameworkLog(`[cdk-local] reload (${n} cached) after ${p}`), - onManifestChange: (p) => - frameworkLog( - `[cdk-local] manifest changed (${p}); restart the process to apply route topology changes`, - ), - onFrameworkLog: frameworkLog, - }); - const port = Number(opts.port); - await listenUntilSignal({ - app: handle.app, - port, - onListening: () => - logSuccess( - `[cdk-local] listening on http://localhost:${port} (${handle.routes.length} routes)`, - ), - onStop: () => handle.stop(), - }); + const frameworkLog = opts.quiet + ? () => {} + : (msg: string) => console.error(msg); + const manifestPath = abs(opts.manifest); + const manifest = JSON.parse( + readFileSync(manifestPath, 'utf8') + ) as LocalManifest; + const handle = await createLocalApp({ + manifest, + manifestPath, + watch: opts.watch, + onReload: (p, n) => + frameworkLog(`[cdk-local] reload (${n} cached) after ${p}`), + onManifestChange: p => + frameworkLog( + `[cdk-local] manifest changed (${p}); restart the process to apply route topology changes` + ), + onFrameworkLog: frameworkLog + }); + const port = Number(opts.port); + await listenUntilSignal({ + app: handle.app, + port, + onListening: () => + logSuccess( + `[cdk-local] listening on http://localhost:${port} (${handle.routes.length} routes)` + ), + onStop: () => handle.stop() + }); } interface DevArgs { - cdkOut: string; - stack: string; - stage?: string; - port: string; - watch: boolean; - repoRoot?: string; - quiet: boolean; + cdkOut: string; + stack: string; + stage?: string; + port: string; + watch: boolean; + repoRoot?: string; + quiet: boolean; } async function cmdDev(opts: DevArgs): Promise { - const frameworkLog = opts.quiet ? () => {} : (msg: string) => console.error(msg); - const repoRoot = opts.repoRoot ? abs(opts.repoRoot) : process.cwd(); - const manifest = await extractManifest({ - cdkOut: abs(opts.cdkOut), - stack: opts.stack, - stage: opts.stage, - repoRoot, - onWarning: (w) => console.warn(`[cdk-local] ${w}`), - onFrameworkLog: frameworkLog, - }); - const handle = await createLocalApp({ - manifest, - repoRoot, - watch: opts.watch, - onReload: (p, n) => frameworkLog(`[cdk-local] reload (${n} cached) after ${p}`), - onFrameworkLog: frameworkLog, - }); - const port = Number(opts.port); - await listenUntilSignal({ - app: handle.app, - port, - onListening: () => - logSuccess( - `[cdk-local] listening on http://localhost:${port} (${handle.routes.length} routes)`, - ), - onStop: () => handle.stop(), - }); + const frameworkLog = opts.quiet + ? () => {} + : (msg: string) => console.error(msg); + const repoRoot = opts.repoRoot ? abs(opts.repoRoot) : process.cwd(); + const manifest = await extractManifest({ + cdkOut: abs(opts.cdkOut), + stack: opts.stack, + stage: opts.stage, + repoRoot, + onWarning: w => console.warn(`[cdk-local] ${w}`), + onFrameworkLog: frameworkLog + }); + const handle = await createLocalApp({ + manifest, + repoRoot, + watch: opts.watch, + onReload: (p, n) => + frameworkLog(`[cdk-local] reload (${n} cached) after ${p}`), + onFrameworkLog: frameworkLog + }); + const port = Number(opts.port); + await listenUntilSignal({ + app: handle.app, + port, + onListening: () => + logSuccess( + `[cdk-local] running http on http://localhost:${port} (${handle.routes.length} routes)` + ), + onStop: () => handle.stop() + }); } const program = new Command(); -program.name("cdk-local").description("Synth-driven local Lambda dev runner"); +program.name('cdk-local').description('Synth-driven local Lambda dev runner'); program - .command("extract") - .requiredOption("--cdk-out ", "path to cdk.out") - .requiredOption("--stack ", "CloudFormation stack name") - .option( - "--stage ", - "deployment stage suffix used to strip function name prefixes (e.g. dev)", - ) - .option("--out ", "output manifest path (default: stdout)") - .option("--synth", "run cdk synth before extracting", false) - .option("--repo-root ", "repo root for resolving handler source paths (default: cwd)") - .option("--quiet", "suppress framework log output", false) - .action(cmdExtract); + .command('extract') + .requiredOption('--cdk-out ', 'path to cdk.out') + .requiredOption('--stack ', 'CloudFormation stack name') + .option( + '--stage ', + 'deployment stage suffix used to strip function name prefixes (e.g. dev)' + ) + .option('--out ', 'output manifest path (default: stdout)') + .option('--synth', 'run cdk synth before extracting', false) + .option( + '--repo-root ', + 'repo root for resolving handler source paths (default: cwd)' + ) + .option('--quiet', 'suppress framework log output', false) + .action(cmdExtract); program - .command("serve") - .requiredOption("--manifest ", "path to a v2 manifest") - .option("--port ", "port", "3001") - .option("--watch", "enable file watching", false) - .option("--quiet", "suppress framework log output", false) - .action((o: { manifest: string; port: string; watch?: boolean; quiet?: boolean }) => - cmdServe({ ...o, watch: o.watch === true, quiet: o.quiet === true }), - ); + .command('serve') + .requiredOption('--manifest ', 'path to a v2 manifest') + .option('--port ', 'port', '3001') + .option('--watch', 'enable file watching', false) + .option('--quiet', 'suppress framework log output', false) + .action( + (o: { manifest: string; port: string; watch?: boolean; quiet?: boolean }) => + cmdServe({ ...o, watch: o.watch === true, quiet: o.quiet === true }) + ); program - .command("dev") - .requiredOption("--cdk-out ", "path to cdk.out") - .requiredOption("--stack ", "CloudFormation stack name") - .option( - "--stage ", - "deployment stage suffix used to strip function name prefixes (e.g. dev)", - ) - .option("--port ", "port", "3001") - .option("--no-watch", "disable file watching") - .option("--repo-root ", "repo root for resolving handler source paths (default: cwd)") - .option("--quiet", "suppress framework log output", false) - .action( - (o: { - cdkOut: string; - stack: string; - stage?: string; - port: string; - watch?: boolean; - repoRoot?: string; - quiet?: boolean; - }) => cmdDev({ ...o, watch: o.watch !== false, quiet: o.quiet === true }), - ); - -program.parseAsync(process.argv).catch((err) => { - console.error(err); - process.exit(1); + .command('dev') + .requiredOption('--cdk-out ', 'path to cdk.out') + .requiredOption('--stack ', 'CloudFormation stack name') + .option( + '--stage ', + 'deployment stage suffix used to strip function name prefixes (e.g. dev)' + ) + .option('--port ', 'port', '3001') + .option('--no-watch', 'disable file watching') + .option( + '--repo-root ', + 'repo root for resolving handler source paths (default: cwd)' + ) + .option('--quiet', 'suppress framework log output', false) + .action( + (o: { + cdkOut: string; + stack: string; + stage?: string; + port: string; + watch?: boolean; + repoRoot?: string; + quiet?: boolean; + }) => cmdDev({ ...o, watch: o.watch !== false, quiet: o.quiet === true }) + ); + +program.parseAsync(process.argv).catch(err => { + console.error(err); + process.exit(1); }); diff --git a/packages/aws-cdk-local-lambda/src/constants/http.ts b/packages/aws-cdk-local-lambda/src/constants/http.ts index 74f2581..665b2fc 100644 --- a/packages/aws-cdk-local-lambda/src/constants/http.ts +++ b/packages/aws-cdk-local-lambda/src/constants/http.ts @@ -1,7 +1,17 @@ -const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] as const; +const HTTP_METHODS = [ + 'get', + 'post', + 'put', + 'patch', + 'delete', + 'head', + 'options' +] as const; /** Union of lowercase HTTP methods supported by the local server. */ export type SupportedHttpMethod = (typeof HTTP_METHODS)[number]; /** Set of all {@link SupportedHttpMethod} values, used to filter out unsupported methods from the manifest. */ -export const SUPPORTED_HTTP_METHODS: ReadonlySet = new Set(HTTP_METHODS); +export const SUPPORTED_HTTP_METHODS: ReadonlySet = new Set( + HTTP_METHODS +); diff --git a/packages/aws-cdk-local-lambda/src/constants/recover-entry.ts b/packages/aws-cdk-local-lambda/src/constants/recover-entry.ts index fedfaaa..1aecdc6 100644 --- a/packages/aws-cdk-local-lambda/src/constants/recover-entry.ts +++ b/packages/aws-cdk-local-lambda/src/constants/recover-entry.ts @@ -1 +1,2 @@ -export const ENTRY_MARKER_RE = /^\/\/ (\S+\.(?:ts|tsx|mts|cts|js|mjs|cjs))\s*$/gm; +export const ENTRY_MARKER_RE = + /^\/\/ (\S+\.(?:ts|tsx|mts|cts|js|mjs|cjs))\s*$/gm; diff --git a/packages/aws-cdk-local-lambda/src/constants/watcher.ts b/packages/aws-cdk-local-lambda/src/constants/watcher.ts index e97b09f..9492146 100644 --- a/packages/aws-cdk-local-lambda/src/constants/watcher.ts +++ b/packages/aws-cdk-local-lambda/src/constants/watcher.ts @@ -1,7 +1,7 @@ /** Applied by chokidar; {@link ../server/hot-reloader} exempts `manifestPath` from these rules. */ export const WATCHER_IGNORED_PATTERNS: readonly RegExp[] = [ - /(^|[\\/])node_modules([\\/]|$)/, - /(^|[\\/])\.git([\\/]|$)/, - /\.map$/, - /\.generated\./, + /(^|[\\/])node_modules([\\/]|$)/, + /(^|[\\/])\.git([\\/]|$)/, + /\.map$/, + /\.generated\./ ]; diff --git a/packages/aws-cdk-local-lambda/src/extract/build-manifest.test.ts b/packages/aws-cdk-local-lambda/src/extract/build-manifest.test.ts index 54115cc..851971a 100644 --- a/packages/aws-cdk-local-lambda/src/extract/build-manifest.test.ts +++ b/packages/aws-cdk-local-lambda/src/extract/build-manifest.test.ts @@ -1,134 +1,137 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; -import { extractManifest } from "./build-manifest"; +import { extractManifest } from './build-manifest'; function makeRepo() { - const root = mkdtempSync(join(tmpdir(), "cdk-local-int-")); - const cdkOut = join(root, "cdk.out"); - mkdirSync(cdkOut); + const root = mkdtempSync(join(tmpdir(), 'cdk-local-int-')); + const cdkOut = join(root, 'cdk.out'); + mkdirSync(cdkOut); - mkdirSync(join(root, "api/src/functions/hello"), { recursive: true }); - writeFileSync( - join(root, "api/src/functions/hello/handler.ts"), - 'export const main = async () => ({ statusCode: 200, body: "ok" });', - ); - mkdirSync(join(root, "api/src/functions/authorizer"), { recursive: true }); - writeFileSync( - join(root, "api/src/functions/authorizer/handler.ts"), - 'export const main = async () => ({ policyDocument: { Statement: [{ Effect: "Allow" }] } });', - ); + mkdirSync(join(root, 'api/src/functions/hello'), { recursive: true }); + writeFileSync( + join(root, 'api/src/functions/hello/handler.ts'), + 'export const main = async () => ({ statusCode: 200, body: "ok" });' + ); + mkdirSync(join(root, 'api/src/functions/authorizer'), { recursive: true }); + writeFileSync( + join(root, 'api/src/functions/authorizer/handler.ts'), + 'export const main = async () => ({ policyDocument: { Statement: [{ Effect: "Allow" }] } });' + ); - mkdirSync(join(cdkOut, "asset.abcdef01")); - writeFileSync( - join(cdkOut, "asset.abcdef01/index.js"), - "// api/src/functions/hello/handler.ts\nexports.main = async () => {};", - ); - mkdirSync(join(cdkOut, "asset.feedface")); - writeFileSync( - join(cdkOut, "asset.feedface/index.js"), - "// api/src/functions/authorizer/handler.ts\nexports.main = async () => {};", - ); + mkdirSync(join(cdkOut, 'asset.abcdef01')); + writeFileSync( + join(cdkOut, 'asset.abcdef01/index.js'), + '// api/src/functions/hello/handler.ts\nexports.main = async () => {};' + ); + mkdirSync(join(cdkOut, 'asset.feedface')); + writeFileSync( + join(cdkOut, 'asset.feedface/index.js'), + '// api/src/functions/authorizer/handler.ts\nexports.main = async () => {};' + ); - const template = { - Resources: { - Api: { Type: "AWS::ApiGateway::RestApi", Properties: {} }, - HelloRes: { - Type: "AWS::ApiGateway::Resource", - Properties: { - ParentId: { "Fn::GetAtt": ["Api", "RootResourceId"] }, - PathPart: "hello", - RestApiId: { Ref: "Api" }, - }, - }, - HelloMethod: { - Type: "AWS::ApiGateway::Method", - Properties: { - HttpMethod: "GET", - ResourceId: { Ref: "HelloRes" }, - AuthorizationType: "CUSTOM", - AuthorizerId: { Ref: "Auth1" }, - Integration: { - Type: "AWS_PROXY", - Uri: { - "Fn::Join": ["", ["a/", { "Fn::GetAtt": ["HelloFn", "Arn"] }, "/i"]], - }, - }, - }, - }, - HelloFn: { - Type: "AWS::Lambda::Function", - Properties: { - FunctionName: "zephyr-wombat-dev-grizzly", - Handler: "index.main", - Runtime: "nodejs22.x", - Code: { S3Key: "abcdef01.zip" }, - Environment: { Variables: { STAGE: "dev" } }, - }, - }, - Auth1: { - Type: "AWS::ApiGateway::Authorizer", - Properties: { - Type: "REQUEST", - AuthorizerUri: { - "Fn::Join": ["", ["a/", { "Fn::GetAtt": ["AuthFn", "Arn"] }, "/i"]], - }, - }, - }, - AuthFn: { - Type: "AWS::Lambda::Function", - Properties: { - FunctionName: "mango-blizzard-dev-pelican", - Handler: "index.main", - Runtime: "nodejs22.x", - Code: { S3Key: "feedface.zip" }, - Environment: { Variables: {} }, - }, - }, - }, - }; - writeFileSync(join(cdkOut, "Stack.template.json"), JSON.stringify(template)); - writeFileSync( - join(cdkOut, "Stack.assets.json"), - JSON.stringify({ - files: { - abcdef01: { source: { path: "asset.abcdef01", packaging: "zip" } }, - feedface: { source: { path: "asset.feedface", packaging: "zip" } }, - }, - }), - ); - return { root, cdkOut }; + const template = { + Resources: { + Api: { Type: 'AWS::ApiGateway::RestApi', Properties: {} }, + HelloRes: { + Type: 'AWS::ApiGateway::Resource', + Properties: { + ParentId: { 'Fn::GetAtt': ['Api', 'RootResourceId'] }, + PathPart: 'hello', + RestApiId: { Ref: 'Api' } + } + }, + HelloMethod: { + Type: 'AWS::ApiGateway::Method', + Properties: { + HttpMethod: 'GET', + ResourceId: { Ref: 'HelloRes' }, + AuthorizationType: 'CUSTOM', + AuthorizerId: { Ref: 'Auth1' }, + Integration: { + Type: 'AWS_PROXY', + Uri: { + 'Fn::Join': [ + '', + ['a/', { 'Fn::GetAtt': ['HelloFn', 'Arn'] }, '/i'] + ] + } + } + } + }, + HelloFn: { + Type: 'AWS::Lambda::Function', + Properties: { + FunctionName: 'zephyr-wombat-dev-grizzly', + Handler: 'index.main', + Runtime: 'nodejs22.x', + Code: { S3Key: 'abcdef01.zip' }, + Environment: { Variables: { STAGE: 'dev' } } + } + }, + Auth1: { + Type: 'AWS::ApiGateway::Authorizer', + Properties: { + Type: 'REQUEST', + AuthorizerUri: { + 'Fn::Join': ['', ['a/', { 'Fn::GetAtt': ['AuthFn', 'Arn'] }, '/i']] + } + } + }, + AuthFn: { + Type: 'AWS::Lambda::Function', + Properties: { + FunctionName: 'mango-blizzard-dev-pelican', + Handler: 'index.main', + Runtime: 'nodejs22.x', + Code: { S3Key: 'feedface.zip' }, + Environment: { Variables: {} } + } + } + } + }; + writeFileSync(join(cdkOut, 'Stack.template.json'), JSON.stringify(template)); + writeFileSync( + join(cdkOut, 'Stack.assets.json'), + JSON.stringify({ + files: { + abcdef01: { source: { path: 'asset.abcdef01', packaging: 'zip' } }, + feedface: { source: { path: 'asset.feedface', packaging: 'zip' } } + } + }) + ); + return { root, cdkOut }; } -describe("extractManifest", () => { - it("produces a v2 manifest with lambdas, route, and authorizerKey", async () => { - const { root, cdkOut } = makeRepo(); - const m = await extractManifest({ - cdkOut, - stack: "Stack", - stage: "dev", - repoRoot: root, - }); - expect(m.source).toBe("cdk-synth"); - expect(m.stack).toBe("Stack"); - expect(m.stage).toBe("dev"); - expect(m.cdkOut).toBe(cdkOut); +describe('extractManifest', () => { + it('produces a v2 manifest with lambdas, route, and authorizerKey', async () => { + const { root, cdkOut } = makeRepo(); + const m = await extractManifest({ + cdkOut, + stack: 'Stack', + stage: 'dev', + repoRoot: root + }); + expect(m.source).toBe('cdk-synth'); + expect(m.stack).toBe('Stack'); + expect(m.stage).toBe('dev'); + expect(m.cdkOut).toBe(cdkOut); - expect(Object.keys(m.lambdas).sort()).toEqual(["grizzly", "pelican"]); - const grizzly = m.lambdas.grizzly!; - expect(grizzly.handler).toBe("main"); - expect(grizzly.runtime).toBe("nodejs22.x"); - expect(grizzly.entry).toContain("api/src/functions/hello/handler.ts"); - expect(grizzly.environment.STAGE).toBe("dev"); - expect(grizzly.lambdaFunctionName).toBe("zephyr-wombat-dev-grizzly"); + expect(Object.keys(m.lambdas).sort()).toEqual(['grizzly', 'pelican']); + const grizzly = m.lambdas.grizzly!; + expect(grizzly.handler).toBe('main'); + expect(grizzly.runtime).toBe('nodejs22.x'); + expect(grizzly.entry).toContain('api/src/functions/hello/handler.ts'); + expect(grizzly.environment.STAGE).toBe('dev'); + expect(grizzly.lambdaFunctionName).toBe('zephyr-wombat-dev-grizzly'); - expect(m.routes["GET /hello"]).toEqual({ - method: "GET", - path: "/hello", - functionKey: "grizzly", - authorizerKey: "pelican", - }); - }); + expect(m.routes['GET /hello']).toEqual({ + method: 'GET', + path: '/hello', + functionKey: 'grizzly', + authorizerKey: 'pelican' + }); + }); }); diff --git a/packages/aws-cdk-local-lambda/src/extract/build-manifest.ts b/packages/aws-cdk-local-lambda/src/extract/build-manifest.ts index 2a5818b..a743461 100644 --- a/packages/aws-cdk-local-lambda/src/extract/build-manifest.ts +++ b/packages/aws-cdk-local-lambda/src/extract/build-manifest.ts @@ -1,29 +1,30 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import type { LocalLambda, LocalManifest, LocalRoute } from "../types"; +import type { LocalLambda, LocalManifest, LocalRoute } from '../types'; +import type { CfnResources } from './cfn-types'; -import { normalizeEnv, splitHandler, stageKey } from "../utils/manifest"; -import { sortRecord } from "../utils/object"; -import type { CfnResources } from "./cfn-types"; -import { buildAuthorizerLambdaMap } from "./parse-authorizers"; -import { parseApiGatewayMethods } from "./parse-routes"; -import { recoverEntry } from "./recover-entry"; -import { resolveAssetDir } from "./resolve-asset"; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { normalizeEnv, splitHandler, stageKey } from '../utils/manifest'; +import { sortRecord } from '../utils/object'; +import { buildAuthorizerLambdaMap } from './parse-authorizers'; +import { parseApiGatewayMethods } from './parse-routes'; +import { recoverEntry } from './recover-entry'; +import { resolveAssetDir } from './resolve-asset'; /** Options for {@link extractManifest}. */ export interface ExtractOptions { - /** Absolute path to the CDK output directory (e.g. `path.resolve("cdk.out")`). */ - readonly cdkOut: string; - /** CloudFormation stack name to parse (must match the synthesised template filename). */ - readonly stack: string; - /** Optional stage name; strips the `--` infix from Lambda function names to produce stable keys. */ - readonly stage?: string; - /** Repository root used when recovering TypeScript entry paths. Defaults to `process.cwd()`. */ - readonly repoRoot?: string; - /** Called with non-fatal warning messages (e.g. env vars that couldn't be normalised). */ - readonly onWarning?: (message: string) => void; - /** Called with verbose framework log lines. Pass `() => {}` to silence them. */ - readonly onFrameworkLog?: (message: string) => void; + /** Absolute path to the CDK output directory (e.g. `path.resolve("cdk.out")`). */ + readonly cdkOut: string; + /** CloudFormation stack name to parse (must match the synthesised template filename). */ + readonly stack: string; + /** Optional stage name; strips the `--` infix from Lambda function names to produce stable keys. */ + readonly stage?: string; + /** Repository root used when recovering TypeScript entry paths. Defaults to `process.cwd()`. */ + readonly repoRoot?: string; + /** Called with non-fatal warning messages (e.g. env vars that couldn't be normalised). */ + readonly onWarning?: (message: string) => void; + /** Called with verbose framework log lines. Pass `() => {}` to silence them. */ + readonly onFrameworkLog?: (message: string) => void; } /** @@ -40,90 +41,97 @@ export interface ExtractOptions { * }); * ``` */ -export async function extractManifest(opts: ExtractOptions): Promise { - const repoRoot = opts.repoRoot ?? process.cwd(); - const templatePath = join(opts.cdkOut, `${opts.stack}.template.json`); - const template = JSON.parse(readFileSync(templatePath, "utf8")) as { - Resources?: CfnResources; - }; - const resources = template.Resources ?? {}; +export async function extractManifest( + opts: ExtractOptions +): Promise { + const repoRoot = opts.repoRoot ?? process.cwd(); + const templatePath = join(opts.cdkOut, `${opts.stack}.template.json`); + const template = JSON.parse(readFileSync(templatePath, 'utf8')) as { + Resources?: CfnResources; + }; + const resources = template.Resources ?? {}; - const authorizerMap = buildAuthorizerLambdaMap(resources); - const methods = parseApiGatewayMethods(resources); + const authorizerMap = buildAuthorizerLambdaMap(resources); + const methods = parseApiGatewayMethods(resources); - const lambdas: Record = {}; - const logicalIdToKey = new Map(); + const lambdas: Record = {}; + const logicalIdToKey = new Map(); - const collect = (logicalId: string): string => { - const existing = logicalIdToKey.get(logicalId); - if (existing) return existing; + const collect = (logicalId: string): string => { + const existing = logicalIdToKey.get(logicalId); + if (existing) return existing; - const fn = resources[logicalId]; - if (!fn || fn.Type !== "AWS::Lambda::Function" || !fn.Properties) { - throw new Error(`extractManifest: expected AWS::Lambda::Function at ${logicalId}`); - } - const props = fn.Properties; - const functionName = typeof props.FunctionName === "string" ? props.FunctionName : null; - const functionKey = functionName ? stageKey(functionName, opts.stage) : logicalId; - const code = props.Code as { S3Key?: unknown } | undefined; - const assetDir = resolveAssetDir({ - cdkOut: opts.cdkOut, - stack: opts.stack, - codeS3Key: code?.S3Key, - }); - const entry = recoverEntry({ - assetDir, - repoRoot, - onWarning: opts.onWarning, - onFrameworkLog: opts.onFrameworkLog, - }); - lambdas[functionKey] = { - functionKey, - lambdaLogicalId: logicalId, - lambdaFunctionName: functionName ?? logicalId, - assetDir, - entry, - handler: splitHandler(props.Handler), - runtime: typeof props.Runtime === "string" ? props.Runtime : "nodejs22.x", - environment: normalizeEnv( - (props.Environment as { Variables?: unknown } | undefined)?.Variables, - opts.onWarning, - logicalId, - ), - }; - logicalIdToKey.set(logicalId, functionKey); - return functionKey; - }; + const fn = resources[logicalId]; + if (!fn || fn.Type !== 'AWS::Lambda::Function' || !fn.Properties) { + throw new Error( + `extractManifest: expected AWS::Lambda::Function at ${logicalId}` + ); + } + const props = fn.Properties; + const functionName = + typeof props.FunctionName === 'string' ? props.FunctionName : null; + const functionKey = functionName + ? stageKey(functionName, opts.stage) + : logicalId; + const code = props.Code as { S3Key?: unknown } | undefined; + const assetDir = resolveAssetDir({ + cdkOut: opts.cdkOut, + stack: opts.stack, + codeS3Key: code?.S3Key + }); + const entry = recoverEntry({ + assetDir, + repoRoot, + onWarning: opts.onWarning, + onFrameworkLog: opts.onFrameworkLog + }); + lambdas[functionKey] = { + functionKey, + lambdaLogicalId: logicalId, + lambdaFunctionName: functionName ?? logicalId, + assetDir, + entry, + handler: splitHandler(props.Handler), + runtime: typeof props.Runtime === 'string' ? props.Runtime : 'nodejs22.x', + environment: normalizeEnv( + (props.Environment as { Variables?: unknown } | undefined)?.Variables, + opts.onWarning, + logicalId + ) + }; + logicalIdToKey.set(logicalId, functionKey); + return functionKey; + }; - const routes: Record = {}; + const routes: Record = {}; - for (const m of methods) { - const functionKey = collect(m.lambdaLogicalId); - let authorizerKey: string | null = null; - if (m.authorizerLogicalId) { - const authLambdaLogical = authorizerMap.get(m.authorizerLogicalId); - if (!authLambdaLogical) { - throw new Error( - `extractManifest: authorizer ${m.authorizerLogicalId} on ${m.httpMethod} ${m.path} has no resolvable lambda`, - ); - } - authorizerKey = collect(authLambdaLogical); - } - const key = `${m.httpMethod} ${m.path}`; - routes[key] = { - method: m.httpMethod, - path: m.path, - functionKey, - authorizerKey, - }; - } + for (const m of methods) { + const functionKey = collect(m.lambdaLogicalId); + let authorizerKey: string | null = null; + if (m.authorizerLogicalId) { + const authLambdaLogical = authorizerMap.get(m.authorizerLogicalId); + if (!authLambdaLogical) { + throw new Error( + `extractManifest: authorizer ${m.authorizerLogicalId} on ${m.httpMethod} ${m.path} has no resolvable lambda` + ); + } + authorizerKey = collect(authLambdaLogical); + } + const key = `${m.httpMethod} ${m.path}`; + routes[key] = { + method: m.httpMethod, + path: m.path, + functionKey, + authorizerKey + }; + } - return { - source: "cdk-synth", - stack: opts.stack, - stage: opts.stage, - cdkOut: opts.cdkOut, - lambdas: sortRecord(lambdas), - routes: sortRecord(routes), - }; + return { + source: 'cdk-synth', + stack: opts.stack, + stage: opts.stage, + cdkOut: opts.cdkOut, + lambdas: sortRecord(lambdas), + routes: sortRecord(routes) + }; } diff --git a/packages/aws-cdk-local-lambda/src/extract/cfn-types.ts b/packages/aws-cdk-local-lambda/src/extract/cfn-types.ts index 1b78595..5a1d25d 100644 --- a/packages/aws-cdk-local-lambda/src/extract/cfn-types.ts +++ b/packages/aws-cdk-local-lambda/src/extract/cfn-types.ts @@ -1,9 +1,9 @@ /** A single CloudFormation resource entry from a synthesised template. */ export interface CfnResource { - /** CloudFormation resource type (e.g. `"AWS::Lambda::Function"`). */ - readonly Type?: string; - /** Resource-specific properties from the CloudFormation template. */ - readonly Properties?: Record; + /** CloudFormation resource type (e.g. `"AWS::Lambda::Function"`). */ + readonly Type?: string; + /** Resource-specific properties from the CloudFormation template. */ + readonly Properties?: Record; } /** All resources in a CloudFormation template, keyed by logical resource ID. */ @@ -11,21 +11,24 @@ export type CfnResources = Readonly>; /** Type guard — returns `true` if `x` is a CloudFormation `{ Ref: string }` intrinsic. */ export function isRef(x: unknown): x is { Ref: string } { - return ( - typeof x === "object" && - x !== null && - "Ref" in x && - typeof (x as { Ref: unknown }).Ref === "string" - ); + return ( + typeof x === 'object' && + x !== null && + 'Ref' in x && + typeof (x as { Ref: unknown }).Ref === 'string' + ); } /** Type guard — returns `true` if `x` is a CloudFormation `{ "Fn::GetAtt": [logicalId, attribute] }` intrinsic. */ -export function isGetAtt(x: unknown): x is { "Fn::GetAtt": [string, string] } { - if (typeof x !== "object" || x === null || !("Fn::GetAtt" in x)) { - return false; - } - const ga = (x as { "Fn::GetAtt": unknown })["Fn::GetAtt"]; - return ( - Array.isArray(ga) && ga.length === 2 && typeof ga[0] === "string" && typeof ga[1] === "string" - ); +export function isGetAtt(x: unknown): x is { 'Fn::GetAtt': [string, string] } { + if (typeof x !== 'object' || x === null || !('Fn::GetAtt' in x)) { + return false; + } + const ga = (x as { 'Fn::GetAtt': unknown })['Fn::GetAtt']; + return ( + Array.isArray(ga) && + ga.length === 2 && + typeof ga[0] === 'string' && + typeof ga[1] === 'string' + ); } diff --git a/packages/aws-cdk-local-lambda/src/extract/index.ts b/packages/aws-cdk-local-lambda/src/extract/index.ts index 029d892..ed7d424 100644 --- a/packages/aws-cdk-local-lambda/src/extract/index.ts +++ b/packages/aws-cdk-local-lambda/src/extract/index.ts @@ -1,2 +1,2 @@ -export * from "./build-manifest"; -export type { ParsedMethod } from "./parse-routes"; +export * from './build-manifest'; +export type { ParsedMethod } from './parse-routes'; diff --git a/packages/aws-cdk-local-lambda/src/extract/parse-api-gateway-paths.test.ts b/packages/aws-cdk-local-lambda/src/extract/parse-api-gateway-paths.test.ts index bc94fd9..771dc46 100644 --- a/packages/aws-cdk-local-lambda/src/extract/parse-api-gateway-paths.test.ts +++ b/packages/aws-cdk-local-lambda/src/extract/parse-api-gateway-paths.test.ts @@ -1,58 +1,58 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest'; -import { buildApiGatewayResourcePaths } from "./parse-api-gateway-paths"; +import { buildApiGatewayResourcePaths } from './parse-api-gateway-paths'; -describe("buildApiGatewayResourcePaths", () => { - it("builds /hello from root + child resource", () => { - const resources = { - ZephyrBoulderApi3F9D214B: { - Type: "AWS::ApiGateway::RestApi", - Properties: { Name: "wombat-nebula-staging" }, - }, - WaffleMooseResource8C1D3E27: { - Type: "AWS::ApiGateway::Resource", - Properties: { - ParentId: { - "Fn::GetAtt": ["ZephyrBoulderApi3F9D214B", "RootResourceId"], - }, - PathPart: "hello", - RestApiId: { Ref: "ZephyrBoulderApi3F9D214B" }, - }, - }, - }; +describe('buildApiGatewayResourcePaths', () => { + it('builds /hello from root + child resource', () => { + const resources = { + ZephyrBoulderApi3F9D214B: { + Type: 'AWS::ApiGateway::RestApi', + Properties: { Name: 'wombat-nebula-staging' } + }, + WaffleMooseResource8C1D3E27: { + Type: 'AWS::ApiGateway::Resource', + Properties: { + ParentId: { + 'Fn::GetAtt': ['ZephyrBoulderApi3F9D214B', 'RootResourceId'] + }, + PathPart: 'hello', + RestApiId: { Ref: 'ZephyrBoulderApi3F9D214B' } + } + } + }; - const paths = buildApiGatewayResourcePaths(resources); - expect(paths.get("WaffleMooseResource8C1D3E27")).toBe("/hello"); - }); + const paths = buildApiGatewayResourcePaths(resources); + expect(paths.get('WaffleMooseResource8C1D3E27')).toBe('/hello'); + }); - it("builds nested /common/get-all-assessments", () => { - const resources = { - Api: { - Type: "AWS::ApiGateway::RestApi", - Properties: {}, - }, - CommonRes: { - Type: "AWS::ApiGateway::Resource", - Properties: { - ParentId: { - "Fn::GetAtt": ["Api", "RootResourceId"], - }, - PathPart: "common", - RestApiId: { Ref: "Api" }, - }, - }, - NestedRes: { - Type: "AWS::ApiGateway::Resource", - Properties: { - ParentId: { Ref: "CommonRes" }, - PathPart: "get-all-assessments", - RestApiId: { Ref: "Api" }, - }, - }, - }; + it('builds nested /common/get-all-assessments', () => { + const resources = { + Api: { + Type: 'AWS::ApiGateway::RestApi', + Properties: {} + }, + CommonRes: { + Type: 'AWS::ApiGateway::Resource', + Properties: { + ParentId: { + 'Fn::GetAtt': ['Api', 'RootResourceId'] + }, + PathPart: 'common', + RestApiId: { Ref: 'Api' } + } + }, + NestedRes: { + Type: 'AWS::ApiGateway::Resource', + Properties: { + ParentId: { Ref: 'CommonRes' }, + PathPart: 'get-all-assessments', + RestApiId: { Ref: 'Api' } + } + } + }; - const paths = buildApiGatewayResourcePaths(resources); - expect(paths.get("CommonRes")).toBe("/common"); - expect(paths.get("NestedRes")).toBe("/common/get-all-assessments"); - }); + const paths = buildApiGatewayResourcePaths(resources); + expect(paths.get('CommonRes')).toBe('/common'); + expect(paths.get('NestedRes')).toBe('/common/get-all-assessments'); + }); }); diff --git a/packages/aws-cdk-local-lambda/src/extract/parse-api-gateway-paths.ts b/packages/aws-cdk-local-lambda/src/extract/parse-api-gateway-paths.ts index 90c42f8..c736e23 100644 --- a/packages/aws-cdk-local-lambda/src/extract/parse-api-gateway-paths.ts +++ b/packages/aws-cdk-local-lambda/src/extract/parse-api-gateway-paths.ts @@ -1,56 +1,62 @@ -import { type CfnResources, isGetAtt, isRef } from "./cfn-types"; +import { type CfnResources, isGetAtt, isRef } from './cfn-types'; function isRootParent(parentId: unknown): boolean { - return isGetAtt(parentId) && parentId["Fn::GetAtt"][1] === "RootResourceId"; + return isGetAtt(parentId) && parentId['Fn::GetAtt'][1] === 'RootResourceId'; } /** * Walks the `AWS::ApiGateway::Resource` tree in a CloudFormation template and returns a map * from each resource's logical ID to its resolved absolute path (e.g. `"/users/{id}"`). */ -export function buildApiGatewayResourcePaths(resources: CfnResources): Map { - const result = new Map(); - - const resourceIds = new Set(); - for (const [id, res] of Object.entries(resources)) { - if (res?.Type === "AWS::ApiGateway::Resource") { - resourceIds.add(id); - } - } - - function pathFor(logicalId: string): string { - if (result.has(logicalId)) { - return result.get(logicalId)!; - } - const res = resources[logicalId]; - if (!res || res.Type !== "AWS::ApiGateway::Resource") { - throw new Error(`Not an ApiGateway Resource: ${logicalId}`); - } - const props = res.Properties ?? {}; - const pathPart = props.PathPart; - const parentId = props.ParentId; - if (typeof pathPart !== "string") { - throw new Error(`Missing PathPart on ${logicalId}`); - } - - let prefix = ""; - if (isRootParent(parentId)) { - prefix = ""; - } else if (isRef(parentId)) { - prefix = pathFor(parentId.Ref); - } else { - throw new Error(`Unsupported ParentId on ${logicalId}: ${JSON.stringify(parentId)}`); - } - - const full = - prefix === "" || prefix === "/" ? `/${pathPart}` : `${prefix.replace(/\/$/, "")}/${pathPart}`; - - result.set(logicalId, full); - return full; - } - - for (const id of resourceIds) { - pathFor(id); - } - return result; +export function buildApiGatewayResourcePaths( + resources: CfnResources +): Map { + const result = new Map(); + + const resourceIds = new Set(); + for (const [id, res] of Object.entries(resources)) { + if (res?.Type === 'AWS::ApiGateway::Resource') { + resourceIds.add(id); + } + } + + function pathFor(logicalId: string): string { + if (result.has(logicalId)) { + return result.get(logicalId)!; + } + const res = resources[logicalId]; + if (!res || res.Type !== 'AWS::ApiGateway::Resource') { + throw new Error(`Not an ApiGateway Resource: ${logicalId}`); + } + const props = res.Properties ?? {}; + const pathPart = props.PathPart; + const parentId = props.ParentId; + if (typeof pathPart !== 'string') { + throw new Error(`Missing PathPart on ${logicalId}`); + } + + let prefix = ''; + if (isRootParent(parentId)) { + prefix = ''; + } else if (isRef(parentId)) { + prefix = pathFor(parentId.Ref); + } else { + throw new Error( + `Unsupported ParentId on ${logicalId}: ${JSON.stringify(parentId)}` + ); + } + + const full = + prefix === '' || prefix === '/' + ? `/${pathPart}` + : `${prefix.replace(/\/$/, '')}/${pathPart}`; + + result.set(logicalId, full); + return full; + } + + for (const id of resourceIds) { + pathFor(id); + } + return result; } diff --git a/packages/aws-cdk-local-lambda/src/extract/parse-authorizers.test.ts b/packages/aws-cdk-local-lambda/src/extract/parse-authorizers.test.ts index aeff80f..78d17db 100644 --- a/packages/aws-cdk-local-lambda/src/extract/parse-authorizers.test.ts +++ b/packages/aws-cdk-local-lambda/src/extract/parse-authorizers.test.ts @@ -1,53 +1,53 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest'; -import { buildAuthorizerLambdaMap } from "./parse-authorizers"; +import { buildAuthorizerLambdaMap } from './parse-authorizers'; -describe("buildAuthorizerLambdaMap", () => { - it("maps authorizer logical id to its lambda logical id", () => { - const resources = { - MyAuthorizer: { - Type: "AWS::ApiGateway::Authorizer", - Properties: { - Type: "REQUEST", - AuthorizerUri: { - "Fn::Join": [ - "", - [ - "arn:", - { Ref: "AWS::Partition" }, - ":apigateway:", - { Ref: "AWS::Region" }, - ":lambda:path/2015-03-31/functions/", - { "Fn::GetAtt": ["AuthLambdaABC", "Arn"] }, - "/invocations", - ], - ], - }, - }, - }, - }; - const map = buildAuthorizerLambdaMap(resources); - expect(map.get("MyAuthorizer")).toBe("AuthLambdaABC"); - }); +describe('buildAuthorizerLambdaMap', () => { + it('maps authorizer logical id to its lambda logical id', () => { + const resources = { + MyAuthorizer: { + Type: 'AWS::ApiGateway::Authorizer', + Properties: { + Type: 'REQUEST', + AuthorizerUri: { + 'Fn::Join': [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':apigateway:', + { Ref: 'AWS::Region' }, + ':lambda:path/2015-03-31/functions/', + { 'Fn::GetAtt': ['AuthLambdaABC', 'Arn'] }, + '/invocations' + ] + ] + } + } + } + }; + const map = buildAuthorizerLambdaMap(resources); + expect(map.get('MyAuthorizer')).toBe('AuthLambdaABC'); + }); - it("skips authorizers without a resolvable lambda GetAtt", () => { - const resources = { - BadAuthorizer: { - Type: "AWS::ApiGateway::Authorizer", - Properties: { - Type: "TOKEN", - AuthorizerUri: "static-uri-no-getatt", - }, - }, - }; - const map = buildAuthorizerLambdaMap(resources); - expect(map.has("BadAuthorizer")).toBe(false); - }); + it('skips authorizers without a resolvable lambda GetAtt', () => { + const resources = { + BadAuthorizer: { + Type: 'AWS::ApiGateway::Authorizer', + Properties: { + Type: 'TOKEN', + AuthorizerUri: 'static-uri-no-getatt' + } + } + }; + const map = buildAuthorizerLambdaMap(resources); + expect(map.has('BadAuthorizer')).toBe(false); + }); - it("ignores non-Authorizer resources", () => { - const resources = { - Method: { Type: "AWS::ApiGateway::Method", Properties: {} }, - }; - expect(buildAuthorizerLambdaMap(resources).size).toBe(0); - }); + it('ignores non-Authorizer resources', () => { + const resources = { + Method: { Type: 'AWS::ApiGateway::Method', Properties: {} } + }; + expect(buildAuthorizerLambdaMap(resources).size).toBe(0); + }); }); diff --git a/packages/aws-cdk-local-lambda/src/extract/parse-authorizers.ts b/packages/aws-cdk-local-lambda/src/extract/parse-authorizers.ts index e17ae19..242cd05 100644 --- a/packages/aws-cdk-local-lambda/src/extract/parse-authorizers.ts +++ b/packages/aws-cdk-local-lambda/src/extract/parse-authorizers.ts @@ -1,21 +1,24 @@ -import { findLambdaLogicalIdInUri } from "../utils/cfn-uri"; -import type { CfnResources } from "./cfn-types"; +import type { CfnResources } from './cfn-types'; + +import { findLambdaLogicalIdInUri } from '../utils/cfn-uri'; /** * Scans CloudFormation resources for `AWS::ApiGateway::Authorizer` entries and returns a map * from authorizer logical ID → Lambda logical ID. */ -export function buildAuthorizerLambdaMap(resources: CfnResources): Map { - const out = new Map(); - for (const [logicalId, res] of Object.entries(resources)) { - if (res?.Type !== "AWS::ApiGateway::Authorizer" || !res.Properties) { - continue; - } - const uri = res.Properties.AuthorizerUri; - const lambdaId = findLambdaLogicalIdInUri(uri); - if (lambdaId) { - out.set(logicalId, lambdaId); - } - } - return out; +export function buildAuthorizerLambdaMap( + resources: CfnResources +): Map { + const out = new Map(); + for (const [logicalId, res] of Object.entries(resources)) { + if (res?.Type !== 'AWS::ApiGateway::Authorizer' || !res.Properties) { + continue; + } + const uri = res.Properties.AuthorizerUri; + const lambdaId = findLambdaLogicalIdInUri(uri); + if (lambdaId) { + out.set(logicalId, lambdaId); + } + } + return out; } diff --git a/packages/aws-cdk-local-lambda/src/extract/parse-routes.test.ts b/packages/aws-cdk-local-lambda/src/extract/parse-routes.test.ts index 957fa77..c8ccad3 100644 --- a/packages/aws-cdk-local-lambda/src/extract/parse-routes.test.ts +++ b/packages/aws-cdk-local-lambda/src/extract/parse-routes.test.ts @@ -1,80 +1,84 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest'; -import { parseApiGatewayMethods } from "./parse-routes"; +import { parseApiGatewayMethods } from './parse-routes'; -describe("parseApiGatewayMethods", () => { - const helloResources = { - Api: { Type: "AWS::ApiGateway::RestApi", Properties: {} }, - HelloRes: { - Type: "AWS::ApiGateway::Resource", - Properties: { - ParentId: { "Fn::GetAtt": ["Api", "RootResourceId"] }, - PathPart: "hello", - RestApiId: { Ref: "Api" }, - }, - }, - HelloMethod: { - Type: "AWS::ApiGateway::Method", - Properties: { - HttpMethod: "GET", - ResourceId: { Ref: "HelloRes" }, - AuthorizationType: "NONE", - Integration: { - Type: "AWS_PROXY", - Uri: { - "Fn::Join": [ - "", - ["arn:...:functions/", { "Fn::GetAtt": ["HelloLambda", "Arn"] }, "/invocations"], - ], - }, - }, - }, - }, - HelloLambda: { - Type: "AWS::Lambda::Function", - Properties: { FunctionName: "zephyr-wombat-dev-grizzly" }, - }, - }; +describe('parseApiGatewayMethods', () => { + const helloResources = { + Api: { Type: 'AWS::ApiGateway::RestApi', Properties: {} }, + HelloRes: { + Type: 'AWS::ApiGateway::Resource', + Properties: { + ParentId: { 'Fn::GetAtt': ['Api', 'RootResourceId'] }, + PathPart: 'hello', + RestApiId: { Ref: 'Api' } + } + }, + HelloMethod: { + Type: 'AWS::ApiGateway::Method', + Properties: { + HttpMethod: 'GET', + ResourceId: { Ref: 'HelloRes' }, + AuthorizationType: 'NONE', + Integration: { + Type: 'AWS_PROXY', + Uri: { + 'Fn::Join': [ + '', + [ + 'arn:...:functions/', + { 'Fn::GetAtt': ['HelloLambda', 'Arn'] }, + '/invocations' + ] + ] + } + } + } + }, + HelloLambda: { + Type: 'AWS::Lambda::Function', + Properties: { FunctionName: 'zephyr-wombat-dev-grizzly' } + } + }; - it("extracts an unauthenticated AWS_PROXY route", () => { - const routes = parseApiGatewayMethods(helloResources); - expect(routes).toEqual([ - { - httpMethod: "GET", - path: "/hello", - lambdaLogicalId: "HelloLambda", - authorizerLogicalId: null, - }, - ]); - }); + it('extracts an unauthenticated AWS_PROXY route', () => { + const routes = parseApiGatewayMethods(helloResources); + expect(routes).toEqual([ + { + httpMethod: 'GET', + path: '/hello', + lambdaLogicalId: 'HelloLambda', + authorizerLogicalId: null + } + ]); + }); - it("attaches authorizer logical id when AuthorizationType is CUSTOM", () => { - const resources = { - ...helloResources, - HelloMethod: { - ...helloResources.HelloMethod, - Properties: { - ...helloResources.HelloMethod.Properties, - AuthorizationType: "CUSTOM", - AuthorizerId: { Ref: "MyAuthorizer" }, - }, - }, - }; - const routes = parseApiGatewayMethods(resources); - expect(routes[0]?.authorizerLogicalId).toBe("MyAuthorizer"); - }); + it('attaches authorizer logical id when AuthorizationType is CUSTOM', () => { + const resources = { + ...helloResources, + HelloMethod: { + ...helloResources.HelloMethod, + Properties: { + ...helloResources.HelloMethod.Properties, + AuthorizationType: 'CUSTOM', + AuthorizerId: { Ref: 'MyAuthorizer' } + } + } + }; + const routes = parseApiGatewayMethods(resources); + expect(routes[0]?.authorizerLogicalId).toBe('MyAuthorizer'); + }); - it("skips MOCK integrations", () => { - const resources = { - ...helloResources, - HelloMethod: { - ...helloResources.HelloMethod, - Properties: { - ...helloResources.HelloMethod.Properties, - Integration: { Type: "MOCK" }, - }, - }, - }; - expect(parseApiGatewayMethods(resources)).toEqual([]); - }); + it('skips MOCK integrations', () => { + const resources = { + ...helloResources, + HelloMethod: { + ...helloResources.HelloMethod, + Properties: { + ...helloResources.HelloMethod.Properties, + Integration: { Type: 'MOCK' } + } + } + }; + expect(parseApiGatewayMethods(resources)).toEqual([]); + }); }); diff --git a/packages/aws-cdk-local-lambda/src/extract/parse-routes.ts b/packages/aws-cdk-local-lambda/src/extract/parse-routes.ts index 337a805..8cfbd11 100644 --- a/packages/aws-cdk-local-lambda/src/extract/parse-routes.ts +++ b/packages/aws-cdk-local-lambda/src/extract/parse-routes.ts @@ -1,53 +1,57 @@ -import { findLambdaLogicalIdInUri } from "../utils/cfn-uri"; -import { type CfnResources, isRef } from "./cfn-types"; -import { buildApiGatewayResourcePaths } from "./parse-api-gateway-paths"; +import { findLambdaLogicalIdInUri } from '../utils/cfn-uri'; +import { type CfnResources, isRef } from './cfn-types'; +import { buildApiGatewayResourcePaths } from './parse-api-gateway-paths'; /** An API Gateway method extracted from a CloudFormation template. */ export interface ParsedMethod { - /** HTTP method in upper-case (e.g. `"GET"`). */ - readonly httpMethod: string; - /** Full API Gateway path pattern (e.g. `"/users/{id}"`). */ - readonly path: string; - /** CloudFormation logical ID of the Lambda backing this method. */ - readonly lambdaLogicalId: string; - /** CloudFormation logical ID of the custom authorizer, or `null` if none. */ - readonly authorizerLogicalId: string | null; + /** HTTP method in upper-case (e.g. `"GET"`). */ + readonly httpMethod: string; + /** Full API Gateway path pattern (e.g. `"/users/{id}"`). */ + readonly path: string; + /** CloudFormation logical ID of the Lambda backing this method. */ + readonly lambdaLogicalId: string; + /** CloudFormation logical ID of the custom authorizer, or `null` if none. */ + readonly authorizerLogicalId: string | null; } /** * Scans CloudFormation resources and returns all `AWS::ApiGateway::Method` entries * that use `AWS_PROXY` integration, resolved to their full paths. */ -export function parseApiGatewayMethods(resources: CfnResources): ParsedMethod[] { - const paths = buildApiGatewayResourcePaths(resources); - const out: ParsedMethod[] = []; - - for (const [, res] of Object.entries(resources)) { - if (res?.Type !== "AWS::ApiGateway::Method" || !res.Properties) continue; - const props = res.Properties; - const integration = props.Integration as Record | undefined; - if (!integration || integration.Type !== "AWS_PROXY") continue; - - const lambdaLogicalId = findLambdaLogicalIdInUri(integration.Uri); - if (!lambdaLogicalId) continue; - - const resourceRef = props.ResourceId; - if (!isRef(resourceRef)) continue; - const path = paths.get(resourceRef.Ref); - if (!path) continue; - - const httpMethod = props.HttpMethod; - if (typeof httpMethod !== "string") continue; - - const authType = props.AuthorizationType; - let authorizerLogicalId: string | null = null; - if (authType === "CUSTOM") { - const authRef = props.AuthorizerId; - if (isRef(authRef)) authorizerLogicalId = authRef.Ref; - } - - out.push({ httpMethod, path, lambdaLogicalId, authorizerLogicalId }); - } - - return out; +export function parseApiGatewayMethods( + resources: CfnResources +): ParsedMethod[] { + const paths = buildApiGatewayResourcePaths(resources); + const out: ParsedMethod[] = []; + + for (const [, res] of Object.entries(resources)) { + if (res?.Type !== 'AWS::ApiGateway::Method' || !res.Properties) continue; + const props = res.Properties; + const integration = props.Integration as + | Record + | undefined; + if (!integration || integration.Type !== 'AWS_PROXY') continue; + + const lambdaLogicalId = findLambdaLogicalIdInUri(integration.Uri); + if (!lambdaLogicalId) continue; + + const resourceRef = props.ResourceId; + if (!isRef(resourceRef)) continue; + const path = paths.get(resourceRef.Ref); + if (!path) continue; + + const httpMethod = props.HttpMethod; + if (typeof httpMethod !== 'string') continue; + + const authType = props.AuthorizationType; + let authorizerLogicalId: string | null = null; + if (authType === 'CUSTOM') { + const authRef = props.AuthorizerId; + if (isRef(authRef)) authorizerLogicalId = authRef.Ref; + } + + out.push({ httpMethod, path, lambdaLogicalId, authorizerLogicalId }); + } + + return out; } diff --git a/packages/aws-cdk-local-lambda/src/extract/recover-entry.test.ts b/packages/aws-cdk-local-lambda/src/extract/recover-entry.test.ts index fb517ce..4fe26ba 100644 --- a/packages/aws-cdk-local-lambda/src/extract/recover-entry.test.ts +++ b/packages/aws-cdk-local-lambda/src/extract/recover-entry.test.ts @@ -1,63 +1,63 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; -import { recoverEntry } from "./recover-entry"; +import { recoverEntry } from './recover-entry'; function makeAsset(indexJs: string, fsLayout: Record) { - const root = mkdtempSync(join(tmpdir(), "recover-entry-")); - const assetDir = join(root, "asset.x"); - mkdirSync(assetDir); - writeFileSync(join(assetDir, "index.js"), indexJs); - for (const [rel, contents] of Object.entries(fsLayout)) { - const full = join(root, rel); - mkdirSync(join(full, ".."), { recursive: true }); - writeFileSync(full, contents); - } - return { root, assetDir }; + const root = mkdtempSync(join(tmpdir(), 'recover-entry-')); + const assetDir = join(root, 'asset.x'); + mkdirSync(assetDir); + writeFileSync(join(assetDir, 'index.js'), indexJs); + for (const [rel, contents] of Object.entries(fsLayout)) { + const full = join(root, rel); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, contents); + } + return { root, assetDir }; } -describe("recoverEntry", () => { - it("returns the last non-node_modules TS marker that exists on disk", () => { - const indexJs = [ - "// node_modules/foo/index.js", - "exports.foo = 1;", - "// api/src/libs/logger.ts", - "var logger = {};", - "// api/src/functions/hello/handler.ts", - "exports.main = async () => {};", - ].join("\n"); - const { root, assetDir } = makeAsset(indexJs, { - "api/src/functions/hello/handler.ts": "export const main = () => {};", - }); - const got = recoverEntry({ assetDir, repoRoot: root }); - expect(got).toBe(join(root, "api/src/functions/hello/handler.ts")); - }); +describe('recoverEntry', () => { + it('returns the last non-node_modules TS marker that exists on disk', () => { + const indexJs = [ + '// node_modules/foo/index.js', + 'exports.foo = 1;', + '// api/src/libs/logger.ts', + 'var logger = {};', + '// api/src/functions/hello/handler.ts', + 'exports.main = async () => {};' + ].join('\n'); + const { root, assetDir } = makeAsset(indexJs, { + 'api/src/functions/hello/handler.ts': 'export const main = () => {};' + }); + const got = recoverEntry({ assetDir, repoRoot: root }); + expect(got).toBe(join(root, 'api/src/functions/hello/handler.ts')); + }); - it("accepts tsx/mts/cts/js/mjs/cjs extensions", () => { - const indexJs = "// src/foo.mts\nexport {};"; - const { root, assetDir } = makeAsset(indexJs, { "src/foo.mts": "x" }); - const got = recoverEntry({ assetDir, repoRoot: root }); - expect(got).toBe(join(root, "src/foo.mts")); - }); + it('accepts tsx/mts/cts/js/mjs/cjs extensions', () => { + const indexJs = '// src/foo.mts\nexport {};'; + const { root, assetDir } = makeAsset(indexJs, { 'src/foo.mts': 'x' }); + const got = recoverEntry({ assetDir, repoRoot: root }); + expect(got).toBe(join(root, 'src/foo.mts')); + }); - it("falls back to index.js when no marker matches a file on disk", () => { - const warnings: string[] = []; - const indexJs = "// src/missing.ts\nexports.x = 1;"; - const { assetDir } = makeAsset(indexJs, {}); - const got = recoverEntry({ - assetDir, - repoRoot: "/tmp", - onWarning: (w) => warnings.push(w), - }); - expect(got).toBe(join(assetDir, "index.js")); - expect(warnings.length).toBe(1); - }); + it('falls back to index.js when no marker matches a file on disk', () => { + const warnings: string[] = []; + const indexJs = '// src/missing.ts\nexports.x = 1;'; + const { assetDir } = makeAsset(indexJs, {}); + const got = recoverEntry({ + assetDir, + repoRoot: '/tmp', + onWarning: w => warnings.push(w) + }); + expect(got).toBe(join(assetDir, 'index.js')); + expect(warnings.length).toBe(1); + }); - it("falls back to index.js when index.js has no marker at all", () => { - const { assetDir } = makeAsset("exports.x = 1;", {}); - const got = recoverEntry({ assetDir, repoRoot: "/tmp" }); - expect(got).toBe(join(assetDir, "index.js")); - }); + it('falls back to index.js when index.js has no marker at all', () => { + const { assetDir } = makeAsset('exports.x = 1;', {}); + const got = recoverEntry({ assetDir, repoRoot: '/tmp' }); + expect(got).toBe(join(assetDir, 'index.js')); + }); }); diff --git a/packages/aws-cdk-local-lambda/src/extract/recover-entry.ts b/packages/aws-cdk-local-lambda/src/extract/recover-entry.ts index 240bdd9..344d4ce 100644 --- a/packages/aws-cdk-local-lambda/src/extract/recover-entry.ts +++ b/packages/aws-cdk-local-lambda/src/extract/recover-entry.ts @@ -1,18 +1,18 @@ -import { existsSync, readFileSync } from "node:fs"; -import { isAbsolute, join, resolve } from "node:path"; +import { existsSync, readFileSync } from 'node:fs'; +import { isAbsolute, join, resolve } from 'node:path'; -import { ENTRY_MARKER_RE } from "../constants/recover-entry"; +import { ENTRY_MARKER_RE } from '../constants/recover-entry'; /** Options for {@link recoverEntry}. */ export interface RecoverEntryOptions { - /** Absolute path to the CDK asset directory containing the bundled `index.js`. */ - readonly assetDir: string; - /** Repository root used to resolve relative source paths found in the bundle. */ - readonly repoRoot: string; - /** Called with non-fatal warnings when the entry file cannot be determined. */ - readonly onWarning?: (message: string) => void; - /** Called with verbose framework log lines. */ - readonly onFrameworkLog?: (message: string) => void; + /** Absolute path to the CDK asset directory containing the bundled `index.js`. */ + readonly assetDir: string; + /** Repository root used to resolve relative source paths found in the bundle. */ + readonly repoRoot: string; + /** Called with non-fatal warnings when the entry file cannot be determined. */ + readonly onWarning?: (message: string) => void; + /** Called with verbose framework log lines. */ + readonly onFrameworkLog?: (message: string) => void; } /** @@ -22,35 +22,37 @@ export interface RecoverEntryOptions { * marker that points to an existing file on disk. Falls back to `index.js` if none is found. */ export function recoverEntry(opts: RecoverEntryOptions): string { - const indexPath = join(opts.assetDir, "index.js"); - const fallback = indexPath; - const log = opts.onFrameworkLog ?? console.error; + const indexPath = join(opts.assetDir, 'index.js'); + const fallback = indexPath; + const log = opts.onFrameworkLog ?? console.error; - let body: string; - try { - body = readFileSync(indexPath, "utf8"); - log(`[cdk-local] attempting to recover entry for ${indexPath}...`); - } catch { - opts.onWarning?.(`recover-entry: could not read ${indexPath}; falling back to itself.`); - return fallback; - } + let body: string; + try { + body = readFileSync(indexPath, 'utf8'); + log(`[cdk-local] attempting to recover entry for ${indexPath}...`); + } catch { + opts.onWarning?.( + `recover-entry: could not read ${indexPath}; falling back to itself.` + ); + return fallback; + } - const matches: string[] = []; - for (const m of body.matchAll(ENTRY_MARKER_RE)) { - const p = m[1]; - if (!p) continue; - if (p.includes("/node_modules/")) continue; - matches.push(p); - } + const matches: string[] = []; + for (const m of body.matchAll(ENTRY_MARKER_RE)) { + const p = m[1]; + if (!p) continue; + if (p.includes('/node_modules/')) continue; + matches.push(p); + } - for (let i = matches.length - 1; i >= 0; i--) { - const raw = matches[i]!; - const abs = isAbsolute(raw) ? raw : resolve(opts.repoRoot, raw); - if (existsSync(abs)) return abs; - } + for (let i = matches.length - 1; i >= 0; i--) { + const raw = matches[i]!; + const abs = isAbsolute(raw) ? raw : resolve(opts.repoRoot, raw); + if (existsSync(abs)) return abs; + } - opts.onWarning?.( - `recover-entry: no non-node_modules TS marker in ${indexPath} matched an existing file on disk; falling back to index.js.`, - ); - return fallback; + opts.onWarning?.( + `recover-entry: no non-node_modules TS marker in ${indexPath} matched an existing file on disk; falling back to index.js.` + ); + return fallback; } diff --git a/packages/aws-cdk-local-lambda/src/extract/resolve-asset.test.ts b/packages/aws-cdk-local-lambda/src/extract/resolve-asset.test.ts index 5503017..b9def55 100644 --- a/packages/aws-cdk-local-lambda/src/extract/resolve-asset.test.ts +++ b/packages/aws-cdk-local-lambda/src/extract/resolve-asset.test.ts @@ -1,52 +1,52 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; -import { resolveAssetDir } from "./resolve-asset"; +import { resolveAssetDir } from './resolve-asset'; function makeFixture() { - const dir = mkdtempSync(join(tmpdir(), "cdk-local-test-")); - mkdirSync(join(dir, "asset.deadbeef")); - writeFileSync( - join(dir, "Stack.assets.json"), - JSON.stringify({ - files: { - deadbeef: { - source: { path: "asset.deadbeef", packaging: "zip" }, - destinations: {}, - }, - }, - }), - ); - return dir; + const dir = mkdtempSync(join(tmpdir(), 'cdk-local-test-')); + mkdirSync(join(dir, 'asset.deadbeef')); + writeFileSync( + join(dir, 'Stack.assets.json'), + JSON.stringify({ + files: { + deadbeef: { + source: { path: 'asset.deadbeef', packaging: 'zip' }, + destinations: {} + } + } + }) + ); + return dir; } -describe("resolveAssetDir", () => { - it("resolves an asset dir from a plain S3Key hash.zip", () => { - const cdkOut = makeFixture(); - const got = resolveAssetDir({ - cdkOut, - stack: "Stack", - codeS3Key: "deadbeef.zip", - }); - expect(got).toBe(join(cdkOut, "asset.deadbeef")); - }); +describe('resolveAssetDir', () => { + it('resolves an asset dir from a plain S3Key hash.zip', () => { + const cdkOut = makeFixture(); + const got = resolveAssetDir({ + cdkOut, + stack: 'Stack', + codeS3Key: 'deadbeef.zip' + }); + expect(got).toBe(join(cdkOut, 'asset.deadbeef')); + }); - it("resolves from an Fn::Sub wrapped key", () => { - const cdkOut = makeFixture(); - const got = resolveAssetDir({ - cdkOut, - stack: "Stack", - codeS3Key: { "Fn::Sub": "something/deadbeef.zip" }, - }); - expect(got).toBe(join(cdkOut, "asset.deadbeef")); - }); + it('resolves from an Fn::Sub wrapped key', () => { + const cdkOut = makeFixture(); + const got = resolveAssetDir({ + cdkOut, + stack: 'Stack', + codeS3Key: { 'Fn::Sub': 'something/deadbeef.zip' } + }); + expect(got).toBe(join(cdkOut, 'asset.deadbeef')); + }); - it("throws when the hash has no entry in assets.json", () => { - const cdkOut = makeFixture(); - expect(() => resolveAssetDir({ cdkOut, stack: "Stack", codeS3Key: "cafef00d.zip" })).toThrow( - /cafef00d/, - ); - }); + it('throws when the hash has no entry in assets.json', () => { + const cdkOut = makeFixture(); + expect(() => + resolveAssetDir({ cdkOut, stack: 'Stack', codeS3Key: 'cafef00d.zip' }) + ).toThrow(/cafef00d/); + }); }); diff --git a/packages/aws-cdk-local-lambda/src/extract/resolve-asset.ts b/packages/aws-cdk-local-lambda/src/extract/resolve-asset.ts index b62dde0..9432fa9 100644 --- a/packages/aws-cdk-local-lambda/src/extract/resolve-asset.ts +++ b/packages/aws-cdk-local-lambda/src/extract/resolve-asset.ts @@ -1,42 +1,46 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; /** Options for {@link resolveAssetDir}. */ export interface ResolveAssetOptions { - /** Absolute path to the CDK output directory. */ - readonly cdkOut: string; - /** CloudFormation stack name (used to locate the `.assets.json` file). */ - readonly stack: string; - /** Raw `Code.S3Key` value from the CloudFormation template — may be a string or `Fn::Sub` intrinsic. */ - readonly codeS3Key: unknown; + /** Absolute path to the CDK output directory. */ + readonly cdkOut: string; + /** CloudFormation stack name (used to locate the `.assets.json` file). */ + readonly stack: string; + /** Raw `Code.S3Key` value from the CloudFormation template — may be a string or `Fn::Sub` intrinsic. */ + readonly codeS3Key: unknown; } interface AssetsFile { - readonly files?: Readonly< - Record< - string, - { - readonly source?: { readonly path?: string }; - } - > - >; + readonly files?: Readonly< + Record< + string, + { + readonly source?: { readonly path?: string }; + } + > + >; } function extractHash(codeS3Key: unknown): string | null { - let raw: string | null = null; - if (typeof codeS3Key === "string") { - raw = codeS3Key; - } else if (typeof codeS3Key === "object" && codeS3Key !== null && "Fn::Sub" in codeS3Key) { - const val = (codeS3Key as { "Fn::Sub": unknown })["Fn::Sub"]; - if (typeof val === "string") raw = val; - else if (Array.isArray(val) && typeof val[0] === "string") raw = val[0]; - } - if (!raw) return null; - const tail = raw.split("/").pop() ?? raw; - const m = /^([0-9a-f]{8,})\.(zip|Zip)$/.exec(tail); - if (m?.[1]) return m[1]; - const m2 = /([0-9a-f]{16,})/.exec(raw); - return m2?.[1] ?? null; + let raw: string | null = null; + if (typeof codeS3Key === 'string') { + raw = codeS3Key; + } else if ( + typeof codeS3Key === 'object' && + codeS3Key !== null && + 'Fn::Sub' in codeS3Key + ) { + const val = (codeS3Key as { 'Fn::Sub': unknown })['Fn::Sub']; + if (typeof val === 'string') raw = val; + else if (Array.isArray(val) && typeof val[0] === 'string') raw = val[0]; + } + if (!raw) return null; + const tail = raw.split('/').pop() ?? raw; + const m = /^([0-9a-f]{8,})\.(zip|Zip)$/.exec(tail); + if (m?.[1]) return m[1]; + const m2 = /([0-9a-f]{16,})/.exec(raw); + return m2?.[1] ?? null; } /** @@ -46,16 +50,18 @@ function extractHash(codeS3Key: unknown): string | null { * and returns the path to the corresponding source directory inside `cdk.out`. */ export function resolveAssetDir(opts: ResolveAssetOptions): string { - const hash = extractHash(opts.codeS3Key); - if (!hash) { - throw new Error(`Cannot extract asset hash from Code.S3Key: ${JSON.stringify(opts.codeS3Key)}`); - } - const assetsPath = join(opts.cdkOut, `${opts.stack}.assets.json`); - const assets = JSON.parse(readFileSync(assetsPath, "utf8")) as AssetsFile; - const file = assets.files?.[hash]; - const sourcePath = file?.source?.path; - if (!sourcePath) { - throw new Error(`No asset source path for hash "${hash}" in ${assetsPath}`); - } - return join(opts.cdkOut, sourcePath); + const hash = extractHash(opts.codeS3Key); + if (!hash) { + throw new Error( + `Cannot extract asset hash from Code.S3Key: ${JSON.stringify(opts.codeS3Key)}` + ); + } + const assetsPath = join(opts.cdkOut, `${opts.stack}.assets.json`); + const assets = JSON.parse(readFileSync(assetsPath, 'utf8')) as AssetsFile; + const file = assets.files?.[hash]; + const sourcePath = file?.source?.path; + if (!sourcePath) { + throw new Error(`No asset source path for hash "${hash}" in ${assetsPath}`); + } + return join(opts.cdkOut, sourcePath); } diff --git a/packages/aws-cdk-local-lambda/src/index.ts b/packages/aws-cdk-local-lambda/src/index.ts index a7312d8..a412a1d 100644 --- a/packages/aws-cdk-local-lambda/src/index.ts +++ b/packages/aws-cdk-local-lambda/src/index.ts @@ -1,3 +1,3 @@ -export * from "./extract/index"; -export * from "./server/index"; -export * from "./types"; +export * from './extract/index'; +export * from './server/index'; +export * from './types'; diff --git a/packages/aws-cdk-local-lambda/src/server/apigateway-proxy.test.ts b/packages/aws-cdk-local-lambda/src/server/apigateway-proxy.test.ts index 71883d6..1ae34c4 100644 --- a/packages/aws-cdk-local-lambda/src/server/apigateway-proxy.test.ts +++ b/packages/aws-cdk-local-lambda/src/server/apigateway-proxy.test.ts @@ -1,50 +1,57 @@ -import type { Request } from "express"; +import type { Request } from 'express'; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest'; -import { buildProxyEvent, buildRequestAuthorizerEvent, lambdaContext } from "./apigateway-proxy"; +import { + buildProxyEvent, + buildRequestAuthorizerEvent, + lambdaContext +} from './apigateway-proxy'; function fakeReq(over: Partial = {}): Request { - return { - headers: { "x-test": "v" }, - query: {}, - body: undefined, - ip: "127.0.0.1", - get: (_h: string) => "agent", - path: "/hello", - ...over, - } as unknown as Request; + return { + headers: { 'x-test': 'v' }, + query: {}, + body: undefined, + ip: '127.0.0.1', + get: (_h: string) => 'agent', + path: '/hello', + ...over + } as unknown as Request; } -describe("apigateway event builders", () => { - it("builds a REQUEST authorizer event with the right shape", () => { - const e = buildRequestAuthorizerEvent(fakeReq(), { - path: "/hello", - httpMethod: "GET", - stage: "dev", - }); - expect(e.type).toBe("REQUEST"); - expect(e.methodArn).toContain("/dev/GET/hello"); - expect(e.headers?.["x-test"]).toBe("v"); - }); +describe('apigateway event builders', () => { + it('builds a REQUEST authorizer event with the right shape', () => { + const e = buildRequestAuthorizerEvent(fakeReq(), { + path: '/hello', + httpMethod: 'GET', + stage: 'dev' + }); + expect(e.type).toBe('REQUEST'); + expect(e.methodArn).toContain('/dev/GET/hello'); + expect(e.headers?.['x-test']).toBe('v'); + }); - it("builds a proxy event and carries authorizer context", () => { - const e = buildProxyEvent(fakeReq({ body: { a: 1 } as unknown as Request["body"] }), { - path: "/hello", - httpMethod: "POST", - stage: "dev", - authorizerContext: { userId: "u-1" }, - }); - expect(e.requestContext.authorizer).toEqual({ userId: "u-1" }); - expect(e.body).toBe('{"a":1}'); - expect(e.httpMethod).toBe("POST"); - }); + it('builds a proxy event and carries authorizer context', () => { + const e = buildProxyEvent( + fakeReq({ body: { a: 1 } as unknown as Request['body'] }), + { + path: '/hello', + httpMethod: 'POST', + stage: 'dev', + authorizerContext: { userId: 'u-1' } + } + ); + expect(e.requestContext.authorizer).toEqual({ userId: 'u-1' }); + expect(e.body).toBe('{"a":1}'); + expect(e.httpMethod).toBe('POST'); + }); - it("lambdaContext exposes the function name and a request id", () => { - const c = lambdaContext("fn-name"); - expect(c.functionName).toBe("fn-name"); - expect(c.invokedFunctionArn).toContain("fn-name"); - expect(typeof c.awsRequestId).toBe("string"); - expect(c.awsRequestId.length).toBeGreaterThan(8); - }); + it('lambdaContext exposes the function name and a request id', () => { + const c = lambdaContext('fn-name'); + expect(c.functionName).toBe('fn-name'); + expect(c.invokedFunctionArn).toContain('fn-name'); + expect(typeof c.awsRequestId).toBe('string'); + expect(c.awsRequestId.length).toBeGreaterThan(8); + }); }); diff --git a/packages/aws-cdk-local-lambda/src/server/apigateway-proxy.ts b/packages/aws-cdk-local-lambda/src/server/apigateway-proxy.ts index 4a25bc4..d5e2124 100644 --- a/packages/aws-cdk-local-lambda/src/server/apigateway-proxy.ts +++ b/packages/aws-cdk-local-lambda/src/server/apigateway-proxy.ts @@ -1,122 +1,127 @@ -import { randomUUID } from "node:crypto"; -import type { APIGatewayProxyEvent, APIGatewayRequestAuthorizerEvent, Context } from "aws-lambda"; -import type { Request } from "express"; +import type { + APIGatewayProxyEvent, + APIGatewayRequestAuthorizerEvent, + Context +} from 'aws-lambda'; +import type { Request } from 'express'; + +import { randomUUID } from 'node:crypto'; import { - lowerCaseHeaderMap, - pathParamsFromRequest, - queryFromRequest, -} from "../utils/request-shape"; + lowerCaseHeaderMap, + pathParamsFromRequest, + queryFromRequest +} from '../utils/request-shape'; /** Builds the `APIGatewayRequestAuthorizerEvent` passed to a custom authorizer Lambda. */ export function buildRequestAuthorizerEvent( - req: Request, - opts: { - readonly path: string; - readonly httpMethod: string; - readonly stage: string | undefined; - }, + req: Request, + opts: { + readonly path: string; + readonly httpMethod: string; + readonly stage: string | undefined; + } ): APIGatewayRequestAuthorizerEvent { - const headers = lowerCaseHeaderMap(req.headers); - const queryStringParameters = queryFromRequest(req); - const stage = opts.stage ?? ""; - const methodArn = `arn:aws:execute-api:us-east-1:000000000000:local/${stage}/${opts.httpMethod}${opts.path}`; + const headers = lowerCaseHeaderMap(req.headers); + const queryStringParameters = queryFromRequest(req); + const stage = opts.stage ?? ''; + const methodArn = `arn:aws:execute-api:us-east-1:000000000000:local/${stage}/${opts.httpMethod}${opts.path}`; - return { - type: "REQUEST", - methodArn, - resource: opts.path, - path: opts.path, - httpMethod: opts.httpMethod, - headers, - multiValueHeaders: {}, - pathParameters: pathParamsFromRequest(req), - queryStringParameters, - multiValueQueryStringParameters: null, - stageVariables: null, - requestContext: { - accountId: "000000000000", - apiId: "local", - authorizer: {}, - protocol: "HTTP/1.1", - httpMethod: opts.httpMethod, - identity: { - sourceIp: req.ip ?? "127.0.0.1", - userAgent: req.get("user-agent") ?? "", - }, - path: opts.path, - stage, - requestId: randomUUID(), - requestTimeEpoch: Date.now(), - resourceId: "local", - resourcePath: opts.path, - } as unknown as APIGatewayRequestAuthorizerEvent["requestContext"], - }; + return { + type: 'REQUEST', + methodArn, + resource: opts.path, + path: opts.path, + httpMethod: opts.httpMethod, + headers, + multiValueHeaders: {}, + pathParameters: pathParamsFromRequest(req), + queryStringParameters, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: { + accountId: '000000000000', + apiId: 'local', + authorizer: {}, + protocol: 'HTTP/1.1', + httpMethod: opts.httpMethod, + identity: { + sourceIp: req.ip ?? '127.0.0.1', + userAgent: req.get('user-agent') ?? '' + }, + path: opts.path, + stage, + requestId: randomUUID(), + requestTimeEpoch: Date.now(), + resourceId: 'local', + resourcePath: opts.path + } as unknown as APIGatewayRequestAuthorizerEvent['requestContext'] + }; } /** Builds the `APIGatewayProxyEvent` passed to a Lambda handler, including any authorizer context. */ export function buildProxyEvent( - req: Request, - opts: { - readonly path: string; - readonly httpMethod: string; - readonly stage: string | undefined; - readonly authorizerContext: Record; - }, + req: Request, + opts: { + readonly path: string; + readonly httpMethod: string; + readonly stage: string | undefined; + readonly authorizerContext: Record; + } ): APIGatewayProxyEvent { - const headers = lowerCaseHeaderMap(req.headers); - const rawBody = - typeof req.body === "string" || Buffer.isBuffer(req.body) - ? (req.body as Buffer | string).toString() - : req.body === undefined - ? null - : JSON.stringify(req.body); + const headers = lowerCaseHeaderMap(req.headers); + const rawBody = + typeof req.body === 'string' || Buffer.isBuffer(req.body) + ? (req.body as Buffer | string).toString() + : req.body === undefined + ? null + : JSON.stringify(req.body); - return { - body: rawBody, - headers, - multiValueHeaders: {}, - httpMethod: opts.httpMethod, - isBase64Encoded: false, - path: opts.path, - pathParameters: pathParamsFromRequest(req), - queryStringParameters: queryFromRequest(req), - multiValueQueryStringParameters: null, - stageVariables: null, - resource: opts.path, - requestContext: { - accountId: "000000000000", - apiId: "local", - authorizer: opts.authorizerContext, - protocol: "HTTP/1.1", - httpMethod: opts.httpMethod, - identity: { - sourceIp: req.ip ?? "127.0.0.1", - userAgent: req.get("user-agent") ?? "", - }, - path: opts.path, - stage: opts.stage ?? "", - requestId: randomUUID(), - requestTimeEpoch: Date.now(), - resourceId: "local", - } as APIGatewayProxyEvent["requestContext"], - }; + return { + body: rawBody, + headers, + multiValueHeaders: {}, + httpMethod: opts.httpMethod, + isBase64Encoded: false, + path: opts.path, + pathParameters: pathParamsFromRequest(req), + queryStringParameters: queryFromRequest(req), + multiValueQueryStringParameters: null, + stageVariables: null, + resource: opts.path, + requestContext: { + accountId: '000000000000', + apiId: 'local', + authorizer: opts.authorizerContext, + protocol: 'HTTP/1.1', + httpMethod: opts.httpMethod, + identity: { + sourceIp: req.ip ?? '127.0.0.1', + userAgent: req.get('user-agent') ?? '' + }, + path: opts.path, + stage: opts.stage ?? '', + requestId: randomUUID(), + requestTimeEpoch: Date.now(), + resourceId: 'local' + } as APIGatewayProxyEvent['requestContext'] + }; } /** Creates a minimal mock Lambda `Context` object for local invocations. */ export function lambdaContext(functionName: string): Context { - return { - callbackWaitsForEmptyEventLoop: false, - functionName, - functionVersion: "$LATEST", - invokedFunctionArn: `arn:aws:lambda:us-east-1:000000000000:function:${functionName}`, - memoryLimitInMB: "1024", - awsRequestId: randomUUID(), - logGroupName: `/aws/lambda/${functionName}`, - logStreamName: "local", - getRemainingTimeInMillis: () => 30000, - done: () => {}, - fail: () => {}, - succeed: () => {}, - }; + return { + callbackWaitsForEmptyEventLoop: false, + functionName, + functionVersion: '$LATEST', + invokedFunctionArn: `arn:aws:lambda:us-east-1:000000000000:function:${functionName}`, + memoryLimitInMB: '1024', + awsRequestId: randomUUID(), + logGroupName: `/aws/lambda/${functionName}`, + logStreamName: 'local', + getRemainingTimeInMillis: () => 30000, + done: () => {}, + fail: () => {}, + succeed: () => {} + }; } diff --git a/packages/aws-cdk-local-lambda/src/server/authorizer.test.ts b/packages/aws-cdk-local-lambda/src/server/authorizer.test.ts index 57efecf..1cc367e 100644 --- a/packages/aws-cdk-local-lambda/src/server/authorizer.test.ts +++ b/packages/aws-cdk-local-lambda/src/server/authorizer.test.ts @@ -1,38 +1,38 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest'; -import { isAuthorizerAllow } from "./authorizer"; +import { isAuthorizerAllow } from './authorizer'; -describe("isAuthorizerAllow", () => { - it("allows single-statement Allow", () => { - expect( - isAuthorizerAllow({ - policyDocument: { Statement: [{ Effect: "Allow" }] }, - }).allow, - ).toBe(true); - }); +describe('isAuthorizerAllow', () => { + it('allows single-statement Allow', () => { + expect( + isAuthorizerAllow({ + policyDocument: { Statement: [{ Effect: 'Allow' }] } + }).allow + ).toBe(true); + }); - it("denies if any statement is Deny", () => { - expect( - isAuthorizerAllow({ - policyDocument: { - Statement: [{ Effect: "Allow" }, { Effect: "Deny" }], - }, - }).allow, - ).toBe(false); - }); + it('denies if any statement is Deny', () => { + expect( + isAuthorizerAllow({ + policyDocument: { + Statement: [{ Effect: 'Allow' }, { Effect: 'Deny' }] + } + }).allow + ).toBe(false); + }); - it("denies on unknown input", () => { - expect(isAuthorizerAllow(null).allow).toBe(false); - expect(isAuthorizerAllow("no").allow).toBe(false); - }); + it('denies on unknown input', () => { + expect(isAuthorizerAllow(null).allow).toBe(false); + expect(isAuthorizerAllow('no').allow).toBe(false); + }); - it("forwards string context when allowing", () => { - const r = isAuthorizerAllow({ - policyDocument: { Statement: [{ Effect: "Allow" }] }, - context: { userId: "u1", roles: ["admin"] }, - }); - expect(r.allow).toBe(true); - expect(r.context?.userId).toBe("u1"); - expect(r.context?.roles).toBe('["admin"]'); - }); + it('forwards string context when allowing', () => { + const r = isAuthorizerAllow({ + policyDocument: { Statement: [{ Effect: 'Allow' }] }, + context: { userId: 'u1', roles: ['admin'] } + }); + expect(r.allow).toBe(true); + expect(r.context?.userId).toBe('u1'); + expect(r.context?.roles).toBe('["admin"]'); + }); }); diff --git a/packages/aws-cdk-local-lambda/src/server/authorizer.ts b/packages/aws-cdk-local-lambda/src/server/authorizer.ts index 2e642b8..a9ead0a 100644 --- a/packages/aws-cdk-local-lambda/src/server/authorizer.ts +++ b/packages/aws-cdk-local-lambda/src/server/authorizer.ts @@ -1,16 +1,16 @@ /** Result of evaluating a Lambda authorizer response. */ export interface AuthorizerDecision { - /** `true` if the authorizer granted access, `false` to short-circuit with a 403. */ - readonly allow: boolean; - /** Key/value context propagated to the downstream Lambda via `requestContext.authorizer`. */ - readonly context?: Readonly>; + /** `true` if the authorizer granted access, `false` to short-circuit with a 403. */ + readonly allow: boolean; + /** Key/value context propagated to the downstream Lambda via `requestContext.authorizer`. */ + readonly context?: Readonly>; } interface PolicyLike { - readonly policyDocument?: { - readonly Statement?: ReadonlyArray<{ readonly Effect?: string }>; - }; - readonly context?: Readonly>; + readonly policyDocument?: { + readonly Statement?: ReadonlyArray<{ readonly Effect?: string }>; + }; + readonly context?: Readonly>; } /** @@ -20,20 +20,20 @@ interface PolicyLike { * and no `"Deny"` statements. Any `context` values are coerced to strings. */ export function isAuthorizerAllow(result: unknown): AuthorizerDecision { - if (!result || typeof result !== "object") return { allow: false }; - const r = result as PolicyLike; - const statements = r.policyDocument?.Statement ?? []; - let sawAllow = false; - for (const s of statements) { - if (s?.Effect === "Deny") return { allow: false }; - if (s?.Effect === "Allow") sawAllow = true; - } - if (!sawAllow) return { allow: false }; - const ctx: Record = {}; - if (r.context) { - for (const [k, v] of Object.entries(r.context)) { - ctx[k] = typeof v === "string" ? v : JSON.stringify(v); - } - } - return { allow: true, context: ctx }; + if (!result || typeof result !== 'object') return { allow: false }; + const r = result as PolicyLike; + const statements = r.policyDocument?.Statement ?? []; + let sawAllow = false; + for (const s of statements) { + if (s?.Effect === 'Deny') return { allow: false }; + if (s?.Effect === 'Allow') sawAllow = true; + } + if (!sawAllow) return { allow: false }; + const ctx: Record = {}; + if (r.context) { + for (const [k, v] of Object.entries(r.context)) { + ctx[k] = typeof v === 'string' ? v : JSON.stringify(v); + } + } + return { allow: true, context: ctx }; } diff --git a/packages/aws-cdk-local-lambda/src/server/create-app.test.ts b/packages/aws-cdk-local-lambda/src/server/create-app.test.ts index d53dd38..99167db 100644 --- a/packages/aws-cdk-local-lambda/src/server/create-app.test.ts +++ b/packages/aws-cdk-local-lambda/src/server/create-app.test.ts @@ -1,289 +1,300 @@ -import { mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import request from "supertest"; -import { describe, expect, it } from "vitest"; -import type { LocalManifest } from "../types"; +import type { LocalManifest } from '../types'; -import { createLocalApp } from "./create-app"; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; + +import { createLocalApp } from './create-app'; function mkFn(body: string): string { - const dir = mkdtempSync(join(tmpdir(), "app-")); - const file = join(dir, "h.mjs"); - writeFileSync(file, body); - return file; + const dir = mkdtempSync(join(tmpdir(), 'app-')); + const file = join(dir, 'h.mjs'); + writeFileSync(file, body); + return file; } -describe("createLocalApp", () => { - it("serves a public GET route", async () => { - const entry = mkFn('export const main = async () => ({ statusCode: 200, body: "hi" });'); - const m: LocalManifest = { - source: "cdk-synth", - stack: "S", - stage: "dev", - cdkOut: "/x", - lambdas: { - hello: { - functionKey: "hello", - lambdaLogicalId: "X", - lambdaFunctionName: "x", - assetDir: "/x", - entry, - handler: "main", - runtime: "nodejs22.x", - environment: {}, - }, - }, - routes: { - "GET /hello": { - method: "GET", - path: "/hello", - functionKey: "hello", - authorizerKey: null, - }, - }, - }; - const handle = await createLocalApp({ manifest: m, watch: false }); - const r = await request(handle.app).get("/hello"); - expect(r.status).toBe(200); - expect(r.text).toBe("hi"); - await handle.stop(); - }); +describe('createLocalApp', () => { + it('serves a public GET route', async () => { + const entry = mkFn( + 'export const main = async () => ({ statusCode: 200, body: "hi" });' + ); + const m: LocalManifest = { + source: 'cdk-synth', + stack: 'S', + stage: 'dev', + cdkOut: '/x', + lambdas: { + hello: { + functionKey: 'hello', + lambdaLogicalId: 'X', + lambdaFunctionName: 'x', + assetDir: '/x', + entry, + handler: 'main', + runtime: 'nodejs22.x', + environment: {} + } + }, + routes: { + 'GET /hello': { + method: 'GET', + path: '/hello', + functionKey: 'hello', + authorizerKey: null + } + } + }; + const handle = await createLocalApp({ manifest: m, watch: false }); + const r = await request(handle.app).get('/hello'); + expect(r.status).toBe(200); + expect(r.text).toBe('hi'); + await handle.stop(); + }); - it("calls the authorizer and 403s on deny", async () => { - const authEntry = mkFn( - 'export const main = async () => ({ policyDocument: { Statement: [{ Effect: "Deny" }] } });', - ); - const entry = mkFn('export const main = async () => ({ statusCode: 200, body: "private" });'); - const m: LocalManifest = { - source: "cdk-synth", - stack: "S", - stage: "dev", - cdkOut: "/x", - lambdas: { - authorizer: { - functionKey: "authorizer", - lambdaLogicalId: "A", - lambdaFunctionName: "a", - assetDir: "/x", - entry: authEntry, - handler: "main", - runtime: "nodejs22.x", - environment: {}, - }, - secret: { - functionKey: "secret", - lambdaLogicalId: "S", - lambdaFunctionName: "s", - assetDir: "/x", - entry, - handler: "main", - runtime: "nodejs22.x", - environment: {}, - }, - }, - routes: { - "GET /secret": { - method: "GET", - path: "/secret", - functionKey: "secret", - authorizerKey: "authorizer", - }, - }, - }; - const handle = await createLocalApp({ manifest: m, watch: false }); - const r = await request(handle.app).get("/secret"); - expect(r.status).toBe(403); - await handle.stop(); - }); + it('calls the authorizer and 403s on deny', async () => { + const authEntry = mkFn( + 'export const main = async () => ({ policyDocument: { Statement: [{ Effect: "Deny" }] } });' + ); + const entry = mkFn( + 'export const main = async () => ({ statusCode: 200, body: "private" });' + ); + const m: LocalManifest = { + source: 'cdk-synth', + stack: 'S', + stage: 'dev', + cdkOut: '/x', + lambdas: { + authorizer: { + functionKey: 'authorizer', + lambdaLogicalId: 'A', + lambdaFunctionName: 'a', + assetDir: '/x', + entry: authEntry, + handler: 'main', + runtime: 'nodejs22.x', + environment: {} + }, + secret: { + functionKey: 'secret', + lambdaLogicalId: 'S', + lambdaFunctionName: 's', + assetDir: '/x', + entry, + handler: 'main', + runtime: 'nodejs22.x', + environment: {} + } + }, + routes: { + 'GET /secret': { + method: 'GET', + path: '/secret', + functionKey: 'secret', + authorizerKey: 'authorizer' + } + } + }; + const handle = await createLocalApp({ manifest: m, watch: false }); + const r = await request(handle.app).get('/secret'); + expect(r.status).toBe(403); + await handle.stop(); + }); - it("serves the health route", async () => { - const m: LocalManifest = { - source: "cdk-synth", - stack: "S", - stage: "dev", - cdkOut: "/x", - lambdas: {}, - routes: {}, - }; - const handle = await createLocalApp({ manifest: m, watch: false }); - const r = await request(handle.app).get("/__local/health"); - expect(r.status).toBe(200); - expect(r.body.ok).toBe(true); - await handle.stop(); - }); + it('serves the health route', async () => { + const m: LocalManifest = { + source: 'cdk-synth', + stack: 'S', + stage: 'dev', + cdkOut: '/x', + lambdas: {}, + routes: {} + }; + const handle = await createLocalApp({ manifest: m, watch: false }); + const r = await request(handle.app).get('/__local/health'); + expect(r.status).toBe(200); + expect(r.body.ok).toBe(true); + await handle.stop(); + }); - it("forwards authorizer context to the main handler on allow", async () => { - const authEntry = mkFn( - "export const main = async () => ({ " + - 'principalId: "u1", ' + - 'policyDocument: { Version: "2012-10-17", Statement: [{ Effect: "Allow", Action: "execute-api:Invoke", Resource: "*" }] }, ' + - 'context: { userId: "42", role: "admin" } ' + - "});", - ); - const entry = mkFn( - "export const main = async (event) => ({ statusCode: 200, body: JSON.stringify(event.requestContext.authorizer) });", - ); - const m: LocalManifest = { - source: "cdk-synth", - stack: "S", - stage: "dev", - cdkOut: "/x", - lambdas: { - authorizer: { - functionKey: "authorizer", - lambdaLogicalId: "A", - lambdaFunctionName: "a", - assetDir: "/x", - entry: authEntry, - handler: "main", - runtime: "nodejs22.x", - environment: {}, - }, - secret: { - functionKey: "secret", - lambdaLogicalId: "S", - lambdaFunctionName: "s", - assetDir: "/x", - entry, - handler: "main", - runtime: "nodejs22.x", - environment: {}, - }, - }, - routes: { - "GET /secret": { - method: "GET", - path: "/secret", - functionKey: "secret", - authorizerKey: "authorizer", - }, - }, - }; - const handle = await createLocalApp({ manifest: m, watch: false }); - const r = await request(handle.app).get("/secret"); - expect(r.status).toBe(200); - const ctx = JSON.parse(r.text); - expect(ctx.userId).toBe("42"); - expect(ctx.role).toBe("admin"); - await handle.stop(); - }); + it('forwards authorizer context to the main handler on allow', async () => { + const authEntry = mkFn( + 'export const main = async () => ({ ' + + 'principalId: "u1", ' + + 'policyDocument: { Version: "2012-10-17", Statement: [{ Effect: "Allow", Action: "execute-api:Invoke", Resource: "*" }] }, ' + + 'context: { userId: "42", role: "admin" } ' + + '});' + ); + const entry = mkFn( + 'export const main = async (event) => ({ statusCode: 200, body: JSON.stringify(event.requestContext.authorizer) });' + ); + const m: LocalManifest = { + source: 'cdk-synth', + stack: 'S', + stage: 'dev', + cdkOut: '/x', + lambdas: { + authorizer: { + functionKey: 'authorizer', + lambdaLogicalId: 'A', + lambdaFunctionName: 'a', + assetDir: '/x', + entry: authEntry, + handler: 'main', + runtime: 'nodejs22.x', + environment: {} + }, + secret: { + functionKey: 'secret', + lambdaLogicalId: 'S', + lambdaFunctionName: 's', + assetDir: '/x', + entry, + handler: 'main', + runtime: 'nodejs22.x', + environment: {} + } + }, + routes: { + 'GET /secret': { + method: 'GET', + path: '/secret', + functionKey: 'secret', + authorizerKey: 'authorizer' + } + } + }; + const handle = await createLocalApp({ manifest: m, watch: false }); + const r = await request(handle.app).get('/secret'); + expect(r.status).toBe(200); + const ctx = JSON.parse(r.text); + expect(ctx.userId).toBe('42'); + expect(ctx.role).toBe('admin'); + await handle.stop(); + }); - it("invokes onError and returns 500 when the handler throws", async () => { - const entry = mkFn('export const main = async () => { throw new Error("boom"); };'); - const m: LocalManifest = { - source: "cdk-synth", - stack: "S", - stage: "dev", - cdkOut: "/x", - lambdas: { - f: { - functionKey: "f", - lambdaLogicalId: "F", - lambdaFunctionName: "f", - assetDir: "/x", - entry, - handler: "main", - runtime: "nodejs22.x", - environment: {}, - }, - }, - routes: { - "GET /fail": { - method: "GET", - path: "/fail", - functionKey: "f", - authorizerKey: null, - }, - }, - }; - const seen: Error[] = []; - const handle = await createLocalApp({ - manifest: m, - watch: false, - onError: (err) => { - if (err instanceof Error) seen.push(err); - }, - }); - const r = await request(handle.app).get("/fail"); - expect(r.status).toBe(500); - expect(r.body.message).toBe("boom"); - expect(seen).toHaveLength(1); - expect(seen[0].message).toBe("boom"); - await handle.stop(); - }); + it('invokes onError and returns 500 when the handler throws', async () => { + const entry = mkFn( + 'export const main = async () => { throw new Error("boom"); };' + ); + const m: LocalManifest = { + source: 'cdk-synth', + stack: 'S', + stage: 'dev', + cdkOut: '/x', + lambdas: { + f: { + functionKey: 'f', + lambdaLogicalId: 'F', + lambdaFunctionName: 'f', + assetDir: '/x', + entry, + handler: 'main', + runtime: 'nodejs22.x', + environment: {} + } + }, + routes: { + 'GET /fail': { + method: 'GET', + path: '/fail', + functionKey: 'f', + authorizerKey: null + } + } + }; + const seen: Error[] = []; + const handle = await createLocalApp({ + manifest: m, + watch: false, + onError: err => { + if (err instanceof Error) seen.push(err); + } + }); + const r = await request(handle.app).get('/fail'); + expect(r.status).toBe(500); + expect(r.body.message).toBe('boom'); + expect(seen).toHaveLength(1); + expect(seen[0].message).toBe('boom'); + await handle.stop(); + }); - it("throws when a route references an unknown function", async () => { - const m: LocalManifest = { - source: "cdk-synth", - stack: "S", - stage: "dev", - cdkOut: "/x", - lambdas: {}, - routes: { - "GET /x": { - method: "GET", - path: "/x", - functionKey: "missing", - authorizerKey: null, - }, - }, - }; - await expect(createLocalApp({ manifest: m, watch: false })).rejects.toThrow(/unknown lambda/i); - }); + it('throws when a route references an unknown function', async () => { + const m: LocalManifest = { + source: 'cdk-synth', + stack: 'S', + stage: 'dev', + cdkOut: '/x', + lambdas: {}, + routes: { + 'GET /x': { + method: 'GET', + path: '/x', + functionKey: 'missing', + authorizerKey: null + } + } + }; + await expect(createLocalApp({ manifest: m, watch: false })).rejects.toThrow( + /unknown lambda/i + ); + }); - it("prefers literal routes over parameterized routes (specificity)", async () => { - const meEntry = mkFn('export const main = async () => ({ statusCode: 200, body: "me" });'); - const idEntry = mkFn( - `export const main = async (event) => ({ statusCode: 200, body: \`id:\${event.pathParameters?.id ?? ""}\` });`, - ); - const m: LocalManifest = { - source: "cdk-synth", - stack: "S", - stage: "dev", - cdkOut: "/x", - lambdas: { - me: { - functionKey: "me", - lambdaLogicalId: "M", - lambdaFunctionName: "m", - assetDir: "/x", - entry: meEntry, - handler: "main", - runtime: "nodejs22.x", - environment: {}, - }, - byId: { - functionKey: "byId", - lambdaLogicalId: "I", - lambdaFunctionName: "i", - assetDir: "/x", - entry: idEntry, - handler: "main", - runtime: "nodejs22.x", - environment: {}, - }, - }, - routes: { - "GET /users/{id}": { - method: "GET", - path: "/users/{id}", - functionKey: "byId", - authorizerKey: null, - }, - "GET /users/me": { - method: "GET", - path: "/users/me", - functionKey: "me", - authorizerKey: null, - }, - }, - }; - const handle = await createLocalApp({ manifest: m, watch: false }); - const rMe = await request(handle.app).get("/users/me"); - expect(rMe.text).toBe("me"); - const rId = await request(handle.app).get("/users/42"); - expect(rId.text).toBe("id:42"); - await handle.stop(); - }); + it('prefers literal routes over parameterized routes (specificity)', async () => { + const meEntry = mkFn( + 'export const main = async () => ({ statusCode: 200, body: "me" });' + ); + const idEntry = mkFn( + `export const main = async (event) => ({ statusCode: 200, body: \`id:\${event.pathParameters?.id ?? ""}\` });` + ); + const m: LocalManifest = { + source: 'cdk-synth', + stack: 'S', + stage: 'dev', + cdkOut: '/x', + lambdas: { + me: { + functionKey: 'me', + lambdaLogicalId: 'M', + lambdaFunctionName: 'm', + assetDir: '/x', + entry: meEntry, + handler: 'main', + runtime: 'nodejs22.x', + environment: {} + }, + byId: { + functionKey: 'byId', + lambdaLogicalId: 'I', + lambdaFunctionName: 'i', + assetDir: '/x', + entry: idEntry, + handler: 'main', + runtime: 'nodejs22.x', + environment: {} + } + }, + routes: { + 'GET /users/{id}': { + method: 'GET', + path: '/users/{id}', + functionKey: 'byId', + authorizerKey: null + }, + 'GET /users/me': { + method: 'GET', + path: '/users/me', + functionKey: 'me', + authorizerKey: null + } + } + }; + const handle = await createLocalApp({ manifest: m, watch: false }); + const rMe = await request(handle.app).get('/users/me'); + expect(rMe.text).toBe('me'); + const rId = await request(handle.app).get('/users/42'); + expect(rId.text).toBe('id:42'); + await handle.stop(); + }); }); diff --git a/packages/aws-cdk-local-lambda/src/server/create-app.ts b/packages/aws-cdk-local-lambda/src/server/create-app.ts index 3380568..3b0f5ad 100644 --- a/packages/aws-cdk-local-lambda/src/server/create-app.ts +++ b/packages/aws-cdk-local-lambda/src/server/create-app.ts @@ -1,61 +1,74 @@ -import type { APIGatewayProxyResult } from "aws-lambda"; -import cors, { type CorsOptions } from "cors"; -import express, { type Application, type Request, type Response } from "express"; -import { SUPPORTED_HTTP_METHODS, type SupportedHttpMethod } from "../constants/http"; -import type { LocalManifest } from "../types"; -import { applyLambdaEnv } from "../utils/env"; +import type { LocalManifest } from '../types'; +import type { APIGatewayProxyResult } from 'aws-lambda'; + +import cors, { type CorsOptions } from 'cors'; +import express, { + type Application, + type Request, + type Response +} from 'express'; + +import { + SUPPORTED_HTTP_METHODS, + type SupportedHttpMethod +} from '../constants/http'; +import { applyLambdaEnv } from '../utils/env'; +import { + defaultWatchPaths, + inferRepoRootFromManifest, + sortRoutesBySpecificity +} from '../utils/local-app'; import { - defaultWatchPaths, - inferRepoRootFromManifest, - sortRoutesBySpecificity, -} from "../utils/local-app"; -import { buildProxyEvent, buildRequestAuthorizerEvent, lambdaContext } from "./apigateway-proxy"; -import { isAuthorizerAllow } from "./authorizer"; -import { startWatcher } from "./hot-reloader"; -import { ModuleLoader } from "./module-loader"; -import { toExpressPath } from "./path-convert"; -import { sendProxyResult } from "./response-writer"; + buildProxyEvent, + buildRequestAuthorizerEvent, + lambdaContext +} from './apigateway-proxy'; +import { isAuthorizerAllow } from './authorizer'; +import { startWatcher } from './hot-reloader'; +import { ModuleLoader } from './module-loader'; +import { toExpressPath } from './path-convert'; +import { sendProxyResult } from './response-writer'; /** Options for {@link createLocalApp}. */ export interface ServerOptions { - /** The manifest produced by {@link extractManifest}. Defines all routes and Lambdas. */ - readonly manifest: LocalManifest; - /** When set with {@link ServerOptions.onManifestChange}, this file is watched so topology edits can surface. */ - readonly manifestPath?: string; - /** Enable file-system watching and hot-reloading of Lambda handlers. Defaults to `true`. */ - readonly watch?: boolean; - /** Override the directories watched for hot-reload. Defaults to the `src/` folder of each Lambda entry. */ - readonly watchPaths?: readonly string[]; - /** Repository root used when resolving module paths. Defaults to inference from `manifest.cdkOut`. */ - readonly repoRoot?: string; - /** CORS configuration forwarded to the `cors` middleware. Defaults to `{ origin: true, credentials: true }`. */ - readonly corsOptions?: CorsOptions; - /** Express body-parser size limit (e.g. `"10mb"`). Defaults to `"6mb"`. */ - readonly bodyLimit?: string; - /** Path for the built-in health-check endpoint. Defaults to `"/__local/health"`. */ - readonly healthPath?: string; - /** Called after a file change causes Lambda modules to be invalidated and reloaded. */ - readonly onReload?: (changedPath: string, invalidatedCount: number) => void; - /** Called when a Lambda handler throws an unhandled error. Use for custom logging or alerting. */ - readonly onError?: (err: unknown, req: Request) => void; - /** Called when the manifest file itself changes (requires `manifestPath` to be set). */ - readonly onManifestChange?: (path: string) => void; - /** - * Called for each framework-level log line (file changes, module invalidations, etc.). - * Pass a no-op `() => {}` to silence all framework logs. - * Defaults to `console.error` when omitted. - */ - readonly onFrameworkLog?: (message: string) => void; + /** The manifest produced by {@link extractManifest}. Defines all routes and Lambdas. */ + readonly manifest: LocalManifest; + /** When set with {@link ServerOptions.onManifestChange}, this file is watched so topology edits can surface. */ + readonly manifestPath?: string; + /** Enable file-system watching and hot-reloading of Lambda handlers. Defaults to `true`. */ + readonly watch?: boolean; + /** Override the directories watched for hot-reload. Defaults to the `src/` folder of each Lambda entry. */ + readonly watchPaths?: readonly string[]; + /** Repository root used when resolving module paths. Defaults to inference from `manifest.cdkOut`. */ + readonly repoRoot?: string; + /** CORS configuration forwarded to the `cors` middleware. Defaults to `{ origin: true, credentials: true }`. */ + readonly corsOptions?: CorsOptions; + /** Express body-parser size limit (e.g. `"10mb"`). Defaults to `"6mb"`. */ + readonly bodyLimit?: string; + /** Path for the built-in health-check endpoint. Defaults to `"/__local/health"`. */ + readonly healthPath?: string; + /** Called after a file change causes Lambda modules to be invalidated and reloaded. */ + readonly onReload?: (changedPath: string, invalidatedCount: number) => void; + /** Called when a Lambda handler throws an unhandled error. Use for custom logging or alerting. */ + readonly onError?: (err: unknown, req: Request) => void; + /** Called when the manifest file itself changes (requires `manifestPath` to be set). */ + readonly onManifestChange?: (path: string) => void; + /** + * Called for each framework-level log line (file changes, module invalidations, etc.). + * Pass a no-op `() => {}` to silence all framework logs. + * Defaults to `console.error` when omitted. + */ + readonly onFrameworkLog?: (message: string) => void; } /** Handle returned by {@link createLocalApp} to inspect and shut down the server. */ export interface ServerHandle { - /** The underlying Express application. Mount middleware or add routes before calling `app.listen()`. */ - readonly app: Application; - /** List of registered routes in `"METHOD /path"` format. */ - readonly routes: readonly string[]; - /** Stops the file watcher and releases all resources. Does not close the HTTP server itself. */ - readonly stop: () => Promise; + /** The underlying Express application. Mount middleware or add routes before calling `app.listen()`. */ + readonly app: Application; + /** List of registered routes in `"METHOD /path"` format. */ + readonly routes: readonly string[]; + /** Stops the file watcher and releases all resources. Does not close the HTTP server itself. */ + readonly stop: () => Promise; } /** @@ -77,129 +90,133 @@ export interface ServerHandle { * // routes: ["GET /users", "POST /users", ...] * ``` */ -export async function createLocalApp(opts: ServerOptions): Promise { - const { manifest } = opts; - - const app = express(); - app.use(cors(opts.corsOptions ?? { origin: true, credentials: true })); - app.use(express.json({ limit: opts.bodyLimit ?? "6mb" })); - app.use(express.urlencoded({ extended: true })); - - const healthPath = opts.healthPath ?? "/__local/health"; - app.get(healthPath, (_req, res) => { - res.json({ ok: true, stage: manifest.stage, pid: process.pid }); - }); - - const repoRoot = opts.repoRoot ?? inferRepoRootFromManifest(manifest); - const onFrameworkLog = opts.onFrameworkLog ?? console.error; - const loader = new ModuleLoader({ repoRoot, onFrameworkLog }); - const routes: string[] = []; - - const sorted = sortRoutesBySpecificity(manifest.routes); - - for (const [, route] of sorted) { - const method = route.method.toLowerCase() as SupportedHttpMethod; - if (!SUPPORTED_HTTP_METHODS.has(method)) continue; - const expressPath = toExpressPath(route.path); - - const lambda = manifest.lambdas[route.functionKey]; - if (!lambda) { - throw new Error( - `createLocalApp: route ${route.method} ${route.path} references unknown lambda "${route.functionKey}"`, - ); - } - const authorizer = route.authorizerKey ? manifest.lambdas[route.authorizerKey] : null; - if (route.authorizerKey && !authorizer) { - throw new Error( - `createLocalApp: route ${route.method} ${route.path} references unknown authorizer "${route.authorizerKey}"`, - ); - } - - const handler = async (req: Request, res: Response): Promise => { - try { - const apiPath = req.path || route.path; - let authContext: Record = {}; - if (authorizer) { - applyLambdaEnv(authorizer.environment); - const authHandler = await loader.load(authorizer); - const authEvent = buildRequestAuthorizerEvent(req, { - path: apiPath, - httpMethod: route.method, - stage: manifest.stage, - }); - const authResult = await authHandler( - authEvent, - lambdaContext(authorizer.lambdaFunctionName), - () => {}, - ); - const decision = isAuthorizerAllow(authResult); - if (!decision.allow) { - res.status(403).json({ message: "Forbidden" }); - return; - } - authContext = { ...decision.context }; - } - - const proxyEvent = buildProxyEvent(req, { - path: apiPath, - httpMethod: route.method, - stage: manifest.stage, - authorizerContext: authContext, - }); - - applyLambdaEnv(lambda.environment); - const mainHandler = await loader.load(lambda); - const out = (await mainHandler( - proxyEvent, - lambdaContext(lambda.lambdaFunctionName), - () => {}, - )) as APIGatewayProxyResult | undefined; - sendProxyResult(res, out); - } catch (err) { - try { - opts.onError?.(err, req); - } catch { - // ignored - } - if (!res.headersSent) { - res.status(500).json({ - message: err instanceof Error ? err.message : "Internal error", - }); - } - } - }; - - app[method](expressPath, (req, res) => { - void handler(req, res); - }); - - routes.push(`${route.method} ${route.path}`); - } - - let stopWatcher: (() => Promise) | null = null; - if (opts.watch !== false) { - const paths = - opts.watchPaths && opts.watchPaths.length > 0 - ? [...opts.watchPaths] - : defaultWatchPaths(manifest.lambdas); - - if (paths.length > 0) { - stopWatcher = await startWatcher({ - paths, - loader, - onReload: opts.onReload, - onManifestChange: opts.onManifestChange, - manifestPath: opts.manifestPath, - onFrameworkLog, - }); - } - } - - return { - app, - routes, - stop: async () => { - if (stopWatcher) await stopWatcher(); - }, - }; +export async function createLocalApp( + opts: ServerOptions +): Promise { + const { manifest } = opts; + + const app = express(); + app.use(cors(opts.corsOptions ?? { origin: true, credentials: true })); + app.use(express.json({ limit: opts.bodyLimit ?? '6mb' })); + app.use(express.urlencoded({ extended: true })); + + const healthPath = opts.healthPath ?? '/__local/health'; + app.get(healthPath, (_req, res) => { + res.json({ ok: true, stage: manifest.stage, pid: process.pid }); + }); + + const repoRoot = opts.repoRoot ?? inferRepoRootFromManifest(manifest); + const onFrameworkLog = opts.onFrameworkLog ?? console.error; + const loader = new ModuleLoader({ repoRoot, onFrameworkLog }); + const routes: string[] = []; + + const sorted = sortRoutesBySpecificity(manifest.routes); + + for (const [, route] of sorted) { + const method = route.method.toLowerCase() as SupportedHttpMethod; + if (!SUPPORTED_HTTP_METHODS.has(method)) continue; + const expressPath = toExpressPath(route.path); + + const lambda = manifest.lambdas[route.functionKey]; + if (!lambda) { + throw new Error( + `createLocalApp: route ${route.method} ${route.path} references unknown lambda "${route.functionKey}"` + ); + } + const authorizer = route.authorizerKey + ? manifest.lambdas[route.authorizerKey] + : null; + if (route.authorizerKey && !authorizer) { + throw new Error( + `createLocalApp: route ${route.method} ${route.path} references unknown authorizer "${route.authorizerKey}"` + ); + } + + const handler = async (req: Request, res: Response): Promise => { + try { + const apiPath = req.path || route.path; + let authContext: Record = {}; + if (authorizer) { + applyLambdaEnv(authorizer.environment); + const authHandler = await loader.load(authorizer); + const authEvent = buildRequestAuthorizerEvent(req, { + path: apiPath, + httpMethod: route.method, + stage: manifest.stage + }); + const authResult = await authHandler( + authEvent, + lambdaContext(authorizer.lambdaFunctionName), + () => {} + ); + const decision = isAuthorizerAllow(authResult); + if (!decision.allow) { + res.status(403).json({ message: 'Forbidden' }); + return; + } + authContext = { ...decision.context }; + } + + const proxyEvent = buildProxyEvent(req, { + path: apiPath, + httpMethod: route.method, + stage: manifest.stage, + authorizerContext: authContext + }); + + applyLambdaEnv(lambda.environment); + const mainHandler = await loader.load(lambda); + const out = (await mainHandler( + proxyEvent, + lambdaContext(lambda.lambdaFunctionName), + () => {} + )) as APIGatewayProxyResult | undefined; + sendProxyResult(res, out); + } catch (err) { + try { + opts.onError?.(err, req); + } catch { + // ignored + } + if (!res.headersSent) { + res.status(500).json({ + message: err instanceof Error ? err.message : 'Internal error' + }); + } + } + }; + + app[method](expressPath, (req, res) => { + void handler(req, res); + }); + + routes.push(`${route.method} ${route.path}`); + } + + let stopWatcher: (() => Promise) | null = null; + if (opts.watch !== false) { + const paths = + opts.watchPaths && opts.watchPaths.length > 0 + ? [...opts.watchPaths] + : defaultWatchPaths(manifest.lambdas); + + if (paths.length > 0) { + stopWatcher = await startWatcher({ + paths, + loader, + onReload: opts.onReload, + onManifestChange: opts.onManifestChange, + manifestPath: opts.manifestPath, + onFrameworkLog + }); + } + } + + return { + app, + routes, + stop: async () => { + if (stopWatcher) await stopWatcher(); + } + }; } diff --git a/packages/aws-cdk-local-lambda/src/server/hot-reloader.test.ts b/packages/aws-cdk-local-lambda/src/server/hot-reloader.test.ts index 0f9c54c..590096e 100644 --- a/packages/aws-cdk-local-lambda/src/server/hot-reloader.test.ts +++ b/packages/aws-cdk-local-lambda/src/server/hot-reloader.test.ts @@ -1,69 +1,69 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; -import { startWatcher } from "./hot-reloader"; -import { ModuleLoader } from "./module-loader"; +import { startWatcher } from './hot-reloader'; +import { ModuleLoader } from './module-loader'; -const WAIT = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const WAIT = (ms: number) => new Promise(r => setTimeout(r, ms)); -describe("startWatcher", () => { - it("calls onReload after a file change (debounced)", async () => { - const dir = mkdtempSync(join(tmpdir(), "hr-")); - const file = join(dir, "f.ts"); - writeFileSync(file, "x=1"); - const loader = new ModuleLoader(); - const events: Array<[string, number]> = []; - const stop = await startWatcher({ - paths: [dir], - loader, - onReload: (p, n) => events.push([p, n]), - debounceMs: 50, - }); - await WAIT(200); - writeFileSync(file, "x=2"); - await WAIT(400); - expect(events.length).toBeGreaterThanOrEqual(1); - await stop(); - }); +describe('startWatcher', () => { + it('calls onReload after a file change (debounced)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hr-')); + const file = join(dir, 'f.ts'); + writeFileSync(file, 'x=1'); + const loader = new ModuleLoader(); + const events: Array<[string, number]> = []; + const stop = await startWatcher({ + paths: [dir], + loader, + onReload: (p, n) => events.push([p, n]), + debounceMs: 50 + }); + await WAIT(200); + writeFileSync(file, 'x=2'); + await WAIT(400); + expect(events.length).toBeGreaterThanOrEqual(1); + await stop(); + }); - it("calls onManifestChange when a *.generated.json manifest is saved (not ignored)", async () => { - const dir = mkdtempSync(join(tmpdir(), "hr-manifest-")); - mkdirSync(join(dir, "src"), { recursive: true }); - const watchedTs = join(dir, "src", "handler.ts"); - writeFileSync(watchedTs, "export const main = () => {}"); - const manifestPath = join(dir, "local-route-manifest.generated.json"); - writeFileSync(manifestPath, '{"v":1}\n'); + it('calls onManifestChange when a *.generated.json manifest is saved (not ignored)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hr-manifest-')); + mkdirSync(join(dir, 'src'), { recursive: true }); + const watchedTs = join(dir, 'src', 'handler.ts'); + writeFileSync(watchedTs, 'export const main = () => {}'); + const manifestPath = join(dir, 'local-route-manifest.generated.json'); + writeFileSync(manifestPath, '{"v":1}\n'); - const loader = new ModuleLoader(); - const manifestEvents: string[] = []; - const reloadEvents: Array<[string, number]> = []; - const stop = await startWatcher({ - paths: [join(dir, "src")], - manifestPath, - loader, - debounceMs: 50, - onManifestChange: (p) => manifestEvents.push(p), - onReload: (p, n) => reloadEvents.push([p, n]), - }); - await WAIT(250); - writeFileSync(manifestPath, '{"v":2}\n'); - await WAIT(400); - expect(manifestEvents.length).toBeGreaterThanOrEqual(1); - // A later debounce flush may see only a watched source path with nothing cached to evict (n === 0); real reloads always invalidate at least one handler. - expect(reloadEvents.filter(([, n]) => n > 0)).toHaveLength(0); - await stop(); - }); + const loader = new ModuleLoader(); + const manifestEvents: string[] = []; + const reloadEvents: Array<[string, number]> = []; + const stop = await startWatcher({ + paths: [join(dir, 'src')], + manifestPath, + loader, + debounceMs: 50, + onManifestChange: p => manifestEvents.push(p), + onReload: (p, n) => reloadEvents.push([p, n]) + }); + await WAIT(250); + writeFileSync(manifestPath, '{"v":2}\n'); + await WAIT(400); + expect(manifestEvents.length).toBeGreaterThanOrEqual(1); + // A later debounce flush may see only a watched source path with nothing cached to evict (n === 0); real reloads always invalidate at least one handler. + expect(reloadEvents.filter(([, n]) => n > 0)).toHaveLength(0); + await stop(); + }); - it("stop is idempotent", async () => { - const loader = new ModuleLoader(); - const stop = await startWatcher({ - paths: [process.cwd()], - loader, - debounceMs: 10, - }); - await stop(); - await stop(); - }); + it('stop is idempotent', async () => { + const loader = new ModuleLoader(); + const stop = await startWatcher({ + paths: [process.cwd()], + loader, + debounceMs: 10 + }); + await stop(); + await stop(); + }); }); diff --git a/packages/aws-cdk-local-lambda/src/server/hot-reloader.ts b/packages/aws-cdk-local-lambda/src/server/hot-reloader.ts index ec27e0a..8bd9164 100644 --- a/packages/aws-cdk-local-lambda/src/server/hot-reloader.ts +++ b/packages/aws-cdk-local-lambda/src/server/hot-reloader.ts @@ -1,25 +1,26 @@ -import { normalize } from "node:path"; +import type { ModuleLoader } from './module-loader'; -import chokidar, { type FSWatcher } from "chokidar"; -import { WATCHER_IGNORED_PATTERNS } from "../constants/watcher"; -import type { ModuleLoader } from "./module-loader"; +import chokidar, { type FSWatcher } from 'chokidar'; +import { normalize } from 'node:path'; + +import { WATCHER_IGNORED_PATTERNS } from '../constants/watcher'; /** Options for {@link startWatcher}. */ export interface StartWatcherOptions { - /** Directories or files to watch for changes. */ - readonly paths: readonly string[]; - /** Module loader whose cache is invalidated when a watched file changes. */ - readonly loader: ModuleLoader; - /** Called after handler modules are invalidated due to a file change. */ - readonly onReload?: (changedPath: string, invalidatedCount: number) => void; - /** Called when the manifest file itself changes (requires `manifestPath`). */ - readonly onManifestChange?: (path: string) => void; - /** Absolute path to the manifest file to watch alongside source files. */ - readonly manifestPath?: string; - /** Debounce window in milliseconds before processing a batch of changes. Defaults to `100`. */ - readonly debounceMs?: number; - /** Called with verbose framework log lines. Pass `() => {}` to silence them. */ - readonly onFrameworkLog?: (message: string) => void; + /** Directories or files to watch for changes. */ + readonly paths: readonly string[]; + /** Module loader whose cache is invalidated when a watched file changes. */ + readonly loader: ModuleLoader; + /** Called after handler modules are invalidated due to a file change. */ + readonly onReload?: (changedPath: string, invalidatedCount: number) => void; + /** Called when the manifest file itself changes (requires `manifestPath`). */ + readonly onManifestChange?: (path: string) => void; + /** Absolute path to the manifest file to watch alongside source files. */ + readonly manifestPath?: string; + /** Debounce window in milliseconds before processing a batch of changes. Defaults to `100`. */ + readonly debounceMs?: number; + /** Called with verbose framework log lines. Pass `() => {}` to silence them. */ + readonly onFrameworkLog?: (message: string) => void; } /** @@ -27,51 +28,53 @@ export interface StartWatcherOptions { * * Returns an async stop function that closes the watcher and cancels any pending debounce timers. */ -export async function startWatcher(opts: StartWatcherOptions): Promise<() => Promise> { - const watchTargets = [...opts.paths]; - if (opts.manifestPath) watchTargets.push(opts.manifestPath); +export async function startWatcher( + opts: StartWatcherOptions +): Promise<() => Promise> { + const watchTargets = [...opts.paths]; + if (opts.manifestPath) watchTargets.push(opts.manifestPath); - const manifestNorm = opts.manifestPath ? normalize(opts.manifestPath) : null; + const manifestNorm = opts.manifestPath ? normalize(opts.manifestPath) : null; - const watcher: FSWatcher = chokidar.watch(watchTargets, { - ignoreInitial: true, - ignored: (path: string) => { - if (manifestNorm && normalize(path) === manifestNorm) return false; - return WATCHER_IGNORED_PATTERNS.some((re) => re.test(path)); - }, - }); + const watcher: FSWatcher = chokidar.watch(watchTargets, { + ignoreInitial: true, + ignored: (path: string) => { + if (manifestNorm && normalize(path) === manifestNorm) return false; + return WATCHER_IGNORED_PATTERNS.some(re => re.test(path)); + } + }); - let timer: NodeJS.Timeout | null = null; - const batch: string[] = []; - const debounceMs = opts.debounceMs ?? 100; + let timer: NodeJS.Timeout | null = null; + const batch: string[] = []; + const debounceMs = opts.debounceMs ?? 100; - const fire = (): void => { - timer = null; - const paths = batch.splice(0, batch.length); - if (paths.length === 0) return; - if (manifestNorm) { - const manifestHit = paths.find((p) => normalize(p) === manifestNorm); - if (manifestHit) { - opts.onManifestChange?.(manifestHit); - return; - } - } - const p = paths[paths.length - 1]!; - const n = opts.loader.invalidate(p); - opts.onReload?.(p, n); - }; + const fire = (): void => { + timer = null; + const paths = batch.splice(0, batch.length); + if (paths.length === 0) return; + if (manifestNorm) { + const manifestHit = paths.find(p => normalize(p) === manifestNorm); + if (manifestHit) { + opts.onManifestChange?.(manifestHit); + return; + } + } + const p = paths[paths.length - 1]!; + const n = opts.loader.invalidate(p); + opts.onReload?.(p, n); + }; - watcher.on("all", (_event, p) => { - batch.push(p); - if (timer) clearTimeout(timer); - timer = setTimeout(fire, debounceMs); - }); + watcher.on('all', (_event, p) => { + batch.push(p); + if (timer) clearTimeout(timer); + timer = setTimeout(fire, debounceMs); + }); - let stopped = false; - return async () => { - if (stopped) return; - stopped = true; - if (timer) clearTimeout(timer); - await watcher.close(); - }; + let stopped = false; + return async () => { + if (stopped) return; + stopped = true; + if (timer) clearTimeout(timer); + await watcher.close(); + }; } diff --git a/packages/aws-cdk-local-lambda/src/server/index.ts b/packages/aws-cdk-local-lambda/src/server/index.ts index e6a34ac..a065782 100644 --- a/packages/aws-cdk-local-lambda/src/server/index.ts +++ b/packages/aws-cdk-local-lambda/src/server/index.ts @@ -1,15 +1,15 @@ export { - buildProxyEvent, - buildRequestAuthorizerEvent, - lambdaContext, -} from "./apigateway-proxy"; -export type { AuthorizerDecision } from "./authorizer"; -export { isAuthorizerAllow } from "./authorizer"; -export type { ServerHandle, ServerOptions } from "./create-app"; -export * from "./create-app"; -export type { StartWatcherOptions } from "./hot-reloader"; -export { startWatcher } from "./hot-reloader"; -export type { ModuleLoaderOptions } from "./module-loader"; -export { ModuleLoader } from "./module-loader"; -export { toExpressPath } from "./path-convert"; -export { sendProxyResult } from "./response-writer"; + buildProxyEvent, + buildRequestAuthorizerEvent, + lambdaContext +} from './apigateway-proxy'; +export type { AuthorizerDecision } from './authorizer'; +export { isAuthorizerAllow } from './authorizer'; +export type { ServerHandle, ServerOptions } from './create-app'; +export * from './create-app'; +export type { StartWatcherOptions } from './hot-reloader'; +export { startWatcher } from './hot-reloader'; +export type { ModuleLoaderOptions } from './module-loader'; +export { ModuleLoader } from './module-loader'; +export { toExpressPath } from './path-convert'; +export { sendProxyResult } from './response-writer'; diff --git a/packages/aws-cdk-local-lambda/src/server/module-loader.test.ts b/packages/aws-cdk-local-lambda/src/server/module-loader.test.ts index c0a0bd7..d6c65a5 100644 --- a/packages/aws-cdk-local-lambda/src/server/module-loader.test.ts +++ b/packages/aws-cdk-local-lambda/src/server/module-loader.test.ts @@ -1,11 +1,12 @@ -import { mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { APIGatewayProxyEvent, Context, Handler } from "aws-lambda"; -import { describe, expect, it } from "vitest"; -import type { LocalLambda } from "../types"; +import type { LocalLambda } from '../types'; +import type { APIGatewayProxyEvent, Context, Handler } from 'aws-lambda'; -import { ModuleLoader } from "./module-loader"; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { ModuleLoader } from './module-loader'; /** Minimal stubs so a loaded {@link Handler} can be invoked like API Gateway does at runtime. */ const stubEvent = {} as APIGatewayProxyEvent; @@ -13,53 +14,53 @@ const stubContext = {} as Context; const stubCallback: Parameters[2] = () => {}; async function invokeLoadedHandler(fn: Handler): Promise { - return await Promise.resolve(fn(stubEvent, stubContext, stubCallback)); + return await Promise.resolve(fn(stubEvent, stubContext, stubCallback)); } -function makeLambda(entry: string, handler = "main"): LocalLambda { - return { - functionKey: "fn", - lambdaLogicalId: "X", - lambdaFunctionName: "x", - assetDir: "/n/a", - entry, - handler, - runtime: "nodejs22.x", - environment: {}, - }; +function makeLambda(entry: string, handler = 'main'): LocalLambda { + return { + functionKey: 'fn', + lambdaLogicalId: 'X', + lambdaFunctionName: 'x', + assetDir: '/n/a', + entry, + handler, + runtime: 'nodejs22.x', + environment: {} + }; } -describe("ModuleLoader", () => { - it("loads a handler and caches it", async () => { - const dir = mkdtempSync(join(tmpdir(), "ml-")); - const file = join(dir, "h.mjs"); - writeFileSync(file, 'export const main = () => "v1";'); - const ml = new ModuleLoader(); - const fn = await ml.load(makeLambda(file)); - expect(await invokeLoadedHandler(fn)).toBe("v1"); - const fn2 = await ml.load(makeLambda(file)); - expect(fn2).toBe(fn); - }); +describe('ModuleLoader', () => { + it('loads a handler and caches it', async () => { + const dir = mkdtempSync(join(tmpdir(), 'ml-')); + const file = join(dir, 'h.mjs'); + writeFileSync(file, 'export const main = () => "v1";'); + const ml = new ModuleLoader(); + const fn = await ml.load(makeLambda(file)); + expect(await invokeLoadedHandler(fn)).toBe('v1'); + const fn2 = await ml.load(makeLambda(file)); + expect(fn2).toBe(fn); + }); - it("throws a clear error when the handler name is missing", async () => { - const dir = mkdtempSync(join(tmpdir(), "ml-")); - const file = join(dir, "h.mjs"); - writeFileSync(file, "export const other = () => 1;"); - const ml = new ModuleLoader(); - await expect(ml.load(makeLambda(file))).rejects.toThrow(/main/); - }); + it('throws a clear error when the handler name is missing', async () => { + const dir = mkdtempSync(join(tmpdir(), 'ml-')); + const file = join(dir, 'h.mjs'); + writeFileSync(file, 'export const other = () => 1;'); + const ml = new ModuleLoader(); + await expect(ml.load(makeLambda(file))).rejects.toThrow(/main/); + }); - it("invalidate clears the cache and picks up changes", async () => { - const dir = mkdtempSync(join(tmpdir(), "ml-")); - const file = join(dir, "h.mjs"); - writeFileSync(file, 'export const main = () => "v1";'); - const ml = new ModuleLoader(); - const fn1 = await ml.load(makeLambda(file)); - expect(await invokeLoadedHandler(fn1)).toBe("v1"); - writeFileSync(file, 'export const main = () => "v2";'); - const cleared = ml.invalidate(); - expect(cleared).toBe(1); - const fn2 = await ml.load(makeLambda(file)); - expect(await invokeLoadedHandler(fn2)).toBe("v2"); - }); + it('invalidate clears the cache and picks up changes', async () => { + const dir = mkdtempSync(join(tmpdir(), 'ml-')); + const file = join(dir, 'h.mjs'); + writeFileSync(file, 'export const main = () => "v1";'); + const ml = new ModuleLoader(); + const fn1 = await ml.load(makeLambda(file)); + expect(await invokeLoadedHandler(fn1)).toBe('v1'); + writeFileSync(file, 'export const main = () => "v2";'); + const cleared = ml.invalidate(); + expect(cleared).toBe(1); + const fn2 = await ml.load(makeLambda(file)); + expect(await invokeLoadedHandler(fn2)).toBe('v2'); + }); }); diff --git a/packages/aws-cdk-local-lambda/src/server/module-loader.ts b/packages/aws-cdk-local-lambda/src/server/module-loader.ts index 23ab0ba..a5cc1bb 100644 --- a/packages/aws-cdk-local-lambda/src/server/module-loader.ts +++ b/packages/aws-cdk-local-lambda/src/server/module-loader.ts @@ -1,21 +1,25 @@ -import { randomBytes } from "node:crypto"; -import { mkdirSync, unlinkSync, writeFileSync } from "node:fs"; -import { dirname, join, normalize } from "node:path"; -import { pathToFileURL } from "node:url"; -import type { Handler } from "aws-lambda"; -import { buildSync } from "esbuild"; -import type { LocalLambda } from "../types"; +import type { LocalLambda } from '../types'; +import type { Handler } from 'aws-lambda'; -import { findAncestorWithNodeModules, findNearestNamedFile } from "../utils/path-search"; +import { buildSync } from 'esbuild'; +import { randomBytes } from 'node:crypto'; +import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join, normalize } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + findAncestorWithNodeModules, + findNearestNamedFile +} from '../utils/path-search'; let loadSeq = 0; /** Options for {@link ModuleLoader}. */ export interface ModuleLoaderOptions { - /** Repository root used when locating `tsconfig.json` and `node_modules`. Defaults to `process.cwd()`. */ - readonly repoRoot?: string; - /** Called with verbose framework log lines. Pass `() => {}` to silence them. */ - readonly onFrameworkLog?: (message: string) => void; + /** Repository root used when locating `tsconfig.json` and `node_modules`. Defaults to `process.cwd()`. */ + readonly repoRoot?: string; + /** Called with verbose framework log lines. Pass `() => {}` to silence them. */ + readonly onFrameworkLog?: (message: string) => void; } /** @@ -25,145 +29,157 @@ export interface ModuleLoaderOptions { * source files change to evict stale handlers so they are rebundled on the next request. */ export class ModuleLoader { - private readonly cache = new Map>(); - private readonly deps = new Map>(); - private readonly repoRoot: string; - private readonly onFrameworkLog: (message: string) => void; + private readonly cache = new Map>(); + private readonly deps = new Map>(); + private readonly repoRoot: string; + private readonly onFrameworkLog: (message: string) => void; - constructor(opts?: ModuleLoaderOptions) { - this.repoRoot = opts?.repoRoot ?? process.cwd(); - this.onFrameworkLog = opts?.onFrameworkLog ?? console.error; - } + constructor(opts?: ModuleLoaderOptions) { + this.repoRoot = opts?.repoRoot ?? process.cwd(); + this.onFrameworkLog = opts?.onFrameworkLog ?? console.error; + } - private async bundleAndImportTs( - absPath: string, - ): Promise<{ mod: Record; deps: Set }> { - const tsconfig = findNearestNamedFile(absPath, "tsconfig.json"); - if (!tsconfig) { - throw new Error( - "bundleAndImportTs requires a tsconfig.json on the path from the handler file", - ); - } - const absWorkingDir = dirname(tsconfig); - const result = buildSync({ - entryPoints: [absPath], - absWorkingDir, - bundle: true, - platform: "node", - format: "esm", - target: "node22", - tsconfig, - packages: "external", - write: false, - metafile: true, - logLevel: "silent", - }); - const code = result.outputFiles?.[0]?.text; - if (!code) { - throw new Error("esbuild produced no output for local handler bundle"); - } - const deps = new Set( - Object.keys(result.metafile?.inputs ?? {}).map((f) => normalize(join(absWorkingDir, f))), - ); - const cacheBase = findAncestorWithNodeModules(absPath) ?? this.repoRoot; - const cacheDir = join(cacheBase, "node_modules", ".cache", "aws-cdk-local-lambda"); - mkdirSync(cacheDir, { recursive: true }); - const tmp = join( - cacheDir, - `bundle-${Date.now()}-${++loadSeq}-${randomBytes(8).toString("hex")}.mjs`, - ); - writeFileSync(tmp, code, "utf8"); - try { - const mod = (await import(pathToFileURL(tmp).href)) as Record; - return { mod, deps }; - } finally { - try { - unlinkSync(tmp); - } catch { - // best-effort - } - } - } + private async bundleAndImportTs( + absPath: string + ): Promise<{ mod: Record; deps: Set }> { + const tsconfig = findNearestNamedFile(absPath, 'tsconfig.json'); + if (!tsconfig) { + throw new Error( + 'bundleAndImportTs requires a tsconfig.json on the path from the handler file' + ); + } + const absWorkingDir = dirname(tsconfig); + const result = buildSync({ + entryPoints: [absPath], + absWorkingDir, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node22', + tsconfig, + packages: 'external', + write: false, + metafile: true, + logLevel: 'silent' + }); + const code = result.outputFiles?.[0]?.text; + if (!code) { + throw new Error('esbuild produced no output for local handler bundle'); + } + const deps = new Set( + Object.keys(result.metafile?.inputs ?? {}).map(f => + normalize(join(absWorkingDir, f)) + ) + ); + const cacheBase = findAncestorWithNodeModules(absPath) ?? this.repoRoot; + const cacheDir = join( + cacheBase, + 'node_modules', + '.cache', + 'aws-cdk-local-lambda' + ); + mkdirSync(cacheDir, { recursive: true }); + const tmp = join( + cacheDir, + `bundle-${Date.now()}-${++loadSeq}-${randomBytes(8).toString('hex')}.mjs` + ); + writeFileSync(tmp, code, 'utf8'); + try { + const mod = (await import(pathToFileURL(tmp).href)) as Record< + string, + unknown + >; + return { mod, deps }; + } finally { + try { + unlinkSync(tmp); + } catch { + // best-effort + } + } + } - private async importModule( - path: string, - ): Promise<{ mod: Record; deps: Set }> { - const isTs = path.endsWith(".ts") || path.endsWith(".tsx"); - if (isTs) { - try { - return await this.bundleAndImportTs(path); - } catch { - // fall through to direct import - } - } - const url = `${pathToFileURL(path).href}?t=${Date.now()}-${++loadSeq}`; - return { - mod: (await import(url)) as Record, - deps: new Set([path]), - }; - } + private async importModule( + path: string + ): Promise<{ mod: Record; deps: Set }> { + const isTs = path.endsWith('.ts') || path.endsWith('.tsx'); + if (isTs) { + try { + return await this.bundleAndImportTs(path); + } catch { + // fall through to direct import + } + } + const url = `${pathToFileURL(path).href}?t=${Date.now()}-${++loadSeq}`; + return { + mod: (await import(url)) as Record, + deps: new Set([path]) + }; + } - /** - * Loads (and caches) the handler function for the given Lambda. - * TypeScript entry files are bundled with esbuild before import. - * Throws if the named export cannot be found. - */ - async load(lambda: LocalLambda): Promise { - const path = lambda.entry; - const cached = this.cache.get(path); - if (cached) return cached; + /** + * Loads (and caches) the handler function for the given Lambda. + * TypeScript entry files are bundled with esbuild before import. + * Throws if the named export cannot be found. + */ + async load(lambda: LocalLambda): Promise { + const path = lambda.entry; + const cached = this.cache.get(path); + if (cached) return cached; - const p = (async () => { - const { mod, deps } = await this.importModule(path); - this.deps.set(path, deps); - const direct = mod[lambda.handler]; - const viaDefault = - typeof mod.default === "object" && mod.default !== null - ? (mod.default as Record)[lambda.handler] - : undefined; - const fn = direct ?? viaDefault; - if (typeof fn !== "function") { - throw new Error(`Handler "${lambda.handler}" not exported by ${path}`); - } - return fn as Handler; - })(); - this.cache.set(path, p); - try { - await p; - } catch (e) { - this.cache.delete(path); - this.deps.delete(path); - throw e; - } - return p; - } + const p = (async () => { + const { mod, deps } = await this.importModule(path); + this.deps.set(path, deps); + const direct = mod[lambda.handler]; + const viaDefault = + typeof mod.default === 'object' && mod.default !== null + ? (mod.default as Record)[lambda.handler] + : undefined; + const fn = direct ?? viaDefault; + if (typeof fn !== 'function') { + throw new Error(`Handler "${lambda.handler}" not exported by ${path}`); + } + return fn as Handler; + })(); + this.cache.set(path, p); + try { + await p; + } catch (e) { + this.cache.delete(path); + this.deps.delete(path); + throw e; + } + return p; + } - /** - * Evicts cached handlers that depend on `changedFile`. - * If `changedFile` is omitted, the entire cache is cleared. - * Returns the number of handlers invalidated. - */ - invalidate(changedFile?: string): number { - if (!changedFile) { - const count = this.cache.size; - this.cache.clear(); - this.deps.clear(); - return count; - } + /** + * Evicts cached handlers that depend on `changedFile`. + * If `changedFile` is omitted, the entire cache is cleared. + * Returns the number of handlers invalidated. + */ + invalidate(changedFile?: string): number { + if (!changedFile) { + const count = this.cache.size; + this.cache.clear(); + this.deps.clear(); + return count; + } - const normalized = normalize(changedFile); - this.onFrameworkLog(`[cdk-local] invalidating modules dependent on ${normalized}`); - let count = 0; - for (const [handlerPath, fileDeps] of this.deps) { - if (fileDeps.has(normalized)) { - this.cache.delete(handlerPath); - this.deps.delete(handlerPath); - count++; - this.onFrameworkLog( - `[cdk-local] invalidating handler "${handlerPath}" (changed: ${normalized})`, - ); - } - } - return count; - } + const normalized = normalize(changedFile); + this.onFrameworkLog( + `[cdk-local] invalidating modules dependent on ${normalized}` + ); + let count = 0; + for (const [handlerPath, fileDeps] of this.deps) { + if (fileDeps.has(normalized)) { + this.cache.delete(handlerPath); + this.deps.delete(handlerPath); + count++; + this.onFrameworkLog( + `[cdk-local] invalidating handler "${handlerPath}" (changed: ${normalized})` + ); + } + } + return count; + } } diff --git a/packages/aws-cdk-local-lambda/src/server/path-convert.test.ts b/packages/aws-cdk-local-lambda/src/server/path-convert.test.ts index e221e54..d600a17 100644 --- a/packages/aws-cdk-local-lambda/src/server/path-convert.test.ts +++ b/packages/aws-cdk-local-lambda/src/server/path-convert.test.ts @@ -1,21 +1,21 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest'; -import { toExpressPath } from "./path-convert"; +import { toExpressPath } from './path-convert'; -describe("toExpressPath", () => { - it("rewrites {id} to :id", () => { - expect(toExpressPath("/users/{id}")).toBe("/users/:id"); - }); - it("rewrites {proxy+} to *", () => { - expect(toExpressPath("/files/{proxy+}")).toBe("/files/*"); - }); - it("preserves plain paths", () => { - expect(toExpressPath("/hello")).toBe("/hello"); - }); - it("handles multiple params", () => { - expect(toExpressPath("/a/{x}/b/{y}")).toBe("/a/:x/b/:y"); - }); - it("throws on unsupported patterns", () => { - expect(() => toExpressPath("/a/{x+}/b")).toThrow(); - }); +describe('toExpressPath', () => { + it('rewrites {id} to :id', () => { + expect(toExpressPath('/users/{id}')).toBe('/users/:id'); + }); + it('rewrites {proxy+} to *', () => { + expect(toExpressPath('/files/{proxy+}')).toBe('/files/*'); + }); + it('preserves plain paths', () => { + expect(toExpressPath('/hello')).toBe('/hello'); + }); + it('handles multiple params', () => { + expect(toExpressPath('/a/{x}/b/{y}')).toBe('/a/:x/b/:y'); + }); + it('throws on unsupported patterns', () => { + expect(() => toExpressPath('/a/{x+}/b')).toThrow(); + }); }); diff --git a/packages/aws-cdk-local-lambda/src/server/path-convert.ts b/packages/aws-cdk-local-lambda/src/server/path-convert.ts index d3fe911..d9a5865 100644 --- a/packages/aws-cdk-local-lambda/src/server/path-convert.ts +++ b/packages/aws-cdk-local-lambda/src/server/path-convert.ts @@ -8,9 +8,11 @@ const PLACEHOLDER_RE = /\{([^}]+)\}/g; * @example `"/users/{id}/posts"` → `"/users/:id/posts"` */ export function toExpressPath(apiPath: string): string { - return apiPath.replace(PLACEHOLDER_RE, (_m, inner: string) => { - if (inner === "proxy+") return "*"; - if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(inner)) return `:${inner}`; - throw new Error(`Unsupported path placeholder "{${inner}}" in "${apiPath}"`); - }); + return apiPath.replace(PLACEHOLDER_RE, (_m, inner: string) => { + if (inner === 'proxy+') return '*'; + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(inner)) return `:${inner}`; + throw new Error( + `Unsupported path placeholder "{${inner}}" in "${apiPath}"` + ); + }); } diff --git a/packages/aws-cdk-local-lambda/src/server/response-writer.test.ts b/packages/aws-cdk-local-lambda/src/server/response-writer.test.ts index bbe67c9..489a2dc 100644 --- a/packages/aws-cdk-local-lambda/src/server/response-writer.test.ts +++ b/packages/aws-cdk-local-lambda/src/server/response-writer.test.ts @@ -1,63 +1,63 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest'; -import { sendProxyResult } from "./response-writer"; +import { sendProxyResult } from './response-writer'; function fakeRes() { - const headers: Record = {}; - let body: unknown; - let status = 200; - const res = { - status(s: number) { - status = s; - return this; - }, - setHeader(k: string, v: string) { - headers[k] = v; - }, - send(b: unknown) { - body = b; - return this; - }, - get statusCode() { - return status; - }, - get body() { - return body; - }, - get headers() { - return headers; - }, - }; - return res; + const headers: Record = {}; + let body: unknown; + let status = 200; + const res = { + status(s: number) { + status = s; + return this; + }, + setHeader(k: string, v: string) { + headers[k] = v; + }, + send(b: unknown) { + body = b; + return this; + }, + get statusCode() { + return status; + }, + get body() { + return body; + }, + get headers() { + return headers; + } + }; + return res; } -describe("sendProxyResult", () => { - it("applies statusCode, headers, and text body", () => { - const res = fakeRes(); - sendProxyResult(res as never, { - statusCode: 201, - headers: { "x-a": "1", "x-b": undefined as unknown as string }, - body: "hi", - }); - expect(res.statusCode).toBe(201); - expect(res.headers["x-a"]).toBe("1"); - expect(res.body).toBe("hi"); - }); +describe('sendProxyResult', () => { + it('applies statusCode, headers, and text body', () => { + const res = fakeRes(); + sendProxyResult(res as never, { + statusCode: 201, + headers: { 'x-a': '1', 'x-b': undefined as unknown as string }, + body: 'hi' + }); + expect(res.statusCode).toBe(201); + expect(res.headers['x-a']).toBe('1'); + expect(res.body).toBe('hi'); + }); - it("decodes base64 body", () => { - const res = fakeRes(); - sendProxyResult(res as never, { - statusCode: 200, - body: Buffer.from("hello").toString("base64"), - isBase64Encoded: true, - }); - expect(Buffer.isBuffer(res.body)).toBe(true); - expect((res.body as Buffer).toString()).toBe("hello"); - }); + it('decodes base64 body', () => { + const res = fakeRes(); + sendProxyResult(res as never, { + statusCode: 200, + body: Buffer.from('hello').toString('base64'), + isBase64Encoded: true + }); + expect(Buffer.isBuffer(res.body)).toBe(true); + expect((res.body as Buffer).toString()).toBe('hello'); + }); - it("502s on missing result", () => { - const res = fakeRes(); - sendProxyResult(res as never, undefined); - expect(res.statusCode).toBe(502); - }); + it('502s on missing result', () => { + const res = fakeRes(); + sendProxyResult(res as never, undefined); + expect(res.statusCode).toBe(502); + }); }); diff --git a/packages/aws-cdk-local-lambda/src/server/response-writer.ts b/packages/aws-cdk-local-lambda/src/server/response-writer.ts index 4355426..82b74ac 100644 --- a/packages/aws-cdk-local-lambda/src/server/response-writer.ts +++ b/packages/aws-cdk-local-lambda/src/server/response-writer.ts @@ -1,24 +1,27 @@ -import type { APIGatewayProxyResult } from "aws-lambda"; -import type { Response } from "express"; +import type { APIGatewayProxyResult } from 'aws-lambda'; +import type { Response } from 'express'; /** * Writes an `APIGatewayProxyResult` to an Express `Response`. * Responds with 502 if the Lambda returned `undefined`. */ -export function sendProxyResult(res: Response, result: APIGatewayProxyResult | undefined): void { - if (!result) { - res.status(502).send("Empty Lambda response"); - return; - } - res.status(result.statusCode ?? 200); - const headers = result.headers ?? {}; - for (const [k, v] of Object.entries(headers)) { - if (v !== undefined) res.setHeader(k, String(v)); - } - const body = result.body ?? ""; - if (result.isBase64Encoded) { - res.send(Buffer.from(body, "base64")); - } else { - res.send(body); - } +export function sendProxyResult( + res: Response, + result: APIGatewayProxyResult | undefined +): void { + if (!result) { + res.status(502).send('Empty Lambda response'); + return; + } + res.status(result.statusCode ?? 200); + const headers = result.headers ?? {}; + for (const [k, v] of Object.entries(headers)) { + if (v !== undefined) res.setHeader(k, String(v)); + } + const body = result.body ?? ''; + if (result.isBase64Encoded) { + res.send(Buffer.from(body, 'base64')); + } else { + res.send(body); + } } diff --git a/packages/aws-cdk-local-lambda/src/types.test.ts b/packages/aws-cdk-local-lambda/src/types.test.ts index 9f64d30..81a1744 100644 --- a/packages/aws-cdk-local-lambda/src/types.test.ts +++ b/packages/aws-cdk-local-lambda/src/types.test.ts @@ -1,33 +1,34 @@ -import { describe, expect, it } from "vitest"; -import type { LocalLambda, LocalManifest, LocalRoute } from "./types"; +import type { LocalLambda, LocalManifest, LocalRoute } from './types'; -describe("LocalManifest types", () => { - it("LocalManifest shape accepts the minimal valid object", () => { - const lambda: LocalLambda = { - functionKey: "hello", - lambdaLogicalId: "HelloFnABC123", - lambdaFunctionName: "zephyr-wombat-dev-grizzly", - assetDir: "/abs/cdk.out/asset.deadbeef", - entry: "/abs/api/src/functions/hello/handler.ts", - handler: "main", - runtime: "nodejs22.x", - environment: { STAGE: "dev" }, - }; - const route: LocalRoute = { - method: "GET", - path: "/hello", - functionKey: "hello", - authorizerKey: null, - }; - const manifest: LocalManifest = { - source: "cdk-synth", - stack: "TurbineRocketStack", - stage: "dev", - cdkOut: "/abs/cdk.out", - lambdas: { hello: lambda }, - routes: { "GET /hello": route }, - }; - expect(manifest.lambdas.hello?.handler).toBe("main"); - expect(manifest.routes["GET /hello"]?.authorizerKey).toBeNull(); - }); +import { describe, expect, it } from 'vitest'; + +describe('LocalManifest types', () => { + it('LocalManifest shape accepts the minimal valid object', () => { + const lambda: LocalLambda = { + functionKey: 'hello', + lambdaLogicalId: 'HelloFnABC123', + lambdaFunctionName: 'zephyr-wombat-dev-grizzly', + assetDir: '/abs/cdk.out/asset.deadbeef', + entry: '/abs/api/src/functions/hello/handler.ts', + handler: 'main', + runtime: 'nodejs22.x', + environment: { STAGE: 'dev' } + }; + const route: LocalRoute = { + method: 'GET', + path: '/hello', + functionKey: 'hello', + authorizerKey: null + }; + const manifest: LocalManifest = { + source: 'cdk-synth', + stack: 'TurbineRocketStack', + stage: 'dev', + cdkOut: '/abs/cdk.out', + lambdas: { hello: lambda }, + routes: { 'GET /hello': route } + }; + expect(manifest.lambdas.hello?.handler).toBe('main'); + expect(manifest.routes['GET /hello']?.authorizerKey).toBeNull(); + }); }); diff --git a/packages/aws-cdk-local-lambda/src/types.ts b/packages/aws-cdk-local-lambda/src/types.ts index db9f43e..271712e 100644 --- a/packages/aws-cdk-local-lambda/src/types.ts +++ b/packages/aws-cdk-local-lambda/src/types.ts @@ -1,33 +1,33 @@ /** A single Lambda function extracted from a CDK CloudFormation template. */ export interface LocalLambda { - /** Stable key used to look up this Lambda in the manifest (derived from function name or logical ID). */ - readonly functionKey: string; - /** CloudFormation logical resource ID for this Lambda. */ - readonly lambdaLogicalId: string; - /** The deployed function name as it appears in CloudFormation. */ - readonly lambdaFunctionName: string; - /** Absolute path to the CDK asset directory containing the bundled handler code. */ - readonly assetDir: string; - /** Absolute path to the TypeScript entry file recovered from the bundled asset. */ - readonly entry: string; - /** Exported handler name (e.g. `"handler"`), extracted from the CloudFormation `Handler` property. */ - readonly handler: string; - /** Lambda runtime identifier (e.g. `"nodejs22.x"`). */ - readonly runtime: string; - /** Environment variables for this function, normalised to string values. */ - readonly environment: Readonly>; + /** Stable key used to look up this Lambda in the manifest (derived from function name or logical ID). */ + readonly functionKey: string; + /** CloudFormation logical resource ID for this Lambda. */ + readonly lambdaLogicalId: string; + /** The deployed function name as it appears in CloudFormation. */ + readonly lambdaFunctionName: string; + /** Absolute path to the CDK asset directory containing the bundled handler code. */ + readonly assetDir: string; + /** Absolute path to the TypeScript entry file recovered from the bundled asset. */ + readonly entry: string; + /** Exported handler name (e.g. `"handler"`), extracted from the CloudFormation `Handler` property. */ + readonly handler: string; + /** Lambda runtime identifier (e.g. `"nodejs22.x"`). */ + readonly runtime: string; + /** Environment variables for this function, normalised to string values. */ + readonly environment: Readonly>; } /** An API Gateway route mapped to a Lambda function. */ export interface LocalRoute { - /** HTTP method in upper-case (e.g. `"GET"`, `"POST"`). */ - readonly method: string; - /** API Gateway path pattern (e.g. `"/users/{id}"`). */ - readonly path: string; - /** Key into `LocalManifest.lambdas` for the handler Lambda. */ - readonly functionKey: string; - /** Key into `LocalManifest.lambdas` for the custom authorizer Lambda, or `null` if none. */ - readonly authorizerKey: string | null; + /** HTTP method in upper-case (e.g. `"GET"`, `"POST"`). */ + readonly method: string; + /** API Gateway path pattern (e.g. `"/users/{id}"`). */ + readonly path: string; + /** Key into `LocalManifest.lambdas` for the handler Lambda. */ + readonly functionKey: string; + /** Key into `LocalManifest.lambdas` for the custom authorizer Lambda, or `null` if none. */ + readonly authorizerKey: string | null; } /** @@ -36,16 +36,16 @@ export interface LocalRoute { * Pass this to {@link createLocalApp} to spin up the local HTTP server. */ export interface LocalManifest { - /** Discriminator — always `"cdk-synth"`. */ - readonly source: "cdk-synth"; - /** CloudFormation stack name that was synthesised. */ - readonly stack: string; - /** Optional stage name used to strip prefixes from Lambda function keys. */ - readonly stage: string | undefined; - /** Absolute path to the CDK output directory (`cdk.out`). */ - readonly cdkOut: string; - /** All Lambda functions keyed by their `functionKey`. */ - readonly lambdas: Readonly>; - /** All API Gateway routes keyed by `"METHOD /path"`. */ - readonly routes: Readonly>; + /** Discriminator — always `"cdk-synth"`. */ + readonly source: 'cdk-synth'; + /** CloudFormation stack name that was synthesised. */ + readonly stack: string; + /** Optional stage name used to strip prefixes from Lambda function keys. */ + readonly stage: string | undefined; + /** Absolute path to the CDK output directory (`cdk.out`). */ + readonly cdkOut: string; + /** All Lambda functions keyed by their `functionKey`. */ + readonly lambdas: Readonly>; + /** All API Gateway routes keyed by `"METHOD /path"`. */ + readonly routes: Readonly>; } diff --git a/packages/aws-cdk-local-lambda/src/utils/cfn-uri.ts b/packages/aws-cdk-local-lambda/src/utils/cfn-uri.ts index d620e57..126c201 100644 --- a/packages/aws-cdk-local-lambda/src/utils/cfn-uri.ts +++ b/packages/aws-cdk-local-lambda/src/utils/cfn-uri.ts @@ -4,30 +4,30 @@ * Handles both `Fn::GetAtt: [LogicalId, Arn]` and `Fn::Join` intrinsics. Returns `null` if no ID is found. */ export function findLambdaLogicalIdInUri(uri: unknown): string | null { - if (uri === null || uri === undefined) return null; + if (uri === null || uri === undefined) return null; - if (typeof uri === "object" && "Fn::GetAtt" in uri) { - const getAtt = (uri as { "Fn::GetAtt": unknown })["Fn::GetAtt"]; - if ( - Array.isArray(getAtt) && - getAtt.length === 2 && - typeof getAtt[0] === "string" && - getAtt[1] === "Arn" - ) { - return getAtt[0]; - } - } + if (typeof uri === 'object' && 'Fn::GetAtt' in uri) { + const getAtt = (uri as { 'Fn::GetAtt': unknown })['Fn::GetAtt']; + if ( + Array.isArray(getAtt) && + getAtt.length === 2 && + typeof getAtt[0] === 'string' && + getAtt[1] === 'Arn' + ) { + return getAtt[0]; + } + } - if (typeof uri === "object" && uri !== null && "Fn::Join" in uri) { - const joinVal = (uri as { "Fn::Join": unknown })["Fn::Join"]; - const parts = Array.isArray(joinVal) ? joinVal[1] : undefined; - if (Array.isArray(parts)) { - for (const part of parts) { - const id = findLambdaLogicalIdInUri(part); - if (id) return id; - } - } - } + if (typeof uri === 'object' && uri !== null && 'Fn::Join' in uri) { + const joinVal = (uri as { 'Fn::Join': unknown })['Fn::Join']; + const parts = Array.isArray(joinVal) ? joinVal[1] : undefined; + if (Array.isArray(parts)) { + for (const part of parts) { + const id = findLambdaLogicalIdInUri(part); + if (id) return id; + } + } + } - return null; + return null; } diff --git a/packages/aws-cdk-local-lambda/src/utils/env.ts b/packages/aws-cdk-local-lambda/src/utils/env.ts index 6f256e3..73d497b 100644 --- a/packages/aws-cdk-local-lambda/src/utils/env.ts +++ b/packages/aws-cdk-local-lambda/src/utils/env.ts @@ -1,6 +1,6 @@ /** Merges a Lambda's environment variables into `process.env` before invoking its handler. */ export function applyLambdaEnv(env: Readonly>): void { - for (const [k, v] of Object.entries(env)) { - process.env[k] = v; - } + for (const [k, v] of Object.entries(env)) { + process.env[k] = v; + } } diff --git a/packages/aws-cdk-local-lambda/src/utils/local-app.ts b/packages/aws-cdk-local-lambda/src/utils/local-app.ts index d41f052..c7d6db9 100644 --- a/packages/aws-cdk-local-lambda/src/utils/local-app.ts +++ b/packages/aws-cdk-local-lambda/src/utils/local-app.ts @@ -1,5 +1,6 @@ -import { dirname } from "node:path"; -import type { LocalLambda, LocalManifest, LocalRoute } from "../types"; +import type { LocalLambda, LocalManifest, LocalRoute } from '../types'; + +import { dirname } from 'node:path'; type RouteEntry = [string, LocalRoute]; @@ -7,30 +8,34 @@ type RouteEntry = [string, LocalRoute]; * Infers the repository root from a manifest by walking up from `cdkOut`. * Assumes the conventional layout where `cdk.out` lives at `/cdk.out` or `/infra/cdk.out`. */ -export function inferRepoRootFromManifest(manifest: Pick): string { - if (/\/(infra\/)?cdk\.out$/.test(manifest.cdkOut.replace(/\\/g, "/"))) { - return dirname(dirname(manifest.cdkOut)); - } - return process.cwd(); +export function inferRepoRootFromManifest( + manifest: Pick +): string { + if (/\/(infra\/)?cdk\.out$/.test(manifest.cdkOut.replace(/\\/g, '/'))) { + return dirname(dirname(manifest.cdkOut)); + } + return process.cwd(); } /** * Returns the set of directories to watch for hot-reloading, derived from each Lambda's entry path. * Resolves to the nearest `src/` ancestor directory, falling back to the entry's parent directory. */ -export function defaultWatchPaths(lambdas: Readonly>): string[] { - const set = new Set(); - for (const lambda of Object.values(lambdas)) { - const source = lambda.entry; - const parts = source.split(/[\\/]/); - const idx = parts.lastIndexOf("src"); - if (idx > 0) { - set.add(parts.slice(0, idx + 1).join("/")); - } else { - set.add(dirname(source)); - } - } - return [...set]; +export function defaultWatchPaths( + lambdas: Readonly> +): string[] { + const set = new Set(); + for (const lambda of Object.values(lambdas)) { + const source = lambda.entry; + const parts = source.split(/[\\/]/); + const idx = parts.lastIndexOf('src'); + if (idx > 0) { + set.add(parts.slice(0, idx + 1).join('/')); + } else { + set.add(dirname(source)); + } + } + return [...set]; } /** @@ -38,15 +43,15 @@ export function defaultWatchPaths(lambdas: Readonly> * Routes with fewer path parameter placeholders come first; ties are broken by descending path length. */ export function sortRoutesBySpecificity( - routes: Readonly>, + routes: Readonly> ): RouteEntry[] { - return Object.entries(routes).sort(([, a], [, b]) => { - const score = (path: string): [number, number] => [ - (path.match(/\{[^}]+\}/g) ?? []).length, - -path.length, - ]; - const [pa, la] = score(a.path); - const [pb, lb] = score(b.path); - return pa - pb || la - lb; - }); + return Object.entries(routes).sort(([, a], [, b]) => { + const score = (path: string): [number, number] => [ + (path.match(/\{[^}]+\}/g) ?? []).length, + -path.length + ]; + const [pa, la] = score(a.path); + const [pb, lb] = score(b.path); + return pa - pb || la - lb; + }); } diff --git a/packages/aws-cdk-local-lambda/src/utils/manifest.ts b/packages/aws-cdk-local-lambda/src/utils/manifest.ts index 8f82b74..46cd83d 100644 --- a/packages/aws-cdk-local-lambda/src/utils/manifest.ts +++ b/packages/aws-cdk-local-lambda/src/utils/manifest.ts @@ -7,9 +7,9 @@ export type WarningLogger = (message: string) => void; * @example `"index.handler"` → `"handler"`, `"handler"` → `"handler"` */ export function splitHandler(handlerProp: unknown): string { - if (typeof handlerProp !== "string") return "handler"; - const idx = handlerProp.lastIndexOf("."); - return idx === -1 ? handlerProp : handlerProp.slice(idx + 1); + if (typeof handlerProp !== 'string') return 'handler'; + const idx = handlerProp.lastIndexOf('.'); + return idx === -1 ? handlerProp : handlerProp.slice(idx + 1); } /** @@ -17,31 +17,36 @@ export function splitHandler(handlerProp: unknown): string { * * @example `stageKey("MyStack-prod-MyFn", "prod")` → `"MyFn"` */ -export function stageKey(functionName: string, stage: string | undefined): string { - if (!stage) return functionName; - const needle = `-${stage}-`; - const idx = functionName.indexOf(needle); - if (idx === -1) return functionName; - return functionName.slice(idx + needle.length); +export function stageKey( + functionName: string, + stage: string | undefined +): string { + if (!stage) return functionName; + const needle = `-${stage}-`; + const idx = functionName.indexOf(needle); + if (idx === -1) return functionName; + return functionName.slice(idx + needle.length); } /** Normalises a raw CloudFormation `Environment.Variables` object to `Record`, stringifying any non-string values. */ export function normalizeEnv( - raw: unknown, - onWarning: WarningLogger | undefined, - logicalId: string, + raw: unknown, + onWarning: WarningLogger | undefined, + logicalId: string ): Readonly> { - if (!raw || typeof raw !== "object") return {}; + if (!raw || typeof raw !== 'object') return {}; - const obj = raw as Record; - const out: Record = {}; - for (const [key, value] of Object.entries(obj)) { - if (typeof value === "string") { - out[key] = value; - continue; - } - onWarning?.(`extractManifest: ${logicalId} env var "${key}" is not a string; stringifying.`); - out[key] = JSON.stringify(value); - } - return out; + const obj = raw as Record; + const out: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (typeof value === 'string') { + out[key] = value; + continue; + } + onWarning?.( + `extractManifest: ${logicalId} env var "${key}" is not a string; stringifying.` + ); + out[key] = JSON.stringify(value); + } + return out; } diff --git a/packages/aws-cdk-local-lambda/src/utils/object.ts b/packages/aws-cdk-local-lambda/src/utils/object.ts index 27095bc..8287efe 100644 --- a/packages/aws-cdk-local-lambda/src/utils/object.ts +++ b/packages/aws-cdk-local-lambda/src/utils/object.ts @@ -1,8 +1,8 @@ /** Returns a shallow copy of `record` with keys sorted alphabetically. */ export function sortRecord(record: Record): Record { - const out: Record = {}; - for (const key of Object.keys(record).sort()) { - out[key] = record[key]!; - } - return out; + const out: Record = {}; + for (const key of Object.keys(record).sort()) { + out[key] = record[key]!; + } + return out; } diff --git a/packages/aws-cdk-local-lambda/src/utils/path-search.ts b/packages/aws-cdk-local-lambda/src/utils/path-search.ts index bc10874..723529e 100644 --- a/packages/aws-cdk-local-lambda/src/utils/path-search.ts +++ b/packages/aws-cdk-local-lambda/src/utils/path-search.ts @@ -1,25 +1,30 @@ -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; /** Walks up the directory tree from `fromAbsFile` and returns the first ancestor path that contains `fileName`, or `undefined` if not found. */ -export function findNearestNamedFile(fromAbsFile: string, fileName: string): string | undefined { - let dir = dirname(fromAbsFile); - for (;;) { - const candidate = join(dir, fileName); - if (existsSync(candidate)) return candidate; - const parent = dirname(dir); - if (parent === dir) return undefined; - dir = parent; - } +export function findNearestNamedFile( + fromAbsFile: string, + fileName: string +): string | undefined { + let dir = dirname(fromAbsFile); + for (;;) { + const candidate = join(dir, fileName); + if (existsSync(candidate)) return candidate; + const parent = dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } } /** Walks up the directory tree from `fromAbsFile` and returns the first ancestor that contains a `node_modules` directory, or `undefined` if not found. */ -export function findAncestorWithNodeModules(fromAbsFile: string): string | undefined { - let dir = dirname(fromAbsFile); - for (;;) { - if (existsSync(join(dir, "node_modules"))) return dir; - const parent = dirname(dir); - if (parent === dir) return undefined; - dir = parent; - } +export function findAncestorWithNodeModules( + fromAbsFile: string +): string | undefined { + let dir = dirname(fromAbsFile); + for (;;) { + if (existsSync(join(dir, 'node_modules'))) return dir; + const parent = dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } } diff --git a/packages/aws-cdk-local-lambda/src/utils/request-shape.ts b/packages/aws-cdk-local-lambda/src/utils/request-shape.ts index 876f381..c455bdb 100644 --- a/packages/aws-cdk-local-lambda/src/utils/request-shape.ts +++ b/packages/aws-cdk-local-lambda/src/utils/request-shape.ts @@ -1,40 +1,44 @@ -import type { Request } from "express"; +import type { Request } from 'express'; /** Converts Express request headers to a flat `Record` with lower-cased keys, joining multi-value headers with `","`. */ -export function lowerCaseHeaderMap(headers: Request["headers"]): Record { - const out: Record = {}; - for (const [key, value] of Object.entries(headers)) { - if (value === undefined) continue; - const normalized = Array.isArray(value) ? value.join(",") : value; - out[key.toLowerCase()] = normalized; - } - return out; +export function lowerCaseHeaderMap( + headers: Request['headers'] +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) continue; + const normalized = Array.isArray(value) ? value.join(',') : value; + out[key.toLowerCase()] = normalized; + } + return out; } /** Extracts query string parameters from an Express request as a flat string map, or `null` if there are none. */ export function queryFromRequest(req: Request): Record | null { - const qs = req.query as Record; - const entries = Object.entries(qs); - if (entries.length === 0) return null; + const qs = req.query as Record; + const entries = Object.entries(qs); + if (entries.length === 0) return null; - const out: Record = {}; - for (const [key, value] of entries) { - if (value === undefined) continue; - out[key] = Array.isArray(value) ? value.join(",") : String(value); - } - return out; + const out: Record = {}; + for (const [key, value] of entries) { + if (value === undefined) continue; + out[key] = Array.isArray(value) ? value.join(',') : String(value); + } + return out; } /** Extracts Express path parameters (e.g. `:id`) as a flat string map, or `null` if there are none. */ -export function pathParamsFromRequest(req: Request): Record | null { - const params = (req.params ?? {}) as Record; - const entries = Object.entries(params); - if (entries.length === 0) return null; +export function pathParamsFromRequest( + req: Request +): Record | null { + const params = (req.params ?? {}) as Record; + const entries = Object.entries(params); + if (entries.length === 0) return null; - const out: Record = {}; - for (const [key, value] of entries) { - if (value === undefined) continue; - out[key] = String(value); - } - return Object.keys(out).length === 0 ? null : out; + const out: Record = {}; + for (const [key, value] of entries) { + if (value === undefined) continue; + out[key] = String(value); + } + return Object.keys(out).length === 0 ? null : out; } diff --git a/packages/aws-cdk-local-lambda/tsup.config.ts b/packages/aws-cdk-local-lambda/tsup.config.ts index a8be185..565ffa0 100644 --- a/packages/aws-cdk-local-lambda/tsup.config.ts +++ b/packages/aws-cdk-local-lambda/tsup.config.ts @@ -1,18 +1,18 @@ -import { defineConfig } from "tsup"; +import { defineConfig } from 'tsup'; export default defineConfig({ - entry: { - index: "src/index.ts", - "extract/index": "src/extract/index.ts", - "server/index": "src/server/index.ts", - types: "src/types.ts", - "bin/cdk-local": "src/bin/cdk-local.ts", - }, - format: ["esm", "cjs"], - sourcemap: true, - clean: true, - target: "node22", - splitting: false, - shims: true, - outDir: "dist", + entry: { + index: 'src/index.ts', + 'extract/index': 'src/extract/index.ts', + 'server/index': 'src/server/index.ts', + types: 'src/types.ts', + 'bin/cdk-local': 'src/bin/cdk-local.ts' + }, + format: ['esm', 'cjs'], + sourcemap: true, + clean: true, + target: 'node22', + splitting: false, + shims: true, + outDir: 'dist' });