📦

サーバーサイドでTypeScriptのESM(ES Modules)プロジェクトをesbuildでbundleする

2023/02/05に公開

esbuildで実行ファイルをビルドするところで結構ハマったのでメモ。

TL;DR;

esbuildを使う場合に特有な部分のざっくりとしたまとめとしては、

  • formatオプションでesmを指定することで出力形式をESMにする
  • commonjsライブラリをインポートしてbundleするとrequireなどcommonjs特有の文法も一緒にビルド結果に混じってエラーが発生するため、bannarオプションなどで対策する。または、packageオプションを使ってサードパーティライブラリを外だしして対処する。

といった感じ。

以下、詳細。

検証環境

  • Linux: Ubuntu22.04LTS @WSL2(Windows11)
  • nodejs 18.13.0LTS
  • pnpm@7.26.3
    • npmやyarnを使っている人は適宜読み替えてください
  • (TypeScript: 4.9.5)

プロジェクトの設定

ディレクトリ・ファイル構成は今回は以下のようなものを想定

.
├── package.json
├── pnpm-lock.yaml
├── build.ts
├── dist
│   └── main.mjs ★ esbuildでビルドされた実行ファイル
├── src
│   ├── main.ts ★プロジェクトのエントリーポイント
│   └── (main.tsからimportされるファイル・ディレクトリたち)
└── tsconfig.json

上記の構成に対して最終的に、

# ビルド
pnpm build

# 実行
pnpm start

のようにしてビルドと実行が出来るようにセットアップしていく。

package.json

例えば以下のような感じ:

package.json
{
  "name": "test-esm-app",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "tsx build.ts",
    "start": "node dist/main.mjs"
  },
  "devDependencies": {
    "@types/node": "^18.11.18",
    "esbuild": "^0.17.5",
    "tsx": "^3.12.2",
    "typescript": "^4.9.5"
    // 他、適宜必要なもの
  },
  "dependencies": {
    // 適宜必要なもの
  }
}

ポイントおよび補足として、

  • ESMプロジェクトなので、"type": "module"を入れる
  • 必須ではないが、ビルド用スクリプト(build.ts)の実行の際、便利のためtsxをインストールしている。

tsconfig.json

例えば以下のような感じ:

