Node.jsモジュール読み込みフックの新時代 node:moduleのregister APIについて

皆さんこんにちは。先月リリースされた[Node.js 18.19.0](https://nodejs.org/en/blog/release/v18.19.0)の更新内容の一つに、`node:module`組み込みモジュールの`register` APIがあります。これは、Node.js 20.6.0で先行実装されていたものが、Node.js 18系にバックポートされたものです。

これにより、現在サポートされているすべてのメジャーバージョンでこの機能が利用可能になりました。筆者は、このことによるインパクトは大きいと考えています。そこで、この記事では`register` APIについて解説します。

## `register` APIについて

この記事で`register` APIと読んでいるのは、Node.jsの組み込みモジュールである`node:module` からエクスポートされる関数を指しています。

インパクトが大きいとは言いましたが、実は皆さんが日頃のアプリ開発で使うようなAPIではありません。このAPIは、**ts-node**や**esbuild-register**といったパッケージが裏で使うようなAPIです。

これらのパッケージは、Node.jsのモジュール読み込みにフックして、Node.jsが読み込む前にTypeScriptをJavaScriptにトランスパイルする機能を持ちます。これにより、Node.jsで直接TypeScriptファイルを実行しているかのような使い心地が可能になります。

これでピンと来たでしょうか。そう、`register` APIは、モジュール読み込みに対するフックを登録するための新しいAPIなのです。

今後のts-nodeやesbuild-registerのリリースをウォッチしていれば、実装がこのAPIを使ったものに書き換わるのが見られるかもしれません。

## 従来のモジュール読み込みフックとの違い

`register` APIが出る前からもモジュール読み込みに対する割り込みが行われてきました。では、それらと`register` APIは何が違うのでしょうか。それを知るために、まず従来の方法を振り返りましょう。

従来のCJS向けの方法は、`require`や`Module`を経由して露出しているNode.jsの内部実装にモンキーパッチするというなかなか行儀の悪い方法です（とはいえ本当の内部実装は露出しないので、パッチを当てる前提で露出しているところもあるでしょう）。

実際の様子は、この辺りをうまくやってくれるライブラリであるpiratesの実装を見ると分かりやすいでしょう。本体は150行くらいでパッと読めます。端的に言えば、`Module._extensions`といういかにも内部実装的なところに手を加えるのがコアです。

https://github.com/danez/pirates

従来のESM向けの方法は、[--experimental-loader](https://nodejs.org/dist/latest-v20.x/docs/api/cli.html#--experimental-loadermodule)というNode.jsのコマンドライン引数を使うことです。このオプションはNode.js v8という結構昔から存在していますが、ずっとexperimentalのままでした。

`--experimental-loader`の引数にモジュール名を与えることで、そのモジュールがフックとして登録されます。このモジュールは`load`や`resolve`といった関数をexportすることができ、その場合それがフックとして呼び出されます。モンキーパッチに比べるとかなり進化した良い感じのAPIですね。

```sh
node --experimental-loader hook-module-name myProgram.ts
```

```js
// hook-module-name
export async load() { ... }
export async resolve() { ... }
```

従来のESM向けフックのAPIはインターフェースは良い感じなので、これは`register` APIにも引き継がれました。実は、表面的な使い方としては、コマンドライン引数として`--experimental-loader`にフックの実装を担当するモジュール名を渡していたところを、プログラム内から`register`を使うように変わっただけです。

```sh
# 従来のESM向けフック
node --experimental-loader hook-module-name myProgram.ts
```

```js
// register API
import { register } from "node:module";

register("hook-module-name");
```

ただし、モジュール読み込みのフックは早い段階で登録する必要があるため、メインのプログラムのエントリーポイントよりも前に`register`を実行する必要があります。このために、Node.jsの`--require`または`--import`オプションが使えます。そのため、`register` APIの典型的な使い方は次のようになります。

```sh
node --import hook-module-name/register myProgram.ts
```

```js
// hook-module-name/register
import { register } from "node:module";

register("hook-module-name/hook");

// hook-module-name/hook
export async load() { ... }
export async resolve() { ... }
```

このように、`register` APIでは従来のESM向けの方法をベースに、使い方が少し変わりました。

ただし、表面的な使い方以外にも、`register` APIでは色々と変わった点があります。

最大のポイントは、`register` APIでは**CJSとESMのフックが統一された**ことです。上で見たように、従来の方法では、CJS (CommonJS) とESM (ES Modules) では別々の方法を使う必要がありました。つまり、`require`を使う読み込み（CJS → CJS）では従来のCJS向けの方法が使われ、`import`を使う読み込み（ESM → ESM と ESM → CJS）ではESM向けフックが使われていました。

新しい`register` APIでは、`require`を用いるCJS → CJSの読み込みであっても、`register`で登録したフックを噛ませることができます（ただし、プログラムのエントリーポイントはESMでなければならないという制限が存在します）。

## CJSと非同期処理

上で見たように、`register` APIのフックは`load`と`resolve`から成ります（他に`initialize`もあります）。名前の通り、`resolve`がモジュール名の解決を担当し、`load`がモジュールの読み込みを担当します。例えば `./foo` が `./foo.js` ではなく `./foo.ts` を探し当てられるようにするのは`resolve`の役目で、`foo.ts`を読み込む際にTSからJSにトランスパイルするのは`load`の役目です。

目ざとい方は、先ほどの例で`resolve`や`load`がasync関数であることに気づいたでしょう。これらの処理はファイルシステムへのアクセスを含みうるため、非同期処理となるのは自然なことです。

一方で、CJSにおける`require`というのは同期関数です。つまり、`require`の返り値はPromiseなどではなく、読み込まれたモジュールの`exports`が即座に返ります。

`require`の結果を同期的に返さないといけないのに、それを処理するフックが非同期処理なのは何か不思議な感じがしますね。

この謎を説明してくれるのが、`register`で登録されたフックのモジュールは別のスレッドで動作するという事実です。別のスレッドの処理を待つことは頑張れば同期的なJavaScriptでできるので[^wait_thread]、`require`に対するフックも非同期処理で行うことができるのです。

[^wait_thread]: 実際のNode.jsの内部実装がどうなっているのかは確認していませんが、アプリケーションレイヤーのコードでは[Atomics](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics)を使って待つのが典型的な方法です。

ちなみに、フックが別のスレッドで動作するようになったのはNode.js 20.0からです。つまり、`--experimental-loader`（同じスレッド）→`--experimental-loader`（別スレッド）→`register`（別スレッド）という変遷を遂げてきたことになります。別スレッドになった理由は変な相互作用を避けるためであることが[20.0のリリースノート](https://nodejs.org/en/blog/release/v20.0.0)から伺えますが、別スレッドにしないとCJS対応ができなかったので、その狙いもあったのかもしれません。

また、`register`でフックが登録された場合の`require`は、従来の`require`とは別に用意された、言わば次世代の`require`となります。このため、モンキーパッチ的な方法で従来の`require`を加工したとしても、`register`フックの影響下にあるCJSモジュールには影響を与えられません。

このことは新しいAPIを古い方法から分離できるため嬉しい一方で、従来の`require`との互換性が完全ではないことを意味しています。関連するNode.jsリポジトリのPRでは、いずれは次世代の`require`に統一したいという考えも議論されていますが、後方互換性の観点からは現実的ではないようです。

## `register` API使ってみた

筆者がこの記事を書こうと思ったきっかけの一つは、筆者も最近`register` APIを使ってみたからです。

というのも、記事冒頭で紹介したesbuild-registerは従来のESM向けフック（`--experimental-loader`）をサポートしていましたが、これがNode.js 20.6.0で動かなくなってしまったのです。

このことの顛末は次の記事で紹介しています。これ自体もなかなか面白い話なのでよければお読みください。

https://zenn.dev/uhyo/articles/esbuild-register-node-20-6

esbuild-registerが素早く問題を解消するのが難しそうだったため、筆者は`esbuild-register`の代替実装を用意することにしました。これは[nitrogql](https://nitrogql.vercel.app/)から使いたかったので`@nitrogql/esbuild-register` という名前で公開されています（名前が同じですが、フォークではなくスクラッチの実装です）。この実装では、`register` API実装済みの環境では`register` APIを使うようになっています。実装はこちらから確認できます。

https://github.com/uhyo/nitrogql/tree/1f671e8fe526305f3c7778d4d386c6c6d3a0d2c5/packages/esbuild-register

実際にやってみた結果、色々と分かったことがあります。まず、`register`を使えば、従来のモンキーパッチの手法が無くてもCJS・ESMが両方とも動くのです。当たり前のことではありますが、実際にやってみると感動的でした。（ただし、`@nitrogql/esbuild-register`は`register` API非対応のバージョンでも動いてほしかったため、従来の手法も同時に実装してあります。）

しかしながら、`register` APIを使う場合でも、まだ1箇所だけモンキーパッチが必要な箇所がありました。これはNode.jsのコード内でTODOとして言及されているので、将来的には不要になることが期待されます。

具体的には、CJS→CJSの場合、新世代の`require`を使う場合であっても、新しい`resolve`フックを呼び出さずに従来の`Module._resolveFilename`を使っているところがあります。

https://github.com/nodejs/node/blob/7a2a4d07fe44684f90a0fb79227a176fdf42417d/lib/internal/modules/esm/translators.js#L417-L425

筆者はこれにあとから気づきました。そして、`Module._resolveFilename`は従来のものであるため同期処理でなくてはなりません。そのため、`@nitrogql/esbuild-register`にはresolveの処理が同期と非同期の2系統実装してあります。 :innocent: 

この点は将来に期待するとしても、実際にやってみると、新しい `register` APIを使えばモジュール読み込みにフックする処理がかなりきれいに書けると感じました。これが主流になった世界がとても楽しみです。

## tsimp

2ヶ月くらい前に、isaacsがTypeScriptローダーを探しているのを見ました。

https://x.com/izs/status/1712252914029531153

`--import`を使っているものを探しているということなので、新しい`register` APIを使っているものが求められていたのでしょう。ただ、当時は恐らく無かったはずです。

ということでisaacsによって作られたのがtsimpです。

https://github.com/tapjs/tsimp

Readmeに書いてある通り、やはり`register` APIを使っています。tsimpができたのにはこういった文脈もあったのでしょう。

> It supports the `--import` and `Module.register()` behavior added in node v20.6, only falling back to warning-laden experimental APIs when that's not available.

筆者の`@nitrogql/esbuild-register`の方が早く、あるいは同時期くらいに`register` APIを使っていました……という自慢は一応しておきますが、両者の性質はかなり違います。

まず、`@nitrogql/esbuild-register`はesbuildを使ってトランスパイルしますが、tsimpはTypeScriptそのものを使ってトランスパイルをしてくれる上に、`ts-node`のように型チェックまでしてくれます。

本物のTypeScriptを使っているとなると遅そうな印象を受けますが、キャッシュをちゃんと作ったり、デーモンを立てたりしてかなり高速化しているようです。`ts-node`の早くて新しいAPIを使ってる版だと受け取るのがよいでしょう。

## まとめ

この記事では、Node.js 20.6.0およびNode.js 18.19.0で追加された`register` APIについて解説しました。後半は雑多な内容になりましたが、`register` APIの現状とそれを取り巻く動きが分かったのではないかと思います。

`register` APIは、特にCJSに対しては初めてのちゃんとしたフックメカニズムです。ここに辿り着くまでとても長かったなと思います。

ちなみに、この記事公開当時（Node.js 20.10.0）では、`register` APIは「Stability: 1.2 - Release candidate」とされていて、一応まだexperimentalの部類なので使うときは注意しましょう。