皆さんこんにちは。先月リリースされた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
--import
andModule.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の部類なので使うときは注意しましょう。