見出し画像

AWS CDKをESMにする

本記事の内容を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/@smithy+util-buffer-from@2.2.0/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もだいぶ広まってきました。早いうちに対応しておくと、今後楽かもしれません。

この記事が気に入ったらサポートをしてみませんか?