Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 6 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
name: CI

on:
push:
branches:
- develop
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
ci:
name: Lint, Typecheck, Build
name: Lint, Typecheck, Build, Test
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand All @@ -34,12 +32,7 @@ jobs:
run: pnpm install --frozen-lockfile

- name: Set Nx base
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "NX_BASE=origin/${{ github.base_ref }}" >> $GITHUB_ENV
else
echo "NX_BASE=${{ github.event.before }}" >> $GITHUB_ENV
fi
run: echo "NX_BASE=origin/${{ github.base_ref }}" >> $GITHUB_ENV

- name: Lint
run: pnpx nx affected --target=lint --base=$NX_BASE --head=HEAD
Expand All @@ -49,3 +42,6 @@ jobs:

- name: Build
run: pnpx nx affected --target=build --base=$NX_BASE --head=HEAD

- name: Test aws-cdk-local-lambda
run: pnpm --filter aws-cdk-local-lambda test
2 changes: 2 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
pnpx nano-staged
pnpm --filter aws-cdk-local-lambda build
pnpm --filter aws-cdk-local-lambda test
2 changes: 1 addition & 1 deletion examples/simple-crud/cdk/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as cdk from "aws-cdk-lib";
import { SimpleCrudStack } from "./simple-crud-stack.js";
import { SimpleCrudStack } from "./simple-crud-stack";

const app = new cdk.App();

Expand Down
2 changes: 1 addition & 1 deletion examples/simple-crud/lambdas/create-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { PutItemCommand } from "@aws-sdk/client-dynamodb";
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";

import { createDynamoDBClient, errorResponse, getTableName, successResponse } from "./utils.js";
import { createDynamoDBClient, errorResponse, getTableName, successResponse } from "./utils";

const client = createDynamoDBClient();

Expand Down
3 changes: 1 addition & 2 deletions examples/simple-crud/lambdas/get-item.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { GetItemCommand } from "@aws-sdk/client-dynamodb";
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";

import { createDynamoDBClient, errorResponse, getTableName, successResponse } from "./utils.js";
import { createDynamoDBClient, errorResponse, getTableName, successResponse } from "./utils";

const client = createDynamoDBClient();

Expand All @@ -25,7 +25,6 @@ export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayPr
if (!result.Item) {
return errorResponse(404, "Item not found");
}

return successResponse({
id: result.Item.id?.S,
title: result.Item.title?.S,
Expand Down
2 changes: 1 addition & 1 deletion examples/simple-crud/lambdas/upload-attachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
getBucketName,
getTableName,
successResponse,
} from "./utils.js";
} from "./utils";

const dynamodb = createDynamoDBClient();
const s3 = createS3Client();
Expand Down
4 changes: 3 additions & 1 deletion examples/simple-crud/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
"compilerOptions": {
"rootDir": ".",
"outDir": "dist",
"types": ["node"]
"types": ["node"],
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["cdk/**/*.ts", "lambdas/**/*.ts"]
}
14 changes: 13 additions & 1 deletion packages/aws-cdk-local-lambda/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,5 +102,17 @@
},
"repository": {
"url": "https://github.com/tiny-build/aws-cdk-local-lambda"
}
},
"keywords": [
"aws",
"cdk",
"lambda",
"local",
"api-gateway",
"express",
"serverless",
"aws-cdk",
"local-development",
"emulator"
]
}
6 changes: 6 additions & 0 deletions packages/aws-cdk-local-lambda/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
"options": {
"cwd": "packages/aws-cdk-local-lambda"
}
},
"test": {
"command": "pnpm test",
"options": {
"cwd": "packages/aws-cdk-local-lambda"
}
}
}
}
2 changes: 2 additions & 0 deletions packages/aws-cdk-local-lambda/src/constants/http.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
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<SupportedHttpMethod> = new Set(HTTP_METHODS);
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

import { extractManifest } from "./build-manifest.js";
import { extractManifest } from "./build-manifest";

function makeRepo() {
const root = mkdtempSync(join(tmpdir(), "cdk-local-int-"));
Expand Down
21 changes: 21 additions & 0 deletions packages/aws-cdk-local-lambda/src/extract/build-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,36 @@ 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 `-<stage>-` 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;
}

