AWS CDK Template for ESM
The completed template is available on GitHub.
Overview
AWS CDK is very convenient for infrastructure deployment, but it generates CJS when initialized. Considering the current trend, we want to make the entire project code ESM.
What Needs to be Changed
Let's take a look at the process from initialization.
When you run cdk init --language=typescript
, a project template is generated. First, let's confirm that it works by running cdk synth
. I named the project cdk-esm-boilerplate
.
First, add "type": "module"
to package.json
. Also, change the tsconfig.json
parameters to make it ESM-like.
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"lib": ["esnext", "dom"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": false,
"inlineSourceMap": true,
"inlineSources": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"typeRoots": ["./node_modules/@types"]
},
"exclude": ["node_modules", "cdk.out"]
}
Note that "moduleResolution": "node"
is added. If this is not set, it will default to classic and cause errors in the IDE.
Error analyzed in VSCode
Error Handling
If you run synth
in this state, an error will occur.
TypeError: Unknown file extension ".ts" for hoge/bin/cdk-esm-boilerplate.ts
Looking at cdk.json
, it runs npx ts-node
at execution time, which is the cause. One way to handle this is to use tsx
.
"app": "npx tsx bin/cdk-esm-boilerplate.ts",
This completes the ESM support for CDK itself. It's just a matter of using tsx
, but it took a lot of effort to get to this point. Thanks to tsx
.
Making Lambda ESM
Since it's a project-wide change, let's not forget to make Lambda ESM as well. CDK's aws_lambda_nodejs
can be used almost as is. One point to note is that when specifying the entry point, __dirname
cannot be used.
new cdk.aws_lambda_nodejs.NodejsFunction(this, "testLambda", {
entry: path.join(__dirname, "../lambda/index.ts"),
});
Instead, use import.meta.dirname
.
new cdk.aws_lambda_nodejs.NodejsFunction(this, "testLambda", {
entry: path.join(import.meta.dirname, "../lambda/index.ts"),
});
Next is the Lambda code-side settings. Let's add ESM and ES2020-like descriptions to the Lambda code.
import * as url from "node:url";
const main = async (
event: any
): Promise<{ exists: boolean; statusCode: number }> => {
const fs = await import("fs");
const result = {
exists: fs.existsSync("/tmp/hoge"),
statusCode: 200,
};
console.log("result:", result);
return result;
};
export const handler = async (event: any): Promise<any> => {
console.log("EVENT: \n" + JSON.stringify(event, null, 2));
return await main(event);
};
if (import.meta.url.startsWith("file:")) {
const modulePath = url.fileURLToPath(import.meta.url);
if (process.argv[1] === modulePath) {
await main({});
}
}
Note that top-level await and dynamic import are used. import.meta
may look unfamiliar, but it's a good way to write code that should only be executed when run directly.
If you run it directly, you can see that the main function is executed.
$ pnpx tsx ./lambda/index.ts
result: { exists: false, statusCode: 200 }
However, if you try to deploy it as is, you'll get an error.
✘ [ERROR] Top-level await is currently not supported with the "cjs" output format
aws_lambda_nodejs
defaults to using esbuild to convert to CJS and package it. Therefore, ESM descriptions are not recognized, and it gets angry. Conveniently, you can easily set the output format. Bundling is a parameter that allows you to change various esbuild settings.
new cdk.aws_lambda_nodejs.NodejsFunction(this, "testLambda", {
entry: path.join(import.meta.dirname, "../lambda/index.ts"),
bundling: {
format: cdk.aws_lambda_nodejs.OutputFormat.ESM,
},
});
If you redeploy, you can see that it's properly uploaded to Lambda. Lambda execution also works without issues.
Using aws-sdk
Now, if you try to use aws-sdk, you'll get an error. Let's add an S3 description to the Lambda code to test it.
import { S3Client } from "@aws-sdk/client-s3";
const s3Client = new S3Client({});
If you deploy and run the Lambda function, you'll get an error.
{
"errorType": "Error",
"errorMessage": "Dynamic require of \"buffer\" is not supported",
"trace": [
"Error: Dynamic require of \"buffer\" is not supported",
" at file:///var/task/index.mjs:12:9",
" at node_modules/.pnpm/@[email protected]/node_modules/@smithy/util-buffer-from/dist-cjs/index.js (file:///var/task/index.mjs:1011:25)",
Looking at the error message, it's complaining about the dist-cjs
folder in smithy (middleware that provides AWS SDK to various languages).
There are two ways to avoid this issue.
One is to use the AWS SDK provided by the Lambda runtime. You can exclude @aws-sdk
by specifying it in the bundling options or by setting NODEJS_18_X
or NODEJS_20_X
in the CDK doc.
The other is to use the bundling options mainFields
and banner
. This is recommended in the issue.
new cdk.aws_lambda_nodejs.NodejsFunction(this, "testLambda", {
entry: path.join(import.meta.dirname, "../lambda/index.ts"),
runtime: cdk.aws_lambda.Runtime.NODEJS_LATEST,
bundling: {
format: cdk.aws_lambda_nodejs.OutputFormat.ESM,
mainFields: ["module", "main"],
banner:
"const require = (await import('node:module')).createRequire(import.meta.url);const __filename = (await import('node:url')).fileURLToPath(import.meta.url);const __dirname = (await import('node:path')).dirname(__filename);",
},
});
By teaching esbuild that it's ESM, we've avoided the issue. Now, deployment works normally.
Testing
It's troublesome because jest support is necessary. In conclusion, delete the default jest.config.js
and create a new jest.config.ts
like this.
import type { JestConfigWithTsJest } from "ts-jest";
const jestConfig: JestConfigWithTsJest = {
extensionsToTreatAsEsm: [".ts"],
transform: {
"\\.[jt]sx?$": [
"ts-jest",
{
useESM: true,
},
],
},
resolver: "ts-jest-resolver",
};
export default jestConfig;
Additionally, you need to install the required library.
pnpm i -D ts-jest-resolver
You also need to change the node options. Refer to the jest official guide and add options to the test execution script in package.json
.
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
This should make it work.
For example, if you want to test a Lambda function, create a test/lambda.test.ts
and write code like this. Since Lambda has multiple files and the handler function name is duplicated, it's convenient to dynamically import it in the test case.
test("lamda", async () => {
const hander = (await import("../lambda/index")).handler;
const result = await hander(null);
console.log(result);
expect(result.statusCode).toBe(200);
});
GitHub Template
Since it's a hassle to do this process every time you create a CDK project, we made it a GitHub template. Unfortunately, variables cannot be used yet, so you need to manually run a script to replace the stack name and project name after using the template.
RESOURCE_NAME="RepoName"
FILE_NAME="repo-name"
sed -i '' -E "s/CdkEsmBoilerplate/${RESOURCE_NAME}/g" **/*.ts *.json
sed -i '' -E "s/cdk-esm-boilerplate/${FILE_NAME}/g" **/*.ts *.json
mv bin/cdk-esm-boilerplate.ts bin/${FILE_NAME}.ts
mv lib/cdk-esm-boilerplate-stack.ts lib/${FILE_NAME}-stack.ts
Conclusion
ESM has become quite widespread. It's a good idea to support it early on, as it may make things easier in the future.