header source
my icon
esplo.net
ぷるぷるした直方体
Cover Image for AWS CDK Template for ESM

AWS CDK Template for ESM

about17mins to read

The completed template is available on GitHub.

https://github.com/esplo/cdk-esm-boilerplate

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.

https://github.com/microsoft/TypeScript/issues/50058

Error analyzed in VSCode
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).

https://github.com/aws/aws-cdk/issues/29310

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.

Share