⚒️

npm → pnpm v10移行(Turborepo編)

2025/02/21に公開

はじめに

今回は現在プロジェクトで使用しているturborepoというmonorepoをツール上で、npmからpnpm(v10.0.0)に移行した時の設定やつまづきポイントの備忘録となってます。

https://pnpm.io/ja/

移行する上での変更点

  1. package.json周り
  2. .npmrcの管理
  3. nodejsのバージョン管理
  4. CI周り

1. package.json周り

i. workspaceの変更

npmからpnpmのworkspaceに移行する際は、
・pnpm-workspace.yamlに各アプリケーション、パッケージのpackage.jsonをrootで管理させるために作成します。

pnpm-workspace.yaml
例)turborepoでは、apps, packages毎に区切られているので以下のように定義
packages:
  - 'apps/*'
  - 'packages/*'

公式:https://pnpm.io/ja/workspaces

そして、npmではworkspace内の内部パッケージの参照を@hoge/eslint-config: "*"として参照していましたが、pnpmでは@hoge/eslint-config: "workspace*"として定義することで内部パッケージを参照することができます。

例)内部パッケージを参照した時の定義

root/packages/ui/package.json
{
  "name": "@hoge/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ///
  },
  "devDependencies": {
-   "@hoge/eslint-config": "*",
+   "@hoge/eslint-config": "workspace:*",
    "@turbo/gen": "^2.4.0",
    ///
  },
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

i. 各種コマンド変更

npxpnpm exec

pnpm exexは、インストール済みであるパッケージのシェルコマンドを実行するために使用します。
私はこの存在を知らず最新バージョンのパッケージを実行するpnpx(pnpm dlx)に置き換えていて、後から誤っていることに気づきました💧

https://pnpm.io/ja/cli/exec

npm runpnpm
pnpm(or pnpm run)でコマンド実行するので置き換え

i. packageManegerの変更

packageManagerをpnpmに変更。
後に紹介する、CI周りのバージョン参照やrenovateもバージョンアプデを参照してくれるのも助かります。

root/package.json
{
  "name": "pnpm-turborepo",
  "private": true,
  ///
- "packageManager": "npm@10.9.2",
+ "packageManager": "pnpm@10.0.0",
  "engines": {
     ///
  }
}

2. .npmrcの管理

.npmrc
- auto-install-peers = true
- engine-strict=true
+ link-workspace-packages=true
+ prefer-workspace-packages=true
+ public-hoist-pattern[]=*eslint*

上記の管理に変わりました。上から見ていきます。

auto-install-peersの削除

pnpm v10からデフォルトとなったので不要
公式:https://pnpm.io/next/npmrc#auto-install-peers

engine-strictの削除

後に紹介しますが、pnpm.executionEnv.nodeVersionでnodeVersionを管理することができたので外しました。

workspace 内で管理されているパッケージが、package.jsonに定義されたバージョンと一致する場合、シンボリックリンクとして扱われます。

通常、pnpm は外部パッケージ(react など)をデフォルトでシンボリックリンク化しますが、ワークスペース内の内部パッケージをシンボリックリンク化するには、この設定を true にする必要があります。

trueの場合:内部パッケージ(例:packages/ui,packages/libs)にreactがある場合、rootのnode_modulesに一度だけreactがインストールされ、それぞれ参照可能。

falseの場合:逆にリンク化されていないと、packages/ui, packages/libsにreactがある場合それぞれのnode_modules内でreactをインストール・管理。

公式 https://pnpm.io/npmrc#link-workspace-packages

prefer-workspace-packagesの追加

ワークスペース内のパッケージが、npm レジストリのパッケージよりも優先して使用されます。」
そうすることで内部パッケージを変更した際、参照先にも即時反映されます。

例えば、workspace(monorepo)内にあるシンボリックリンクとして参照されるpackages/ui,packages/libsがあります。そして、apps/hogepackages/ui を参照している場合に、packages/ui を変更すれば、即座に apps/hoge にも反映されます。

公式 https://pnpm.io/npmrc#prefer-workspace-packages

public-hoist-pattern[]=*eslint*の追加

node_moduels内でネストしているパッケージを、node_modules内のrootに引き上げる(hoist)するための設定です。
こちらは、筆者が最近直面したvscodeの拡張機能であるESLintが突如効いていないことがありました。
原因としては、VSCodeのESLintがnode_modules内のネストされているeslintのパッケージを参照できていませんでした。

公式 https://pnpm.io/npmrc#hoist-workspace-packages

pnpmではnode_modules/.pnpm/に依存パッケージがあり、node_modules配下のパッケージはシンボリックリンクされている構造です。そのnode_modulesの構造の読み込みが出来ていない関係でESLintが機能してなかったので注意が必要です。

https://pnpm.io/ja/blog/2020/05/27/flat-node-modules-is-not-the-only-way

3. nodejsのバージョン管理

今までは、リポジトリのnodejsバージョン管理をvoltaを活用していましたがpnpmで指定したnodejsのバージョンをコマンド実行することが可能になったのでvoltaを卒業しました。
具体的には、pnpm.executionEnv.nodeVersionの機能でpnpm runなどの実行時に、指定されたnodejsのバージョンで実行してくれます。
(CI上でも、pnpm実行時に定義されているnodeVersionを参照してくれます。)

