From 3d36b9cc8ce24304c0d3203a1a8b7829c6a37a7e Mon Sep 17 00:00:00 2001 From: Gary Sassano <10464497+garysassano@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:44:22 +0200 Subject: [PATCH 1/7] feat(lib): add native Node.js Lambda constructs with Rolldown --- README.md | 2 + .../typescript/aws-nodejs-function/README.md | 15 + .../typescript/aws-nodejs-function/cdktf.json | 5 + .../typescript/aws-nodejs-function/main.ts | 17 + .../aws-nodejs-function/package.json | 15 + .../aws-nodejs-function/src/hello.ts | 7 + knip.jsonc | 8 + packages/@cdktn/aws-lambda-nodejs/README.md | 135 +++++++ .../aws-lambda-nodejs/eslint.config.mjs | 1 + .../@cdktn/aws-lambda-nodejs/jest.config.js | 5 + .../@cdktn/aws-lambda-nodejs/package.json | 23 ++ .../@cdktn/aws-lambda-nodejs/src/index.ts | 233 ++++++++++++ .../aws-lambda-nodejs/test/function.test.ts | 209 +++++++++++ .../@cdktn/aws-lambda-nodejs/tsconfig.json | 5 + packages/@cdktn/bundler-nodejs/README.md | 21 ++ .../@cdktn/bundler-nodejs/eslint.config.mjs | 1 + packages/@cdktn/bundler-nodejs/jest.config.js | 5 + packages/@cdktn/bundler-nodejs/package.json | 23 ++ packages/@cdktn/bundler-nodejs/runner.mjs | 147 ++++++++ packages/@cdktn/bundler-nodejs/src/index.ts | 140 +++++++ .../@cdktn/bundler-nodejs/test/asset.test.ts | 261 +++++++++++++ packages/@cdktn/bundler-nodejs/tsconfig.json | 14 + pnpm-lock.yaml | 349 +++++++++++++----- tools/pack-node-package.mjs | 11 + 24 files changed, 1566 insertions(+), 86 deletions(-) create mode 100644 examples/typescript/aws-nodejs-function/README.md create mode 100644 examples/typescript/aws-nodejs-function/cdktf.json create mode 100644 examples/typescript/aws-nodejs-function/main.ts create mode 100644 examples/typescript/aws-nodejs-function/package.json create mode 100644 examples/typescript/aws-nodejs-function/src/hello.ts create mode 100644 packages/@cdktn/aws-lambda-nodejs/README.md create mode 100644 packages/@cdktn/aws-lambda-nodejs/eslint.config.mjs create mode 100644 packages/@cdktn/aws-lambda-nodejs/jest.config.js create mode 100644 packages/@cdktn/aws-lambda-nodejs/package.json create mode 100644 packages/@cdktn/aws-lambda-nodejs/src/index.ts create mode 100644 packages/@cdktn/aws-lambda-nodejs/test/function.test.ts create mode 100644 packages/@cdktn/aws-lambda-nodejs/tsconfig.json create mode 100644 packages/@cdktn/bundler-nodejs/README.md create mode 100644 packages/@cdktn/bundler-nodejs/eslint.config.mjs create mode 100644 packages/@cdktn/bundler-nodejs/jest.config.js create mode 100644 packages/@cdktn/bundler-nodejs/package.json create mode 100644 packages/@cdktn/bundler-nodejs/runner.mjs create mode 100644 packages/@cdktn/bundler-nodejs/src/index.ts create mode 100644 packages/@cdktn/bundler-nodejs/test/asset.test.ts create mode 100644 packages/@cdktn/bundler-nodejs/tsconfig.json create mode 100644 tools/pack-node-package.mjs diff --git a/README.md b/README.md index dedb5c4a6..61e81f284 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ CDKTN includes two packages: - [cdktn-cli](./packages/cdktn-cli) - A CLI that allows users to run commands to initialize, import, and synthesize CDK Terrain applications. - [cdktn](./packages/cdktn) - A library for defining Terraform resources using programming constructs. +First-party TypeScript/JavaScript packages add [Node.js Lambda functions](./packages/@cdktn/aws-lambda-nodejs) and reusable [native Node.js bundle assets](./packages/@cdktn/bundler-nodejs). + ## Get Started - [Overview](https://cdktn.io/docs) diff --git a/examples/typescript/aws-nodejs-function/README.md b/examples/typescript/aws-nodejs-function/README.md new file mode 100644 index 000000000..83c932fe2 --- /dev/null +++ b/examples/typescript/aws-nodejs-function/README.md @@ -0,0 +1,15 @@ +# Ship a Node.js Lambda + +This example deploys a TypeScript handler with one `NodejsFunction` construct. Its Rolldown bundle, ZIP, code hash, role and log group are created automatically. + +From the repository root, build the first-party packages: + +```sh +pnpm exec nx run-many -t build -p @cdktn/aws-lambda-nodejs cdktn-cli +cd examples/typescript/aws-nodejs-function +pnpm run synth +``` + +To deploy into your configured AWS account in `eu-central-1`, run `pnpm run deploy`. Use `cdktn destroy` to remove the example's resources afterwards. To run the app directly, use `node main.ts` on Node.js 22.18 or newer. + +[`main.ts`](main.ts) contains the infrastructure and [`src/hello.ts`](src/hello.ts) is the deployed handler. See the [package guide](../../../packages/@cdktn/aws-lambda-nodejs/README.md) for runtime settings, permissions, existing roles, VPC support and bundling options. diff --git a/examples/typescript/aws-nodejs-function/cdktf.json b/examples/typescript/aws-nodejs-function/cdktf.json new file mode 100644 index 000000000..5275d2f85 --- /dev/null +++ b/examples/typescript/aws-nodejs-function/cdktf.json @@ -0,0 +1,5 @@ +{ + "language": "typescript", + "app": "node main.ts", + "sendCrashReports": "false" +} diff --git a/examples/typescript/aws-nodejs-function/main.ts b/examples/typescript/aws-nodejs-function/main.ts new file mode 100644 index 000000000..d1887c34e --- /dev/null +++ b/examples/typescript/aws-nodejs-function/main.ts @@ -0,0 +1,17 @@ +// Copyright (c) OpenConstructs +// SPDX-License-Identifier: MPL-2.0 +import { App, TerraformOutput, TerraformStack } from "cdktn"; +import { AwsProvider } from "@cdktn/provider-aws/lib/provider/index.js"; +import { NodejsFunction } from "@cdktn/aws-lambda-nodejs"; + +const app = new App(); +const stack = new TerraformStack(app, "hello"); +new AwsProvider(stack, "aws", { region: "eu-central-1" }); + +const hello = new NodejsFunction(stack, "hello", { + entry: "src/hello.ts", + environment: { GREETING: "Hello from CDK Terrain" }, +}); + +new TerraformOutput(stack, "function_name", { value: hello.functionName }); +app.synth(); diff --git a/examples/typescript/aws-nodejs-function/package.json b/examples/typescript/aws-nodejs-function/package.json new file mode 100644 index 000000000..47bdac1f4 --- /dev/null +++ b/examples/typescript/aws-nodejs-function/package.json @@ -0,0 +1,15 @@ +{ + "name": "aws-nodejs-function", + "private": true, + "version": "0.0.0", + "type": "module", + "engines": { "node": ">=22.18.0" }, + "scripts": { "synth": "cdktn synth", "deploy": "cdktn deploy" }, + "dependencies": { + "@cdktn/aws-lambda-nodejs": "workspace:*", + "@cdktn/provider-aws": "25.4.0", + "cdktn": "workspace:*", + "constructs": "10.6.0" + }, + "devDependencies": { "cdktn-cli": "workspace:*" } +} diff --git a/examples/typescript/aws-nodejs-function/src/hello.ts b/examples/typescript/aws-nodejs-function/src/hello.ts new file mode 100644 index 000000000..0a770b1d7 --- /dev/null +++ b/examples/typescript/aws-nodejs-function/src/hello.ts @@ -0,0 +1,7 @@ +// Copyright (c) OpenConstructs +// SPDX-License-Identifier: MPL-2.0 +export async function handler(event: { name?: string } = {}) { + return { + message: `${process.env.GREETING ?? "Hello"}, ${event.name ?? "world"}!`, + }; +} diff --git a/knip.jsonc b/knip.jsonc index 13e602d49..4c71d6ab1 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -48,6 +48,14 @@ "packages/@cdktn/commons": { "project": ["src/**/*.ts"] }, + "packages/@cdktn/bundler-nodejs": { + "entry": ["src/index.ts", "runner.mjs"], + "project": ["src/**/*.ts", "test/**/*.ts", "runner.mjs"] + }, + "packages/@cdktn/aws-lambda-nodejs": { + "entry": ["src/index.ts"], + "project": ["src/**/*.ts", "test/**/*.ts"] + }, "packages/@cdktn/hcl-tools": { "project": ["src/**/*.ts"] }, diff --git a/packages/@cdktn/aws-lambda-nodejs/README.md b/packages/@cdktn/aws-lambda-nodejs/README.md new file mode 100644 index 000000000..838cc7804 --- /dev/null +++ b/packages/@cdktn/aws-lambda-nodejs/README.md @@ -0,0 +1,135 @@ +# Node.js functions for CDK Terrain + +Ship a TypeScript or JavaScript Lambda by pointing at its entry file: + +```ts +import { NodejsFunction } from "@cdktn/aws-lambda-nodejs"; + +const hello = new NodejsFunction(stack, "hello", { + entry: "src/hello.ts", +}); +``` + +```ts +// src/hello.ts +export async function handler(event: { name?: string }) { + return { message: `Hello ${event.name ?? "world"}` }; +} +``` + +Run `cdktn deploy`. The construct bundles the handler and its dependencies, creates a deterministic ZIP, stages it in the synthesized stack, and configures the Lambda, execution role, logging permissions, and log group. Terraform uploads the ZIP during apply. There is no packaging provider, prebuild script, asset bucket, or bootstrap step. + +## Installation + +This feature branch adds two first-party packages: `@cdktn/aws-lambda-nodejs` and the reusable `@cdktn/bundler-nodejs`. They are not published yet. Inside this monorepo, use the [working example](../../../examples/typescript/aws-nodejs-function). Release packaging uses the same workspace version and `dist/js` convention as the other CDKTN packages. + +After publication: + +```sh +pnpm add @cdktn/aws-lambda-nodejs @cdktn/provider-aws cdktn constructs +``` + +The packages currently expose TypeScript/JavaScript APIs, including CommonJS and native ESM imports. Node.js 22.12 or newer is required on the synthesis machine. They use the AWS provider's generated classes and require `@cdktn/provider-aws` 25.4 or a compatible 25.x release. Other generated provider classes and their tokens remain usable in the same stack. + +## Defaults + +| Setting | Default | +| ------------------- | ------------------------------------------------------------------------- | +| Runtime | `nodejs24.x` | +| Architecture | `arm64` | +| Memory / timeout | 512 MiB / 10 seconds | +| Source export | `handler` | +| Bundler | Rolldown 1.2.7, using its native Rust binding | +| Output | Minified ESM, with lazy dynamic imports preserved | +| Dependencies | Bundled, including installed AWS SDK clients | +| Source maps | Included, with `--enable-source-maps` in `NODE_OPTIONS` | +| Log retention | 30 days | +| Execution role | Automatically created, with permissions scoped to the log group's streams | +| Deployment identity | SHA-256 of the actual ZIP bytes | + +Node.js 24 is the latest stable managed Lambda runtime; Node.js 26 is currently a public preview. The runtime is configurable, and the bundler's syntax target follows it. See [AWS's runtime documentation](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html) and [preview announcement](https://aws.amazon.com/about-aws/whats-new/2026/08/aws-lambda-node-js-python-public-preview/). + +## Configure the function + +`NodejsFunction` extends the generated `LambdaFunction`. Its outputs, provider support, lifecycle controls, setters, and Terraform overrides remain available. Packaging inputs are owned by the construct. Define multiple functions with ordinary language loops rather than Terraform `count` or `forEach`. + +```ts +const worker = new NodejsFunction(stack, "worker", { + entry: "src/worker.ts", + handler: "processEvent", + runtime: "nodejs24.x", + architectures: ["x86_64"], + memorySize: 1024, + timeout: 30, + environment: { TABLE_NAME: table.name }, + initialPolicy: [ + { + actions: ["dynamodb:GetItem", "dynamodb:PutItem"], + resources: [table.arn], + }, + ], +}); + +worker.addEnvironment("STAGE", "production"); +worker.addToRolePolicy({ + actions: ["s3:GetObject"], + resources: [`${bucket.arn}/*`], +}); + +new TerraformOutput(stack, "functionArn", { value: worker.arn }); +``` + +You can supply `role` as an existing execution-role ARN and `logGroup` as a `CloudwatchLogGroup`. Manage permissions on an existing role yourself. A `provider` alias is forwarded to every owned AWS resource; a per-resource `region` is forwarded to the Lambda and log group. Supplying `vpcConfig` adds the [network-interface permissions required by Lambda](https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc.html#configuration-vpc-permissions) to an automatically created role. + +## Customize bundling + +Relative paths resolve from `projectRoot`, the directory containing `cdktf.json`, or the current directory if there is no configuration file. Rolldown reads the handler's TypeScript configuration, including path aliases. TypeScript is transpiled; run your application's type checker separately. + +```ts +new NodejsFunction(stack, "worker", { + entry: "src/worker.ts", + bundling: { + format: "esm", // or "cjs" + minify: true, + sourceMap: true, + tsconfig: "tsconfig.lambda.json", + define: { "process.env.BUILD_MODE": JSON.stringify("production") }, + copyFiles: [{ from: "templates", to: "templates" }], + externalModules: ["package-from-my-layer"], + }, +}); +``` + +An external package, including its subpaths, must be supplied by a layer or explicitly copied into `node_modules` in the ZIP. Dependencies are never silently externalized when resolution fails. `copyFiles` rejects traversal, collisions, and symlinks; provide a prepared directory containing real files. Native addons must be built for the selected Lambda architecture and Amazon Linux runtime, then supplied this way or through a layer. Automatic native dependency installation and Docker builds are outside this first implementation. + +For Rollup-compatible plugins or advanced Rolldown transforms, supply `bundling.configFile` pointing to a JavaScript configuration module: + +```js +// rolldown.lambda.config.mjs +export default { + plugins: [ + /* Rolldown / compatible Rollup plugins */ + ], + output: { keepNames: true }, +}; +``` + +The construct controls the entry point, Node platform, target, externals, output paths, format, minification and source maps. Other input/output options are passed through. This configuration module runs at synthesis; handler code does not. + +## Build and deployment behavior + +Bundling happens while constructing the app, because output hashes require the completed artifact. Every construction rebuilds the import graph; there is no source-only cache that can miss a changed dependency, config file or plugin. Unrelated files do not change the digest. ZIP entries have stable ordering, timestamps and permissions, and source maps use relative paths. Intermediate ZIPs live under `cdktf.out/.nodejs-assets`; `TerraformAsset` copies the selected ZIP into the stack's `assets` directory. The entire build output can be removed with the app's output directory. + +The synthesized stack contains the complete artifact. Subsequent Terraform plan/apply can consume that stack directory without source files, Rolldown, Node.js, or a separate packaging provider. Preserve the stack's assets when transferring it to a remote runner. + +This implementation uses Lambda's direct ZIP upload path and is subject to its [deployment package limits](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html). S3 publishing for larger artifacts, JSII language bindings, deployment/bootstrap services, and a watch server are not included. It composes with the existing `TerraformAsset` API and does not depend on the pending asset-pipeline PRs ([#380](https://github.com/open-constructs/cdk-terrain/issues/380)). + +## Development validation + +```sh +pnpm exec nx run-many -t build test lint -p @cdktn/bundler-nodejs @cdktn/aws-lambda-nodejs +pnpm --filter @cdktn/bundler-nodejs run package:js +pnpm --filter @cdktn/aws-lambda-nodejs run package:js +``` + +Tests run real native builds and invoke extracted ESM/CommonJS handlers. They cover dependency resolution, TypeScript aliases, lazy imports, top-level await, maps, reproducible ZIP identity, plugin/copy inputs, failure diagnostics, provider aliases, IAM dependencies, existing roles, and VPC permissions. diff --git a/packages/@cdktn/aws-lambda-nodejs/eslint.config.mjs b/packages/@cdktn/aws-lambda-nodejs/eslint.config.mjs new file mode 100644 index 000000000..1fb723435 --- /dev/null +++ b/packages/@cdktn/aws-lambda-nodejs/eslint.config.mjs @@ -0,0 +1 @@ +export { default } from "../../../eslint.config.mjs"; diff --git a/packages/@cdktn/aws-lambda-nodejs/jest.config.js b/packages/@cdktn/aws-lambda-nodejs/jest.config.js new file mode 100644 index 000000000..b75db5209 --- /dev/null +++ b/packages/@cdktn/aws-lambda-nodejs/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + displayName: '@cdktn/aws-lambda-nodejs', + preset: '../../../jest.preset.js', + transform: { '^.+\\.tsx?$': ['@swc/jest', { jsc: { parser: { syntax: 'typescript' }, target: 'es2022' }, module: { type: 'commonjs' } }] }, +}; diff --git a/packages/@cdktn/aws-lambda-nodejs/package.json b/packages/@cdktn/aws-lambda-nodejs/package.json new file mode 100644 index 000000000..8382344c2 --- /dev/null +++ b/packages/@cdktn/aws-lambda-nodejs/package.json @@ -0,0 +1,23 @@ +{ + "name": "@cdktn/aws-lambda-nodejs", + "version": "0.0.0", + "description": "Ship Node.js and TypeScript Lambda functions with CDK Terrain", + "license": "MPL-2.0", + "author": { "name": "OpenConstructs", "url": "https://github.com/open-constructs" }, + "repository": { "type": "git", "url": "https://github.com/open-constructs/cdk-terrain.git", "directory": "packages/@cdktn/aws-lambda-nodejs" }, + "publishConfig": { "access": "public" }, + "main": "build/index.js", + "types": "build/index.d.ts", + "exports": { ".": { "types": "./build/index.d.ts", "default": "./build/index.js" } }, + "files": ["build"], + "engines": { "node": ">=22.12.0" }, + "scripts": { + "build": "tsc", + "package": "node ../../../tools/pack-node-package.mjs", + "package:js": "node ../../../tools/pack-node-package.mjs" + }, + "nx": { "tags": ["unit-test"] }, + "dependencies": { "@cdktn/bundler-nodejs": "workspace:*" }, + "peerDependencies": { "@cdktn/provider-aws": "^25.4.0", "cdktn": "^0.24.0", "constructs": ">=10.6.0 <10.8.0" }, + "devDependencies": { "@cdktn/provider-aws": "25.4.0", "@types/node": "22.20.1", "cdktn": "workspace:*", "constructs": "10.6.0", "typescript": "^5.0.4" } +} diff --git a/packages/@cdktn/aws-lambda-nodejs/src/index.ts b/packages/@cdktn/aws-lambda-nodejs/src/index.ts new file mode 100644 index 000000000..e3469b8fa --- /dev/null +++ b/packages/@cdktn/aws-lambda-nodejs/src/index.ts @@ -0,0 +1,233 @@ +// Copyright (c) OpenConstructs +// SPDX-License-Identifier: MPL-2.0 +import { createHash } from "node:crypto"; +import { Construct } from "constructs"; +import { dependable, Fn } from "cdktn"; +import { NodejsAsset, NodejsBundlingOptions } from "@cdktn/bundler-nodejs"; +import { + LambdaFunction, + LambdaFunctionConfig, + LambdaFunctionLoggingConfig, +} from "@cdktn/provider-aws/lib/lambda-function/index.js"; +import { IamRole } from "@cdktn/provider-aws/lib/iam-role/index.js"; +import { IamRolePolicy } from "@cdktn/provider-aws/lib/iam-role-policy/index.js"; +import { CloudwatchLogGroup } from "@cdktn/provider-aws/lib/cloudwatch-log-group/index.js"; + +export { + NodejsAsset, + NodejsAssetProps, + NodejsBundlingOptions, + CopyFile, +} from "@cdktn/bundler-nodejs"; + +/** An execution-role permission, with Terraform tokens supported in values. */ +export interface PolicyStatement { + readonly actions: string[]; + readonly resources: string[]; + readonly effect?: "Allow" | "Deny"; + readonly conditions?: Record>; +} + +/** Lambda options, with code packaging and execution-role setup handled for you. */ +export interface NodejsFunctionProps extends Omit< + LambdaFunctionConfig, + | "filename" + | "imageUri" + | "s3Bucket" + | "s3Key" + | "s3ObjectVersion" + | "sourceCodeHash" + | "codeSha256" + | "packageType" + | "handler" + | "role" + | "functionName" + | "environment" + | "loggingConfig" + | "count" + | "forEach" +> { + /** TypeScript or JavaScript source. Relative to projectRoot. */ + readonly entry: string; + /** Export in the entry file. @default "handler" */ + readonly handler?: string; + /** @default directory containing cdktf.json, or cwd */ + readonly projectRoot?: string; + /** @default generated from the construct path */ + readonly functionName?: string; + /** Existing execution-role ARN. Otherwise a role with scoped logging permissions is created. */ + readonly role?: string; + readonly environment?: Record; + readonly bundling?: NodejsBundlingOptions; + /** Permissions to add to the automatically created execution role. */ + readonly initialPolicy?: PolicyStatement[]; + /** Existing log group. Otherwise a log group with 30-day retention is created. */ + readonly logGroup?: CloudwatchLogGroup; + /** Retention for the created log group; 0 means indefinite. @default 30 */ + readonly logRetentionDays?: number; + readonly loggingConfig?: Omit; +} + +/** A Node.js Lambda with native bundling, deterministic code assets, IAM and logs. */ +export class NodejsFunction extends LambdaFunction { + public readonly code: NodejsAsset; + public readonly executionRole?: IamRole; + public readonly logGroup: CloudwatchLogGroup; + private readonly executionPolicy?: IamRolePolicy; + private readonly statements: PolicyStatement[] = []; + + constructor(scope: Construct, id: string, props: NodejsFunctionProps) { + const { + entry, + handler, + projectRoot, + bundling, + environment, + initialPolicy, + logGroup, + logRetentionDays, + loggingConfig, + ...lambda + } = props; + const runtime = props.runtime ?? "nodejs24.x"; + const runtimeMatch = /^nodejs(\d+)\.x$/.exec(runtime); + if (!runtimeMatch) + throw new Error( + `NodejsFunction requires a concrete Node.js runtime, received ${runtime}`, + ); + if (props.role && initialPolicy?.length) + throw new Error( + "initialPolicy requires an automatically created execution role. Add permissions to your existing role directly.", + ); + + const functionName = props.functionName ?? uniqueName(scope, id); + const variables = { ...environment }; + if (bundling?.sourceMap !== false) { + const options = variables.NODE_OPTIONS ?? ""; + variables.NODE_OPTIONS = options.includes("--enable-source-maps") + ? options + : `${options} --enable-source-maps`.trim(); + } + super(scope, id, { + architectures: ["arm64"], + memorySize: 512, + timeout: 10, + ...lambda, + runtime, + functionName, + // Assigned below once this construct exists to own its role. + role: props.role ?? "", + environment: Object.keys(variables).length ? { variables } : undefined, + packageType: "Zip", + }); + + this.code = new NodejsAsset(this, "Code", { + entry, + handler, + projectRoot, + bundling, + target: `node${runtimeMatch[1]}`, + }); + this.filename = this.code.path; + this.sourceCodeHash = this.code.sourceCodeHash; + this.handler = this.code.handler; + + this.logGroup = + logGroup ?? + new CloudwatchLogGroup(this, "LogGroup", { + name: `/aws/lambda/${functionName}`, + retentionInDays: logRetentionDays ?? 30, + provider: props.provider, + region: props.region, + tags: props.tags, + }); + this.putLoggingConfig({ + logFormat: "Text", + ...loggingConfig, + logGroup: this.logGroup.name, + }); + this.dependsOn = [...(this.dependsOn ?? []), dependable(this.logGroup)]; + + if (!props.role) { + this.executionRole = new IamRole(this, "ExecutionRole", { + namePrefix: `${uniqueName(scope, id).slice(0, 31)}-`, + assumeRolePolicy: Fn.jsonencode({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "lambda.amazonaws.com" }, + Action: "sts:AssumeRole", + }, + ], + }), + provider: props.provider, + tags: props.tags, + }); + this.role = this.executionRole.arn; + this.statements.push({ + actions: ["logs:CreateLogStream", "logs:PutLogEvents"], + resources: [`${this.logGroup.arn}:*`], + }); + if (props.vpcConfig) { + this.statements.push({ + actions: [ + "ec2:CreateNetworkInterface", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeSubnets", + "ec2:DeleteNetworkInterface", + "ec2:AssignPrivateIpAddresses", + "ec2:UnassignPrivateIpAddresses", + ], + resources: ["*"], + }); + } + this.statements.push(...(initialPolicy ?? [])); + this.executionPolicy = new IamRolePolicy(this, "ExecutionPolicy", { + role: this.executionRole.name, + policy: this.policyDocument(), + provider: props.provider, + }); + // An ARN reference orders the role, but not its permissions, before Lambda. + this.dependsOn.push(dependable(this.executionPolicy)); + } + } + + /** Add a permission to the execution role created by this function. */ + public addToRolePolicy(statement: PolicyStatement): void { + if (!this.executionPolicy) + throw new Error( + "This function uses an existing role. Add permissions to that role directly.", + ); + this.statements.push(statement); + this.executionPolicy.policy = this.policyDocument(); + } + + /** Add a runtime environment variable without replacing the existing map. */ + public addEnvironment(name: string, value: string): void { + this.putEnvironment({ + variables: { ...this.environmentInput?.variables, [name]: value }, + }); + } + + private policyDocument(): string { + return Fn.jsonencode({ + Version: "2012-10-17", + Statement: this.statements.map((statement) => ({ + Effect: statement.effect ?? "Allow", + Action: statement.actions, + Resource: statement.resources, + ...(statement.conditions ? { Condition: statement.conditions } : {}), + })), + }); + } +} + +function uniqueName(scope: Construct, id: string): string { + const constructPath = `${scope.node.path}/${id}`; + const suffix = createHash("sha256") + .update(constructPath) + .digest("hex") + .slice(0, 8); + return `${constructPath.replace(/[^a-zA-Z0-9_-]/g, "-").slice(-55)}-${suffix}`; +} diff --git a/packages/@cdktn/aws-lambda-nodejs/test/function.test.ts b/packages/@cdktn/aws-lambda-nodejs/test/function.test.ts new file mode 100644 index 000000000..b331cd120 --- /dev/null +++ b/packages/@cdktn/aws-lambda-nodejs/test/function.test.ts @@ -0,0 +1,209 @@ +// Copyright (c) OpenConstructs +// SPDX-License-Identifier: MPL-2.0 +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { createHash } from "node:crypto"; +import { App, TerraformStack, TerraformOutput, Testing } from "cdktn"; +import { AwsProvider } from "@cdktn/provider-aws/lib/provider/index.js"; +import { CloudwatchLogGroup } from "@cdktn/provider-aws/lib/cloudwatch-log-group/index.js"; +import { NodejsFunction, NodejsFunctionProps } from "../src"; + +let root: string; +let app: App; +let stack: TerraformStack; +let props: NodejsFunctionProps; +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "cdktn-lambda-")); + const entry = path.join(root, "handler.ts"); + fs.writeFileSync( + entry, + 'export async function handler() { return "hello"; }', + ); + props = { entry }; + app = new App({ outdir: path.join(root, "out") }); + stack = new TerraformStack(app, "test"); + new AwsProvider(stack, "aws", { region: "eu-central-1" }); +}); +afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + +function resource(config: any, type: string): any { + return Object.values(config.resource[type])[0]; +} + +test("entry alone creates a deployable function, scoped role, log group and matching ZIP hash", () => { + const fn = new NodejsFunction(stack, "hello", props); + new TerraformOutput(stack, "arn", { value: fn.arn }); + app.synth(); + const stackDirectory = path.join(app.outdir, "stacks/test"); + const config = JSON.parse( + fs.readFileSync(path.join(stackDirectory, "cdk.tf.json"), "utf8"), + ); + const lambda = resource(config, "aws_lambda_function"); + const log = resource(config, "aws_cloudwatch_log_group"); + const policy = resource(config, "aws_iam_role_policy"); + expect(Object.keys(config.resource).sort()).toEqual([ + "aws_cloudwatch_log_group", + "aws_iam_role", + "aws_iam_role_policy", + "aws_lambda_function", + ]); + expect(lambda).toMatchObject({ + runtime: "nodejs24.x", + architectures: ["arm64"], + memory_size: 512, + timeout: 10, + package_type: "Zip", + handler: "index.handler", + }); + expect(lambda.function_name).toMatch(/^test-hello-[a-f0-9]{8}$/); + expect(log).toMatchObject({ + name: `/aws/lambda/${lambda.function_name}`, + retention_in_days: 30, + }); + expect(lambda.environment.variables.NODE_OPTIONS).toBe( + "--enable-source-maps", + ); + expect(policy.policy).toContain("logs:CreateLogStream"); + expect(policy.policy).toContain("logs:PutLogEvents"); + expect(policy.policy).not.toContain("logs:CreateLogGroup"); + expect(policy.policy).toContain("aws_cloudwatch_log_group"); + expect(lambda.depends_on.join(" ")).toContain("aws_iam_role_policy"); + expect(lambda.depends_on.join(" ")).toContain("aws_cloudwatch_log_group"); + expect(lambda.depends_on).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^aws_iam_role_policy\.[\w]+$/), + expect.stringMatching(/^aws_cloudwatch_log_group\.[\w]+$/), + ]), + ); + expect(config.output.arn.value).toContain("aws_lambda_function"); + const archive = fs.readFileSync(path.join(stackDirectory, lambda.filename)); + expect(lambda.source_code_hash).toBe( + createHash("sha256").update(archive).digest("base64"), + ); +}); + +test("preserves Lambda options and propagates provider aliases to all owned resources", () => { + const provider = new AwsProvider(stack, "other", { + alias: "other", + region: "us-west-2", + }); + const fn = new NodejsFunction(stack, "hello", { + ...props, + provider, + region: "eu-west-1", + runtime: "nodejs22.x", + architectures: ["x86_64"], + memorySize: 1024, + timeout: 30, + publish: true, + reservedConcurrentExecutions: 4, + tags: { app: "hello" }, + environment: { MESSAGE: "hello", NODE_OPTIONS: "--stack-trace-limit=50" }, + lifecycle: { preventDestroy: true }, + logRetentionDays: 7, + }); + fn.addEnvironment("ADDED", "value"); + fn.addToRolePolicy({ + actions: ["s3:GetObject"], + resources: ["arn:aws:s3:::example/*"], + }); + const config = JSON.parse(Testing.synth(stack)); + const lambda = resource(config, "aws_lambda_function"); + expect(lambda).toMatchObject({ + runtime: "nodejs22.x", + architectures: ["x86_64"], + memory_size: 1024, + timeout: 30, + publish: true, + reserved_concurrent_executions: 4, + lifecycle: { prevent_destroy: true }, + }); + expect(lambda.environment.variables).toEqual({ + MESSAGE: "hello", + NODE_OPTIONS: "--stack-trace-limit=50 --enable-source-maps", + ADDED: "value", + }); + for (const resources of Object.values(config.resource) as any[]) { + for (const value of Object.values(resources) as any[]) + expect(value.provider).toBe("aws.other"); + } + expect(resource(config, "aws_cloudwatch_log_group")).toMatchObject({ + region: "eu-west-1", + retention_in_days: 7, + }); + expect(resource(config, "aws_iam_role_policy").policy).toContain( + "s3:GetObject", + ); +}); + +test("supports existing execution roles and log groups", () => { + const logGroup = new CloudwatchLogGroup(stack, "existingLog", { + name: "/custom/logs", + }); + const fn = new NodejsFunction(stack, "hello", { + ...props, + role: "arn:aws:iam::123456789012:role/existing", + logGroup, + bundling: { sourceMap: false }, + loggingConfig: { logFormat: "JSON" }, + }); + const config = JSON.parse(Testing.synth(stack)); + const lambda = resource(config, "aws_lambda_function"); + expect(config.resource.aws_iam_role).toBeUndefined(); + expect(config.resource.aws_iam_role_policy).toBeUndefined(); + expect(lambda.role).toBe("arn:aws:iam::123456789012:role/existing"); + expect(lambda.environment).toBeUndefined(); + expect(lambda.logging_config.log_format).toBe("JSON"); + expect(Object.keys(config.resource.aws_cloudwatch_log_group)).toHaveLength(1); + expect(fn.executionRole).toBeUndefined(); + expect(() => + fn.addToRolePolicy({ actions: ["s3:GetObject"], resources: ["*"] }), + ).toThrow(/existing role/); +}); + +test("adds network-interface permissions when attaching to a VPC", () => { + new NodejsFunction(stack, "hello", { + ...props, + vpcConfig: { subnetIds: ["subnet-123"], securityGroupIds: ["sg-123"] }, + initialPolicy: [ + { + actions: ["dynamodb:GetItem"], + resources: ["arn:aws:dynamodb:eu-central-1:123456789012:table/example"], + }, + ], + }); + const policy = resource( + JSON.parse(Testing.synth(stack)), + "aws_iam_role_policy", + ).policy; + expect(policy).toContain("ec2:CreateNetworkInterface"); + expect(policy).toContain("ec2:DescribeSubnets"); + expect(policy).toContain("dynamodb:GetItem"); +}); + +test("runtime variables change configuration without changing the code digest", () => { + const first = new NodejsFunction(stack, "first", { + ...props, + environment: { VALUE: "one" }, + }); + const second = new NodejsFunction(stack, "second", { + ...props, + environment: { VALUE: "two" }, + }); + expect(first.code.sourceCodeHash).toBe(second.code.sourceCodeHash); +}); + +test("validates options which cannot produce a Node.js function", () => { + expect( + () => new NodejsFunction(stack, "bad", { ...props, runtime: "python3.14" }), + ).toThrow(/Node.js runtime/); + expect( + () => + new NodejsFunction(stack, "badrole", { + ...props, + role: "existing", + initialPolicy: [{ actions: ["s3:GetObject"], resources: ["*"] }], + }), + ).toThrow(/initialPolicy/); +}); diff --git a/packages/@cdktn/aws-lambda-nodejs/tsconfig.json b/packages/@cdktn/aws-lambda-nodejs/tsconfig.json new file mode 100644 index 000000000..d4b37bc9e --- /dev/null +++ b/packages/@cdktn/aws-lambda-nodejs/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../bundler-nodejs/tsconfig.json", + "compilerOptions": { "rootDir": "src", "outDir": "build" }, + "include": ["src/**/*.ts"] +} diff --git a/packages/@cdktn/bundler-nodejs/README.md b/packages/@cdktn/bundler-nodejs/README.md new file mode 100644 index 000000000..45a1878a3 --- /dev/null +++ b/packages/@cdktn/bundler-nodejs/README.md @@ -0,0 +1,21 @@ +# Native Node.js bundle assets + +`NodejsAsset` bundles TypeScript or JavaScript with Rolldown and produces a deterministic ZIP through CDK Terrain's `TerraformAsset` staging API. + +```ts +import { NodejsAsset } from "@cdktn/bundler-nodejs"; + +const code = new NodejsAsset(stack, "Code", { + entry: "src/handler.ts", + target: "node24", +}); + +// Inputs for an existing Lambda resource: +// filename: code.path +// handler: code.handler +// sourceCodeHash: code.sourceCodeHash +``` + +For automatic Lambda, IAM and logging setup, use [`NodejsFunction`](../aws-lambda-nodejs/README.md). That guide also documents bundling options, path resolution, reproducibility, plugin configuration, platform requirements and native dependency boundaries. + +This package uses Rolldown's platform-specific native Rust bindings distributed through npm; it requires no Rust compiler or global bundler installation. Keep optional npm dependencies enabled so the correct binding is installed for the synthesis host. No bundler or build dependencies are included in deployment ZIPs unless the handler itself imports them. diff --git a/packages/@cdktn/bundler-nodejs/eslint.config.mjs b/packages/@cdktn/bundler-nodejs/eslint.config.mjs new file mode 100644 index 000000000..1fb723435 --- /dev/null +++ b/packages/@cdktn/bundler-nodejs/eslint.config.mjs @@ -0,0 +1 @@ +export { default } from "../../../eslint.config.mjs"; diff --git a/packages/@cdktn/bundler-nodejs/jest.config.js b/packages/@cdktn/bundler-nodejs/jest.config.js new file mode 100644 index 000000000..c2d160a88 --- /dev/null +++ b/packages/@cdktn/bundler-nodejs/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + displayName: '@cdktn/bundler-nodejs', + preset: '../../../jest.preset.js', + transform: { '^.+\\.tsx?$': ['@swc/jest', { jsc: { parser: { syntax: 'typescript' }, target: 'es2022' }, module: { type: 'commonjs' } }] }, +}; diff --git a/packages/@cdktn/bundler-nodejs/package.json b/packages/@cdktn/bundler-nodejs/package.json new file mode 100644 index 000000000..37b854a7c --- /dev/null +++ b/packages/@cdktn/bundler-nodejs/package.json @@ -0,0 +1,23 @@ +{ + "name": "@cdktn/bundler-nodejs", + "version": "0.0.0", + "description": "Native Node.js and TypeScript assets for CDK Terrain, powered by Rolldown", + "license": "MPL-2.0", + "author": { "name": "OpenConstructs", "url": "https://github.com/open-constructs" }, + "repository": { "type": "git", "url": "https://github.com/open-constructs/cdk-terrain.git", "directory": "packages/@cdktn/bundler-nodejs" }, + "publishConfig": { "access": "public" }, + "main": "build/index.js", + "types": "build/index.d.ts", + "exports": { ".": { "types": "./build/index.d.ts", "default": "./build/index.js" } }, + "files": ["build", "runner.mjs"], + "engines": { "node": ">=22.12.0" }, + "scripts": { + "build": "tsc", + "package": "node ../../../tools/pack-node-package.mjs", + "package:js": "node ../../../tools/pack-node-package.mjs" + }, + "nx": { "tags": ["unit-test"] }, + "dependencies": { "fflate": "0.8.3", "rolldown": "1.2.7" }, + "peerDependencies": { "cdktn": "^0.24.0", "constructs": ">=10.6.0 <10.8.0" }, + "devDependencies": { "@types/node": "22.20.1", "cdktn": "workspace:*", "constructs": "10.6.0", "typescript": "^5.0.4" } +} diff --git a/packages/@cdktn/bundler-nodejs/runner.mjs b/packages/@cdktn/bundler-nodejs/runner.mjs new file mode 100644 index 000000000..3945bd73d --- /dev/null +++ b/packages/@cdktn/bundler-nodejs/runner.mjs @@ -0,0 +1,147 @@ +// Copyright (c) OpenConstructs +// SPDX-License-Identifier: MPL-2.0 +// An async native bundler behind CDKTN's synchronous construct API. No shell, +// global bundler, compiler installation, Docker, or execution of handler code. +import { readFileSync, writeFileSync, readdirSync, lstatSync } from "node:fs"; +import * as path from "node:path"; +import { pathToFileURL } from "node:url"; +import { rolldown } from "rolldown"; +import { zipSync } from "fflate"; + +try { + const request = JSON.parse(readFileSync(0, "utf8")); + const format = request.format ?? "esm"; + const extension = format === "esm" ? "mjs" : "cjs"; + let custom = {}; + if (request.configFile) { + custom = ( + await import( + pathToFileURL(path.resolve(request.projectRoot, request.configFile)) + .href + ) + ).default; + if (!custom || typeof custom !== "object" || Array.isArray(custom)) { + throw new Error( + "The bundling config must default-export one Rolldown options object.", + ); + } + } + const { output: customOutput, ...customInput } = custom; + if (Array.isArray(customOutput)) + throw new Error("The bundling config must have one output options object."); + const facade = "\0cdktn-nodejs-entry"; + const external = request.externalModules ?? []; + const bundle = await rolldown({ + ...customInput, + cwd: request.projectRoot, + input: facade, + platform: "node", + preserveEntrySignatures: "strict", + tsconfig: request.tsconfig + ? path.resolve(request.projectRoot, request.tsconfig) + : undefined, + external: (id) => + external.some( + (name) => + id === name || + id.startsWith(`${name}/`) || + (name.endsWith("/*") && id.startsWith(name.slice(0, -1))), + ), + transform: { + ...customInput.transform, + target: request.target, + define: { ...customInput.transform?.define, ...request.define }, + }, + plugins: [ + { + name: "cdktn-nodejs-entry", + resolveId: (id) => + id === facade + ? facade + : id === "cdktn:user-entry" + ? request.entry + : null, + load: (id) => + id === facade + ? `export { ${request.handler} } from "cdktn:user-entry";` + : null, + }, + ...(customInput.plugins ?? []), + ], + onwarn(warning, warn) { + // A misspelled dependency must not silently become a broken deployed import. + if (warning.code === "UNRESOLVED_IMPORT") + throw new Error(warning.message); + if (customInput.onwarn) customInput.onwarn(warning, warn); + else warn(warning); + }, + }); + let output; + try { + ({ output } = await bundle.generate({ + ...customOutput, + dir: request.projectRoot, + file: undefined, + format, + entryFileNames: `index.${extension}`, + chunkFileNames: `chunks/[name]-[hash].${extension}`, + assetFileNames: "assets/[name]-[hash][extname]", + minify: request.minify ?? true, + sourcemap: request.sourceMap ?? true, + sourcemapPathTransform: (source) => source.split(path.sep).join("/"), + polyfillRequire: true, + })); + } finally { + await bundle.close(); + } + + const files = new Map(); + function add(name, data) { + const normalized = path.posix.normalize(name); + if ( + name.includes("\\") || + name.includes(":") || + normalized === "." || + normalized.startsWith("/") || + normalized === ".." || + normalized.startsWith("../") + ) { + throw new Error(`ZIP destination must stay inside the archive: ${name}`); + } + if (files.has(normalized)) + throw new Error(`Duplicate ZIP destination: ${normalized}`); + files.set(normalized, typeof data === "string" ? Buffer.from(data) : data); + } + for (const file of output) + add(file.fileName, file.type === "chunk" ? file.code : file.source); + function copy(source, destination) { + const stat = lstatSync(source); + if (stat.isSymbolicLink()) + throw new Error( + `copyFiles does not follow symlinks: ${source}. Copy a prepared dependency directory instead.`, + ); + if (stat.isDirectory()) { + for (const name of readdirSync(source).sort()) + copy(path.join(source, name), path.posix.join(destination, name)); + } else if (stat.isFile()) add(destination, readFileSync(source)); + else + throw new Error( + `copyFiles requires regular files or directories: ${source}`, + ); + } + for (const file of request.copyFiles ?? []) + copy(path.resolve(request.projectRoot, file.from), file.to); + + const zipped = Object.create(null); + for (const name of [...files.keys()].sort()) { + zipped[name] = [files.get(name), { os: 3, attrs: (0o100644 << 16) >>> 0 }]; + } + // A fixed local date produces the same DOS timestamp in every timezone. + writeFileSync( + request.resultFile, + zipSync(zipped, { level: 9, mtime: new Date(1980, 0, 1, 0, 0, 0) }), + ); +} catch (error) { + process.stderr.write(`${error.stack ?? error}\n`); + process.exitCode = 1; +} diff --git a/packages/@cdktn/bundler-nodejs/src/index.ts b/packages/@cdktn/bundler-nodejs/src/index.ts new file mode 100644 index 000000000..91c9b9505 --- /dev/null +++ b/packages/@cdktn/bundler-nodejs/src/index.ts @@ -0,0 +1,140 @@ +// Copyright (c) OpenConstructs +// SPDX-License-Identifier: MPL-2.0 +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { App, AssetType, TerraformAsset, Token } from "cdktn"; +import { Construct } from "constructs"; + +/** An additional file or directory to include in the deployment ZIP. */ +export interface CopyFile { + readonly from: string; + /** Path inside the ZIP. Use "." to copy a directory's contents to its root. */ + readonly to: string; +} + +/** Optional controls over the native Rolldown build. */ +export interface NodejsBundlingOptions { + /** @default "esm" */ + readonly format?: "esm" | "cjs"; + /** @default true */ + readonly minify?: boolean; + /** Include source maps and their original sources. @default true */ + readonly sourceMap?: boolean; + /** Packages supplied by a Lambda layer or copyFiles, including their subpaths. */ + readonly externalModules?: string[]; + /** Compile-time substitutions. Values are JavaScript expressions. */ + readonly define?: Record; + /** Optional tsconfig path, relative to projectRoot. Otherwise discovered by Rolldown. */ + readonly tsconfig?: string; + /** JS/MJS/CJS module exporting Rolldown options, including plugins and output options. */ + readonly configFile?: string; + readonly copyFiles?: CopyFile[]; +} + +/** Source code to bundle into a deployable Node.js asset. */ +export interface NodejsAssetProps { + /** TypeScript or JavaScript entry file, relative to projectRoot. */ + readonly entry: string; + /** Export to expose from the bundle. @default "handler" */ + readonly handler?: string; + /** Root for relative paths. @default directory containing cdktf.json, or cwd */ + readonly projectRoot?: string; + /** Node.js syntax target. @default "node24" */ + readonly target?: string; + readonly bundling?: NodejsBundlingOptions; +} + +/** A deterministic ZIP built by Rolldown and staged by TerraformAsset. */ +export class NodejsAsset extends TerraformAsset { + /** Lambda-compatible module and export, for example "index.handler". */ + public readonly handler: string; + /** Base64 SHA-256 of the exact ZIP bytes, suitable for source_code_hash. */ + public readonly sourceCodeHash: string; + + constructor(scope: Construct, id: string, props: NodejsAssetProps) { + const projectRoot = resolveProjectRoot(scope, props.projectRoot); + const entry = path.resolve(projectRoot, props.entry); + const handler = props.handler ?? "handler"; + if (Token.isUnresolved(props.entry) || !fs.existsSync(entry)) { + throw new Error(`Node.js entry must be an existing local file: ${entry}`); + } + if (!fs.statSync(entry).isFile()) { + throw new Error(`Node.js entry is not a file: ${entry}`); + } + if (!/^[A-Za-z_$][\w$]*$/.test(handler)) { + throw new Error( + `Node.js handler must be an exported identifier, received ${JSON.stringify(handler)}`, + ); + } + if (props.target && !/^node\d+(\.\d+)*$/.test(props.target)) { + throw new Error(`Invalid Node.js target: ${props.target}`); + } + + // Output hashing needs a real build. Never reuse a source-only cache: imports, + // lockfiles, package exports, plugins and copied files can all change output. + const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "cdktn-nodejs-")); + let archive: Buffer; + try { + const resultFile = path.join(scratch, "archive.zip"); + const result = spawnSync( + process.execPath, + [path.join(__dirname, "../runner.mjs")], + { + input: JSON.stringify({ + ...props.bundling, + entry, + handler, + projectRoot, + target: props.target ?? "node24", + resultFile, + }), + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + timeout: 120_000, + windowsHide: true, + }, + ); + if (result.error || result.status !== 0) { + throw new Error( + `Failed to bundle ${entry}:\n${result.error?.message ?? result.stderr ?? result.signal}`, + ); + } + if (result.stderr) process.stderr.write(result.stderr); + archive = fs.readFileSync(resultFile); + } finally { + fs.rmSync(scratch, { recursive: true, force: true }); + } + + const digest = createHash("sha256").update(archive).digest(); + const assetHash = digest.toString("hex"); + // Keep immutable source artifacts within the app output, so repeated synths + // can use TerraformAsset's normal staging without leaked temporary trees. + const sourcePath = path.resolve( + App.of(scope).outdir, + ".nodejs-assets", + assetHash, + "archive.zip", + ); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, archive); + super(scope, id, { path: sourcePath, type: AssetType.FILE, assetHash }); + this.handler = `index.${handler}`; + this.sourceCodeHash = digest.toString("base64"); + } +} + +function resolveProjectRoot(scope: Construct, explicit?: string): string { + if (explicit) return path.resolve(explicit); + const configPath = scope.node.tryGetContext("cdktfJsonPath"); + if (configPath) return path.dirname(path.resolve(configPath)); + let directory = process.cwd(); + while (true) { + if (fs.existsSync(path.join(directory, "cdktf.json"))) return directory; + const parent = path.dirname(directory); + if (parent === directory) return process.cwd(); + directory = parent; + } +} diff --git a/packages/@cdktn/bundler-nodejs/test/asset.test.ts b/packages/@cdktn/bundler-nodejs/test/asset.test.ts new file mode 100644 index 000000000..b5fcb8cc3 --- /dev/null +++ b/packages/@cdktn/bundler-nodejs/test/asset.test.ts @@ -0,0 +1,261 @@ +// Copyright (c) OpenConstructs +// SPDX-License-Identifier: MPL-2.0 +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { unzipSync } from "fflate"; +import { App, TerraformStack } from "cdktn"; +import { NodejsAsset, NodejsAssetProps } from "../src"; + +let root: string; +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "cdktn bundle & spaces-")); +}); +afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + +function write(name: string, content: string, directory = root) { + const file = path.join(directory, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + return file; +} + +function synth(props: Partial = {}, directory = root) { + const app = new App({ outdir: path.join(directory, "out") }); + const stack = new TerraformStack(app, "test"); + const asset = new NodejsAsset(stack, "Code", { + entry: "src/handler.ts", + projectRoot: directory, + ...props, + }); + app.synth(); + const zip = fs.readFileSync(path.join(app.outdir, "stacks/test", asset.path)); + const files = unzipSync(zip); + return { asset, zip, files, app }; +} + +function invoke( + files: Record, + format = "esm", + handler = "handler", +) { + const directory = fs.mkdtempSync(path.join(root, "invoke-")); + for (const [name, data] of Object.entries(files)) + write(name, Buffer.from(data).toString(), directory); + const url = pathToFileURL( + path.join(directory, format === "esm" ? "index.mjs" : "index.cjs"), + ).href; + const load = + format === "esm" + ? `await import(${JSON.stringify(url)})` + : `createRequire(import.meta.url)(fileURLToPath(${JSON.stringify(url)}))`; + return JSON.parse( + execFileSync( + process.execPath, + [ + "--input-type=module", + "-e", + `import {createRequire} from "node:module"; import {fileURLToPath} from "node:url"; const m = ${load}; console.log(JSON.stringify(await m[${JSON.stringify(handler)}]({name:"Ada"})));`, + ], + { encoding: "utf8" }, + ), + ); +} + +test("bundles TypeScript, JSON, path aliases, CommonJS dependencies and Node builtins into an executable ESM ZIP", () => { + write("package.json", '{"type":"module"}'); + write( + "tsconfig.json", + '{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}', + ); + write("src/data.json", '{"prefix":"Hello"}'); + write( + "src/message.ts", + 'import data from "./data.json"; export const prefix: string = data.prefix;', + ); + write( + "node_modules/greeting/package.json", + '{"name":"greeting","main":"index.cjs"}', + ); + write( + "node_modules/greeting/index.cjs", + 'const path = require("node:path"); module.exports = value => path.basename(value);', + ); + write( + "src/handler.ts", + 'import greet from "greeting"; import {prefix} from "@app/message"; export async function handler(event: {name:string}) {return `${prefix} ${greet(event.name)}`;} export const unused = "REMOVE_UNUSED_EXPORT";', + ); + const { asset, zip, files } = synth(); + expect(invoke(files)).toBe("Hello Ada"); + expect(Buffer.from(files["index.mjs"]).toString()).not.toContain( + "REMOVE_UNUSED_EXPORT", + ); + expect(files["index.mjs.map"]).toBeDefined(); + expect(asset.sourceCodeHash).toBe( + createHash("sha256").update(zip).digest("base64"), + ); + expect(asset.assetHash).toBe(createHash("sha256").update(zip).digest("hex")); +}); + +test.each(["esm", "cjs"] as const)( + "supports a CommonJS handler with %s output", + (format) => { + write( + "src/handler.cjs", + "exports.run = async event => ({hello:event.name});", + ); + const { files, asset } = synth({ + entry: "src/handler.cjs", + handler: "run", + bundling: { format }, + }); + expect(asset.handler).toBe("index.run"); + expect(invoke(files, format, "run")).toEqual({ hello: "Ada" }); + }, +); + +test("preserves lazy dynamic imports and ESM top-level await", () => { + write("src/lazy.ts", "globalThis.counter += 1; export const value = 7;"); + write( + "src/handler.ts", + 'await Promise.resolve(); globalThis.counter = 0; export async function handler() {const before = globalThis.counter; const {value} = await import("./lazy"); return {before,after:globalThis.counter,value};}', + ); + const { files } = synth(); + expect(Object.keys(files).some((name) => name.startsWith("chunks/"))).toBe( + true, + ); + expect(invoke(files)).toEqual({ before: 0, after: 1, value: 7 }); +}); + +test("ZIP identity survives checkout relocation, timestamps and timezones", () => { + const source = 'export async function handler() {return "same";}'; + write("src/handler.ts", source); + const first = synth(); + const moved = path.join(root, "moved checkout"); + write("src/handler.ts", source, moved); + fs.utimesSync(path.join(moved, "src/handler.ts"), 12345, 12345); + const oldTimezone = process.env.TZ; + try { + process.env.TZ = "Pacific/Honolulu"; + expect(synth({}, moved).zip).toEqual(first.zip); + } finally { + if (oldTimezone === undefined) delete process.env.TZ; + else process.env.TZ = oldTimezone; + } +}); + +test("rebuilds transitive dependencies and ignores unrelated source changes", () => { + write("src/value.ts", 'export const value = "one";'); + write( + "src/handler.ts", + 'import {value} from "./value"; export const handler = () => value;', + ); + const first = synth(); + write("unrelated.txt", "irrelevant"); + expect(synth().asset.assetHash).toBe(first.asset.assetHash); + write("src/value.ts", 'export const value = "two";'); + const changed = synth(); + expect(changed.asset.assetHash).not.toBe(first.asset.assetHash); + expect(invoke(changed.files)).toBe("two"); +}); + +test("supports explicitly external packages, compile-time defines and copied assets", () => { + write( + "src/handler.ts", + 'import {value} from "provided/subpath"; export const handler = () => `${MODE}:${value}`;', + ); + write( + "provided/package.json", + '{"name":"provided","type":"module","exports":{"./subpath":"./subpath.js"}}', + ); + write("provided/subpath.js", "export const value = 42;"); + const { files } = synth({ + bundling: { + externalModules: ["provided"], + define: { MODE: '"production"' }, + copyFiles: [{ from: "provided", to: "node_modules/provided" }], + }, + }); + expect(invoke(files)).toBe("production:42"); +}); + +test("loads Rollup-style plugins and hashes their output and copied content", () => { + write("src/handler.ts", 'export const handler = () => "ok";'); + write( + "config.mjs", + 'export default {plugins:[{name:"fixture",generateBundle(){this.emitFile({type:"asset",fileName:"plugin.txt",source:"plugin output"});}}]};', + ); + write("extra.txt", "first"); + const props = { + bundling: { + configFile: "config.mjs", + copyFiles: [{ from: "extra.txt", to: "extra.txt" }], + }, + }; + const first = synth(props); + expect(Buffer.from(first.files["plugin.txt"]).toString()).toBe( + "plugin output", + ); + write("extra.txt", "second"); + expect(synth(props).asset.assetHash).not.toBe(first.asset.assetHash); +}); + +test("supports disabling minification and source maps", () => { + write( + "src/handler.ts", + 'export function handler() { const readableVariable = "hello"; return readableVariable; }', + ); + const { files } = synth({ bundling: { sourceMap: false, minify: false } }); + expect(Object.keys(files)).toEqual(["index.mjs"]); + expect(invoke(files)).toBe("hello"); +}); + +test("missing imports, missing exports and syntax errors fail before deployment", () => { + write( + "src/handler.ts", + 'import value from "missing-package"; export const handler = () => value;', + ); + expect(() => synth()).toThrow(/missing-package/); + write("src/handler.ts", "export const wrong = () => 1;"); + expect(() => synth()).toThrow(/handler/); + write("src/handler.ts", "export const handler = =>"); + expect(() => synth()).toThrow(/Failed to bundle/); +}); + +test("rejects missing files, malformed export names, traversal and output collisions", () => { + expect(() => synth()).toThrow(/existing local file/); + write("src/handler.ts", "export const handler = () => 1;"); + expect(() => synth({ handler: "file.handler" })).toThrow( + /exported identifier/, + ); + write("extra.txt", "data"); + expect(() => + synth({ + bundling: { copyFiles: [{ from: "extra.txt", to: "../escape" }] }, + }), + ).toThrow(/inside the archive/); + expect(() => + synth({ + bundling: { copyFiles: [{ from: "extra.txt", to: "index.mjs" }] }, + }), + ).toThrow(/Duplicate ZIP/); +}); + +test("resolves relative entries from the cdktf.json context and can synthesize twice", () => { + write("src/handler.ts", "export const handler = () => 1;"); + const app = new App({ + outdir: path.join(root, "out"), + context: { cdktfJsonPath: path.join(root, "cdktf.json") }, + }); + const asset = new NodejsAsset(new TerraformStack(app, "test"), "Code", { + entry: "src/handler.ts", + }); + app.synth(); + const staged = path.join(app.outdir, "stacks/test", asset.path); + const first = fs.readFileSync(staged); + app.synth(); + expect(fs.readFileSync(staged)).toEqual(first); +}); diff --git a/packages/@cdktn/bundler-nodejs/tsconfig.json b/packages/@cdktn/bundler-nodejs/tsconfig.json new file mode 100644 index 000000000..12a71fa98 --- /dev/null +++ b/packages/@cdktn/bundler-nodejs/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "rootDir": "src", + "outDir": "build", + "esModuleInterop": true + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d2bcdc40..2cd57c068 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,13 +215,13 @@ importers: version: 9.39.4 '@nx/eslint': specifier: 22.7.5 - version: 22.7.5(@babel/traverse@7.29.0)(@nx/jest@22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)))(@swc/core@1.15.40(@swc/helpers@0.5.21))(@zkochan/js-yaml@0.0.7)(eslint@9.39.4(jiti@2.7.0))(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) + version: 22.7.5(@babel/traverse@7.29.0)(@nx/jest@22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)))(@swc/core@1.15.40(@swc/helpers@0.5.21))(@zkochan/js-yaml@0.0.7)(eslint@9.39.4(jiti@2.7.0))(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) '@nx/eslint-plugin': specifier: 22.7.5 version: 22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.4.5))(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0))(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) '@nx/jest': specifier: 22.7.5 - version: 22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) + version: 22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) '@swc/core': specifier: ~1.15.5 version: 1.15.40(@swc/helpers@0.5.21) @@ -269,7 +269,7 @@ importers: version: 9.1.7 jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) + version: 30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) knip: specifier: ^6.16.1 version: 6.16.1 @@ -506,6 +506,25 @@ importers: specifier: ^5.0.0 version: 5.4.5 + examples/typescript/aws-nodejs-function: + dependencies: + '@cdktn/aws-lambda-nodejs': + specifier: workspace:* + version: link:../../../packages/@cdktn/aws-lambda-nodejs + '@cdktn/provider-aws': + specifier: 25.4.0 + version: 25.4.0(cdktn@packages+cdktn)(constructs@10.6.0) + cdktn: + specifier: workspace:* + version: link:../../../packages/cdktn + constructs: + specifier: 10.6.0 + version: 10.6.0 + devDependencies: + cdktn-cli: + specifier: workspace:* + version: link:../../../packages/cdktn-cli + examples/typescript/aws-prebuilt: dependencies: '@cdktn/provider-aws': @@ -982,6 +1001,50 @@ importers: specifier: ^5.0.0 version: 5.4.5 + packages/@cdktn/aws-lambda-nodejs: + dependencies: + '@cdktn/bundler-nodejs': + specifier: workspace:* + version: link:../bundler-nodejs + devDependencies: + '@cdktn/provider-aws': + specifier: 25.4.0 + version: 25.4.0(cdktn@packages+cdktn)(constructs@10.6.0) + '@types/node': + specifier: 22.20.1 + version: 22.20.1 + cdktn: + specifier: workspace:* + version: link:../../cdktn + constructs: + specifier: 10.6.0 + version: 10.6.0 + typescript: + specifier: ^5.0.4 + version: 5.4.5 + + packages/@cdktn/bundler-nodejs: + dependencies: + fflate: + specifier: 0.8.3 + version: 0.8.3 + rolldown: + specifier: 1.2.7 + version: 1.2.7 + devDependencies: + '@types/node': + specifier: 22.20.1 + version: 22.20.1 + cdktn: + specifier: workspace:* + version: link:../../cdktn + constructs: + specifier: 10.6.0 + version: 10.6.0 + typescript: + specifier: ^5.0.4 + version: 5.4.5 + packages/@cdktn/cli-core: dependencies: '@cdktn/commons': @@ -2330,6 +2393,13 @@ packages: cdktn: ^0.23.0 constructs: ^10.6.0 + '@cdktn/provider-aws@25.4.0': + resolution: {integrity: sha512-x3s88MQvYavAd1yXcaOQRGDe5rdeN4NiuTrsXTdO7Zq/oO00joVr3l8pS+XMY1vwcuDBpnKBA+CN6rChj4Gv4g==} + engines: {node: '>= 22.11.0'} + peerDependencies: + cdktn: ^0.24.0 + constructs: '>=10.6.0 <10.8.0' + '@cdktn/provider-azurerm@16.1.0': resolution: {integrity: sha512-ArrHLoh/ebmY2yymKIxh2wL4HWqgvrK4EOVDfbOnNW8/qKA6/+EjIMBTacfA9C2tUq5zgBoNzImIZyqOiT/JYg==} engines: {node: '>= 20.16.0'} @@ -3286,6 +3356,9 @@ packages: '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@oxc-project/types@0.148.0': + resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} + '@oxc-resolver/binding-android-arm-eabi@11.20.0': resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==} cpu: [arm] @@ -3405,6 +3478,105 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@rolldown/binding-android-arm-eabi@1.2.7': + resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.7': + resolution: {integrity: sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.7': + resolution: {integrity: sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.7': + resolution: {integrity: sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.7': + resolution: {integrity: sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + resolution: {integrity: sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.7': + resolution: {integrity: sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.7': + resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.7': + resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.7': + resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.7': + resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.7': + resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.7': + resolution: {integrity: sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.7': + resolution: {integrity: sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sentry-internal/tracing@7.120.4': resolution: {integrity: sha512-Fz5+4XCg3akeoFK+K7g+d7HqGMjmnLoY2eJlpONJmaeT9pXY7yfUyXKZMmMajdE2LxxKJgQ2YKvSCaGVamTjHw==} engines: {node: '>=8'} @@ -3669,9 +3841,6 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@18.19.130': - resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} @@ -4974,6 +5143,7 @@ packages: eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -6928,6 +7098,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rolldown@1.2.7: + resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -7480,9 +7655,6 @@ packages: resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==} engines: {node: '>=14'} - undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -8854,6 +9026,11 @@ snapshots: cdktn: link:packages/cdktn constructs: 10.6.0 + '@cdktn/provider-aws@25.4.0(cdktn@packages+cdktn)(constructs@10.6.0)': + dependencies: + cdktn: link:packages/cdktn + constructs: 10.6.0 + '@cdktn/provider-azurerm@16.1.0(cdktn@packages+cdktn)(constructs@10.6.0)': dependencies: cdktn: link:packages/cdktn @@ -9647,7 +9824,7 @@ snapshots: - typescript - verdaccio - '@nx/eslint@22.7.5(@babel/traverse@7.29.0)(@nx/jest@22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)))(@swc/core@1.15.40(@swc/helpers@0.5.21))(@zkochan/js-yaml@0.0.7)(eslint@9.39.4(jiti@2.7.0))(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0))': + '@nx/eslint@22.7.5(@babel/traverse@7.29.0)(@nx/jest@22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)))(@swc/core@1.15.40(@swc/helpers@0.5.21))(@zkochan/js-yaml@0.0.7)(eslint@9.39.4(jiti@2.7.0))(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0))': dependencies: '@nx/devkit': 22.7.5(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21))) '@nx/js': 22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) @@ -9656,7 +9833,7 @@ snapshots: tslib: 2.8.1 typescript: 5.9.3 optionalDependencies: - '@nx/jest': 22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) + '@nx/jest': 22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) '@zkochan/js-yaml': 0.0.7 transitivePeerDependencies: - '@babel/traverse' @@ -9667,7 +9844,7 @@ snapshots: - supports-color - verdaccio - '@nx/jest@22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0))': + '@nx/jest@22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0))': dependencies: '@jest/reporters': 30.3.0 '@jest/test-result': 30.3.0 @@ -9675,7 +9852,7 @@ snapshots: '@nx/js': 22.7.5(@babel/traverse@7.29.0)(@swc/core@1.15.40(@swc/helpers@0.5.21))(nx@22.7.5(@swc/core@1.15.40(@swc/helpers@0.5.21)))(verdaccio@6.6.0(encoding@0.1.13)(typanion@3.14.0)) '@phenomnomnominal/tsquery': 6.2.0(typescript@5.4.5) identity-obj-proxy: 3.0.0 - jest-config: 30.3.0(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) + jest-config: 30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) jest-resolve: 30.3.0 jest-util: 30.3.0 minimatch: 10.2.5 @@ -9858,6 +10035,8 @@ snapshots: '@oxc-project/types@0.133.0': {} + '@oxc-project/types@0.148.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.20.0': optional: true @@ -9933,6 +10112,53 @@ snapshots: '@pkgr/core@0.2.9': {} + '@rolldown/binding-android-arm-eabi@1.2.7': + optional: true + + '@rolldown/binding-android-arm64@1.2.7': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.7': + optional: true + + '@rolldown/binding-darwin-x64@1.2.7': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.7': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.7': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.7': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.7': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.7': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.7': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@sentry-internal/tracing@7.120.4': dependencies: '@sentry/core': 7.120.4 @@ -10147,7 +10373,7 @@ snapshots: '@types/cross-spawn@6.0.6': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.20.1 '@types/debug@4.1.13': dependencies: @@ -10163,12 +10389,12 @@ snapshots: '@types/follow-redirects@1.14.4': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.20.1 '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 - '@types/node': 18.19.130 + '@types/node': 22.20.1 '@types/fs-extra@8.1.5': dependencies: @@ -10203,10 +10429,6 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@18.19.130': - dependencies: - undici-types: 5.26.5 - '@types/node@22.20.1': dependencies: undici-types: 6.21.0 @@ -12598,25 +12820,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.3.0(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)): - dependencies: - '@jest/core': 30.3.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) - '@jest/test-result': 30.3.0 - '@jest/types': 30.3.0 - chalk: 4.1.2 - exit-x: 0.2.2 - import-local: 3.2.0 - jest-config: 30.3.0(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) - jest-util: 30.3.0 - jest-validate: 30.3.0 - yargs: 17.7.3 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest-cli@30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)): dependencies: '@jest/core': 30.3.0(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) @@ -12655,38 +12858,6 @@ snapshots: - supports-color - ts-node - jest-config@30.3.0(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)): - dependencies: - '@babel/core': 7.29.0 - '@jest/get-type': 30.1.0 - '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.3.0 - '@jest/types': 30.3.0 - babel-jest: 30.3.0(@babel/core@7.29.0) - chalk: 4.1.2 - ci-info: 4.4.0 - deepmerge: 4.3.1 - glob: 10.5.0 - graceful-fs: 4.2.11 - jest-circus: 30.3.0(babel-plugin-macros@3.1.0) - jest-docblock: 30.2.0 - jest-environment-node: 30.3.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.3.0 - jest-runner: 30.3.0 - jest-util: 30.3.0 - jest-validate: 30.3.0 - parse-json: 5.2.0 - pretty-format: 30.3.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 18.19.130 - ts-node: 10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)): dependencies: '@babel/core': 7.29.0 @@ -12968,19 +13139,6 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.3.0(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)): - dependencies: - '@jest/core': 30.3.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) - '@jest/types': 30.3.0 - import-local: 3.2.0 - jest-cli: 30.3.0(@types/node@18.19.130)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest@30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)): dependencies: '@jest/core': 30.3.0(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.40(@swc/helpers@0.5.21))(@types/node@22.20.1)(typescript@5.4.5)) @@ -14429,6 +14587,27 @@ snapshots: rfdc@1.4.1: {} + rolldown@1.2.7: + dependencies: + '@oxc-project/types': 0.148.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.7 + '@rolldown/binding-android-arm64': 1.2.7 + '@rolldown/binding-darwin-arm64': 1.2.7 + '@rolldown/binding-darwin-x64': 1.2.7 + '@rolldown/binding-freebsd-x64': 1.2.7 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.7 + '@rolldown/binding-linux-arm64-gnu': 1.2.7 + '@rolldown/binding-linux-arm64-musl': 1.2.7 + '@rolldown/binding-linux-ppc64-gnu': 1.2.7 + '@rolldown/binding-linux-s390x-gnu': 1.2.7 + '@rolldown/binding-linux-x64-gnu': 1.2.7 + '@rolldown/binding-linux-x64-musl': 1.2.7 + '@rolldown/binding-openharmony-arm64': 1.2.7 + '@rolldown/binding-win32-arm64-msvc': 1.2.7 + '@rolldown/binding-win32-x64-msvc': 1.2.7 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -15034,8 +15213,6 @@ snapshots: unbash@3.0.0: {} - undici-types@5.26.5: {} - undici-types@6.21.0: {} undici@8.7.0: {} diff --git a/tools/pack-node-package.mjs b/tools/pack-node-package.mjs new file mode 100644 index 000000000..1f39fadf0 --- /dev/null +++ b/tools/pack-node-package.mjs @@ -0,0 +1,11 @@ +// Copyright (c) OpenConstructs +// SPDX-License-Identifier: MPL-2.0 +import { execFileSync } from "node:child_process"; +import { mkdirSync } from "node:fs"; + +// pnpm resolves workspace: dependencies to release versions when packing. +mkdirSync("dist/js", { recursive: true }); +execFileSync("pnpm", ["pack", "--pack-destination", "dist/js"], { + stdio: "inherit", + shell: process.platform === "win32", +}); From 95abd17ca0905fe9e69344ac134f69dba30aa484 Mon Sep 17 00:00:00 2001 From: Gary Sassano <10464497+garysassano@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:16:43 +0200 Subject: [PATCH 2/7] docs(lib): clarify NodejsFunction naming and API scope --- packages/@cdktn/aws-lambda-nodejs/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/@cdktn/aws-lambda-nodejs/README.md b/packages/@cdktn/aws-lambda-nodejs/README.md index 838cc7804..dec6af5ac 100644 --- a/packages/@cdktn/aws-lambda-nodejs/README.md +++ b/packages/@cdktn/aws-lambda-nodejs/README.md @@ -51,6 +51,8 @@ Node.js 24 is the latest stable managed Lambda runtime; Node.js 26 is currently ## Configure the function +The class name follows AWS CDK's familiar `NodejsFunction` terminology. This is a CDK Terrain implementation over Terraform's generated `LambdaFunction`, with its own props and defaults; the `@cdktn` package scope identifies the framework. + `NodejsFunction` extends the generated `LambdaFunction`. Its outputs, provider support, lifecycle controls, setters, and Terraform overrides remain available. Packaging inputs are owned by the construct. Define multiple functions with ordinary language loops rather than Terraform `count` or `forEach`. ```ts From cfd766974bb505600e00fe447303080af5d5c54a Mon Sep 17 00:00:00 2001 From: "hashicorp-copywrite[bot]" <110428419+hashicorp-copywrite[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:21:20 +0000 Subject: [PATCH 3/7] chore: add required copyright headers Signed-off-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com> --- packages/@cdktn/aws-lambda-nodejs/eslint.config.mjs | 5 +++++ packages/@cdktn/aws-lambda-nodejs/jest.config.js | 5 +++++ packages/@cdktn/bundler-nodejs/eslint.config.mjs | 5 +++++ packages/@cdktn/bundler-nodejs/jest.config.js | 5 +++++ 4 files changed, 20 insertions(+) diff --git a/packages/@cdktn/aws-lambda-nodejs/eslint.config.mjs b/packages/@cdktn/aws-lambda-nodejs/eslint.config.mjs index 1fb723435..2453bdeb6 100644 --- a/packages/@cdktn/aws-lambda-nodejs/eslint.config.mjs +++ b/packages/@cdktn/aws-lambda-nodejs/eslint.config.mjs @@ -1 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + export { default } from "../../../eslint.config.mjs"; diff --git a/packages/@cdktn/aws-lambda-nodejs/jest.config.js b/packages/@cdktn/aws-lambda-nodejs/jest.config.js index b75db5209..aa1e8fd58 100644 --- a/packages/@cdktn/aws-lambda-nodejs/jest.config.js +++ b/packages/@cdktn/aws-lambda-nodejs/jest.config.js @@ -1,3 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + module.exports = { displayName: '@cdktn/aws-lambda-nodejs', preset: '../../../jest.preset.js', diff --git a/packages/@cdktn/bundler-nodejs/eslint.config.mjs b/packages/@cdktn/bundler-nodejs/eslint.config.mjs index 1fb723435..2453bdeb6 100644 --- a/packages/@cdktn/bundler-nodejs/eslint.config.mjs +++ b/packages/@cdktn/bundler-nodejs/eslint.config.mjs @@ -1 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + export { default } from "../../../eslint.config.mjs"; diff --git a/packages/@cdktn/bundler-nodejs/jest.config.js b/packages/@cdktn/bundler-nodejs/jest.config.js index c2d160a88..2aec4214a 100644 --- a/packages/@cdktn/bundler-nodejs/jest.config.js +++ b/packages/@cdktn/bundler-nodejs/jest.config.js @@ -1,3 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + module.exports = { displayName: '@cdktn/bundler-nodejs', preset: '../../../jest.preset.js', From 412285192490c298f96ec21a098a8a64d347fb8c Mon Sep 17 00:00:00 2001 From: Gary Sassano <10464497+garysassano@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:39:28 +0200 Subject: [PATCH 4/7] test(lib): validate native Node.js functions on AWS with JSON logs --- .../typescript/aws-nodejs-function/README.md | 13 + .../aws-nodejs-function/package.json | 2 +- .../aws-nodejs-function/verify-aws.mjs | 433 ++++++++++++++++++ packages/@cdktn/aws-lambda-nodejs/README.md | 1 + .../@cdktn/aws-lambda-nodejs/src/index.ts | 2 +- .../aws-lambda-nodejs/test/function.test.ts | 7 +- 6 files changed, 454 insertions(+), 4 deletions(-) create mode 100644 examples/typescript/aws-nodejs-function/verify-aws.mjs diff --git a/examples/typescript/aws-nodejs-function/README.md b/examples/typescript/aws-nodejs-function/README.md index 83c932fe2..f9a6d66ee 100644 --- a/examples/typescript/aws-nodejs-function/README.md +++ b/examples/typescript/aws-nodejs-function/README.md @@ -13,3 +13,16 @@ pnpm run synth To deploy into your configured AWS account in `eu-central-1`, run `pnpm run deploy`. Use `cdktn destroy` to remove the example's resources afterwards. To run the app directly, use `node main.ts` on Node.js 22.18 or newer. [`main.ts`](main.ts) contains the infrastructure and [`src/hello.ts`](src/hello.ts) is the deployed handler. See the [package guide](../../../packages/@cdktn/aws-lambda-nodejs/README.md) for runtime settings, permissions, existing roles, VPC support and bundling options. + +## AWS smoke test + +After building the packages, run the opt-in deployment test with an AWS profile permitted to manage Lambda functions, IAM execution roles/policies and CloudWatch log groups: + +```sh +node verify-aws.mjs --synth-only +node verify-aws.mjs --profile my-test-profile --region eu-central-1 +``` + +The test deploys three uniquely named Node.js 24 functions: this example's handler, an ESM handler with top-level await on ARM64, and a CommonJS handler on x86-64. It verifies deployed ZIP hashes, invocation results, TypeScript aliases, CommonJS dependencies, lazy imports, copied files, environment variables, JSON logs, source maps and log retention. It then checks a fresh synthesis produces no changes, changes a transitive source dependency, verifies that only the two affected functions update, and checks for drift again. + +The test uses the AWS CLI and Terraform from `PATH`; `TERRAFORM_BINARY_NAME` can select another Terraform executable. It stores command output and local Terraform state in the temporary directory printed at startup. These files contain account and resource identifiers and should stay private. The test destroys its resources in `finally` and independently checks that the functions, log groups and execution roles are absent. If the process is forcibly interrupted or cleanup fails, use `terraform -chdir=/cdktf.out/stacks/ destroy` with the same AWS profile to resume cleanup. diff --git a/examples/typescript/aws-nodejs-function/package.json b/examples/typescript/aws-nodejs-function/package.json index 47bdac1f4..f342d93b2 100644 --- a/examples/typescript/aws-nodejs-function/package.json +++ b/examples/typescript/aws-nodejs-function/package.json @@ -1,5 +1,5 @@ { - "name": "aws-nodejs-function", + "name": "@examples/typescript-aws-nodejs-function", "private": true, "version": "0.0.0", "type": "module", diff --git a/examples/typescript/aws-nodejs-function/verify-aws.mjs b/examples/typescript/aws-nodejs-function/verify-aws.mjs new file mode 100644 index 000000000..7c3f687cf --- /dev/null +++ b/examples/typescript/aws-nodejs-function/verify-aws.mjs @@ -0,0 +1,433 @@ +// Copyright (c) OpenConstructs +// SPDX-License-Identifier: MPL-2.0 +// Opt-in deployment test. All resources belong to a unique, temporary stack. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { setTimeout } from "node:timers/promises"; + +const { values } = parseArgs({ + options: { + profile: { type: "string" }, + region: { type: "string", default: "eu-central-1" }, + "synth-only": { type: "boolean", default: false }, + }, +}); +assert( + values.profile || values["synth-only"], + "Supply --profile for AWS validation", +); +const example = path.dirname(fileURLToPath(import.meta.url)); +const root = fs.mkdtempSync(path.join(os.tmpdir(), "cdktn-nodejs-aws-")); +const stackId = `native-nodejs-${randomUUID().slice(0, 8)}`; +const stackDirectory = path.join(root, "cdktf.out/stacks", stackId); +const terraform = process.env.TERRAFORM_BINARY_NAME ?? "terraform"; +const env = { + ...process.env, + AWS_PROFILE: values.profile, + AWS_REGION: values.region, + AWS_PAGER: "", + TF_IN_AUTOMATION: "true", + CHECKPOINT_DISABLE: "1", +}; +// The explicit profile must select both the CLI and provider credentials. +for (const key of [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SECURITY_TOKEN", + "AWS_DEFAULT_PROFILE", +]) + delete env[key]; +let commandNumber = 0; +let attemptedApply = false; +let config; +const summary = { region: values.region, checks: [], cleanup: false }; + +function write(file, value) { + fs.writeFileSync(path.join(root, file), value); +} + +function run(command, args, { cwd = root, statuses = [0] } = {}) { + const result = spawnSync(command, args, { + cwd, + env, + encoding: "utf8", + timeout: 300_000, + maxBuffer: 16 * 1024 * 1024, + }); + const evidence = `${String(++commandNumber).padStart(3, "0")}.json`; + write( + evidence, + JSON.stringify( + { + command, + args, + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + error: result.error?.message, + }, + null, + 2, + ), + ); + assert( + statuses.includes(result.status), + `Command failed: ${command} ${args[0]}; inspect ${path.join(root, evidence)}`, + ); + return result; +} + +function tf(...args) { + return run(terraform, args, { cwd: stackDirectory }).stdout; +} + +function aws(...args) { + return run("aws", [ + "--profile", + values.profile, + "--region", + values.region, + "--output", + "json", + ...args, + ]); +} + +function awsJson(...args) { + return JSON.parse(aws(...args).stdout); +} + +function passed(check) { + summary.checks.push(check); + console.log(`PASS ${check}`); + write("summary.json", JSON.stringify(summary, null, 2)); +} + +function synth() { + run(process.execPath, ["main.mjs"]); + config = JSON.parse( + fs.readFileSync(path.join(stackDirectory, "cdk.tf.json"), "utf8"), + ); + for (const fn of Object.values(config.resource.aws_lambda_function)) { + const archive = fs.readFileSync(path.join(stackDirectory, fn.filename)); + assert.equal( + createHash("sha256").update(archive).digest("base64"), + fn.source_code_hash, + ); + } +} + +function plan(name, expectedAction, count) { + tf("plan", "-input=false", "-no-color", `-out=${name}.tfplan`); + const plan = JSON.parse(tf("show", "-json", `${name}.tfplan`)); + const changes = plan.resource_changes.filter( + (resource) => resource.change.actions.join() !== "no-op", + ); + assert.equal(changes.length, count); + for (const resource of changes) { + assert.deepEqual(resource.change.actions, [expectedAction]); + assert( + [ + "aws_lambda_function", + "aws_iam_role", + "aws_iam_role_policy", + "aws_cloudwatch_log_group", + ].includes(resource.type), + ); + if (expectedAction === "update") + assert.equal(resource.type, "aws_lambda_function"); + } + return changes; +} + +function noChanges() { + tf("plan", "-input=false", "-no-color", "-detailed-exitcode"); +} + +function invoke(name, event) { + const output = path.join(root, `invoke-${commandNumber}.json`); + const metadata = awsJson( + "lambda", + "invoke", + "--function-name", + name, + "--cli-binary-format", + "raw-in-base64-out", + "--payload", + JSON.stringify(event), + "--log-type", + "Tail", + output, + ); + assert.equal(metadata.StatusCode, 200); + return { metadata, response: JSON.parse(fs.readFileSync(output, "utf8")) }; +} + +function verifyFunctions(version) { + const outputs = JSON.parse(tf("output", "-json")); + for (const kind of ["hello", "esm", "cjs"]) { + const fn = outputs[kind].value; + const deployed = awsJson( + "lambda", + "get-function-configuration", + "--function-name", + fn.name, + ); + assert.equal(deployed.State, "Active"); + assert.equal(deployed.LastUpdateStatus, "Successful"); + assert.equal(deployed.Runtime, "nodejs24.x"); + assert.deepEqual(deployed.Architectures, [ + kind === "cjs" ? "x86_64" : "arm64", + ]); + assert.equal(deployed.MemorySize, 512); + assert.equal(deployed.Timeout, 10); + assert.equal(deployed.LoggingConfig.LogFormat, "JSON"); + assert.equal(deployed.LoggingConfig.LogGroup, fn.logGroup); + assert.equal(deployed.CodeSha256, fn.hash); + assert.equal( + deployed.Environment.Variables.NODE_OPTIONS, + "--enable-source-maps", + ); + const { metadata, response } = invoke(fn.name, { + name: "AWS", + token: `${stackId}-${version}-${kind}`, + }); + assert.equal(metadata.FunctionError, undefined, JSON.stringify(response)); + if (kind === "hello") { + assert.deepEqual(response, { message: "Hello from CDK Terrain, AWS!" }); + } else { + assert.deepEqual(response, { + version, + greeting: "Hello from CDK Terrain", + added: "extra", + legacy: "COMMONJS", + copied: "copied asset", + alias: "typescript alias", + lazy: "lazy import", + lazyBefore: false, + architecture: kind === "cjs" ? "x64" : "arm64", + node: 24, + }); + assert( + Buffer.from(metadata.LogResult, "base64") + .toString() + .includes("native-nodejs-validation"), + ); + } + passed( + `${version}: ${kind} invocation, configuration and deployed ZIP hash`, + ); + } + return outputs; +} + +async function verifyLogs(outputs) { + for (const kind of ["esm", "cjs"]) { + const fn = outputs[kind].value; + const groups = awsJson( + "logs", + "describe-log-groups", + "--log-group-name-prefix", + fn.logGroup, + ).logGroups; + assert.equal( + groups.find((group) => group.logGroupName === fn.logGroup) + ?.retentionInDays, + 30, + ); + let events = []; + for (let attempt = 0; attempt < 12; attempt++) { + events = awsJson( + "logs", + "filter-log-events", + "--log-group-name", + fn.logGroup, + "--filter-pattern", + '"native-nodejs-validation"', + ).events; + if (events.length) break; + await setTimeout(5000); + } + assert(events.length > 0, `No CloudWatch application logs for ${kind}`); + assert(events.some((event) => JSON.parse(event.message).level === "INFO")); + const failure = invoke(fn.name, { fail: true }); + assert.equal(failure.metadata.FunctionError, "Unhandled"); + assert.equal(failure.response.errorMessage, "source-map-check"); + assert( + failure.response.trace.some((frame) => /common\.ts:\d+:\d+/.test(frame)), + JSON.stringify(failure.response), + ); + passed( + `${kind}: scoped logging permissions, JSON CloudWatch logs, retention and TypeScript error source maps`, + ); + } +} + +async function verifyAbsent() { + for (const fn of Object.values(config.resource.aws_lambda_function)) { + const result = run( + "aws", + [ + "--profile", + values.profile, + "--region", + values.region, + "lambda", + "get-function", + "--function-name", + fn.function_name, + ], + { statuses: [254] }, + ); + assert(result.stderr.includes("ResourceNotFoundException")); + } + for (const log of Object.values(config.resource.aws_cloudwatch_log_group)) { + const groups = awsJson( + "logs", + "describe-log-groups", + "--log-group-name-prefix", + log.name, + ).logGroups; + assert(!groups.some((group) => group.logGroupName === log.name)); + } + const roles = awsJson("iam", "list-roles").Roles; + for (const role of Object.values(config.resource.aws_iam_role)) + assert( + !roles.some((existing) => existing.RoleName.startsWith(role.name_prefix)), + ); + assert.equal(tf("state", "list").trim(), ""); + summary.cleanup = true; + passed( + "cleanup: empty Terraform state and independent Lambda, log group and IAM absence checks", + ); +} + +console.log(`Evidence and recovery state: ${root}`); +fs.symlinkSync( + path.join(example, "node_modules"), + path.join(root, "node_modules"), + process.platform === "win32" ? "junction" : "dir", +); +write("hello.ts", fs.readFileSync(path.join(example, "src/hello.ts"))); +write("version.ts", 'export const version = "v1";\n'); +write("settings.ts", 'export const alias = "typescript alias";\n'); +write("legacy.cjs", "module.exports = (value) => value.toUpperCase();\n"); +write( + "lazy.ts", + 'globalThis.__nativeNodejsLazy = true; export const lazy = "lazy import";\n', +); +write("message.txt", "copied asset\n"); +write( + "tsconfig.json", + JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@/*": ["./*"] } }, + }), +); +write( + "common.ts", + `import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { version } from "./version"; +import { alias } from "@/settings"; +import legacy from "./legacy.cjs"; +export async function run(event) { + if (event.fail) throw new Error("source-map-check"); + const lazyBefore = globalThis.__nativeNodejsLazy ?? false; + const { lazy } = await import("./lazy"); + console.log("native-nodejs-validation", { token: event.token, version }); + return { + version, alias, lazy, lazyBefore, + greeting: process.env.GREETING, + added: process.env.ADDED, + legacy: legacy("commonjs"), + copied: readFileSync(join(process.env.LAMBDA_TASK_ROOT, "message.txt"), "utf8").trim(), + architecture: process.arch, + node: Number(process.versions.node.split(".")[0]), + }; +} +`, +); +write( + "esm.ts", + 'import { run as handler } from "./common"; export const run = await Promise.resolve(handler);\n', +); +const account = values["synth-only"] + ? undefined + : awsJson("sts", "get-caller-identity").Account; +write( + "main.mjs", + `import { App, LocalBackend, TerraformStack, TerraformOutput } from "cdktn"; +import { AwsProvider } from "@cdktn/provider-aws/lib/provider/index.js"; +import { NodejsFunction } from "@cdktn/aws-lambda-nodejs"; +const app = new App({ outdir: ${JSON.stringify(path.join(root, "cdktf.out"))} }); +const stack = new TerraformStack(app, ${JSON.stringify(stackId)}); +new LocalBackend(stack, { path: ${JSON.stringify(path.join(root, "terraform.tfstate"))} }); +new AwsProvider(stack, "aws", ${JSON.stringify({ region: values.region, profile: values.profile, allowedAccountIds: account ? [account] : undefined })}); +for (const kind of ["hello", "esm", "cjs"]) { + const fn = new NodejsFunction(stack, kind + "Function", { + entry: kind === "hello" ? "hello.ts" : kind === "esm" ? "esm.ts" : "common.ts", + environment: { GREETING: "Hello from CDK Terrain" }, + ...(kind === "hello" ? {} : { + handler: "run", + ...(kind === "cjs" ? { architectures: ["x86_64"] } : {}), + bundling: { format: kind, copyFiles: [{ from: "message.txt", to: "message.txt" }] }, + }), + }); + if (kind !== "hello") fn.addEnvironment("ADDED", "extra"); + new TerraformOutput(stack, kind, { value: { name: fn.functionName, hash: fn.code.sourceCodeHash, logGroup: fn.logGroup.name } }); +} +app.synth(); +`, +); + +try { + synth(); + passed( + "synthesis: example, ESM/top-level await and CommonJS; staged ZIP hashes", + ); + if (!values["synth-only"]) { + tf("init", "-input=false", "-no-color"); + tf("validate", "-no-color"); + plan("create", "create", 12); + console.log( + "Deploying three temporary Lambda functions and their IAM/log resources", + ); + attemptedApply = true; + tf("apply", "-input=false", "-no-color", "create.tfplan"); + const first = verifyFunctions("v1"); + await verifyLogs(first); + synth(); + noChanges(); + passed("unchanged source: no Terraform changes after a fresh synthesis"); + write("version.ts", 'export const version = "v2";\n'); + synth(); + plan("update", "update", 2); + tf("apply", "-input=false", "-no-color", "update.tfplan"); + const second = verifyFunctions("v2"); + assert.equal(first.hello.value.hash, second.hello.value.hash); + for (const kind of ["esm", "cjs"]) + assert.notEqual(first[kind].value.hash, second[kind].value.hash); + noChanges(); + passed( + "transitive source update: only two code updates, new deployed hashes/results, no subsequent drift", + ); + } +} finally { + if (attemptedApply) { + console.log("Destroying temporary resources"); + tf("destroy", "-input=false", "-no-color", "-auto-approve"); + await verifyAbsent(); + } +} +console.log( + values["synth-only"] + ? "Local synthesis checks passed; no AWS resources created" + : "AWS validation passed; all test resources removed", +); diff --git a/packages/@cdktn/aws-lambda-nodejs/README.md b/packages/@cdktn/aws-lambda-nodejs/README.md index dec6af5ac..731b2f88d 100644 --- a/packages/@cdktn/aws-lambda-nodejs/README.md +++ b/packages/@cdktn/aws-lambda-nodejs/README.md @@ -44,6 +44,7 @@ The packages currently expose TypeScript/JavaScript APIs, including CommonJS and | Dependencies | Bundled, including installed AWS SDK clients | | Source maps | Included, with `--enable-source-maps` in `NODE_OPTIONS` | | Log retention | 30 days | +| Log format | JSON | | Execution role | Automatically created, with permissions scoped to the log group's streams | | Deployment identity | SHA-256 of the actual ZIP bytes | diff --git a/packages/@cdktn/aws-lambda-nodejs/src/index.ts b/packages/@cdktn/aws-lambda-nodejs/src/index.ts index e3469b8fa..8aaf79275 100644 --- a/packages/@cdktn/aws-lambda-nodejs/src/index.ts +++ b/packages/@cdktn/aws-lambda-nodejs/src/index.ts @@ -142,7 +142,7 @@ export class NodejsFunction extends LambdaFunction { tags: props.tags, }); this.putLoggingConfig({ - logFormat: "Text", + logFormat: "JSON", ...loggingConfig, logGroup: this.logGroup.name, }); diff --git a/packages/@cdktn/aws-lambda-nodejs/test/function.test.ts b/packages/@cdktn/aws-lambda-nodejs/test/function.test.ts index b331cd120..0f5ce2312 100644 --- a/packages/@cdktn/aws-lambda-nodejs/test/function.test.ts +++ b/packages/@cdktn/aws-lambda-nodejs/test/function.test.ts @@ -55,6 +55,9 @@ test("entry alone creates a deployable function, scoped role, log group and matc timeout: 10, package_type: "Zip", handler: "index.handler", + logging_config: { + log_format: "JSON", + }, }); expect(lambda.function_name).toMatch(/^test-hello-[a-f0-9]{8}$/); expect(log).toMatchObject({ @@ -146,7 +149,7 @@ test("supports existing execution roles and log groups", () => { role: "arn:aws:iam::123456789012:role/existing", logGroup, bundling: { sourceMap: false }, - loggingConfig: { logFormat: "JSON" }, + loggingConfig: { logFormat: "Text" }, }); const config = JSON.parse(Testing.synth(stack)); const lambda = resource(config, "aws_lambda_function"); @@ -154,7 +157,7 @@ test("supports existing execution roles and log groups", () => { expect(config.resource.aws_iam_role_policy).toBeUndefined(); expect(lambda.role).toBe("arn:aws:iam::123456789012:role/existing"); expect(lambda.environment).toBeUndefined(); - expect(lambda.logging_config.log_format).toBe("JSON"); + expect(lambda.logging_config.log_format).toBe("Text"); expect(Object.keys(config.resource.aws_cloudwatch_log_group)).toHaveLength(1); expect(fn.executionRole).toBeUndefined(); expect(() => From fae25298eb03b12acd6c168923d05f82e7851fcc Mon Sep 17 00:00:00 2001 From: Gary Sassano <10464497+garysassano@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:53:14 +0200 Subject: [PATCH 5/7] fix(tools): remove redundant native bundler knip patterns --- knip.jsonc | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/knip.jsonc b/knip.jsonc index 4c71d6ab1..34aa848db 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -49,11 +49,10 @@ "project": ["src/**/*.ts"] }, "packages/@cdktn/bundler-nodejs": { - "entry": ["src/index.ts", "runner.mjs"], - "project": ["src/**/*.ts", "test/**/*.ts", "runner.mjs"] + "entry": ["runner.mjs"], + "project": ["src/**/*.ts", "test/**/*.ts"] }, "packages/@cdktn/aws-lambda-nodejs": { - "entry": ["src/index.ts"], "project": ["src/**/*.ts", "test/**/*.ts"] }, "packages/@cdktn/hcl-tools": { From f1f9aff4ae05e4713f3394b7c1064a7d999ffb64 Mon Sep 17 00:00:00 2001 From: Gary Sassano <10464497+garysassano@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:29:57 +0200 Subject: [PATCH 6/7] feat(nodejs): expose native Rolldown controls and validate Lambda bundles --- packages/@cdktn/aws-lambda-nodejs/README.md | 34 +- .../@cdktn/aws-lambda-nodejs/src/index.ts | 12 + .../aws-lambda-nodejs/test/function.test.ts | 36 ++- .../aws-lambda-nodejs/test/limits.test.ts | 55 ++++ packages/@cdktn/bundler-nodejs/README.md | 4 + packages/@cdktn/bundler-nodejs/runner.mjs | 58 ++-- packages/@cdktn/bundler-nodejs/src/index.ts | 123 +++++++- .../@cdktn/bundler-nodejs/test/asset.test.ts | 291 +++++++++++++++++- 8 files changed, 575 insertions(+), 38 deletions(-) create mode 100644 packages/@cdktn/aws-lambda-nodejs/test/limits.test.ts diff --git a/packages/@cdktn/aws-lambda-nodejs/README.md b/packages/@cdktn/aws-lambda-nodejs/README.md index 731b2f88d..fead20a18 100644 --- a/packages/@cdktn/aws-lambda-nodejs/README.md +++ b/packages/@cdktn/aws-lambda-nodejs/README.md @@ -94,7 +94,9 @@ new NodejsFunction(stack, "worker", { bundling: { format: "esm", // or "cjs" minify: true, + keepNames: true, sourceMap: true, + moduleTypes: { ".sql": "text", ".html": "text" }, tsconfig: "tsconfig.lambda.json", define: { "process.env.BUILD_MODE": JSON.stringify("production") }, copyFiles: [{ from: "templates", to: "templates" }], @@ -103,9 +105,30 @@ new NodejsFunction(stack, "worker", { }); ``` +`keepNames` preserves function and class names for frameworks that inspect them. `moduleTypes` uses Rolldown's [built-in loaders](https://rolldown.rs/reference/InputOptions.moduleTypes): import SQL or HTML as text, binary data as a `Uint8Array`, or a file as an emitted asset. Emitted assets are included in the ZIP automatically. + +`sourceMap` accepts `true`, `false`, `"inline"` or `"hidden"`. `tsconfig` accepts a path or a boolean to enable or disable Rolldown's configuration discovery. + +Build options must be known during synthesis. Unresolved Terraform values are rejected, including values nested inside `define`, path mappings or copied-file options. For a resource attribute such as an API URL, use `environment: { API_URL: api.url }` and read `process.env.API_URL` in the handler. Wrapping a Terraform token in `JSON.stringify` does not make it a build-time value. + An external package, including its subpaths, must be supplied by a layer or explicitly copied into `node_modules` in the ZIP. Dependencies are never silently externalized when resolution fails. `copyFiles` rejects traversal, collisions, and symlinks; provide a prepared directory containing real files. Native addons must be built for the selected Lambda architecture and Amazon Linux runtime, then supplied this way or through a layer. Automatic native dependency installation and Docker builds are outside this first implementation. -For Rollup-compatible plugins or advanced Rolldown transforms, supply `bundling.configFile` pointing to a JavaScript configuration module: +Use the typed `rolldownOptions` object for Rolldown's built-in resolver, transforms, tree shaking, optimizations and output settings. `minify` also accepts Rolldown's native minifier options object: + +```ts +new NodejsFunction(stack, "worker", { + entry: "src/worker.ts", + bundling: { + minify: { compress: true, mangle: false }, + rolldownOptions: { + resolve: { conditionNames: ["lambda", "node", "import", "default"] }, + output: { sourcemapExcludeSources: true }, + }, + }, +}); +``` + +Inline options accept JSON data. For plugins, callbacks or regular expressions, supply `bundling.configFile` pointing to a JavaScript configuration module with the full [Rolldown configuration API](https://rolldown.rs/reference/Interface.RolldownOptions): ```js // rolldown.lambda.config.mjs @@ -113,11 +136,10 @@ export default { plugins: [ /* Rolldown / compatible Rollup plugins */ ], - output: { keepNames: true }, }; ``` -The construct controls the entry point, Node platform, target, externals, output paths, format, minification and source maps. Other input/output options are passed through. This configuration module runs at synthesis; handler code does not. +The construct controls the entry point, Node platform, target, externals, output paths and format. Other input/output options are passed through. Inline settings take precedence over matching configuration-file settings; loader maps, transform settings and output settings are merged. Minification and source maps default to enabled when neither form configures them. This configuration module runs at synthesis; handler code does not. ## Build and deployment behavior @@ -125,7 +147,9 @@ Bundling happens while constructing the app, because output hashes require the c The synthesized stack contains the complete artifact. Subsequent Terraform plan/apply can consume that stack directory without source files, Rolldown, Node.js, or a separate packaging provider. Preserve the stack's assets when transferring it to a remote runner. -This implementation uses Lambda's direct ZIP upload path and is subject to its [deployment package limits](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html). S3 publishing for larger artifacts, JSII language bindings, deployment/bootstrap services, and a watch server are not included. It composes with the existing `TerraformAsset` API and does not depend on the pending asset-pipeline PRs ([#380](https://github.com/open-constructs/cdk-terrain/issues/380)). +This implementation uses Lambda's direct ZIP upload path. Synthesis rejects packages exceeding 50 MiB compressed or 250 MiB uncompressed, reporting the actual byte count and the applicable [deployment package limit](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html). The uncompressed count includes source maps, emitted assets and copied files. Attached layers also count toward Lambda's 250 MiB limit; their sizes are not available locally and are checked by AWS during deployment. `code.compressedSize` and `code.uncompressedSize` expose the ZIP's sizes in bytes without extracting it. + +S3 publishing for larger artifacts, JSII language bindings, deployment/bootstrap services, and a watch server are not included. It composes with the existing `TerraformAsset` API and does not depend on the pending asset-pipeline PRs ([#380](https://github.com/open-constructs/cdk-terrain/issues/380)). ## Development validation @@ -135,4 +159,4 @@ pnpm --filter @cdktn/bundler-nodejs run package:js pnpm --filter @cdktn/aws-lambda-nodejs run package:js ``` -Tests run real native builds and invoke extracted ESM/CommonJS handlers. They cover dependency resolution, TypeScript aliases, lazy imports, top-level await, maps, reproducible ZIP identity, plugin/copy inputs, failure diagnostics, provider aliases, IAM dependencies, existing roles, and VPC permissions. +Tests run real native builds and invoke extracted ESM/CommonJS handlers. They cover dependency resolution, TypeScript aliases, native loaders and configuration, lazy imports, top-level await, names, maps, reproducible ZIP identity, plugin/copy inputs, unresolved build values, ZIP size accounting and Lambda quota boundaries, provider aliases, IAM dependencies, existing roles, and VPC permissions. diff --git a/packages/@cdktn/aws-lambda-nodejs/src/index.ts b/packages/@cdktn/aws-lambda-nodejs/src/index.ts index 8aaf79275..ddfd96a3e 100644 --- a/packages/@cdktn/aws-lambda-nodejs/src/index.ts +++ b/packages/@cdktn/aws-lambda-nodejs/src/index.ts @@ -17,6 +17,7 @@ export { NodejsAsset, NodejsAssetProps, NodejsBundlingOptions, + NodejsRolldownOptions, CopyFile, } from "@cdktn/bundler-nodejs"; @@ -128,6 +129,17 @@ export class NodejsFunction extends LambdaFunction { bundling, target: `node${runtimeMatch[1]}`, }); + const mebibyte = 1024 * 1024; + if (this.code.uncompressedSize > 250 * mebibyte) { + throw new Error( + `NodejsFunction ${this.node.path}: uncompressed package is ${this.code.uncompressedSize} bytes; Lambda allows at most 250 MiB (${250 * mebibyte} bytes), including layers. Reduce bundled dependencies or copied assets.`, + ); + } + if (this.code.compressedSize > 50 * mebibyte) { + throw new Error( + `NodejsFunction ${this.node.path}: ZIP is ${this.code.compressedSize} bytes; Lambda direct uploads allow at most 50 MiB (${50 * mebibyte} bytes). Reduce the bundle, or use LambdaFunction with an S3 code asset for larger ZIPs.`, + ); + } this.filename = this.code.path; this.sourceCodeHash = this.code.sourceCodeHash; this.handler = this.code.handler; diff --git a/packages/@cdktn/aws-lambda-nodejs/test/function.test.ts b/packages/@cdktn/aws-lambda-nodejs/test/function.test.ts index 0f5ce2312..3a55a4ad9 100644 --- a/packages/@cdktn/aws-lambda-nodejs/test/function.test.ts +++ b/packages/@cdktn/aws-lambda-nodejs/test/function.test.ts @@ -4,7 +4,13 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { createHash } from "node:crypto"; -import { App, TerraformStack, TerraformOutput, Testing } from "cdktn"; +import { + App, + TerraformStack, + TerraformOutput, + TerraformVariable, + Testing, +} from "cdktn"; import { AwsProvider } from "@cdktn/provider-aws/lib/provider/index.js"; import { CloudwatchLogGroup } from "@cdktn/provider-aws/lib/cloudwatch-log-group/index.js"; import { NodejsFunction, NodejsFunctionProps } from "../src"; @@ -197,6 +203,34 @@ test("runtime variables change configuration without changing the code digest", expect(first.code.sourceCodeHash).toBe(second.code.sourceCodeHash); }); +test("deployment-time values remain valid in runtime environment variables", () => { + const url = new TerraformVariable(stack, "api_url", { type: "string" }) + .stringValue; + const fn = new NodejsFunction(stack, "runtime", { + ...props, + environment: { API_URL: url }, + bundling: { + keepNames: true, + moduleTypes: { ".sql": "text" }, + rolldownOptions: { output: { sourcemapExcludeSources: true } }, + }, + }); + fn.addEnvironment("SECOND_URL", url); + const lambda = resource( + JSON.parse(Testing.synth(stack)), + "aws_lambda_function", + ); + expect(lambda.environment.variables.API_URL).toBe("${var.api_url}"); + expect(lambda.environment.variables.SECOND_URL).toBe("${var.api_url}"); + expect( + () => + new NodejsFunction(stack, "build", { + ...props, + bundling: { define: { API_URL: JSON.stringify(url) } }, + }), + ).toThrow(/NodejsFunction.environment/); +}); + test("validates options which cannot produce a Node.js function", () => { expect( () => new NodejsFunction(stack, "bad", { ...props, runtime: "python3.14" }), diff --git a/packages/@cdktn/aws-lambda-nodejs/test/limits.test.ts b/packages/@cdktn/aws-lambda-nodejs/test/limits.test.ts new file mode 100644 index 000000000..cf5761e50 --- /dev/null +++ b/packages/@cdktn/aws-lambda-nodejs/test/limits.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) OpenConstructs +// SPDX-License-Identifier: MPL-2.0 +import { App, TerraformStack, Testing } from "cdktn"; +import { NodejsAsset } from "@cdktn/bundler-nodejs"; +import { NodejsFunction } from "../src"; + +// Exercise the Lambda quota boundaries without allocating hundreds of MiB in +// every unit-test run. The bundler suite verifies sizes against real ZIPs. +jest.mock("@cdktn/bundler-nodejs", () => ({ NodejsAsset: jest.fn() })); + +const mib = 1024 * 1024; + +function create(compressedSize: number, uncompressedSize: number) { + jest.mocked(NodejsAsset).mockImplementation( + () => + ({ + compressedSize, + uncompressedSize, + path: "assets/code.zip", + handler: "index.handler", + sourceCodeHash: "code-hash", + }) as NodejsAsset, + ); + const stack = new TerraformStack(new App(), "test"); + const fn = new NodejsFunction(stack, "handler", { entry: "handler.ts" }); + return { stack, fn }; +} + +test.each([ + [50 * mib - 1, 250 * mib - 1], + [50 * mib, 250 * mib], +])( + "accepts a ZIP within both documented limits (%i, %i bytes)", + (compressed, uncompressed) => { + const { stack, fn } = create(compressed, uncompressed); + expect(fn.code.compressedSize).toBe(compressed); + expect(Testing.synth(stack)).toContain("assets/code.zip"); + }, +); + +test("rejects a ZIP one byte above the direct upload limit", () => { + expect(() => create(50 * mib + 1, 100 * mib)).toThrow( + /test\/handler: ZIP is 52428801 bytes.*50 MiB \(52428800 bytes\).*S3/, + ); +}); + +test("rejects a small ZIP whose entries exceed the uncompressed limit", () => { + expect(() => create(mib, 250 * mib + 1)).toThrow( + /uncompressed package is 262144001 bytes.*250 MiB \(262144000 bytes\).*including layers/, + ); +}); + +test("reports the uncompressed limit first when both limits are exceeded", () => { + expect(() => create(51 * mib, 251 * mib)).toThrow(/uncompressed package/); +}); diff --git a/packages/@cdktn/bundler-nodejs/README.md b/packages/@cdktn/bundler-nodejs/README.md index 45a1878a3..e0d0915f1 100644 --- a/packages/@cdktn/bundler-nodejs/README.md +++ b/packages/@cdktn/bundler-nodejs/README.md @@ -18,4 +18,8 @@ const code = new NodejsAsset(stack, "Code", { For automatic Lambda, IAM and logging setup, use [`NodejsFunction`](../aws-lambda-nodejs/README.md). That guide also documents bundling options, path resolution, reproducibility, plugin configuration, platform requirements and native dependency boundaries. +`bundling.keepNames` preserves function and class names. `bundling.moduleTypes` exposes Rolldown's native loaders, and `bundling.rolldownOptions` exposes its built-in resolution, transform, tree-shaking and output options as typed JSON data. Use `bundling.configFile` for plugins, callbacks and regular expressions. Build options must be concrete during synthesis; unresolved Terraform tokens are rejected. + +`compressedSize` is the ZIP's size in bytes; `uncompressedSize` counts all entries, including source maps, emitted assets and copied files. These sizes are read without extracting the archive. `NodejsFunction` enforces Lambda's upload limits; the reusable `NodejsAsset` does not impose AWS-specific limits. + This package uses Rolldown's platform-specific native Rust bindings distributed through npm; it requires no Rust compiler or global bundler installation. Keep optional npm dependencies enabled so the correct binding is installed for the synthesis host. No bundler or build dependencies are included in deployment ZIPs unless the handler itself imports them. diff --git a/packages/@cdktn/bundler-nodejs/runner.mjs b/packages/@cdktn/bundler-nodejs/runner.mjs index 3945bd73d..3768df3d0 100644 --- a/packages/@cdktn/bundler-nodejs/runner.mjs +++ b/packages/@cdktn/bundler-nodejs/runner.mjs @@ -29,42 +29,51 @@ try { const { output: customOutput, ...customInput } = custom; if (Array.isArray(customOutput)) throw new Error("The bundling config must have one output options object."); + const { output: inlineOutput, ...inlineInput } = + request.rolldownOptions ?? {}; + const tsconfig = request.tsconfig ?? customInput.tsconfig; const facade = "\0cdktn-nodejs-entry"; const external = request.externalModules ?? []; const bundle = await rolldown({ ...customInput, + ...inlineInput, cwd: request.projectRoot, input: facade, platform: "node", preserveEntrySignatures: "strict", - tsconfig: request.tsconfig - ? path.resolve(request.projectRoot, request.tsconfig) - : undefined, - external: (id) => - external.some( - (name) => - id === name || - id.startsWith(`${name}/`) || - (name.endsWith("/*") && id.startsWith(name.slice(0, -1))), - ), + tsconfig: + typeof tsconfig === "string" + ? path.resolve(request.projectRoot, tsconfig) + : tsconfig, + // Let the native resolver match package names and subpaths without calling + // JavaScript for every import. Escape package names as literal strings. + external: external.map((name) => { + const wildcard = name.endsWith("/*"); + const prefix = (wildcard ? name.slice(0, -1) : name).replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&", + ); + return new RegExp(`^${prefix}${wildcard ? "" : "(?:/|$)"}`); + }), + moduleTypes: { ...customInput.moduleTypes, ...request.moduleTypes }, transform: { ...customInput.transform, + ...inlineInput.transform, target: request.target, define: { ...customInput.transform?.define, ...request.define }, }, plugins: [ { name: "cdktn-nodejs-entry", - resolveId: (id) => - id === facade - ? facade - : id === "cdktn:user-entry" - ? request.entry - : null, - load: (id) => - id === facade - ? `export { ${request.handler} } from "cdktn:user-entry";` - : null, + resolveId: { + filter: { id: /^(?:\0cdktn-nodejs-entry|cdktn:user-entry)$/ }, + handler: (id) => (id === facade ? facade : request.entry), + }, + load: { + filter: { id: /^\0cdktn-nodejs-entry$/ }, + handler: () => + `export { ${request.handler} } from "cdktn:user-entry";`, + }, }, ...(customInput.plugins ?? []), ], @@ -80,16 +89,19 @@ try { try { ({ output } = await bundle.generate({ ...customOutput, + ...inlineOutput, dir: request.projectRoot, file: undefined, format, entryFileNames: `index.${extension}`, chunkFileNames: `chunks/[name]-[hash].${extension}`, assetFileNames: "assets/[name]-[hash][extname]", - minify: request.minify ?? true, - sourcemap: request.sourceMap ?? true, + minify: request.minify ?? customOutput?.minify ?? true, + keepNames: request.keepNames ?? customOutput?.keepNames, + sourcemap: request.sourceMap ?? customOutput?.sourcemap ?? true, sourcemapPathTransform: (source) => source.split(path.sep).join("/"), - polyfillRequire: true, + polyfillRequire: + inlineOutput?.polyfillRequire ?? customOutput?.polyfillRequire ?? true, })); } finally { await bundle.close(); diff --git a/packages/@cdktn/bundler-nodejs/src/index.ts b/packages/@cdktn/bundler-nodejs/src/index.ts index 91c9b9505..953194f0a 100644 --- a/packages/@cdktn/bundler-nodejs/src/index.ts +++ b/packages/@cdktn/bundler-nodejs/src/index.ts @@ -7,6 +7,59 @@ import * as os from "node:os"; import * as path from "node:path"; import { App, AssetType, TerraformAsset, Token } from "cdktn"; import { Construct } from "constructs"; +import { unzipSync } from "fflate"; +import type { InputOptions, OutputOptions } from "rolldown" with { + "resolution-mode": "import", +}; + +// Inline options cross a JSON boundary into the native bundler process. Config +// modules remain available for plugins, callbacks and regular expressions. +type DataOptions = T extends (...args: any[]) => any + ? never + : T extends RegExp + ? never + : T extends object + ? { [K in keyof T]: DataOptions } + : T; + +/** Rolldown's built-in options, excluding settings managed by the construct. */ +export type NodejsRolldownOptions = DataOptions< + Omit< + InputOptions, + | "input" + | "cwd" + | "platform" + | "preserveEntrySignatures" + | "external" + | "moduleTypes" + | "tsconfig" + | "transform" + | "plugins" + | "watch" + | "devtools" + | "onwarn" + | "onLog" + > & { + transform?: Omit< + NonNullable, + "target" | "define" + >; + output?: Omit< + OutputOptions, + | "dir" + | "file" + | "format" + | "entryFileNames" + | "chunkFileNames" + | "assetFileNames" + | "minify" + | "sourcemap" + | "sourcemapPathTransform" + | "keepNames" + | "plugins" + >; + } +>; /** An additional file or directory to include in the deployment ZIP. */ export interface CopyFile { @@ -20,17 +73,23 @@ export interface NodejsBundlingOptions { /** @default "esm" */ readonly format?: "esm" | "cjs"; /** @default true */ - readonly minify?: boolean; - /** Include source maps and their original sources. @default true */ - readonly sourceMap?: boolean; + readonly minify?: DataOptions; + /** Preserve function and class names when bundling and minifying. @default false */ + readonly keepNames?: boolean; + /** Source maps: separate, inline, hidden, or disabled. @default true */ + readonly sourceMap?: OutputOptions["sourcemap"]; + /** Built-in file loaders, for example { ".sql": "text" }. */ + readonly moduleTypes?: InputOptions["moduleTypes"]; /** Packages supplied by a Lambda layer or copyFiles, including their subpaths. */ readonly externalModules?: string[]; /** Compile-time substitutions. Values are JavaScript expressions. */ readonly define?: Record; - /** Optional tsconfig path, relative to projectRoot. Otherwise discovered by Rolldown. */ - readonly tsconfig?: string; + /** A tsconfig path relative to projectRoot, or false to disable discovery. @default true */ + readonly tsconfig?: InputOptions["tsconfig"]; /** JS/MJS/CJS module exporting Rolldown options, including plugins and output options. */ readonly configFile?: string; + /** Inline built-in resolution, transform, tree-shaking and output options. */ + readonly rolldownOptions?: NodejsRolldownOptions; readonly copyFiles?: CopyFile[]; } @@ -53,12 +112,17 @@ export class NodejsAsset extends TerraformAsset { public readonly handler: string; /** Base64 SHA-256 of the exact ZIP bytes, suitable for source_code_hash. */ public readonly sourceCodeHash: string; + /** Size of the complete ZIP in bytes. */ + public readonly compressedSize: number; + /** Total size of all ZIP entries in bytes, including source maps and copied files. */ + public readonly uncompressedSize: number; constructor(scope: Construct, id: string, props: NodejsAssetProps) { + validateBuildInput(props, "options"); const projectRoot = resolveProjectRoot(scope, props.projectRoot); const entry = path.resolve(projectRoot, props.entry); const handler = props.handler ?? "handler"; - if (Token.isUnresolved(props.entry) || !fs.existsSync(entry)) { + if (!fs.existsSync(entry)) { throw new Error(`Node.js entry must be an existing local file: ${entry}`); } if (!fs.statSync(entry).isFile()) { @@ -108,6 +172,14 @@ export class NodejsAsset extends TerraformAsset { fs.rmSync(scratch, { recursive: true, force: true }); } + let uncompressedSize = 0; + // Read the ZIP directory without allocating or inflating the file contents. + unzipSync(archive, { + filter: (file) => { + uncompressedSize += file.originalSize; + return false; + }, + }); const digest = createHash("sha256").update(archive).digest(); const assetHash = digest.toString("hex"); // Keep immutable source artifacts within the app output, so repeated synths @@ -123,7 +195,46 @@ export class NodejsAsset extends TerraformAsset { super(scope, id, { path: sourcePath, type: AssetType.FILE, assetHash }); this.handler = `index.${handler}`; this.sourceCodeHash = digest.toString("base64"); + this.compressedSize = archive.byteLength; + this.uncompressedSize = uncompressedSize; + } +} + +function validateBuildInput( + value: unknown, + name: string, + parents = new Set(), +): void { + if (Token.isUnresolved(value)) { + throw new Error( + `Node.js build option ${name} contains an unresolved Terraform value. Build inputs must be known during synthesis. Pass deployment-time values through NodejsFunction.environment instead.`, + ); + } + if (value === null || value === undefined) return; + if (typeof value === "number" && !Number.isFinite(value)) { + throw new Error(`Node.js build option ${name} must be a finite number.`); + } + if (["string", "boolean", "number"].includes(typeof value)) return; + if ( + typeof value !== "object" || + (!Array.isArray(value) && + Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + throw new Error( + `Node.js build option ${name} must be JSON data. Put plugins, callbacks and regular expressions in bundling.configFile.`, + ); + } + if (parents.has(value)) + throw new Error( + `Node.js build option ${name} contains a circular reference.`, + ); + parents.add(value); + for (const [key, child] of Object.entries(value)) { + validateBuildInput(key, `${name} key`, parents); + validateBuildInput(child, `${name}.${key}`, parents); } + parents.delete(value); } function resolveProjectRoot(scope: Construct, explicit?: string): string { diff --git a/packages/@cdktn/bundler-nodejs/test/asset.test.ts b/packages/@cdktn/bundler-nodejs/test/asset.test.ts index b5fcb8cc3..ead0970bc 100644 --- a/packages/@cdktn/bundler-nodejs/test/asset.test.ts +++ b/packages/@cdktn/bundler-nodejs/test/asset.test.ts @@ -7,7 +7,7 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { pathToFileURL } from "node:url"; import { unzipSync } from "fflate"; -import { App, TerraformStack } from "cdktn"; +import { App, TerraformStack, TerraformVariable } from "cdktn"; import { NodejsAsset, NodejsAssetProps } from "../src"; let root: string; @@ -16,7 +16,7 @@ beforeEach(() => { }); afterEach(() => fs.rmSync(root, { recursive: true, force: true })); -function write(name: string, content: string, directory = root) { +function write(name: string, content: string | Uint8Array, directory = root) { const file = path.join(directory, name); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, content); @@ -44,7 +44,7 @@ function invoke( ) { const directory = fs.mkdtempSync(path.join(root, "invoke-")); for (const [name, data] of Object.entries(files)) - write(name, Buffer.from(data).toString(), directory); + write(name, data, directory); const url = pathToFileURL( path.join(directory, format === "esm" ? "index.mjs" : "index.cjs"), ).href; @@ -98,6 +98,10 @@ test("bundles TypeScript, JSON, path aliases, CommonJS dependencies and Node bui createHash("sha256").update(zip).digest("base64"), ); expect(asset.assetHash).toBe(createHash("sha256").update(zip).digest("hex")); + expect(asset.compressedSize).toBe(zip.byteLength); + expect(asset.uncompressedSize).toBe( + Object.values(files).reduce((total, file) => total + file.byteLength, 0), + ); }); test.each(["esm", "cjs"] as const)( @@ -213,6 +217,287 @@ test("supports disabling minification and source maps", () => { expect(invoke(files)).toBe("hello"); }); +test.each(["inline", "hidden"] as const)( + "uses Rolldown's native %s source map mode", + (sourceMap) => { + write("src/handler.ts", "export const handler = () => 1;"); + const { files } = synth({ bundling: { sourceMap } }); + expect(invoke(files)).toBe(1); + const code = Buffer.from(files["index.mjs"]).toString(); + if (sourceMap === "inline") { + expect(code).toContain("sourceMappingURL=data:application/json"); + expect(files["index.mjs.map"]).toBeUndefined(); + } else { + expect(code).not.toContain("sourceMappingURL"); + expect(files["index.mjs.map"]).toBeDefined(); + } + }, +); + +test("honors native config-file defaults and explicit boolean overrides", () => { + write("src/handler.ts", "export const handler = () => 1;"); + write("tsconfig.json", "{invalid configuration"); + write( + "config.mjs", + "export default {tsconfig:false,output:{minify:false,sourcemap:false}};", + ); + const inherited = synth({ bundling: { configFile: "config.mjs" } }); + expect(invoke(inherited.files)).toBe(1); + expect(inherited.files["index.mjs.map"]).toBeUndefined(); + const overridden = synth({ + bundling: { configFile: "config.mjs", minify: true, sourceMap: true }, + }); + expect(overridden.files["index.mjs.map"]).toBeDefined(); + expect(overridden.files["index.mjs"].byteLength).toBeLessThan( + inherited.files["index.mjs"].byteLength, + ); + expect(invoke(synth({ bundling: { tsconfig: false } }).files)).toBe(1); + expect(() => + synth({ bundling: { configFile: "config.mjs", tsconfig: true } }), + ).toThrow(/tsconfig/); +}); + +test.each(["esm", "cjs"] as const)( + "preserves class and function names in minified %s output", + (format) => { + write( + "src/handler.ts", + "class RegisteredService {} function registeredAction() {} export const handler = () => [RegisteredService.name, registeredAction.name];", + ); + const { files } = synth({ bundling: { format, keepNames: true } }); + expect(invoke(files, format)).toEqual([ + "RegisteredService", + "registeredAction", + ]); + }, +); + +test("uses native text, binary and asset loaders and counts every packaged byte", () => { + write("src/query.sql", "SELECT 'café';"); + write("src/payload.bin", new Uint8Array([0, 128, 255])); + write("src/template.template", "packaged template"); + write("extra.txt", "copied contents"); + write( + "src/handler.ts", + 'import { readFileSync } from "node:fs"; import query from "./query.sql"; import data from "./payload.bin"; import template from "./template.template"; export const handler = () => ({ query, data: Array.from(data), template: readFileSync(new URL(template, import.meta.url), "utf8") });', + ); + const { asset, zip, files } = synth({ + bundling: { + moduleTypes: { ".sql": "text", ".bin": "binary", ".template": "asset" }, + copyFiles: [{ from: "extra.txt", to: "extra.txt" }], + }, + }); + expect(invoke(files)).toEqual({ + query: "SELECT 'café';", + data: [0, 128, 255], + template: "packaged template", + }); + expect(Object.keys(files).some((file) => file.startsWith("assets/"))).toBe( + true, + ); + expect(asset.compressedSize).toBe(zip.byteLength); + expect(asset.uncompressedSize).toBe( + Object.values(files).reduce((total, file) => total + file.byteLength, 0), + ); +}); + +test("passes built-in aliases, export conditions, minifier and output options to Rolldown", () => { + write("src/value.ts", 'export const value = "aliased";'); + write( + "node_modules/conditional/package.json", + JSON.stringify({ + name: "conditional", + type: "module", + exports: { lambda: "./lambda.js", default: "./default.js" }, + }), + ); + write("node_modules/conditional/lambda.js", 'export default "lambda";'); + write("node_modules/conditional/default.js", 'export default "default";'); + write( + "src/handler.ts", + 'import {value} from "@value"; import condition from "conditional"; export const handler = () => [value, condition, BANNER];', + ); + const { files } = synth({ + bundling: { + minify: { compress: true, mangle: false }, + rolldownOptions: { + resolve: { + alias: { "@value": path.join(root, "src/value.ts") }, + conditionNames: ["lambda", "node", "import", "default"], + }, + output: { + banner: 'const BANNER = "built-in";', + sourcemapExcludeSources: true, + }, + }, + }, + }); + expect(invoke(files)).toEqual(["aliased", "lambda", "built-in"]); + expect( + JSON.parse(Buffer.from(files["index.mjs.map"]).toString()).sourcesContent, + ).toBeUndefined(); +}); + +test("uses native JSX transforms and injected imports without plugins", () => { + write("src/format.ts", 'export const prefix = "native";'); + write( + "src/handler.tsx", + "function h(tag, props, child) { return { tag, child, prefix: PREFIX }; } export const handler = () =>

