レガシー Monorepo を安全かつ素早く pnpm workspace に移行する方法

Developer Experience & Performance チームでエンジニアをしている大矢です。今回はレガシーな monorepo 構成において、比較的コストをかけずに npm から pnpm workspace に移行する際のポイントについて解説します。

背景

プレイドでは 2019 年から Microservice 構成を採用していますが、具体的には Self Contained System (SCS)というような構成になっています。1つの SCS にはフロントエンドのコードや API が package で切られて、いわゆる Monorepo の構成になっています。今回の事例では apiv2 という system を取り扱います。apiv2 は KARTE API を構成する system で内部的に使われている名前です。KARTE API のように SaaS の機能を API として提供し自由度高く利用できることは AI 時代において重要度が高く、開発者体験を改善する必要がありました。

apiv2 system のディレクトリ構成は次のようになっていました(量が多いのでかなり省略しています)。

├── docker-compose.yml
├── Makefile
├── node_modules
├── package.json
├── specs # openapi spec が格納されたディレクトリ、package から参照されることがある。
└── packages
  ├── api-dead-letter-queue-subscriber
  │ ├── __tests__
  │ ├── build
  │ ├── node_modules
  │ ├── package.json
  │ ├── package-lock.json
  │ ├── src
  │ ├── tsconfig.build.json
  │ └── tsconfig.json
  ├── api-gateway-v2
  │ ├── __tests__
  │ ├── build
  │ ├── node_modules
  │ ├── nodemon.json
  │ ├── package.json
  │ ├── package-lock.json
  │ ├── src
  │ ├── tsconfig.build.json
  │ └── tsconfig.json
  ├── api-hook-v2
  │ ├── ...
  ├── apiv2-cache-syncer
  │ ├── ...
  ├── common # front-react を含むほぼ全ての package から参照される
  │ ├── ...
  ├── common-server # front-react 以外のほぼ全ての package から参照される
  │ ├── ...
  ├── front-react # vite を使用
  │ ├── ...
  ├── hook-subscriber 
  │ ├── ...
  ├── internal-api
  │ ├── ...
  ├── migration-batch
  │ ├── ...
  ├── public-api-v2
  │ ├── ...
  ├── remote-mcp
  │ ├── ...
  └── web
    ├── ..

system のルートディレクトリに package.json があり、共通に使える npm package (typescript など)を管理します。各 package にも package.jsonpackage-lock.json があり、それぞれに必要な npm package を管理しています。普通 monorepo(npm workspace, pnpm workspace, yarn workspace など)というと lock ファイルはルートディレクトリに1つですが、プレイドでは https://github.com/plaidev/link-local-dependencies という独自ツールを使用しています。 link-local-dependenciespostinstall スクリプトで実行されることを想定していて、 package.json に定義された localDependencies プロパティに従ってシンボリックリンクを貼るというシンプルな仕組みです。

apiv2 system の規模感をまとめると下記の通りです。

  • 13 package で構成
  • src ディレクトリに含まれるアプリケーションのコードが約 500 ファイル

pnpm workspace 移行のモチベーション

