pnpm で monorepo プロジェクトを構築する
これはなに
pnpm をベースにして実践的な monorepo プロジェクトを構築するまでの手順をまとめたものです。アプリケーション開発の実務では長らく yarn を常用してきましたが、極端に攻めたアプローチをしなければ pnpm でも十分に実務に耐えられると実感しました。筆者が実務で求める要件は大まかに以下のとおりです。
- monorepo をサポートしている
- プロジェクトルートからサブパッケージの npm scripts を直接実行できる
- GitHub Action / CircleCI が動作する
- Renovate のサポート対象に含まれる
本稿では備忘録代わりとしてその内容をご紹介します。
pnpm とは
yarn 同様、npm の代替として開発されているサードパーティのパッケージマネージャーです。インストールの速さと(ディスクスペースの)効率性に主眼を置いています。 Next.js や Vite をはじめ多くの著名な OSS ライブラリーで採用されており、着実に実績を積み上げているのが伺えます。
セットアップ
pnpm をインストール
pnpm は corepack
を使ってインストールします。そのため使用する Node.js のバージョンは corepack
が標準バンドルされている v16.9.0 || v14.19.0
以上であることが前提となります。
corepack enable
corepack prepare pnpm@<version> --activate
インストールする pnpm のバージョンは明記する必要がありますが(省略不可)、 Node.js のバージョンが 16.17
以降であれば latest
タグを指定することで最新バージョンを選択できます。
corepack prepare pnpm@latest --activate
pnpm が corepack 経由でインストールされました。shell を再起動すると pnpm が使えるようになっています。
pnpm -v
7.20.0
Scaffolding
プロジェクト用ディレクトリーを作成し、そこで package.json
を生成します。
mkdir my-project
cd ./my-project
pnpm init
package.json
{
"name": "my-project",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
さらに以下のフィールドを追記し .npmrc
ファイルの作成・設定をすることで、 npm
, yarn
など pnpm
以外のコマンド利用を禁止できます。
{
"name": "my-project",
"version": "1.0.0",
"description": "",
"main": "index.js",
{
+ "engines": {
+ "pnpm": "7.20.0",
+ "npm": "please_use_pnpm_instead"
+ },
+ "packageManager": "pnpm@7.20.0"
+ },
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
engine-strict=true
monorepo
npm, yarn では package.json
に workspaces
フィールドを記述することで monorepo ( ≒ workspaces ) 機能を有効にしますが、pnpm は pnpm-workspace.yaml
ファイルを別途用意し、そこで monorepo を定義します。
packages:
- 'packages/*'
サブパッケージの npm scripts をプロジェクトルートから実行する
以下のような monorepo 構成があるとします。
.
├── package.json
└── packages/
├── app1/
| └── package.json
└── app2/
└── package.json
my-project/packages/app1/package.json
{
"name": "app1",
"scripts": {
"test": "echo \"hello app1!\""
}
}
my-project/packages/app2/package.json
{
"name": "app2",
"scripts": {
"test": "echo \"hello app2!\""
}
}
yarn は workspace
コマンドを使うことでサブパッケージの npm scripts をプロジェクトルートから直接実行できます。
yarn workspace app1 test
各サブパッケージのディレクトリーに潜ることなく直接 npm scripts を実行できるのは非常に便利なため、pnpm でも同様の設定をします。プロジェクトルートの package.json
を以下のように編集します。
{
"scripts": {
- "test": "echo \"Error: no test specified\" && exit 1"
+ "app1": "pnpm -F \"app1\"",
+ "app2": "pnpm -F \"app2\""
}
}
-F
/ --filter
は pnpm のフィルタリングという機能です。これを使うことで各サブパッケージの npm scripts をプロジェクトルートから直接実行できるようになります。
pnpm app1 test # hello app1!
pnpm app2 test # hello app2!
- 参考文献: フィルタリング | pnpm
【補足】`-C` オプションでも同等の挙動を実現可能。
{
"scripts": {
"app1": "pnpm -C packages/app1",
"app2": "pnpm -C packages/app2"
}
}
pnpm app1 test # hello app1!
pnpm app2 test # hello app2!
- 参考文献: pnpm CLI | pnpm
node モジュールをインストールする
各サブパッケージのディレクトリーで pnpm add
コマンドを実行して node モジュールをインストールします。
cd packages/app1
pnpm add -E <package_name>
先述したフィルタリングの設定がされていれば、各サブパッケージのディレクトリーに潜らずともプロジェクトルートから直接インストールできます。
# packages/app1 にインストールする。
pnpm app1 add -E <package_name>
monorepo 横断での利用のためにプロジェクトルートにインストールする場合は -w
オプションを付与します。
pnpm add -w -E <package_name>
- 参考文献: pnpm add <pkg> | pnpm
CI
GitHub Actions
公式の action が公開されているため、そちらを利用します。
jobs:
setup:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set node version
run: echo "NODE_VERSION=$(cat .node-version)" >> $GITHUB_ENV
- uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
# package.json に記載されている pnpm のバージョンを参照する。
- name: Set pnpm version
shell: bash
run: |
pnpm=$(cat package.json | jq -r .engines.pnpm)
echo "PNPM_VERSION=${pnpm}" >> $GITHUB_ENV
- uses: pnpm/action-setup@v2.2.2
with:
version: ${{ env.PNPM_VERSION }}
- name: Cache node modules
uses: actions/cache@v3
env:
cache-name: cache-node-modules
with:
path: node_modules
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Install Dependencies
run: pnpm i
今回のプロジェクトでは Node.js と同様に pnpm のバージョンも明確に定めているため、GHA でもそれと同じバージョンを利用する必要があります。バージョン情報は package.json
の engines
フィールドに記載されているため、これを参照させます。ここではジョブ実行マシンである ubuntu-latest
に標準バンドルされている jq
を使って engines
フィールドを参照しています。これにより GHA ワークフローファイルに pnpm のバージョン情報をハードコーディングせずに済みます。
# package.json に記載されている pnpm のバージョンを参照する。
- name: Set pnpm version
shell: bash
run: |
pnpm=$(cat package.json | jq -r .engines.pnpm)
echo "PNPM_VERSION=${pnpm}" >> $GITHUB_ENV
- uses: pnpm/action-setup@v2.2.2
with:
version: ${{ env.PNPM_VERSION }}
- 参考文献: jq
CircleCI
GHA のような公式イメージはありませんが、 Node.js さえあれば corepack
を使ってインストールできるので問題ありません。
version: 2.1
executors:
default:
working_directory: ~/workspace
docker:
- image: cimg/node:18.12.1
environment:
TZ: 'Asia/Tokyo'
commands:
pnpm_install:
steps:
- run:
name: Install pnpm package manager
command: |
version=$(cat package.json | jq -r .engines.pnpm)
com="sudo corepack enable && corepack prepare pnpm@$version --activate"
eval ${com}
- run:
name: Execute pnpm install
command: pnpm i
こちらでも jq
を使って pnpm のバージョンを指定します。
公式ドキュメントで紹介されている方法では失敗する
curl
コマンドを使ってインストールする方法が紹介されていますが、これだと失敗します(当記事執筆時点)。
#!/bin/bash -eo pipefail
curl -L https://pnpm.js.org/pnpm.js | node - add --global pnpm@7.13.2
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 4367k 0 4367k 0 0 16.4M 0 --:--:-- --:--:-- --:--:-- 16.4M
ERROR No write access to the found global executable directories
The found directories:
/usr/local/bin
Exited with code exit status 1
CircleCI received exit code 1a
関連する Issue はありますが、当記事執筆時点でまだ解決していないようです。
Renovate
Renovate は pnpm をサポートしているため、npm, yarn の場合と同じく基本的に問題なく動作します。
pnpm のバージョンアップデート
package.json
に記載されている pnpm のバージョン情報を Renovate が検知してアップデートを管理してくれます。GHA ワークフローファイル中のバージョン情報も同様に管理してくれますが、先述の方法を使うことで package.json
に一元化されます。
Why pnpm?
pnpm には、node モジュールの実体を同一ディレクトリー配下で一元管理し、複数プロジェクト横断でこれを参照する という npm や yarn には無い強みがあります。
マシンのディスク容量節約 / node モジュールインストールが( npm, yarn よりも)高速
例えば npm を土台にしたプロジェクトの場合、 npm i typescript
を実行すればそのプロジェクト配下の node_modules
ディレクトリーに typescript
の実体がインストールされます。つまり TypeScript を用いたプロジェクトを一斉に 100 個作ると、全く同じ typescript
モジュールが同一マシンに 100 個インストールされることとなります。プロジェクト毎に typescript
のバージョンが異なるならまだしも、同一バージョンなら単純にディスク容量の無駄使いです。
pnpm の場合、node モジュールはプロジェクトディレクトリー配下ではなく特定のフォルダー( content-addressable store )でその実態を管理し、各プロジェクト下の node_modules
には実体へのシンボリックリンクが貼られるだけとなります。つまり TypeScript を用いたプロジェクトを 100 個作ったとしても実際にダウンロードされる typescript
モジュールは 1 つのみであり、100 のプロジェクトはその実態を参照しているに過ぎません。プロジェクト間で typescript
のバージョンが異なればそのバージョンの数だけインストールされ、それぞれ参照されます。これによりマシンのディスク容量の節約とインストールの高速化が期待できます。
- 参考文献: モチベーション | pnpm
明示的にインストールされた node モジュール以外を暗黙的に使えなくなる
個人的にはこれが最大のメリットだと思っています。例えば @emotion/css
をインストールしようとすると @babel/core
も暗黙的にインストールされます。これは @emotion/css
が @babel/core
に(peerDeps として)依存しているためであり、動作させるために芋づる式にインストール( = hoisting )せざるを得ないからです。
npm や yarn の場合は、芋づる式にインストールされた(つまり package.json
に明記されていない) @babel/core
もアプリケーションコードから参照・使用できてしまいます。なぜならプロジェクト下の node_modules
ディレクトリーに実体がある以上、Node.js 的にはなんの問題もなく参照できてしまうためです。
pnpm の場合、プロジェクト下の node_modules
ディレクトリーには package.json
に明記されたモジュールのシンボリックリンクしかないため、peerDeps 等で依存するモジュールは暗黙的に参照できず、つい誤ってそのまま利用してしまうこともありません。
Discussion
大変参考になる記事をありがとうございます。一箇所気になった点があったのでコメントしております。
こちらですが、ルートで
を実行しても同様の結果が得られるようです。
はい、仰るとおりそのコマンドであればルートディレクトリからサブパッケージにインストールできます。ご提案いただいたコマンドは、サブパッケージの npm scripts をプロジェクトルートから実行する 設定にあるスクリプトを直接実行した体ですね。
ご指摘いただきありがとうございます。記事本文に加筆しておきます。