技術約8分で読めます

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
OSmacOS 26.5.2
pnpm12.11.2
最初のNode.js26.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成功
loadedENOENTで失敗
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を比較すると、通常ファイルの数は減った一方、ファイルサイズの合計は増えた。

配置通常ファイル数サイズ合計
isolated2328,268バイト
loaded8126,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.0isolatedネイティブ依存のインストールに失敗
26.10.0loaded、除外なしERR_PNPM_CAS_REQUIRES_MATERIALIZATIONで停止
26.10.0loaded、sharpとbetter-sqlite3を除外ネイティブ依存のインストールに失敗
24.18.0isolatedPNG生成成功、SQL結果は42
24.18.0loaded、除外なしERR_PNPM_CAS_REQUIRES_MATERIALIZATIONで停止
24.18.0loaded、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で試してみた。

いずれの条件でもインストール自体は成功した。

配置VitestDEVサーバービルド
isolated51件成功ブラウザ表示成功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パッケージを除外381103
さらにastroを除外134350