tsconfig.json
{
  "compilerOptions": {
    "target": "es2022", // デフォルトから変更
    "module": "es2022", // デフォルトから変更
    "moduleResolution": "nodenext", // 追加
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

tsc --initで自動生成されるものから設定を変えているのは以下:

  • target: es2022以降にする。(top-level-awaitなどはes2022以降)
  • module: ESMとして実行するのでcommonjsでなく、es2022以降にしておく
  • moduleResolution: node16かそれ以降を設定

上記の設定だと、自作モジュールのインポートの際、拡張子.jsなどをつける必要があるので注意。

// ↓元のファイルが `./path/to/my-module1.ts`
import { foo } from "./path/to/my-module1.js";

// ↓元のファイルが`./path/to/my-module2.mts`
import { bar } from "./path/to/my-module2.mjs";

なお、上記の情報はTypeScript4.9.5まででの話であり、

https://speakerdeck.com/uhyo/tuinilai-ru-typescript5-dot-0noxin-ji-neng?slide=11
https://zenn.dev/general_link/articles/813e47b7a0eef7#--moduleresolution-bundler

などによればTypeScript5.0以降ではtsconfigのmoduleResolutionをbundlerなどに設定すれば拡張子は省略出来るようになるかもしれない。

esbuildのビルドオプション

本記事のメイン。

esbuildは別にCLIから実行しても良いが、オプションが長く複雑なのでビルド用スクリプトbuild.tsを用意している。

今回の構成だと例えば以下のような内容が必要:

build.ts
import esbuild from 'esbuild';

await esbuild.build({
  bundle: true,
  entryPoints: ['./src/main.ts'],
  outdir: './dist',
  outExtension: { // 必須では無いが、ESM形式で出力されることを明示的にするため拡張子を.mjsにしている
    '.js': '.mjs'
  },
  platform: 'node', // nodejsで実行するため必要
  format: 'esm', // ESMプロジェクトなので、出力フォーマットを'esm'に設定する必要
  banner: { // commonjs用ライブラリをESMプロジェクトでbundleする際に生じることのある問題への対策
    js: 'import { createRequire } from "module"; import url from "url"; const require = createRequire(import.meta.url); const __filename = url.fileURLToPath(import.meta.url); const __dirname = url.fileURLToPath(new URL(".", import.meta.url));',
  },
})

特に本質的な部分はplatform以降のオプション。

  • platform: ブラウザ上ではなくサーバーサイドで実行するのでnodeを選択
  • format: esm形式で出力するにはesmを選択する必要
  • banner: commonjs用ライブラリをESMプロジェクトでbundleする際に生じることのある問題への対策(※後述)

※commonjs用ライブラリをesmにbundleする際、require__dirname__filenameといったESMでは使えないcommonjs特有の機能を特に変換せずにそのままビルド結果に含めてしまう問題がある。bannarオプションを使うとビルド結果のファイルの先頭に指定した文字列を追加出来るため、ここでESM形式のファイルから上記の変数を使えるようにするおまじないを設定する。

// require をESMで使う
import { createRequire } from "module";
const require = createRequire(import.meta.url);

// __filename, __dirname をESMで使う
import url from "url";
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = url.fileURLToPath(new URL(".", import.meta.url));

上記の内容に相当する内容を1行にしてbannarに設定する。これをやらないと使っているライブラリによっては例えばError: Dynamic require of "events" is not supportedといった感じのえらーが大量に発生して動かない感じになる。

その他オプションの設定やカスタマイズについては、esbuildの公式ドキュメントを参照のこと。

上記のようなビルドオプションで、esbuildによりnodeで実行するESMプロジェクトのビルドが出来る。

(補足)esbuildでpackagesオプションを使う場合

今回はbannarオプションの設定でエラー対処を行っているが、packagesオプションをexternalに設定する方法も考えられる。

# 公式ドキュメント例より:
esbuild app.js --bundle --packages=external

Use this setting to exclude all of your package's dependencies from the bundle. This is useful when bundling for node because many npm packages use node-specific features that esbuild doesn't support while bundling (such as __dirname, import.meta.url, fs.readFileSync, and *.node native binary modules).

DeepL翻訳: 「この設定は、パッケージの依存関係をすべてバンドルから除外するために使用します。多くの npm パッケージは、esbuild がバンドル中にサポートしないノード固有の機能 (__dirname, import.meta.url, fs.readFileSync, *.node ネイティブバイナリモジュールなど) を使っているので、node 用にバンドルする際に便利です。」

(今回の用途に対して結構ドンピシャですね。)

  • 自作モジュールなどはbundleされるが、サードパーティライブラリは外だしされる
  • ESM形式のファイルはcommonjs形式のファイルを問題なくimportして使える

みたいな形でうまく動作させることが出来そう。
(当然、デプロイ時は必要なライブラリを含むnode_modulesを実行ファイルと一緒に置くようにするなどの対応が必要。)

ライブラリを外だししてbundleに含めないことのデメリットとしては、一般的なbundleすることのメリット(tree-shakingなどによってビルド後のファイルサイズを小さく出来る、など)の反対が言えそう。

テスト

次回記事に記載
https://zenn.dev/junkor/articles/a2f6ecf296552e

参考

この記事の元のスクラップ:
https://zenn.dev/junkor/scraps/f55a5b620c53c5
この記事ではスクラップから必要な部分だけ抽出して実際に動かす部分などは省略して書いている。

そもそもTypeScriptでサーバーサイドのESMをセットアップする部分は以下を参考にした:

https://www.memory-lovers.blog/entry/2022/05/31/110000
https://blog.cybozu.io/entry/2020/10/06/170000
https://quramy.medium.com/typescript-4-7-と-native-node-js-esm-189753a19ba8

また、aws-cdkはnodejsで書いたLambdaをesbuildでビルド・デプロイする機能をサポートしており、ESM対応したLambdaをデプロイする例が掲載されている
https://dev.classmethod.jp/articles/aws-lambda-support-node-js-18/
も参考になった。

GitHubで編集を提案

Discussion