🐝

process.getBuiltinModule(id) は TypeScript を ESM 化させるか?

2024/08/07に公開

こんにちは teppeis です。普段は開発本部長をやってますが、ブログフェスに駆り出されました!

本日は Node v22.3.0 に続いて v20.16.0 にもバックポートされた process.getBuiltinModule(id) について解説します。

問題: 同期的な条件付き require を ESM 化できない

Node v22 にて、フラグ付きで CJS (CommonJS Modules) から ESM を require できるようになりました。いわゆる require(esm) です。これにより、今までは互換性の懸念で ESM 化を足踏みしていた著名ライブラリも ESM 化を試みる動きが出てきました。

TypeScript もその一つで、TypeScript チームは TypeScript 自体を ESM 化しようと試みました。しかしながら、今回の主題である条件付き require の ESM 化が困難という問題にぶつかりました。

https://github.com/nodejs/node/issues/52599

TypeScript のパッケージは、Node.js でもブラウザでもユニバーサルに読み込めるようになっています。そのため、例えば sys オブジェクトを構成する場面では、実行環境が Node.js の場合だけ fs でローカルファイルを操作する、というようなコードがあります。概念的にはこういうコードです。

// in CJS
if (isNodeLikeSystem()) {
  const fs = require("fs");
  // fs を使う処理...
}

こういう条件付き require を ESM 化しようと思ったら、以下のような選択肢があります。

  • top-level await で dynamic import を使う
  • 非同期化して dynamic import を使う
  • module.createRequireを使う

ところが、まず top-level await は require(esm) するとエラーになってしまうので、今回の目的には使えません。次に非同期化については、既存の API を丸ごと非同期に変更することになってしまい、TypeScript エコスシテムの互換性を考えると困難な選択肢です。

最後に createRequire ですが、一般にはこう使います。

import module from "node:module";
const require = module.createRequire(import.meta.url);
const fs = require("fs");

今回のケースでは、Node.js でもブラウザでも動くユニバーサルなコードにしたいので、そもそも 1 行目の ndoe:module を読み込む import 宣言を書くことができません。import 宣言は if 文の中に書くことはできないので、ブラウザなど Node.js 以外の環境で実行するとエラーになります。では dynamic import で module を作ってはどうかというと、結局また top-level await 使えない問題にぶつかってしまいます。

このように、既存の CJS を require(esm) 可能な ESM に変換する場合、同期的な条件付き require を ESM 化できないという課題として見えてきました。

解決編: process.getBuiltinModule(id) による組み込みモジュールの同期的読み込み

そこで導入されたのが getBuiltinModule です。この関数は Node.js の組み込みモジュールを命令的に同期的に読み込めます。これにより、ESM における同期的な条件付き読み込みを可能にします。

// in EMS
if (isNodeLikeSystem()) {
  const fs = process.getBuiltinModule("fs");
  // fs を使う処理...
}

またこれによって module オブジェクトを同期的に作れるので、createRequire を経由することで任意のモジュールを同期的に読み込めます。

if (isNodeLikeSystem()) {
  const module = process.getBuiltinModule("module");
  const require = module.createRequire(import.meta.url);
  const foo = require("foo");
  // foo を使う処理...
}

getBuiltinModule を実装した PR を見ると、差分は非常に小さいことが分かります。元々内部的に存在していた internal な API を露出しただけだからです。また組み込みモジュールは内部的にキャッシュされている確率が高いため、同期的に読んだとしてもパフォーマンス上の課題も小さいということでしょう。

https://github.com/nodejs/node/pull/52762

以下は導入された getBuiltinModule を使って TypeScript の ESM 化を進めている PR です。source-map-support を読む場面で createRequire のイディオムも使っているようです。

https://github.com/microsoft/TypeScript/pull/58419

まとめ

一見すると何のために存在しているのかわかりづらい process.getBuiltinModule(id) が解決する問題を解説しました。
もし既存コードの ESM 化で似たような問題が出てきたら思い出してあげてくださいね。

サイボウズ フロントエンド

Discussion