pnpm Store LoaderをM4 Macの別環境で試した
目次
pnpm Store Loaderは、依存パッケージのJavaScriptやJSONをpnpmの共有ストアから直接読み込む実験機能だ。
2026年10月6日公開のpnpm 12.10.0で、nodeLinkerの新しい方式として追加された。
このブログはNode.js 22.22.0とpnpm 10.27.0で動いており、そのままでは利用条件を満たさない。
既存環境を変えずに確かめるため、M4 Macに専用の実行ファイルと依存ストアを置き、小さなJavaScript依存、ネイティブ拡張、ブログのコピーの順に試してみた。
パッケージを配置する代わりにローダーで読み込む
pnpmの共有ストアは、ファイル内容のハッシュ値で管理する方式で、同じ内容のファイルを共有できるCAS(Content-Addressable Store)と呼ばれる。
通常のisolated方式では、プロジェクトのnode_modules/.pnpmへファイルを配置し、シンボリックリンクで依存関係をつなぐ。公式の構造説明も、この配置を前提にしている。
Store Loaderのloaded方式では、Node.jsの読み込み処理へローダーを登録し、依存関係の対応表を使ってCASの内容を読む。
公式の設定例をpnpm-workspace.yamlに置いてインストールすると、pnpm runやpnpm execがローダーを事前に読み込む。
例にあるexcludedは、指定したパッケージとその依存一式を、実ファイルがある配置へ戻す設定だ。小さな検証環境にはVitestを入れていないので、この例の除外指定は影響しない。
nodeLinker:
type: loaded
excluded:
- vitest
ローダーの対応範囲は、pnpm 12.11.2の実装で^24.18.0 || >=26.2.0となっている。
24系なら24.18.0以降、または26.2.0以降で動く。手元のNode.js 22.22.0で生成済みローダーを起動すると、実際にERR_PNPM_LOADER_UNSUPPORTED_NODEで止まった。
検証環境の構成と比較条件
ツールと依存を専用ディレクトリへ分ける構成で、仮想マシンやOSのアクセス制限を使ったサンドボックスではない。
Node.jsは公式のmacOS ARM64版、pnpmはnpmレジストリの@pnpm/exe.darwin-arm64を展開し、配布元のチェックサムと照合した。
| 項目 | 条件 |
|---|---|
| マシン | Apple M4、メモリ16GB |
| OS | macOS 26.5.2 |
| pnpm | 12.11.2 |
| 最初のNode.js | 26.10.0 |
| ネイティブ依存の再試験とブログ | 24.18.0 |
| JavaScript依存 | nanoid 5.1.5、picocolors 1.1.1 |
| ネイティブ依存 | sharp 0.34.5、better-sqlite3 12.6.2 |
| ブログの主要ツール | Astro 7.1.3、Vitest 4.0.18 |
Node.jsの場所を実験プロセスのPATHへ指定し、pnpmのストア、キャッシュ、状態ファイルも専用ディレクトリに指定した。
ブログはソースと設定をコピーし、元のnode_modulesを使い回さずにインストールした。元のlockfileを使い、ブログの各条件で依存バージョンをそろえている。
実験コードと実行記録は、LiltingChannelLaboのpnpm-store-loaderディレクトリに置いた。
JavaScriptの読み込みとfs.readFileSync()の挙動
Node.js 26.10.0で、ESMのimport、CommonJSのrequire()、JSON importを試した。
nanoidで8文字のIDを作り、picocolorsを両方の方式から呼び、picocolors/package.jsonのバージョンも読ませた。
その後、require.resolve('picocolors/package.json')が返したパスを、通常のファイル読み込みであるfs.readFileSync()に指定した。
ESM、CommonJS、JSON importはいずれの配置でも成功した。一方、fs.readFileSync()の挙動は配置によって分かれた。
| 配置 | resolve先のファイル読み込み |
|---|---|
通常のisolated | 成功 |
loaded | ENOENTで失敗 |
loadedでpicocolorsを除外 | 成功 |
loadedでは次のような仮想パスが返ってきた。ハッシュ部分は省略している。
node_modules/.pnpm/.pnpm-loader/<hash>/package.json
ローダーはこの場所に対応する内容をCASから読めるが、同じ場所に実ファイルがあるわけではない。fs.existsSync()もfalseを返した。
公式の互換性説明にある「一般的なファイルシステムを再現しない」という制約が、そのまま当てはまる挙動だ。
picocolorsをexcludedへ追加すると、resolve先はGVS(Global Virtual Store)内の実ファイルに変わった。GVSは、複数のプロジェクトで共有するパッケージ配置先だ。
生成された対応表でも、picocolorsは実ディレクトリ、除外していないnanoidはCASのファイル情報を指していた。
また、loadedのディレクトリで直接node probe.mjsを実行すると、nanoidが見つからずERR_MODULE_NOT_FOUNDになる。
同じコードをpnpm run probeで起動するか、次のように生成済みローダーを指定すると動いた。
node --import ./node_modules/.pnpm/.store-loader.mjs probe.mjs
依存が少ない場合のnode_modulesサイズ
今回の2パッケージだけでnode_modulesを比較すると、通常ファイルの数は減った一方、ファイルサイズの合計は増えた。
| 配置 | 通常ファイル数 | サイズ合計 |
|---|---|---|
isolated | 23 | 28,268バイト |
loaded | 8 | 126,085バイト |
リンクをたどらず、node_modules内の通常ファイルだけを数えた値だ。共有ストアの容量や、実ディスクの占有量は含めていない。
loaded側は同梱ローダーの.store-loader.mjsだけで115,821バイトあった。依存が2つしかない今回の条件では、このローダー自体のサイズが全体を占める。
ネイティブ拡張の検証とNode.jsバージョンの切り分け
次に、sharpで8×8ピクセルのPNGを作り、better-sqlite3でメモリ上のデータベースへSELECT 6 * 7を実行するコードを用意した。
どちらも、JavaScriptからコンパイル済みの拡張モジュールを呼ぶパッケージだ。
Node.js 26.10.0では、通常配置のインストールで失敗した。
better-sqlite3がこのバージョンのビルド済みバイナリを取得できず、ソースビルドへ切り替わったところでnode-gyp: command not foundになった。今回用意した環境には、ビルド用のツールを入れていなかった。
sharpもソースビルドへ進んで失敗した。ホストにはlibvips 8.18.4があり、sharpの公式説明には、システムのlibvipsを検出してビルドに使う動作がある。
対応範囲内のNode.js 24.18.0へ切り替え、ホストのlibvips検出もSHARP_IGNORE_GLOBAL_LIBVIPS=1で避けて、通常配置から確認し直した。24系の比較では全条件に同じ環境変数を指定している。
| Node.js | 配置 | 結果 |
|---|---|---|
| 26.10.0 | isolated | ネイティブ依存のインストールに失敗 |
| 26.10.0 | loaded、除外なし | ERR_PNPM_CAS_REQUIRES_MATERIALIZATIONで停止 |
| 26.10.0 | loaded、sharpとbetter-sqlite3を除外 | ネイティブ依存のインストールに失敗 |
| 24.18.0 | isolated | PNG生成成功、SQL結果は42 |
| 24.18.0 | loaded、除外なし | ERR_PNPM_CAS_REQUIRES_MATERIALIZATIONで停止 |
| 24.18.0 | loaded、sharpとbetter-sqlite3を除外 | PNG生成成功、SQL結果は42 |
除外なしのloadedでは、インストール時点で実ファイルへの配置を求められた。
Node.js 24.18.0で2パッケージを除外すると、通常配置と同じ出力が得られた。ただし、この設定では依存関係を含めた42エントリーすべてが実ファイル側へ戻るため、ネイティブ拡張をCASから直接読めたわけではない。
ブログのコピーでの通常配置ビルド
ブログのコピーもNode.js 24.18.0で試した。
元のonlyBuiltDependenciesにあったesbuild、sharp、better-sqlite3を、現在のビルド承認設定のallowBuildsへ移した。
allowBuilds:
esbuild: true
sharp: true
better-sqlite3: true
tesseract.js: false
最後のtesseract.js: falseは、通常配置の初回インストールで追加したものだ。
設定が未指定だとERR_PNPM_IGNORED_BUILDSで止まるため、ビルドスクリプトを実行しない指定を明示した。
この状態でpnpm install --frozen-lockfile、pnpm test、pnpm buildが通った。
テストは5ファイル・51件成功。ビルドは記事以外の一覧やタグページも含めて5,861ページを出力し、別ポートで起動したDEVサーバーでも記事をブラウザ表示できた。
pnpm buildにかかった時間は259.55秒、macOSのtime -lによる最大常駐メモリは約2.71GiBだった。
1回動作を確認した数値で、ホストのバックグラウンド処理も動いている状態での参考値だ。
Astro除外とViteの依存解決エラー
Store Loader側では、まずsharp、better-sqlite3、esbuild、vitestを除外した。
これだけではtesseract.jsにも実ファイル配置を求められた。ビルドスクリプトの実行を拒否する設定と、CAS読み込みから除外する設定は別なので、こちらにも追加した。
nodeLinker:
type: loaded
excluded:
- sharp
- better-sqlite3
- esbuild
- vitest
- tesseract.js
インストールは通ったが、VitestはAstroのtsconfigs/strict.jsonを仮想パスから読もうとしてENOENTになった。テスト本体が始まる前に、5ファイルすべてが失敗した。
DEVサーバーとAstroのビルドも、Vite 8.1.2自身のpackage.jsonを仮想パスから読もうとして失敗した。
さらにastroを除外すると、依存するViteも実ファイル側へ戻る。別のコピー環境にこの条件を作り、同じlockfileで試してみた。
いずれの条件でもインストール自体は成功した。
| 配置 | Vitest | DEVサーバー | ビルド |
|---|---|---|---|
isolated | 51件成功 | ブラウザ表示成功 | 5,861ページ出力 |
loaded、上記5パッケージを除外 | tsconfigの読み込みで5ファイル失敗 | Viteのpackage.jsonがENOENT | 同じ読み込みで停止 |
loaded、さらにastroを除外 | 41件成功、1ファイルは依存読み込みで失敗 | astro/configの解決で停止 | 同じ依存解決で停止 |
astroを除外した後のテストは、better-sqlite3が見つからず、残り1ファイルを読み込めなかった。
DEVサーバーとビルドでは、astro.config.mjsのastro/configをViteが見つけられずに停止した。
Cannot find module 'astro/config' imported from '.../astro.config.mjs'
そこで同じディレクトリに確認用コードを置き、Viteを経由せずpnpm exec node resolver-probe.mjsで実行した。
この起動ではastro/configからdefineConfigをimportでき、better-sqlite3もSQLの結果として42を返した。
{"node":"24.18.0","astroConfig":"function","sqliteAnswer":42}
依存の実体が欠けているわけではなく、pnpmのNodeローダー経由では読めている。ViteやVitest側でアプリケーションの依存を探す処理で止まった。
公式ドキュメントにも、ツール自身の依存範囲の外にあるアプリの依存を読む場合、追加の除外や依存解決処理との連携が必要になると記載されている。
生成された対応表の内訳を確認すると、除外設定の影響が分かる。同じパッケージでも、依存関係の組み合わせが異なるものは別エントリーとして数えている。
| Store Loaderの設定 | CASから読む依存 | 実ファイルへ戻した依存 |
|---|---|---|
| 5パッケージを除外 | 381 | 103 |
| さらにastroを除外 | 134 | 350 |