サーバーサイドでESMプロジェクトをesbuildでbundleして実行する(TypeScript)
最終的にesbuildでbundleして、nodejsで動くESM(ES Modules)プロジェクトの作成についてメモする。(TypeScript)
※nodeでサーバーサイドで実行する場合普通はCJS(commonjs)だと思うが、top level awaitが出来ないなど色々あってESM化したい動機があった。
全般的なところは
あたりがかなり参考になった。
あと、esbuildを使ったビルド方法について
も参考になった。(aws-cdkではランタイムがnodejsのlambdaをesbuildでビルド・デプロイすることができる)esbuildでbundleする部分の追加情報、jestをesbuild-jestで実行させる、といったあたりが追加で調べたこと。
以下、
- Linux: Ubuntu22.04LTS @WSL2(Windows11)
- nodejs 18.13.0LTS
- pnpm@7.26.3
- (TypeScript: 4.9.5)
といった環境で検証していく。
唯一の方法では無い?が、基本的なプロジェクトのESM化方法として、package.jsonにtype: moduleを追記する。
+ "type": "module",
詳細:
末尾が .js のファイルは、最も近い親 package.json ファイルに「module」の値を持つ最上位フィールド「type」が含まれている場合に、ES モジュールとしてロードされます。
また、TypeScriptを使うのでtsconfig.json
の内容もcommonjsの場合と比べて変更する必要がある。
tsc --init
でデフォルト設定で作ったときに比べて、変更分は例えば以下のような感じ。
{
"compilerOptions": {
- "target": "es2016",
+ "target": "es2022",
- "module": "commonjs",
+ "module": "es2022",
+ "moduleResolution": "nodenext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
-
target
とmodule
はes2022以降にする。それより古いとtop-level-awaitなどが使えない -
moduleResolution
はnodenext
などにしておく。(うろ覚えだがnode16
とかそれ以降なら良かった気がする。)
各項目の詳細はThe TypeScript Handbookなどを参照する。
検証の題材としては、何でも良いのだがfastifyを使った簡単なアプリにする。
(ESMプロジェクトはcommonjsのライブラリもimportできるが、後で見るようにbundle時にハマりどころが出てくる部分があるので何かしらのライブラリは入れる)
ライブラリのインストール:
pnpm add fastify
pnpm add -D typescript @types/node esbuild tsx
インストール後のpackage.json例:
{
"name": "test-esm-app",
"version": "1.0.0",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"devDependencies": {
"@types/node": "^18.11.18",
"esbuild": "^0.17.5",
"tsx": "^3.12.2",
"typescript": "^4.9.5"
},
"dependencies": {
"fastify": "^4.12.0"
}
}
タイトルにあるようにesbuildは本番ビルド用に使う。
tsxは開発時の実行に使い、内部でesm-loaderなどを使ってよしなにやってくれる。
tsconfig.jsonは先述の通りだが、
{
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
のような感じ
作るアプリの構成について、まずは以下のような感じで作る
.
├── package.json
├── pnpm-lock.yaml
├── src
│ ├── main.ts
│ └── sample-module.ts
└── tsconfig.json
main.tsおよびsample-module.tsは以下のような感じ:
import fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import { awaitedMessage } from './sample-module.js';
const server = fastify({
logger: true,
})
server.get('/', async (request: FastifyRequest, reply: FastifyReply) => {
await reply.code(200).send({
hello: 'world',
tla: awaitedMessage, // top-level-awaitした値のexport/importが出来るか確認
})
})
await server.listen({ port: 3000 })
const messageAfterSleep = async (): Promise<string> => {
console.log('start sleeping');
await new Promise(resolve => setTimeout(resolve, 1000)); // 1秒待機
console.log('finish sleeping');
return 'success.';
}
export const awaitedMessage = await messageAfterSleep();
ポイントとしては、
- 自作モジュール(ここでは
sample-module.ts
)のインポートの際、ESMプロジェクトだと拡張子を省略出来ない。更に、.ts
ではなく.js
にする必要がある。- TypeScript5.0以降ではtsconfigの
moduleResolution
設定を変えることで拡張子は不要になるかもしれない?( ついに来る!TypeScript5.0の新機能
や TypeScript 5.0 Beta の新機能を雑にまとめる を参照)
- TypeScript5.0以降ではtsconfigの
- top-level-awaitを使っている。また、awaitした結果をimportしている
- 余談だが、詳しい挙動はuhyoさんのtop-level awaitがどのようにES Modulesに影響するのか完全に理解する - top-level-awaitとモジュールなどを参照
とりあえず実行してみるには先述のtsxが便利で、
pnpm tsx src/main.ts
# watchモード
pnpm tsx watch src/main.ts
または、package.jsonにスクリプトを追加するのも便利
"scripts": {
+ "dev": "tsx watch src/main.ts",
# 実行
pnpm dev
サーバーを立ち上げると、sample-module.ts
に仕込んでいたログ:"start sleeping"と"finish sleeping"が1秒差で表示され、その後サーバーが立ち上がることが確認出来る。
# fastifyによるサーバーログ
start sleeping
(1秒待機)
finish sleeping
{"level":30,"time":1675526354122,"pid":24834,"hostname":"XXXXX-XXXXXX,"msg":"Server listening at http://127.0.0.1:3000"}
また、レスポンスの方は(1秒待つことなく)一瞬で返ってくる。
curl http://localhost:3000
# {"hello":"world","tla":"success."}
といった感じで、ESMプロジェクトとして立ち上げたfastifyアプリをtop-level-awaitなどESM特有の機能も使いつつ実行出来ている。
次はesbuildで本番用ビルドを行う。
cliで実行してもJavaScriptなどでビルド用スクリプトを書いても良いが、オプションがかなり長く複雑になるので後者の感じにする。
まずは以下のような感じでpackage.json
にスクリプトを追記:
"scripts": {
"dev": "tsx watch src/main.ts",
+ "build": "tsx build.ts",
+ "start": "node dist/main.mjs",
ビルド用のスクリプトbuild.ts
を追加:
import esbuild from 'esbuild';
await esbuild.build({
// logLevel: 'info', // ビルド時のログを出したいときは設定する
entryPoints: ['./src/main.ts'], // トランスパイル対象のコード。今回はbundleするのでimportしているライブラリと自作モジュールも自動で含まれる
outdir: './dist', // トランスパイル結果のファイルの格納先
outExtension: { // 必須では無いが、ESM形式で出力されることを明示的にするため拡張子を.mjsにしている
'.js': '.mjs'
},
minify: true, // 必須では無いが、ファイルサイズ削減のため
bundle: true,
platform: 'node', // ブラウザ上などではなく、サーバーサイドのnodejsで実行するため
// tsconfig: './tsconfig.json', // デフォルトでtsconfig.jsonを使うが、ビルド時に設定を変える場合は指定する
format: 'esm', // ESMプロジェクトなので、出力フォーマットを'esm'に設定する必要
banner: { // commonjs用ライブラリをESMプロジェクトでbundleする際に生じることのある問題への対策
js: 'import { createRequire as topLevelCreateRequire } from "module"; import url from "url"; const require = topLevelCreateRequire(import.meta.url); const __filename = url.fileURLToPath(import.meta.url); const __dirname = url.fileURLToPath(new URL(".", import.meta.url));',
},
})
banner
はトランスパイル結果のコードの先頭に指定した文言を挿入する項目で、ここではESMでは定義されていて使えないrequire
や__filename
、__dirname
をESMで使えるようにするためのおまじない的設定を書いている。
これは、esbuildがトランスパイルとbundleを行う際によしなにやってくれない部分があり、使っているライブラリによっては色々とエラーが発生するため。
(今回のfastifyの例だと、Error: Dynamic require of "events" is not supported
といったエラーが発生した。)
それ以外の各項目についてはコード中のコメントおよびesbuildの公式ドキュメントを参照のこと。
ここで、
pnpm build
pnpm start
とすると、distディレクトリへトランスパイル結果格納とアプリ実行ができ、さきほどtsxで動かしたときと同様に動作する様子が確認出来る。
ここまでのディレクトリ構成:
.
├── build.ts ★追加
├── dist ★ビルド結果
│ └── main.mjs
├── package.json
├── pnpm-lock.yaml
├── src
│ ├── main.ts
│ └── sample-module.ts
└── tsconfig.json
最後にテスト。
今回はjestで行う。
ts-jestを使っても良いが、ビルドはesbuildを使っているので(最後の更新が2021年3月なのが気にはなるが)esbuild-jestを使ってみる。
パッケージの追加:
pnpm add -D jest @types/jest esbuild-jest ts-jest-resolver
ts-jest-resolverは、自作モジュールなどのimportで拡張子.js
などをつけているのを正しく読み込むために必要。
次に設定ファイルjest.config.cjs
を追加する。
/** @type {import('jest').Config} */
const config = {
resolver: 'ts-jest-resolver', // jestでTypeScriptモジュールを拡張子`.js`でimportする
extensionsToTreatAsEsm: ['.ts'], // commonjsでなくesmとして実行するのに必要
transform: {
'^.+\\.(t|j)sx?$': [
'esbuild-jest',
{
sourceMaps: true, // 必須でないが、必要に応じて
// esm形式に変換するため、formatとtargetを設定する必要
format: 'esm',
target: 'es2022',
},
],
},
};
module.exports = config;
そして、テスト用ファイルsample-module.test.ts
を追加
import { awaitedMessage } from './sample-module.js'; // jestが`.js`を解釈してimportするには`ts-jest-resolver`などが必要
test('awaitedMessage', () => {
expect(awaitedMessage).toBe('success.');
})
テスト実行用コマンドを追加する。
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsx build.ts",
"start": "node dist/main.mjs",
- "test": "echo \"Error: no test specified\" && exit 1"
+ "test": "NODE_OPTIONS='--experimental-vm-modules' jest"
},
ここで、少なくともnodejs18.13.0 + jest@29.4.0ではESMプロジェクトのテストに環境変数: NODE_OPTIONS='--experimental-vm-modules'
を設定する必要があるため、test
コマンドに含めている。
(参照: JEST - ECMAScript Modules)
これで、
pnpm test
のようにしてテストを実行出来る。
なお、ここなどにあるように、ESMでjestを実行する際にはいくつか注意点があり、その一つにjest
オブジェクトを使う場合は明示的なimportが必要になる:
import { jest } from `@jest/globals`; // ESMでは明示的にimportが必要
jest.useFakeTimers();
// ...
ここで、pnpmを使っている場合はnode_modulesの構造上の問題で明示的に@jest/globals
をインストールする(pnpm add -D @jest/globals
)か、.npmrcなどでnode-linker=hoistedを指定する必要がある。
あるいは、公式ドキュメントで"alternatively"と書いてあるような感じで、jest
部分をimport.meta.jest
に置き換える必要がある。
(補足)
ts-jestで実行する場合はts-jest
をdevDependenciesに追加した上で、jest.config.cjsを
/** @type {import('jest').Config} */
const config = {
// preset: 'ts-jest/presets/default-esm', // 必要に応じて
resolver: 'ts-jest-resolver',
extensionsToTreatAsEsm: ['.ts'],
transform: {
'^.+\\.(t|j)sx?$': [
'ts-jest',
{ useESM: true }
],
},
};
module.exports = config;
のようにtransform部分(+必要に応じてpreset)を変えれば動く。
(なお、ts-jestは遅い。。。)
要点を清書して
にまとめた。