pnpm monorepo で共有パッケージを複数バージョンの peer に対応させる(injected dependencies)
pnpm の workspace で、複数のアプリが共有パッケージ(内製プラグインや UI ライブラリなど)を workspace:* で参照する構成はよくあります。ところがその共有パッケージが react や astro のようなフレームワークを peer dependency に持ち、各アプリが異なるメジャーバージョンを使っていると、ビルドが壊れます。
本記事では、その原因と、pnpm の injected dependencies による解決方法をまとめます。
前提: 本記事は pnpm 10 系(v10.33 で確認)を想定しています。dependenciesMeta.injected は現行の pnpm でサポートされている機能です。
症状
共有パッケージ @myorg/shared-plugin が peer に some-framework を持ち、アプリ A は some-framework@2、アプリ B は some-framework@1 を使っているとします。すると片方のビルドで「フレームワークの内部モジュールが解決できない」エラーが出ます。
[vite]: Rollup failed to resolve import "virtual:some-framework/internal"
from ".../some-framework@2.0.0/.../internal.mjs"アプリ B(v1)のビルドなのに、共有パッケージが v2 の内部モジュールを引き込んでいるのがポイントです。
なぜ壊れるのか
workspace:* の依存は、pnpm がシンボリックリンクでつなぎます。共有パッケージの実体はモノレポ内に1 つしかありません。
そのため共有パッケージの peer(some-framework)も1 つのバージョンに解決され、全消費者で共有されます。アプリ A(v2)とアプリ B(v1)が同じ共有パッケージをリンクすると、pnpm は peer をどちらか一方(多くは高い方)に解決し、もう片方のアプリが壊れます。
解決策:injected: true
pnpm には、まさにこのための機能があります。消費側の package.json で dependenciesMeta.<pkg>.injected を true にします(公式ドキュメント: dependenciesMeta.injected)。
// apps/app-b/package.json
{
"dependencies": {
"@myorg/shared-plugin": "workspace:*"
},
"dependenciesMeta": {
"@myorg/shared-plugin": { "injected": true }
}
}injected: true にすると、pnpm は共有パッケージをシンボリックリンクではなく各アプリへハードコピーし、そのアプリの依存グラフで peer を解決します。
- アプリ A のコピー →
some-framework@2で解決 - アプリ B のコピー →
some-framework@1で解決
共有パッケージの実体は 1 つ(単一のソース)のまま、各アプリが独立したバージョンで解決できます。修正は共有パッケージ 1 箇所に入れれば全アプリへ伝播します。
共有パッケージ側の peer は、全アプリのバージョンを含むよう広めのレンジにしておきます。
// packages/shared-plugin/package.json
{
"peerDependencies": {
"some-framework": ">=1"
}
}3 つの落とし穴
① 消費者は全員 injected に揃える
一部のアプリだけ injected、残りを素の workspace:*(シンボリックリンク)にすると、シンボリックリンク側が peer レンジ内の最も高いバージョンに引っ張られて壊れます。共有パッケージを使うアプリは全員 injected にしてください。
② src を編集したら pnpm install し直す
injected はコピーなので、共有パッケージのソースを編集しても各アプリのコピーには自動反映されません。pnpm install で再同期が必要です。CI は毎回クリーンインストールなので問題になりませんが、「ローカルで共有パッケージを直したのに反映されない」ときはこれが原因です。
共有パッケージを頻繁に編集するなら、prepare スクリプトで自動ビルドさせるか、`pnpm-sync-dependencies-meta-injected`(watch mode 対応の自動同期ツール)を使うと手間が減ります。
③ injected が吸収するのは「解決」だけ。API 互換は別
injected は「どのバージョンに解決するか」を分けるだけで、API の互換性は保証しません。共有パッケージのソースが v2 専用の API を使い出すと、v1 のアプリのコピーはビルドで壊れます。共有パッケージは使用中の全バージョン共通の API に収めておく必要があります。裏を返すと、アプリのメジャーは揃えて運用し、混在はあくまで移行途中の一時状態と捉えるのが安全です。
まとめ
workspace:*の共有パッケージは peer を単一解決するため、消費側が異なるメジャーだと壊れる。- 消費側で
dependenciesMeta.<pkg>.injected: trueにすると、各アプリへハードコピーされアプリごとに独立して peer を解決する。単一ソースのまま、バージョン混在に強くなる。 - 「全消費者を injected に揃える」「src 編集後は再 install」「API 互換は別」の 3 点に注意。