皆さんこんにちは。先月リリースされたNode.js 18.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といういかにも内部実装的なところに手を加えるのがコアです。
従来のESM向けの方法は、--experimental-loaderというNode.jsのコマンドライン引数を使うことです。このオプションはNode.js v8という結構昔から存在していますが、ずっとexperimentalのままでした。
--experimental-loaderの引数にモジュール名を与えることで、そのモジュールがフックとして登録されます。このモジュールはloadやresolveといった関数をexportすることができ、その場合それがフックとして呼び出されます。モンキーパッチに比べるとかなり進化した良い感じのAPIですね。
node --experimental-loader hook-module-name myProgram.ts
// hook-module-name
export async load() { ... }
export async resolve() { ... }
従来のESM向けフックのAPIはインターフェースは良い感じなので、これはregister APIにも引き継がれました。実は、表面的な使い方としては、コマンドライン引数として--experimental-loaderにフックの実装を担当するモジュール名を渡していたところを、プログラム内からregisterを使うように変わっただけです。
# 従来のESM向けフック
node --experimental-loader hook-module-name myProgram.ts
// register API
import { register } from "node:module";
register("hook-module-name");
ただし、モジュール読み込みのフックは早い段階で登録する必要があるため、メインのプログラムのエントリーポイントよりも前にregisterを実行する必要があります。このために、Node.jsの--requireまたは--importオプションが使えます。そのため、register APIの典型的な使い方は次のようになります。
node --import hook-module-name/register myProgram.ts
// 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でできるので1、requireに対するフックも非同期処理で行うことができるのです。
ちなみに、フックが別のスレッドで動作するようになったのはNode.js 20.0からです。つまり、--experimental-loader(同じスレッド)→--experimental-loader(別スレッド)→register(別スレッド)という変遷を遂げてきたことになります。別スレッドになった理由は変な相互作用を避けるためであることが20.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で動かなくなってしまったのです。
このことの顛末は次の記事で紹介しています。これ自体もなかなか面白い話なのでよければお読みください。
esbuild-registerが素早く問題を解消するのが難しそうだったため、筆者はesbuild-registerの代替実装を用意することにしました。これはnitrogqlから使いたかったので@nitrogql/esbuild-register という名前で公開されています(名前が同じですが、フォークではなくスクラッチの実装です)。この実装では、register API実装済みの環境ではregister APIを使うようになっています。実装はこちらから確認できます。
実際にやってみた結果、色々と分かったことがあります。まず、registerを使えば、従来のモンキーパッチの手法が無くてもCJS・ESMが両方とも動くのです。当たり前のことではありますが、実際にやってみると感動的でした。(ただし、@nitrogql/esbuild-registerはregister API非対応のバージョンでも動いてほしかったため、従来の手法も同時に実装してあります。)
しかしながら、register APIを使う場合でも、まだ1箇所だけモンキーパッチが必要な箇所がありました。これはNode.jsのコード内でTODOとして言及されているので、将来的には不要になることが期待されます。
具体的には、CJS→CJSの場合、新世代のrequireを使う場合であっても、新しいresolveフックを呼び出さずに従来のModule._resolveFilenameを使っているところがあります。
筆者はこれにあとから気づきました。そして、Module._resolveFilenameは従来のものであるため同期処理でなくてはなりません。そのため、@nitrogql/esbuild-registerにはresolveの処理が同期と非同期の2系統実装してあります。 ![]()
この点は将来に期待するとしても、実際にやってみると、新しい register APIを使えばモジュール読み込みにフックする処理がかなりきれいに書けると感じました。これが主流になった世界がとても楽しみです。
tsimp
2ヶ月くらい前に、isaacsがTypeScriptローダーを探しているのを見ました。
--importを使っているものを探しているということなので、新しいregister APIを使っているものが求められていたのでしょう。ただ、当時は恐らく無かったはずです。
ということでisaacsによって作られたのがtsimpです。
Readmeに書いてある通り、やはりregister APIを使っています。tsimpができたのにはこういった文脈もあったのでしょう。
It supports the
--importandModule.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の部類なので使うときは注意しましょう。