🗂

pnpm で monorepo プロジェクトを構築する

2022/12/27に公開
2

これはなに

pnpm をベースにして実践的な monorepo プロジェクトを構築するまでの手順をまとめたものです。アプリケーション開発の実務では長らく yarn を常用してきましたが、極端に攻めたアプローチをしなければ pnpm でも十分に実務に耐えられると実感しました。筆者が実務で求める要件は大まかに以下のとおりです。

  • monorepo をサポートしている
  • プロジェクトルートからサブパッケージの npm scripts を直接実行できる
  • GitHub Action / CircleCI が動作する
  • Renovate のサポート対象に含まれる

本稿では備忘録代わりとしてその内容をご紹介します。

pnpm とは

https://pnpm.io/

yarn 同様、npm の代替として開発されているサードパーティのパッケージマネージャーです。インストールの速さと(ディスクスペースの)効率性に主眼を置いています。 Next.js や Vite をはじめ多くの著名な OSS ライブラリーで採用されており、着実に実績を積み上げているのが伺えます。

https://pnpm.io/ja/workspaces#使用例

セットアップ

pnpm をインストール

https://pnpm.io/ja/installation#corepackの使用

pnpm は corepack を使ってインストールします。そのため使用する Node.js のバージョンは corepack が標準バンドルされている v16.9.0 || v14.19.0 以上であることが前提となります。

console
corepack enable
corepack prepare pnpm@<version> --activate

インストールする pnpm のバージョンは明記する必要がありますが(省略不可)、 Node.js のバージョンが 16.17 以降であれば latest タグを指定することで最新バージョンを選択できます。

corepack prepare pnpm@latest --activate

pnpm が corepack 経由でインストールされました。shell を再起動すると pnpm が使えるようになっています。

console
pnpm -v
7.20.0

Scaffolding

プロジェクト用ディレクトリーを作成し、そこで package.json を生成します。

console
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 以外のコマンド利用を禁止できます。

package.json
{
  "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"
}
.npmrc
engine-strict=true

monorepo

npm, yarn では package.jsonworkspaces フィールドを記述することで monorepo ( ≒ workspaces ) 機能を有効にしますが、pnpm は pnpm-workspace.yaml ファイルを別途用意し、そこで monorepo を定義します。

pnpm-workspace.yaml
packages:
  - 'packages/*'

https://pnpm.io/ja/pnpm-workspace_yaml

サブパッケージの 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
yarn workspace app1 test

各サブパッケージのディレクトリーに潜ることなく直接 npm scripts を実行できるのは非常に便利なため、pnpm でも同様の設定をします。プロジェクトルートの package.json を以下のように編集します。

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!
【補足】`-C` オプションでも同等の挙動を実現可能。
{
  "scripts": {
    "app1": "pnpm -C packages/app1",
    "app2": "pnpm -C packages/app2"
  }
}
pnpm app1 test # hello app1!
pnpm app2 test # hello app2!

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>

CI

https://pnpm.io/ja/continuous-integration

GitHub Actions

公式の action が公開されているため、そちらを利用します。

https://github.com/pnpm/action-setup

GHA ワークフローのサンプル
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.jsonengines フィールドに記載されているため、これを参照させます。ここではジョブ実行マシンである 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 を使ってインストールできるので問題ありません。

.circleci/config.yml
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 のバージョンを指定します。

公式ドキュメントで紹介されている方法では失敗する

https://pnpm.io/ja/continuous-integration#circleci

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 のバージョンが異なればそのバージョンの数だけインストールされ、それぞれ参照されます。これによりマシンのディスク容量の節約とインストールの高速化が期待できます。

明示的にインストールされた 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

(アカウント変更しました)(アカウント変更しました)

大変参考になる記事をありがとうございます。一箇所気になった点があったのでコメントしております。

各サブパッケージのディレクトリーで pnpm add コマンドを実行して node モジュールをインストールします。

こちらですが、ルートで

pnpm add パッケージ --filter=サブパッケージ

を実行しても同様の結果が得られるようです。