サーバーサイドでTypeScriptのESM(ES Modules)プロジェクトをesbuildでbundleする
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
例えば以下のような感じ:
{
"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
例えば以下のような感じ:
{
"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まででの話であり、
などによればTypeScript5.0以降ではtsconfigのmoduleResolution
をbundlerなどに設定すれば拡張子は省略出来るようになるかもしれない。
esbuildのビルドオプション
本記事のメイン。
esbuildは別にCLIから実行しても良いが、オプションが長く複雑なのでビルド用スクリプト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などによってビルド後のファイルサイズを小さく出来る、など)の反対が言えそう。
テスト
次回記事に記載
参考
この記事の元のスクラップ:
この記事ではスクラップから必要な部分だけ抽出して実際に動かす部分などは省略して書いている。そもそもTypeScriptでサーバーサイドのESMをセットアップする部分は以下を参考にした:
また、aws-cdkはnodejsで書いたLambdaをesbuildでビルド・デプロイする機能をサポートしており、ESM対応したLambdaをデプロイする例が掲載されている も参考になった。
Discussion