/**
* Parses a CDK-synthesised CloudFormation template and produces a {@link LocalManifest}
* describing every Lambda function and API Gateway route in the stack.
*
* @example
* ```ts
* import { extractManifest } from "aws-cdk-local-lambda/extract";
*
* const manifest = await extractManifest({
* cdkOut: path.resolve("cdk.out"),
* stack: "MyStack",
* });
* ```
*/
export async function extractManifest(opts: ExtractOptions): Promise<LocalManifest> {
const repoRoot = opts.repoRoot ?? process.cwd();
const templatePath = join(opts.cdkOut, `${opts.stack}.template.json`);
Expand Down
6 changes: 6 additions & 0 deletions packages/aws-cdk-local-lambda/src/extract/cfn-types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
/** 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<string, unknown>;
}

/** All resources in a CloudFormation template, keyed by logical resource ID. */
export type CfnResources = Readonly<Record<string, CfnResource>>;

/** 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" &&
Expand All @@ -14,6 +19,7 @@ export function isRef(x: unknown): x is { 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;
Expand Down
4 changes: 2 additions & 2 deletions packages/aws-cdk-local-lambda/src/extract/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export * from "./build-manifest.js";
export type { ParsedMethod } from "./parse-routes.js";
export * from "./build-manifest";
export type { ParsedMethod } from "./parse-routes";
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { buildApiGatewayResourcePaths } from "./parse-api-gateway-paths.js";
import { buildApiGatewayResourcePaths } from "./parse-api-gateway-paths";

describe("buildApiGatewayResourcePaths", () => {
it("builds /hello from root + child resource", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { type CfnResources, isGetAtt, isRef } from "./cfn-types.js";
import { type CfnResources, isGetAtt, isRef } from "./cfn-types";

function isRootParent(parentId: unknown): boolean {
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<string, string> {
const result = new Map<string, string>();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { buildAuthorizerLambdaMap } from "./parse-authorizers.js";
import { buildAuthorizerLambdaMap } from "./parse-authorizers";

describe("buildAuthorizerLambdaMap", () => {
it("maps authorizer logical id to its lambda logical id", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { findLambdaLogicalIdInUri } from "../utils/cfn-uri.js";
import type { CfnResources } from "./cfn-types.js";
import { findLambdaLogicalIdInUri } from "../utils/cfn-uri";
import type { CfnResources } from "./cfn-types";

/**
* 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<string, string> {
const out = new Map<string, string>();
for (const [logicalId, res] of Object.entries(resources)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { parseApiGatewayMethods } from "./parse-routes.js";
import { parseApiGatewayMethods } from "./parse-routes";

describe("parseApiGatewayMethods", () => {
const helloResources = {
Expand Down
15 changes: 12 additions & 3 deletions packages/aws-cdk-local-lambda/src/extract/parse-routes.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import { findLambdaLogicalIdInUri } from "../utils/cfn-uri.js";
import { type CfnResources, isRef } from "./cfn-types.js";
import { buildApiGatewayResourcePaths } from "./parse-api-gateway-paths.js";
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;
}

/**
* 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[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

import { recoverEntry } from "./recover-entry.js";
import { recoverEntry } from "./recover-entry";

function makeAsset(indexJs: string, fsLayout: Record<string, string>) {
const root = mkdtempSync(join(tmpdir(), "recover-entry-"));
Expand Down
11 changes: 11 additions & 0 deletions packages/aws-cdk-local-lambda/src/extract/recover-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,24 @@ import { isAbsolute, join, resolve } from "node:path";

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;
}

/**
* Attempts to recover the original TypeScript entry file path from a CDK-bundled `index.js`.
*
* CDK embeds source-map markers in bundles. This function reads them and resolves the first
* 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

import { resolveAssetDir } from "./resolve-asset.js";
import { resolveAssetDir } from "./resolve-asset";

function makeFixture() {
const dir = mkdtempSync(join(tmpdir(), "cdk-local-test-"));
Expand Down
10 changes: 10 additions & 0 deletions packages/aws-cdk-local-lambda/src/extract/resolve-asset.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
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;
}

Expand Down Expand Up @@ -35,6 +39,12 @@ function extractHash(codeS3Key: unknown): string | null {
return m2?.[1] ?? null;
}

/**
* Resolves the absolute path to a Lambda asset directory from the CDK assets manifest.
*
* Extracts the asset hash from `Code.S3Key`, looks it up in `<stack>.assets.json`,
* 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) {
Expand Down
6 changes: 3 additions & 3 deletions packages/aws-cdk-local-lambda/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export * from "./extract/index.js";
export * from "./server/index.js";
export * from "./types.js";
export * from "./extract/index";
export * from "./server/index";
export * from "./types";
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Request } from "express";

import { describe, expect, it } from "vitest";

import { buildProxyEvent, buildRequestAuthorizerEvent, lambdaContext } from "./apigateway-proxy.js";
import { buildProxyEvent, buildRequestAuthorizerEvent, lambdaContext } from "./apigateway-proxy";

function fakeReq(over: Partial<Request> = {}): Request {
return {
Expand All @@ -25,7 +25,7 @@ describe("apigateway event builders", () => {
});
expect(e.type).toBe("REQUEST");
expect(e.methodArn).toContain("/dev/GET/hello");
expect(e.headers["x-test"]).toBe("v");
expect(e.headers?.["x-test"]).toBe("v");
});

it("builds a proxy event and carries authorizer context", () => {
Expand Down
Loading
Loading