9月9日、Cloudflareが「How we rebuilt Cloudflare Workers' module registry for Node.js compatibility」と題した記事を公開した。Cloudflare WorkersにおけるNode.js互換性向上の取り組みとして、Workersランタイムのモジュールレジストリを根本から再設計・再実装した詳細を解説したものだ。
Node.jsアプリをCloudflare Workersへ移植しようとして「APIは動くのにどこか挙動が違う」と感じた経験を持つ開発者は少なくないだろう。その違和感の多くは、APIの有無ではなくモジュールシステムの挙動の差に起因していた。import.meta.urlが使えない、同じコードがV8 isolate(Workersが負荷分散のために並列に立ち上げるサンドボックス実行環境)ごとに何度もコンパイルされる、require()とESMの境界が曖昧——こうした問題が、既存Node.jsコードをWorkersへ持ち込む際の見えにくい障壁になっていた。今回の再設計はその根本に手を入れるものだ。
なぜ今、モジュールレジストリを作り直したのか
Cloudflare Workersは2024年ごろからNode.js互換性の強化を本格的に進めており、現在はサーバーレス環境で必要になる主要なstable APIがほぼ網羅され、デフォルトで有効になっている。Workerのサイズ上限も全プランで64 MiBに引き上げられ、圧縮後バンドルサイズの制限も撤廃された。フロントエンド・バックエンド問わず「書いたコードをそのままEdgeで動かす」という需要が急増するなかで、APIの互換性だけでは追いつかない領域が残っていた。それがモジュールシステムだ。
モジュールレジストリとは、ESM・CommonJS・WebAssemblyといった異なる形式のモジュールを「どう解決し、ロードし、キャッシュするか」を管理する仕組みだ。旧実装には根本的な設計上の問題があった。モジュールの識別子(スペシファイア)をURLではなくファイルシステムパスとして解釈していたため、import.meta.urlが実装できず、node:やcloudflare:といったプロトコルも文字列の前処理で誤魔化すしかなかった。また、使われるかどうかに関係なく全モジュールを起動時に一括コンパイルし、V8 isolateごとに独立したコピーをメモリに保持していた。同一Workerが複数のisolateで動く構成では、同じソースコードが何度もコンパイルされ、メモリを無駄に占有し続ける状態だった。
新実装はURLを第一級のスペシファイア形式として扱い、遅延コンパイルとisolate間でのキャッシュ共有を設計の基盤に据えている。
フラグひとつで新レジストリを有効化できる
wrangler.jsoncに以下を追記するだけで新しいモジュールレジストリを試せる。
{
"compatibility_flags": ["new_module_registry"]
}
有効にすると次の変更が適用される。
import.meta.url、import.meta.main、import.meta.resolve()が動作する- モジュールスペシファイアがクエリ文字列・フラグメントを含む本物のURLとして解析される
node:組み込みモジュールがどの経路でも同一インスタンスに解決される- Import attributes(
with { type: 'json' })が仕様通りにバリデーションされる - ESモジュールへの
require()がNode.jsのrequire(esm)ルールに従う - エラーが一貫したクラスとメッセージを使う
- モジュールが最初にimportされたときに遅延コンパイルされる
- WebAssemblyモジュールがソースフェーズimportをサポートする
既存のデプロイ済みWorkerへの影響はない。フラグを明示的に有効にしない限り、従来の動作が維持される。
特に重要な変更点
import.metaが完全対応
export default {
async fetch(request) {
return new Response(`${import.meta.url}, main: ${import.meta.main}`);
},
};
// => "file:///bundle/index.js, main: true"
import.meta.mainはエントリポイントのモジュールでのみtrueになる。import.meta.resolve()はimportを実行せずにスペシファイアを解決する純粋な文字列変換で、new URL()と同じ正規化ルールを適用する。
import.meta.resolve('./utils.js'); // 'file:///bundle/utils.js'
import.meta.resolve('fs'); // 'node:fs'
クエリ文字列で同一ソースから別インスタンスを生成できる
ブラウザ同様、クエリ文字列やフラグメントが異なるスペシファイアは別のモジュールインスタンスとして扱われる。
import { increment as incA } from './counter.js?a';
import { increment as incB } from './counter.js?b';
incA(); // 1
incA(); // 2
incB(); // 1 ← 独立したインスタンス
同じソースファイルを評価しつつ、トップレベルの状態を分離したい場面で活用できる。
require(esm)がNode.js互換に
CommonJSのrequire()でESモジュールを読み込む場合、Node.jsのrequire(esm)ルールに準拠するようになった。モジュールに'module.exports'という名前のエクスポートがあればその値を返し、なければモジュールのnamespaceオブジェクトを返す。
ただし、top-level awaitを含むモジュールをrequire()しようとするとエラーになる。Node.js自身のERR_REQUIRE_ASYNC_MODULE制限と同じ挙動で、require()は同期的に返さなければならないためだ。非同期モジュールにはimport()を使う必要がある。
Import attributesのバリデーションが仕様準拠に
旧実装はimport attributesを黙って無視していた。新実装は不明な属性に対して例外を投げる。
import data from './config.json' with { type: 'json', cache: 'no' };
// TypeError: Unsupported import attribute: "cache"
現在type: 'json'のみが有効で、textとbytesはTC39のProposalとして認識されるが、まだサポートされていないことを示す明示的なエラーになる(黙殺はされない)。
バンドラーへの影響
現在Wranglerはデフォルトでesbuildを使い、ほぼすべてのコードを単一ファイルにバンドルしてから送信する。これはWorkerランタイムのモジュール解決が信頼できなかったことへの対処でもあった。
新しいモジュールレジストリにより、Cloudflare Vite plugin経由でRolldown(RustベースのRollup互換バンドラー)を使う場合は、バンドラー側の変換を減らしてランタイムに委ねる余地が生まれる。--no-bundleオプションやマルチモジュール構成でデプロイする場合も、書いた通りのモジュールグラフがランタイムに届くようになる。モジュールシステムの信頼性が上がることで、バンドルステップ自体の役割を段階的に見直せる可能性がある。
詳細はHow we rebuilt Cloudflare Workers' module registry for Node.js compatibilityを参照していただきたい。