Closed9

サーバーサイドでESMプロジェクトをesbuildでbundleして実行する(TypeScript)

7e+87e+8

最終的にesbuildでbundleして、nodejsで動くESM(ES Modules)プロジェクトの作成についてメモする。(TypeScript)

※nodeでサーバーサイドで実行する場合普通はCJS(commonjs)だと思うが、top level awaitが出来ないなど色々あってESM化したい動機があった。

7e+87e+8

全般的なところは

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

あたりがかなり参考になった。

あと、esbuildを使ったビルド方法について
https://dev.classmethod.jp/articles/aws-lambda-support-node-js-18/
も参考になった。(aws-cdkではランタイムがnodejsのlambdaをesbuildでビルド・デプロイすることができる)

esbuildでbundleする部分の追加情報、jestをesbuild-jestで実行させる、といったあたりが追加で調べたこと。

7e+87e+8

以下、

  • Linux: Ubuntu22.04LTS @WSL2(Windows11)
  • nodejs 18.13.0LTS
  • pnpm@7.26.3
  • (TypeScript: 4.9.5)

といった環境で検証していく。

唯一の方法では無い?が、基本的なプロジェクトのESM化方法として、package.jsonにtype: moduleを追記する。

package.json
+   "type": "module",

詳細:

末尾が .js のファイルは、最も近い親 package.json ファイルに「module」の値を持つ最上位フィールド「type」が含まれている場合に、ES モジュールとしてロードされます。

また、TypeScriptを使うのでtsconfig.jsonの内容もcommonjsの場合と比べて変更する必要がある。

tsc --initでデフォルト設定で作ったときに比べて、変更分は例えば以下のような感じ。

tsconfig.json
{
  "compilerOptions": {
-  "target": "es2016",
+  "target": "es2022",
-  "module": "commonjs",
+  "module": "es2022",
+  "moduleResolution": "nodenext",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}
  • targetmoduleはes2022以降にする。それより古いとtop-level-awaitなどが使えない
  • moduleResolutionnodenextなどにしておく。(うろ覚えだがnode16とかそれ以降なら良かった気がする。)

各項目の詳細はThe TypeScript Handbookなどを参照する。

7e+87e+8

検証の題材としては、何でも良いのだがfastifyを使った簡単なアプリにする。
(ESMプロジェクトはcommonjsのライブラリもimportできるが、後で見るようにbundle時にハマりどころが出てくる部分があるので何かしらのライブラリは入れる)

ライブラリのインストール:

pnpm add fastify
pnpm add -D typescript @types/node esbuild tsx

インストール後のpackage.json例:

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は先述の通りだが、

tsconfig.json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "es2022",
    "moduleResolution": "nodenext",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

のような感じ

7e+87e+8

作るアプリの構成について、まずは以下のような感じで作る

.
├── package.json
├── pnpm-lock.yaml
├── src
│   ├── main.ts
│   └── sample-module.ts 
└── tsconfig.json

main.tsおよびsample-module.tsは以下のような感じ:

src/main.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 })
sample-module.ts
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();

ポイントとしては、

とりあえず実行してみるには先述のtsxが便利で、

pnpm tsx src/main.ts

# watchモード
pnpm tsx watch src/main.ts

または、package.jsonにスクリプトを追加するのも便利

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特有の機能も使いつつ実行出来ている。

7e+87e+8

次はesbuildで本番用ビルドを行う。

cliで実行してもJavaScriptなどでビルド用スクリプトを書いても良いが、オプションがかなり長く複雑になるので後者の感じにする。

まずは以下のような感じでpackage.jsonにスクリプトを追記:

package.json
  "scripts": {
    "dev": "tsx watch src/main.ts",
+   "build": "tsx build.ts",
+   "start": "node dist/main.mjs",

ビルド用のスクリプトbuild.tsを追加:

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
7e+87e+8

最後にテスト。

今回は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を追加する。

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を追加

src/sample-module.test.ts
import { awaitedMessage } from './sample-module.js'; // jestが`.js`を解釈してimportするには`ts-jest-resolver`などが必要

test('awaitedMessage', () => {
  expect(awaitedMessage).toBe('success.');
})

テスト実行用コマンドを追加する。

package.json
  "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に置き換える必要がある。

7e+87e+8

(補足)

ts-jestで実行する場合はts-jestをdevDependenciesに追加した上で、jest.config.cjsを

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は遅い。。。)

このスクラップは2023/02/05にクローズされました