hello

;", + ); + const { files } = synth({ + entry: "src/handler.tsx", + bundling: { + rolldownOptions: { + transform: { + jsx: { runtime: "classic", pragma: "h" }, + inject: { PREFIX: [path.join(root, "src/format.ts"), "prefix"] }, + }, + }, + }, + }); + expect(invoke(files)).toEqual({ + tag: "h1", + child: "hello", + prefix: "native", + }); +}); + +test("explicit keepNames false overrides a configuration module", () => { + write( + "src/handler.ts", + "class RegisteredService {} export const handler = () => RegisteredService.name;", + ); + write("config.mjs", "export default {output:{keepNames:true}};"); + expect(invoke(synth({ bundling: { configFile: "config.mjs" } }).files)).toBe( + "RegisteredService", + ); + expect( + invoke( + synth({ bundling: { configFile: "config.mjs", keepNames: false } }).files, + ), + ).not.toBe("RegisteredService"); +}); + +test("merges native loaders with config modules and gives explicit options precedence", () => { + write("src/query.sql", "query"); + write("src/template.html", "template"); + write( + "src/handler.ts", + 'import query from "./query.sql"; import template from "./template.html"; class RegisteredService {} export const handler = () => [query, template, RegisteredService.name, BANNER, FOOTER];', + ); + write( + "config.mjs", + `export default { + moduleTypes: { ".html": "text", ".sql": "empty" }, + output: { keepNames: false, banner: 'const BANNER = "config";', footer: 'const FOOTER = "config footer";' }, + plugins: [{ name: "extra", generateBundle() { this.emitFile({ type: "asset", fileName: "plugin.txt", source: "plugin output" }); } }], + };`, + ); + const { files } = synth({ + bundling: { + configFile: "config.mjs", + keepNames: true, + moduleTypes: { ".sql": "text" }, + rolldownOptions: { output: { banner: 'const BANNER = "inline";' } }, + }, + }); + expect(invoke(files)).toEqual([ + "query", + "template", + "RegisteredService", + "inline", + "config footer", + ]); + expect(Buffer.from(files["plugin.txt"]).toString()).toBe("plugin output"); +}); + +test.each<[string, (token: string) => Partial]>([ + ["entry", (token) => ({ entry: token })], + ["projectRoot", (token) => ({ projectRoot: token })], + ["handler", (token) => ({ handler: token })], + ["target", (token) => ({ target: token })], + [ + "bundling.define.API_URL", + (token) => ({ bundling: { define: { API_URL: JSON.stringify(token) } } }), + ], + [ + "bundling.externalModules.0", + (token) => ({ bundling: { externalModules: [token] } }), + ], + [ + "bundling.copyFiles.0.to", + (token) => ({ + bundling: { copyFiles: [{ from: "extra.txt", to: token }] }, + }), + ], + [ + "bundling.rolldownOptions.resolve.alias.@api", + (token) => ({ + bundling: { rolldownOptions: { resolve: { alias: { "@api": token } } } }, + }), + ], + [ + "bundling.moduleTypes key", + (token) => ({ bundling: { moduleTypes: { [token]: "text" } } }), + ], +])( + "rejects unresolved Terraform values in %s before starting a build", + (name, options) => { + const inputs = new TerraformStack( + new App({ outdir: path.join(root, "inputs") }), + "inputs", + ); + const token = new TerraformVariable(inputs, "api_url", { type: "string" }) + .stringValue; + write("src/handler.ts", "export const handler = () => API_URL;"); + expect(() => synth(options(token))).toThrow( + `Node.js build option options.${name} contains an unresolved Terraform value.`, + ); + expect(() => synth(options(token))).toThrow(/NodejsFunction.environment/); + }, +); + +test.each([() => "banner", /banner/])( + "rejects executable inline options instead of silently serializing them", + (banner) => { + write("src/handler.ts", "export const handler = () => 1;"); + expect(() => + synth({ + bundling: { + rolldownOptions: { output: { banner: banner as unknown as string } }, + }, + }), + ).toThrow(/bundling.configFile/); + }, +); + +test.each(["@provided/*", "provided.name", "provided+name"])( + "native external matching preserves %s and its subpaths", + (name) => { + const packageName = name.endsWith("/*") + ? `${name.slice(0, -1)}package` + : name; + write( + "src/handler.ts", + `import value from ${JSON.stringify(`${packageName}/subpath`)}; export const handler = () => value;`, + ); + const { files } = synth({ bundling: { externalModules: [name] } }); + expect(Buffer.from(files["index.mjs"]).toString()).toContain( + `${packageName}/subpath`, + ); + }, +); + +test("native external matching does not match similarly named packages", () => { + write( + "src/handler.ts", + 'import value from "providedXname/subpath"; export const handler = () => value;', + ); + expect(() => + synth({ bundling: { externalModules: ["provided.name"] } }), + ).toThrow(/providedXname/); +}); + test("missing imports, missing exports and syntax errors fail before deployment", () => { write( "src/handler.ts", From 127bc9f12f5a466ea15acdb95e10d008846127e7 Mon Sep 17 00:00:00 2001 From: Gary Sassano <10464497+garysassano@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:32:03 +0200 Subject: [PATCH 7/7] refactor(nodejs): isolate provider-independent bundler --- .../aws-lambda-nodejs/test/function.test.ts | 2 +- packages/@cdktn/bundler-nodejs/README.md | 19 ++- packages/@cdktn/bundler-nodejs/src/index.ts | 113 +++++++++++++----- .../@cdktn/bundler-nodejs/test/asset.test.ts | 32 ++++- 4 files changed, 132 insertions(+), 34 deletions(-) diff --git a/packages/@cdktn/aws-lambda-nodejs/test/function.test.ts b/packages/@cdktn/aws-lambda-nodejs/test/function.test.ts index 3a55a4ad9..0903e518e 100644 --- a/packages/@cdktn/aws-lambda-nodejs/test/function.test.ts +++ b/packages/@cdktn/aws-lambda-nodejs/test/function.test.ts @@ -228,7 +228,7 @@ test("deployment-time values remain valid in runtime environment variables", () ...props, bundling: { define: { API_URL: JSON.stringify(url) } }, }), - ).toThrow(/NodejsFunction.environment/); + ).toThrow(/consuming construct or resource/); }); test("validates options which cannot produce a Node.js function", () => { diff --git a/packages/@cdktn/bundler-nodejs/README.md b/packages/@cdktn/bundler-nodejs/README.md index e0d0915f1..d64974c51 100644 --- a/packages/@cdktn/bundler-nodejs/README.md +++ b/packages/@cdktn/bundler-nodejs/README.md @@ -1,6 +1,21 @@ -# Native Node.js bundle assets +# Native Node.js bundles and assets -`NodejsAsset` bundles TypeScript or JavaScript with Rolldown and produces a deterministic ZIP through CDK Terrain's `TerraformAsset` staging API. +`NodejsBundler` bundles TypeScript or JavaScript with Rolldown and produces a deterministic ZIP without requiring an app, stack, provider or staging lifecycle. + +```ts +import { NodejsBundler } from "@cdktn/bundler-nodejs"; + +const bundle = new NodejsBundler().bundle({ + entry: "src/handler.ts", + projectRoot: process.cwd(), + target: "node24", +}); + +// bundle.archive contains the deployment ZIP. +// bundle.handler, assetHash and sourceCodeHash describe the exact ZIP bytes. +``` + +`NodejsAsset` is the CDK Terrain adapter that stages the result through the existing `TerraformAsset` API. ```ts import { NodejsAsset } from "@cdktn/bundler-nodejs"; diff --git a/packages/@cdktn/bundler-nodejs/src/index.ts b/packages/@cdktn/bundler-nodejs/src/index.ts index 953194f0a..4f1fa049a 100644 --- a/packages/@cdktn/bundler-nodejs/src/index.ts +++ b/packages/@cdktn/bundler-nodejs/src/index.ts @@ -93,33 +93,40 @@ export interface NodejsBundlingOptions { readonly copyFiles?: CopyFile[]; } -/** Source code to bundle into a deployable Node.js asset. */ -export interface NodejsAssetProps { +/** Inputs for one provider-independent Node.js bundle operation. */ +export interface NodejsBundleProps { /** TypeScript or JavaScript entry file, relative to projectRoot. */ readonly entry: string; /** Export to expose from the bundle. @default "handler" */ readonly handler?: string; - /** Root for relative paths. @default directory containing cdktf.json, or cwd */ - readonly projectRoot?: string; + /** Root for entry, configuration and copied-file paths. */ + readonly projectRoot: string; /** Node.js syntax target. @default "node24" */ readonly target?: string; readonly bundling?: NodejsBundlingOptions; } -/** A deterministic ZIP built by Rolldown and staged by TerraformAsset. */ -export class NodejsAsset extends TerraformAsset { +/** The deterministic output of a Node.js bundle operation. */ +export interface NodejsBundle { + /** Complete deployment ZIP. */ + readonly archive: Uint8Array; /** Lambda-compatible module and export, for example "index.handler". */ - public readonly handler: string; - /** Base64 SHA-256 of the exact ZIP bytes, suitable for source_code_hash. */ - public readonly sourceCodeHash: string; + readonly handler: string; + /** Hexadecimal SHA-256 of the exact ZIP bytes. */ + readonly assetHash: string; + /** Base64 SHA-256 of the exact ZIP bytes. */ + readonly sourceCodeHash: string; /** Size of the complete ZIP in bytes. */ - public readonly compressedSize: number; - /** Total size of all ZIP entries in bytes, including source maps and copied files. */ - public readonly uncompressedSize: number; + readonly compressedSize: number; + /** Total size of all ZIP entries in bytes. */ + readonly uncompressedSize: number; +} - constructor(scope: Construct, id: string, props: NodejsAssetProps) { +/** Builds deterministic Node.js ZIPs without a construct or staging lifecycle. */ +export class NodejsBundler { + public bundle(props: NodejsBundleProps): NodejsBundle { validateBuildInput(props, "options"); - const projectRoot = resolveProjectRoot(scope, props.projectRoot); + const projectRoot = path.resolve(props.projectRoot); const entry = path.resolve(projectRoot, props.entry); const handler = props.handler ?? "handler"; if (!fs.existsSync(entry)) { @@ -137,8 +144,6 @@ export class NodejsAsset extends TerraformAsset { throw new Error(`Invalid Node.js target: ${props.target}`); } - // Output hashing needs a real build. Never reuse a source-only cache: imports, - // lockfiles, package exports, plugins and copied files can all change output. const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "cdktn-nodejs-")); let archive: Buffer; try { @@ -173,7 +178,6 @@ export class NodejsAsset extends TerraformAsset { } let uncompressedSize = 0; - // Read the ZIP directory without allocating or inflating the file contents. unzipSync(archive, { filter: (file) => { uncompressedSize += file.originalSize; @@ -181,22 +185,72 @@ export class NodejsAsset extends TerraformAsset { }, }); const digest = createHash("sha256").update(archive).digest(); - const assetHash = digest.toString("hex"); + return { + archive, + handler: `index.${handler}`, + assetHash: digest.toString("hex"), + sourceCodeHash: digest.toString("base64"), + compressedSize: archive.byteLength, + uncompressedSize, + }; + } +} + +/** Source code to bundle into a deployable Node.js asset. */ +export interface NodejsAssetProps { + /** TypeScript or JavaScript entry file, relative to projectRoot. */ + readonly entry: string; + /** Export to expose from the bundle. @default "handler" */ + readonly handler?: string; + /** Root for relative paths. @default directory containing cdktf.json, or cwd */ + readonly projectRoot?: string; + /** Node.js syntax target. @default "node24" */ + readonly target?: string; + readonly bundling?: NodejsBundlingOptions; +} + +/** A deterministic ZIP built by Rolldown and staged by TerraformAsset. */ +export class NodejsAsset extends TerraformAsset { + /** Lambda-compatible module and export, for example "index.handler". */ + public readonly handler: string; + /** Base64 SHA-256 of the exact ZIP bytes, suitable for source_code_hash. */ + public readonly sourceCodeHash: string; + /** Size of the complete ZIP in bytes. */ + public readonly compressedSize: number; + /** Total size of all ZIP entries in bytes, including source maps and copied files. */ + public readonly uncompressedSize: number; + + constructor(scope: Construct, id: string, props: NodejsAssetProps) { + const projectRoot = resolveProjectRoot(scope, props.projectRoot); + validateBuildInput(props, "options", new Set(), (value) => + Token.isUnresolved(value), + ); + const bundle = new NodejsBundler().bundle({ + entry: props.entry, + handler: props.handler, + projectRoot, + target: props.target, + bundling: props.bundling, + }); // Keep immutable source artifacts within the app output, so repeated synths // can use TerraformAsset's normal staging without leaked temporary trees. const sourcePath = path.resolve( App.of(scope).outdir, ".nodejs-assets", - assetHash, + bundle.assetHash, "archive.zip", ); fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); - fs.writeFileSync(sourcePath, archive); - super(scope, id, { path: sourcePath, type: AssetType.FILE, assetHash }); - this.handler = `index.${handler}`; - this.sourceCodeHash = digest.toString("base64"); - this.compressedSize = archive.byteLength; - this.uncompressedSize = uncompressedSize; + fs.writeFileSync(sourcePath, bundle.archive); + super(scope, id, { + path: sourcePath, + type: AssetType.FILE, + assetHash: bundle.assetHash, + }); + this.handler = bundle.handler; + this.sourceCodeHash = bundle.sourceCodeHash; + this.compressedSize = bundle.compressedSize; + this.uncompressedSize = bundle.uncompressedSize; } } @@ -204,10 +258,11 @@ function validateBuildInput( value: unknown, name: string, parents = new Set(), + isUnresolved: (value: unknown) => boolean = () => false, ): void { - if (Token.isUnresolved(value)) { + if (isUnresolved(value)) { throw new Error( - `Node.js build option ${name} contains an unresolved Terraform value. Build inputs must be known during synthesis. Pass deployment-time values through NodejsFunction.environment instead.`, + `Node.js build option ${name} contains an unresolved Terraform value. Build inputs must be known during synthesis. Pass deployment-time values to the consuming construct or resource instead.`, ); } if (value === null || value === undefined) return; @@ -231,8 +286,8 @@ function validateBuildInput( ); parents.add(value); for (const [key, child] of Object.entries(value)) { - validateBuildInput(key, `${name} key`, parents); - validateBuildInput(child, `${name}.${key}`, parents); + validateBuildInput(key, `${name} key`, parents, isUnresolved); + validateBuildInput(child, `${name}.${key}`, parents, isUnresolved); } parents.delete(value); } diff --git a/packages/@cdktn/bundler-nodejs/test/asset.test.ts b/packages/@cdktn/bundler-nodejs/test/asset.test.ts index ead0970bc..c4cb2423d 100644 --- a/packages/@cdktn/bundler-nodejs/test/asset.test.ts +++ b/packages/@cdktn/bundler-nodejs/test/asset.test.ts @@ -8,7 +8,7 @@ import { createHash } from "node:crypto"; import { pathToFileURL } from "node:url"; import { unzipSync } from "fflate"; import { App, TerraformStack, TerraformVariable } from "cdktn"; -import { NodejsAsset, NodejsAssetProps } from "../src"; +import { NodejsAsset, NodejsAssetProps, NodejsBundler } from "../src"; let root: string; beforeEach(() => { @@ -65,6 +65,32 @@ function invoke( ); } +test("bundles without a construct or staging lifecycle", () => { + write( + "src/handler.ts", + "export const handler = ({name}: {name: string}) => `Hello ${name}`;", + ); + const bundle = new NodejsBundler().bundle({ + entry: "src/handler.ts", + projectRoot: root, + target: "node24", + }); + const files = unzipSync(bundle.archive); + + expect(invoke(files)).toBe("Hello Ada"); + expect(bundle.handler).toBe("index.handler"); + expect(bundle.assetHash).toBe( + createHash("sha256").update(bundle.archive).digest("hex"), + ); + expect(bundle.sourceCodeHash).toBe( + createHash("sha256").update(bundle.archive).digest("base64"), + ); + expect(bundle.compressedSize).toBe(bundle.archive.byteLength); + expect(bundle.uncompressedSize).toBe( + Object.values(files).reduce((total, file) => total + file.byteLength, 0), + ); +}); + test("bundles TypeScript, JSON, path aliases, CommonJS dependencies and Node builtins into an executable ESM ZIP", () => { write("package.json", '{"type":"module"}'); write( @@ -453,7 +479,9 @@ test.each<[string, (token: string) => Partial]>([ expect(() => synth(options(token))).toThrow( `Node.js build option options.${name} contains an unresolved Terraform value.`, ); - expect(() => synth(options(token))).toThrow(/NodejsFunction.environment/); + expect(() => synth(options(token))).toThrow( + /consuming construct or resource/, + ); }, );