ESMのAWS CDK Template
Table of Contents
完成形はGithubのTemplateにしています。
概要
インフラのデプロイに大層便利なAWS CDKですが、initするとCJSが生まれます。現在の潮流を考えると、プロジェクトコード全体をESMにしたいものです。
どこを変える必要があるか
initから順番に見てゆきます。
cdk init --language=typescript
をすると、プロジェクトの雛形が生まれます。まずは初期状態で cdk synth
をして、通ることを確認します。プロジェクト名は cdk-esm-boilerplate
としました。
まずはpackage.jsonに "type": "module",
を追記します。また、tsconfig.json
のパラメーターをESNextにしてESMっぽくします。
{
"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"]
}
なお、"moduleResolution": "node",
が増えていますが、これが無いと勝手に classic な値になってしまいIDE上でエラーになります。
VSCode上で解析されるエラー
エラー潰し
この状態でsynthをするとエラーが出ます。
TypeError: Unknown file extension ".ts" for hoge/bin/cdk-esm-boilerplate.ts
cdk.json
を見ると、実行時に npx ts-node
をしており、これが原因です。対処法は色々ありますが、自分が試した中では tsx
を使うのが最も上手くいきました。
"app": "npx tsx bin/cdk-esm-boilerplate.ts",
ここまでで、CDK自体のESM対応は完了です。tsxを使うだけですが、これにたどり着くまでが結構大変でした。tsxに感謝。
LambdaもESMにする
プロジェクト全体なので、忘れずLambdaもESM対応します。CDKの aws_lambda_nodejs
がほぼそのまま使えます。一点、entryを独自に指定する場合、このようにしたい……ところですが、ESMだと __dirname
が使えません。
new cdk.aws_lambda_nodejs.NodejsFunction(this, "testLambda", {
entry: path.join(__dirname, "../lambda/index.ts"),
});
代わりに import.meta.dirname
を使えば解決です。
new cdk.aws_lambda_nodejs.NodejsFunction(this, "testLambda", {
entry: path.join(import.meta.dirname, "../lambda/index.ts"),
});
次はLambdaのコード側に関する設定です。まずは、LambdaのコードにESMやES2020な記述を入れてみましょう。
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({});
}
}
Top-level awaitやdynamic importが使われていることに注目です。 import.meta
は見慣れないですが、直接実行された時にのみ実行したいコードはこんな感じで書くのが良いとされています。
直接実行すると、次のようにmainが実行されていることが分かります。ちょっとしたテストに便利ですね。
$ pnpx tsx ./lambda/index.ts
result: { exists: false, statusCode: 200 }
さて、これをそのままデプロイしようとすると怒られます。
✘ [ERROR] Top-level await is currently not supported with the "cjs" output format
aws_lambda_nodejs
のデフォルト設定では、esbuildがcjsに変換してパッケージングします。そのため、ESMな記述は知らんで、と怒ってきます。便利なもので、出力形式も簡単に設定できるので追記します。bundlingはesbuildの設定を色々変更できるパラメーターです。
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,
},
});
再度デプロイしてみると、ちゃんとLambdaに上げてくれていることが分かります。Lambda上での実行も問題ありません。
aws-sdkを使う場合
さて、これで終わりと思いきや、aws-sdkを利用する場合はエラーが出ます。実際に試すため、LambdaコードにS3を使う記述を追加してみましょう。
import { S3Client } from "@aws-sdk/client-s3";
const s3Client = new S3Client({});
この状態でデプロイしてLambda関数を実行すると、次のようなエラーが出ます。
{
"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)",
メッセージを睨むと、smithy(AWS SDKをいろいろな言語に提供してるミドルウェア)のdist-cjs
なるフォルダでエラーを吐いていますね。
すべてESMにしてパッケージングしたのでは??と思いますが、bundlingに関する既知のissueです。
cdk.outのasset.xxxフォルダを見ると、bundlingされたindex.mjsがあるので、そちらで確認してみると良いでしょう。
この問題を回避する方法は2つあります。
1つ目はLambdaランタイム側で用意しているAWS SDKを利用する方法です。@aws-sdk
をexcludeすれば良いので、これらをbundlingのオプションで指定するか、CDKのdocにあるとおりランタイムにNODEJS_18_X
かNODEJS_20_X
を指定すると除外されます。
2つ目はAWS SDK以外にも使える方法で、bundlingオプションのmainFieldsとbannerを次のように指定する方法です。issueの中でもオススメされています。ついでにruntime versionも指定しておくと良いでしょう。NODEJS_LATEST
を指定しておくと、勝手に全リージョンで動く最新版(2024/04現在は18)が使われるので怠慢できます。
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);",
},
});
ESMなことをesbuildに教え込んで事なきを得ました。これでdeployすると、正常に動作させられます。
テスト
jestの対応が必要なため厄介な部分です。結論としては、デフォルトの jest.config.js
を削除して次のような jest.config.ts
を作成します。
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;
追加でライブラリが必要なのでインストールします。
pnpm i -D ts-jest-resolver
また、nodeのオプションも変更する必要があります。jest公式のガイドを参考に、package.json
のtest実行スクリプトにオプションを追記します。
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
これで動くようになるはずです。
例えば Lambda 関数のテストをしたい場合は、 test/lambda.test.ts
を用意し、次のようなコードを実行します。Lambdaは複数ファイルあるとhandlerという関数名が被るので、テストケース内でdynamicにimportすると楽です。
test("lamda", async () => {
const hander = (await import("../lambda/index")).handler;
const result = await hander(null);
console.log(result);
expect(result.statusCode).toBe(200);
});
GitHubのテンプレート化
CDKプロジェクトを作るたびこの処理をするのも面倒なので、GitHubのテンプレートにします。未だに変数が使えないので、"Use 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
まとめ
ESMもだいぶ広まってきました。早いうちに対応しておくと、今後楽かもしれません。