localDependencies は独自ツールのため、問題が起きた時にインターネットで調べても何も情報が出てこなかったり、初見でつまづくことが多いという問題がありました。また package-lock.json が package ごとにあるため重複が起きやすく、install に時間がかかったりディスクの消費が必要以上に大きくなったりします。一括で package のバージョンを上げたい場合や monorepo を構成する全ての package で node_modules をインストールしたい場合には、 for dir in $(ls -d packages/*/); do (cd "$dir" && npm install) & done; wait のようなスクリプトを実行する必要があります。

pnpm は開発も活発で、広く使われています。ディスク効率が良くて依存解決が高速なため新しい system では採用することが多いです。便利な機能もいくつかあり、supportedArchitecture ****は複数の linux アーキテクチャの環境で動かしたい場合に便利ですし、 pnpm -r {command} のように実行すると workspace 内の依存関係を考慮してコマンドを並列実行できるというのも嬉しいポイントです。

今回の開発者体験改善の取り組みでは、pnpm workspace への移行の他にも jest → vitest の移行、CiecleCI から GitHub Actions への移行も行いましたが、pnpm workspace への移行が最も大変で学びが多かったためこの記事ではメインで書いています。

移行の原則

移行の際に気をつけた原則について書いています。これは pnpm の移行に限らず npm package のメジャーバージョンのアップデートやリアーキテクチャなどの際にも気をつけています。

必要最小限の変更

改善をしていると、いろいろなことが気になってついつい PR が大きくなってしまうことがあります。細かいスクリプトの調整や、依存モジュールのバージョンアップ、TypeScript の any を直すなどです。coding agent に実装を任せる場合にも、きちんとコントロールしないとどんどんいろんなところを直し始めると思います。上手くいけば時間の節約になりますが、細かいところを改善した結果別の問題を引き起こす可能性があります。今回の改善では 1 つの PR の変更箇所を最小限にするということを心がけています。

なるべく早い段階で問題を網羅する

CI が通ったのでリリースしてみると、検証環境や本番環境で動かないということがあると思います。CI が通ったとしても検証環境・本番環境でどのようなエラーが起き得るか?その可能性を減らすために何がチェックできると良いか?を考えて事前に対策することが重要です。また、CI に全部任せるというのも危険です。ローカルで CI 環境と同じようなチェックができないために、何度もリモートブランチに push して CI の結果を待ち、エラーを確認してローカルで修正するというのもよくあるパターンですが、気付かないうちに時間と金銭的コストを消費している可能性があります。CI ではランナーを立ち上げたり、リポジトリをチェックアウトしたり、モジュールのインストールやキャッシュからの復元にオーバーヘッドがかかります。

今回の改善では可能な限りローカルでの確認の時点で、CI や検証環境・本番環境で起きるエラーを予防して進めています。

pnpm workspace 移行のポイント

apiv2 を pnpm に移行する際には次のような変更が入ることになります。

  • pnpm によって node_modules の構成が大きく変わり、厳密に依存解決が行われる
  • npm コマンドが pnpm コマンドになり、workspace 機能が使えるようになる
  • pnpm の便利なオプションが使える

厳密な依存解決によって起きる問題への対処

pnpm を使用した node_modules の構成の例として、pnpm 公式ドキュメントに書いてあるものを参照します。bar を依存関係にもつ foo を install したときの node_modules の構成はこのようになります。

node_modules
├── foo -> ./.pnpm/foo@1.0.0/node_modules/foo
└── .pnpm
  ├── bar@1.0.0
  │ └── node_modules
  │   └── bar -> <store>
  └── foo@1.0.0
    └── node_modules
      ├── foo -> <store>
      └── bar -> ../../bar@1.0.0/node_modules/bar

node_modules 直下には .pnpm ディレクトリと、dependencies に存在する npm package のシンボリックリンクがあります。ここで、bar は dependencies に直接書かれていないため、node_modules 直下には存在せず、アプリケーションから直接 import することはできません。これを npm で構成する場合には次のようなディレクトリ構成になるでしょう。

node_modules
├── bar
│ ├── dist
│ ├── package.json
│ └── src
└── foo
  ├── dist
  ├── package.json
  └── src

foo から bar を import することはできますが、アプリケーションから直接 bar を import することもできます(phantom dependencies と言います)。pnpm に移行すると、このように元々解決できていた module が解決できなくなることがあります。

解決できない module の網羅的な検出

解決できない module を import しようとした場合は tsc 実行時に基本的にはエラーになります。pnpm では --no-bail オプションを使うことで workspace の全ての package でコマンドを実行する際に、途中で失敗しても最後まで処理を続けてくれます( pnpm -r --no-bail type-check )。これで一度のコマンドで網羅的に解決できない module がある箇所を洗い出すことができます。

また CommonJS の場合 require が使われると tsc だけではこれを検出することができません。 knip の unlisted dependencies や eslint の import/no-extraneous-dependencies を使ってこのようなケースも検出することができます。eslint はすでに全ての package で使われていましたが、 eslint-plugin-import をインストールする必要があるというのと、全ての eslintrc を書き換えるのが面倒だったため、今回は knip を使用(knip --include=unlisted,unresolved)しました。ちなみに knip は他にも不要な export を検出してくれたりいろいろ便利ですが、「必要最小限の変更」の原則に従って pnpm への移行に関係ないルールは使いませんでした。

暗黙的に解決できていた module は新規に dependencies に追加する必要がありますが、その際に 「そのパッケージが現在実際に使っているバージョンを維持するため、package-lock.json に記録された解決済みのバージョンをそのまま採用します。ここで欲張ってついでに最新版にアップデートしようなどとすると、それがまた新たな問題を引き起こし、PR が肥大化する可能性があります。

vite や tsconfig では、 preserveSymlinks というオプションがあります。これはシンボリックリンクを辿る前のパスから依存関係を解決するというもので、pnpm を使う場合に問題になります。どういうことか先ほどの pnpm 公式の例で説明します。アプリケーションは foo に直接依存していて、 foo からは bar が読み込まれます。

node_modules
├── foo -> ./.pnpm/foo@1.0.0/node_modules/foo // - (1)
└── .pnpm
  ├── bar@1.0.0
  │ └── node_modules
  │   └── bar -> <store>
  └── foo@1.0.0
    └── node_modules
      ├── foo -> <store> // - (2)
      └── bar -> ../../bar@1.0.0/node_modules/bar // - (3)

preserveSymlinkstrue の場合

  1. require('foo') が解決されると (1) が読み込まれます
  2. foo の実体は (2) なので (2) から require('bar') が実行されます
  3. require('bar')(1) の位置から辿ろうとしますが、 node_modules/bar が存在しないため解決エラーになります

preserveSymlinksfalse の場合には、3のステップで (2) の位置から辿るので、 (3)node_modules/bar が解決できます。

KARTE のリポジトリではレガシーな system ではこのオプションが true になっていることが多いです。 front-react package のような vite を使った package から localDependencies を使って CommonJS を読み込むときにこのオプションがついていないと解決できなかったからみたいですが、なぜ解決できなかったのかはよくわかっていません(そして、よくわからないままいろんなところでコピペされて本当にこれが必要なのか誰もわからない状態でした)。ただし、pnpm の仕組み上 preserveSymlinks は常に false にしておくのが良いでしょう(デフォルトが false です)。

front-react package から依存している common package(サーバとフロント両方から読み込まれる package) は CommonJS でビルドされているため、preserveSymlinks をやめると vite でそのまま読み込むことはできません。これを避けるための選択肢としてはいくつかあります。

  • ビルド前の src を直接参照する
  • common package を ES Modules と CommonJS の dual package とする。もしくは ES Modules だけにしてサーバも ES Modules にする。
  • vite-commonjs-plugin を使う

今回、common package の exports にはこのように、export の default として CommonJS のファイルへのパスが書かれていました。

{
  "name": "~common",
  "version": "1.0.0",
  "private": true,
  "description": "",
  "main": "build/src/constants/index.js",
  "types": "build/src/constants/index.d.ts",
  "exports": {
    ".": {
      "types": "./build/src/constants/index.d.ts",
      "default": "./build/src/constants/index.js"
    },
    "./build/src/types": {
      "types": "./build/src/types/index.d.ts",
      "default": "./build/src/types/index.js"
    },
    "./types": {
      "types": "./build/src/types/index.d.ts",
      "default": "./build/src/types/index.js"
    },
    ...(省略)

ES Modules を扱うようにしたり、src を直接参照する方法だと、この exports の部分を書き換える必要があります。exports を書き換えると Node.js で動くアプリケーションからの参照にも影響がある可能性があります。「必要最小限の変更」の原則に従って vite-commonjs-plugin を使うことにしました。これは最も簡単で、 vite.config.js に plugin を足すだけです。

import { defineConfig } from 'vite';
import commonjs from 'vite-plugin-commonjs';

export default defineConfig({
  plugins: [commonjs()],
  ...
})

pnpm コマンドへの移行

npm run を使って package.json の scripts に書いてあるコマンドを実行するような箇所は、すべて pnpm run(省略可能) に移行します。これは機械的にできそうですが、一部注意が必要です。

node_modules/.bin 直接実行をやめる

node 実行時のオプションとして max_old_space_size を渡すために package.json の scripts に次のように書いているケースがあります。

{
  "scripts": {
    "test": "NODE_ENV=test node --max_old_space_size=4096 ../../node_modules/.bin/jest"
    ...(省略)
  }
}

これは pnpm に関係なくやめるべきですが、pnpm workspace にすることで動かなくなることが多いです。 次のように環境変数を使って書き直します。

{
  "scripts": {
    "test": "NODE_ENV=test NODE_OPTIONS=--max_old_space_size=4096 jest"
    ...(省略)
  }
}

failIfNoMatch を有効にする

./packages/web/package.json に書かれている npm script を実行する際に、npm だと次のように書けます。

npm --prefix packages/web type-check

pnpm では workspace にある package の script を実行する場合には --filter オプションをつけます。しかし次のコマンドは一見あってそうですが間違いです(type-check は実行されず、正常終了になります)。

$ pnpm --filter packages/web type-check || echo $?
No projects matched the filters in "systems/apiv2"
0

pnpm では --filter オプションをつけて npm script を実行しようとした時に、filter にマッチする package がない場合は正常終了します。これが原因で、pnpm workspace に移行した時に CI で lint や type-check が実行されていないという問題が起きたことがありました。 failIfNoMatch オプションをつけることでマッチする package がない場合には異常終了にすることができ、問題に早く気づくことができます。

$ pnpm --filter packages/web --fail-if-no-match type-check || echo $?
No projects matched the filters in "systems/apiv2"
1

全てのコマンドにこのオプションをつけるのは面倒です。pnpm では pnpm-workspace.yaml ファイルにいろんなオプションをつけることができるのでそこで指定できないかと考えたのですが、公式ドキュメントには特にこのオプションに関する記述がありませんでした。

調べてみたところ pnpm-workspace.yamlfailIfNoMatch オプションをつけることはできるがドキュメントがないだけ(https://github.com/pnpm/pnpm/issues/10113)で、vscode でこのオプションを指定したときにエラーになるのは、schemaStore という様々な json や yaml などのスキーマの補完・バリデーションを良い感じにしてくれる仕組みに failIfNoMatch の更新が反映されていないから(https://github.com/SchemaStore/schemastore/pull/5081)でした。

おわりに

今回の移行で得た最大の学びは、「必要最小限の変更」と「早期の網羅的な検証」を徹底することでした。pnpm の厳密な依存解決は既存の暗黙依存を炙り出しますが、knip や eslint などの静的解析ツールを使用することで CI/本番での事故を手前で減らせます。さらに、--fail-if-no-match を workspace のデフォルトにすることで、「動いていないのに通る」CI をなくし、再現性のある運用に近づけました。

この記事が、レガシーな Monorepo を抱えるチームが「いま触りたくない」状態でも、一歩目を小さく安全に前進させる助けになれば幸いです。