公式: https://pnpm.io/ja/package_json#pnpmexecutionenvnodeversion

package.json
{
  "name": "my-turborepo",
  "private": true,
  ///
  "packageManager": "pnpm@10.0.0",
- "engines": {
-   "node": "20.x"
- },
- "volta": {
-   "node": "20.18.1",
-   "npm": "10.9.2"
- },
+ "pnpm": {
+   "executionEnv": {
+     "nodeVersion": "20.18.1"
+   }
+ }
}

4. CI周り

主にCIで変更した箇所は以下です。

  1. actions/setup-nodepnpm/action-setup
  2. actions/cacheで指定してるpathをnode_modules**/node_modules
  3. 各種pnpmコマンドへ移行

nodeバージョン管理でも説明入れてますが、CI上のnodeバージョンもpnpmのコマンド実行時に、自動的に指定されたnodeバージョンで実行しているので、setup-nodeが不要になりました。

2の**/node_modulesですが、ワークスペースで運用している都合上、appsやpackages配下の各node_modulesを参照する必要があります。

github action周りの改修した内容
name: Checks on PullRequest

on:
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]

jobs:
  setup:
    name: Setup
    runs-on: ubuntu-22.04

    steps:
      - name: Checkout
        uses: actions/checkout@v4

-     - name: Setup Node.js
-       uses: actions/setup-node@v4
-       with:
-         node-version-file: 'package.json'
-         cache: 'npm'
+      - name: Setup pnpm
+        uses: pnpm/action-setup@v4

      - name: Cache node_modules
        uses: actions/cache@v4
        id: node_modules_cache_id
        with: 
+         path: node_modules
-          key: ${{ runner.os }}-deps-${{ hashFiles(format('{0}{1}', github.workspace, '/package-lock.json')) }}
-          path: '**/node_modules'
+          key: ${{ runner.os }}-deps-${{ hashFiles(format('{0}{1}', github.workspace, '/pnpm-lock.yaml')) }}

      - name: Install Dependencies
        if: steps.node_modules_cache_id.outputs.cache-hit != 'true'
-        run: npm ci
+        run: pnpm install --frozen-lockfile

  lint-check-types:
    needs: setup
    name: Run lint and check-types
    runs-on: ubuntu-22.04

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup pnpm
        uses: pnpm/action-setup@v4

      - name: Restore node_modules from cache
        uses: actions/cache@v4
        with:
-         path: node_modules
-         key: ${{ runner.os }}-deps-${{ hashFiles(format('{0}{1}', github.workspace, '/package-lock.json')) }}
+         path: '**/node_modules'
+         key: ${{ runner.os }}-deps-${{ hashFiles(format('{0}{1}', github.workspace, '/pnpm-lock.yaml')) }}

      - name: Run lint
-       run: npm run lint
+       run: pnpm lint

      - name: Run check-types
-       run: npm run check-types
+       run: pnpm check-types

  test:
    needs: setup
    name: Run test
    runs-on: ubuntu-22.04

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup pnpm
        uses: pnpm/action-setup@v4

      - name: Restore node_modules from cache
        uses: actions/cache@v4
        with:
-         path: node_modules
-         key: ${{ runner.os }}-deps-${{ hashFiles(format('{0}{1}', github.workspace, '/package-lock.json')) }}
+         path: '**/node_modules'
+         key: ${{ runner.os }}-deps-${{ hashFiles(format('{0}{1}', github.workspace, '/pnpm-lock.yaml')) }}

      - name: Run test
-       run: npm run test
+       run: pnpm test

  build:
    needs: setup
    name: Run build
    runs-on: ubuntu-22.04

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup pnpm
        uses: pnpm/action-setup@v4

      - name: Restore node_modules from cache
        uses: actions/cache@v4
        with:
-         path: node_modules
-         key: ${{ runner.os }}-deps-${{ hashFiles(format('{0}{1}', github.workspace, '/package-lock.json')) }}
+         path: '**/node_modules'
+         key: ${{ runner.os }}-deps-${{ hashFiles(format('{0}{1}', github.workspace, '/pnpm-lock.yaml')) }}

      - name: Run build
-       run: npm run build
+       run: pnpm build

実際に変更してみて

  1. ローカルインストールの速度向上
  2. node_modulesのパッケージ管理の厳格化
  3. voltaをやめてpnpmだけでパッケージマネージャーとnodeのversion管理

が感じたメリットでした。

node_modulesのインストールは元々npm ciでやってました。pnpmだと4倍近く早くなりました。

npm ci: 約80秒  
pnpm install --frozen-lockfile: 17秒  

また、モノレポで管理している内部Packagesのパッケージ依存関係をそれぞれ定義しているものの、npmだとnode_modules内部はフラットに管理されているため、依存関係に関係なく参照ができてしまうため厳密な管理ができておらず、pnpmでだいぶ厳密管理することができました。

参考記事

https://pnpm.io/ja/
https://zenn.dev/euxn23/articles/399a6815ddac93

Discussion