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

AWS CDK Template for ESM

だいたい18分で読めます

The completed template is available on GitHub.

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

Overview

While AWS CDK is very convenient for infrastructure deployment, 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 look at it step by step from initialization.

When you run cdk init --language=typescript, it creates a project skeleton. First, let's confirm that cdk synth works in the initial state. I named the project cdk-esm-boilerplate.

First, add "type": "module", to package.json. Also, change the parameters in tsconfig.json to ESNext to make it more 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", has been added, without which it automatically becomes a classic value and causes errors in the IDE.

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

Errors analyzed in VSCode
Errors analyzed in VSCode

Error Handling

If you try to synth in this state, you'll get an error.

TypeError: Unknown file extension ".ts" for hoge/bin/cdk-esm-boilerplate.ts

Looking at cdk.json, it's using npx ts-node during execution, which is the cause. There are various ways to deal with this, but among the ones I tried, using tsx worked best.

"app": "npx tsx bin/cdk-esm-boilerplate.ts",

With this, the ESM support for CDK itself is complete. It's just using tsx, but getting to this point was quite challenging. 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 with ESM.

new cdk.aws_lambda_nodejs.NodejsFunction(this, "testLambda", {
    entry: path.join(__dirname, "../lambda/index.ts"),
});

Instead, use import.meta.dirname to resolve this.

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 the use of top-level await and dynamic import. import.meta might look unfamiliar, but this is considered a good way to write code that you only want to execute when directly run.

When directly executed, you can see that main is executed as follows. This is convenient for quick tests.

$ pnpx tsx ./lambda/index.ts
result: { exists: false, statusCode: 200 }

Now, if you try to deploy this as is, you'll get an error.

✘ [ERROR] Top-level await is currently not supported with the "cjs" output format

With the default settings of aws_lambda_nodejs, esbuild converts to cjs and packages it. Therefore, it complains about ESM descriptions. Conveniently, you can easily configure the output format, so let's add it. The bundling parameter 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 deploy again, you'll see that it's properly uploaded to Lambda. Execution on Lambda is also problem-free.

When Using aws-sdk

Just when you think you're done, you'll get an error if you use aws-sdk. Let's actually try it by adding an S3 usage to the Lambda code.

import { S3Client } from "@aws-sdk/client-s3";
const s3Client = new S3Client({});

If you deploy in this state and execute the Lambda function, you'll get the following 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 message, it seems to be throwing an error in a folder called dist-cjs of smithy (middleware that provides AWS SDK in various languages).

You might think everything was packaged as ESM, but this is a known issue with bundling.

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

If you look at the asset.xxx folder in cdk.out, you'll find the bundled index.mjs, which you can check.

There are two ways to work around this problem.

The first is to use the AWS SDK provided by the Lambda runtime. You just need to exclude @aws-sdk, so you can either specify these in the bundling options or, as mentioned in the CDK docs, specify NODEJS_18_X or NODEJS_20_X for the runtime.

The second method, which can be used for things other than AWS SDK, is to specify mainFields and banner in the bundling options as follows. This is also recommended in the issue. It's also a good idea to specify the runtime version. If you specify NODEJS_LATEST, it will automatically use the latest version that works in all regions (as of 2024/04, it's 18), so you can be lazy.

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 about ESM, we've managed to resolve the issue. If you deploy with this, it should work properly.

Testing

This is a tricky part because jest needs to be adapted. In conclusion, delete the default jest.config.js and create a 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;

Additional libraries are needed, so install them.

pnpm i -D ts-jest-resolver

Also, you need to change the node options. Referring to the official jest guide, 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, prepare test/lambda.test.ts and execute the following code. Since Lambda functions with multiple files will have conflicting function names called handler, it's easier to dynamically import within 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, I 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