📦

pnpmについて調べたのでメモ

2023/06/11に公開

はじめに

pnpmについて調べたのでその備忘録です。

https://pnpm.io/ja/

特徴

  • ディスク容量の節約
    • インストールしたパッケージのファイルはディスク上の1つの場所に保存される。たとえば新しくプロジェクトを作成して、既に他のプロジェクトでインストール済みのパッケージをインストールした場合、重複してインストールされるのではなく、グローバルなストアに保存されているファイルへのハードリンクが作成されるため、ディスク領域を消費せずに済む。
  • インストール速度の向上
    • インストールはResolving, Fetching, Linking、の3段階で行われる。従来のインストールでは全てのパッケージが前のステージを完了しないと次のステージが実行されなかったが、パッケージごとにステージを進めるようになったため時間が短縮される
  • 厳格さ
    • node_modules直下には直接インストールしたパッケージのシンボリックリンクしか置かれないため、直接使用しているパッケージが明確かつ、それ以外のパッケージのimportができないようになっている。
  • Monorepoに対応
    • リポジトリ内マルチパッケージをサポート

https://pnpm.io/ja/motivation

ストアの仕組みを調べてみる

プロジェクトの用意

pnpmのインストール方法はインストール | pnpm
から。

下記のようなプロジェクトを作りました。
hello-pnpm1というディレクトリを作ってpnpmでexpressのみインストールしたものです。

hello-pnpm1
 ┣ node_modules
 ┃ ┣ .pnpm
 ┃ ┣ express
 ┃ ┗ .modules.yaml
 ┣ package.json
 ┗ pnpm-lock.yaml

直接インストールしたものだけがnode_modules配下にパッケージのシンボリックが作成されます。プロジェクトで使われているすべてのパッケージの依存関係はnode_modules/.pnpm配下に.pnpm/<name>@<version>/node_modules/<name>という命名規則で置かれています。
このディレクトリを仮想ストアと呼ぶそうです。(実態となるファイルはグローバルなストアに保存されていて、node_modules/.pnpm配下は依存関係を示すためのシンボリックリンクとグローバルなストアへのハードリンクで構成されているだけなのでそう呼んでいるんじゃないかなと)

(※)フラットな node_modules が唯一の方法ではありません | pnpm

保存先のストアはどこにあるのか?

hello-pnpm1/node_modules/.modules.yamlを見てみると、storeDirというキーに保存先のパスが書かれていました。

storeDir: /Users/ユーザー名/Library/pnpm/store/v3

実際に見てみるとfilesというディレクトリがあり、その中にハッシュ化された名前のファイルが格納されています。

$ cd /Users/ユーザー名/Library/pnpm/store/v3
$ ls -1 
files
github.com+vercel+pkg@7e4fef9f0828b11c7a9655b95425f32094f2daa2
tmp

試しにファイルを一つ覗いてみると何かしらの構成ファイルの中身だということがわかります。

$ cd files/00
$ cat 023cd7f571ac2aff67ed087e213b41824bd653816f82eb5e24e14532dd2ecfbccb9b328529e4aaa5bab5eec0a7d461e541b4614fb7745938c4cf21f26ed577
{
  "sideEffects": false,
  "module": "../esm/Modal/index.js",
  "typings": "./index.d.ts"
}%

どうやってプロジェクトのファイルとストアのファイルを紐づけているのか?

hello-pnpm1/node_modules/.pnpm/lock.yamlの中を覗くと、integrityというキーにsha512のハッシュ値が書かれています。

hello-pnpm1/node_modules/.pnpm/lock.yaml
...
/express@4.18.2:
    resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==}
    engines: {node: '>= 0.10.0'}
...

pnpmのコードを読むとcreateCafsStore関数の中で、storeDirfilesという文字列でパスを繋げている箇所があります。

https://github.com/pnpm/pnpm/blob/v8.6.1/store/create-cafs-store/src/index.ts#L78

ちなみにcafsとはcontent-addressable filesystemの略のようです。

読み進めると、ここでファイルのマップを作っています。
https://github.com/pnpm/pnpm/blob/v8.6.1/store/create-cafs-store/src/index.ts#L66

getFilePathByModeInCafsを見るとディレクトリ名にパスを繋げています。
https://github.com/pnpm/pnpm/blob/v8.6.1/store/cafs/src/getFilePathInCafs.ts#L8-L15

contentPathFromIntegrityを見てみるとssriでBase64データの16進数に変換した文字列を作って、contentPathFromHex関数に渡しています。
https://github.com/pnpm/pnpm/blob/v8.6.1/store/cafs/src/getFilePathInCafs.ts#L25-L31

contentPathFromHex関数では受け取った16進数の最初の2桁をディレクトリ名、それ以降の文字列をファイル名とした文字列を作っています。
https://github.com/pnpm/pnpm/blob/v8.6.1/store/cafs/src/getFilePathInCafs.ts#L33-L43

例えばindexタイプのファイルだとこんな感じになるわけですね。

e7f3ec2fa8863dd7d0fe528cd54ba27a5620bf7054a097f3d5a53053dbc767e27b832bf07505c510120421ac5e19fd0621cade013372044c6d6a58ac0dbb8ca9
↓
e7/f3ec2fa8863dd7d0fe528cd54ba27a5620bf7054a097f3d5a53053dbc767e27b832bf07505c510120421ac5e19fd0621cade013372044c6d6a58ac0dbb8ca9-index.json

ということはhello-pnpm1/node_modules/.pnpm/lock.yamlのパッケージごとのintegrityのハッシュ値からストアのファイル名が割り出せそうです。

実際に紐付けを確認してみる

とりあえず適当にハッシュ値をパースするためのコードを用意します。

index.js
import ssri from 'ssri'

const shaValue = process.argv[2]

const integrity = ssri.parse(shaValue)
const hex = integrity.hexDigest()

console.log("hex: ", hex)

https://github.com/t-shiratori/ssri-parser

hello-pnpm1/node_modules/.pnpm/lock.yamlにあるexpress@4.18.2パッケージのintegrityのハッシュ値をパースしてみます。

$ node index.js sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==
hex:  e7f3ec2fa8863dd7d0fe528cd54ba27a5620bf7054a097f3d5a53053dbc767e27b832bf07505c510120421ac5e19fd0621cade013372044c6d6a58ac0dbb8ca9

/Users/ユーザー名/Library/pnpm/store/v3/配下のfiles/e7/f3ec2fa8863dd7d0fe528cd54ba27a5620bf7054a097f3d5a53053dbc767e27b832bf07505c510120421ac5e19fd0621cade013372044c6d6a58ac0dbb8ca9-index.jsonを見てみます。
expressパッケージの情報が書かれいます。

files/e7/f3ec2fa8863dd7d0fe528cd54ba27a5620bf7054a097f3d5a53053dbc767e27b832bf07505c510120421ac5e19fd0621cade013372044c6d6a58ac0dbb8ca9-index.json
{
    "name": "express",
    "version": "4.18.2",
    "files": {
        "LICENSE": {
            "checkedAt": 1682147597317,
            "integrity": "sha512-XggvFK90CGus/6a6u1h0Hs5AgOJqhMkVFp6F7JXtYHz/PDAgiK1yJad47jTiTl2DfiKwIWHNMLO2JyvsH9ItVg==",
            "mode": 420,
            "size": 1249
        },
        "lib/application.js": {
            "checkedAt": 1682147597339,
            "integrity": "sha512-wGMnelm4Pf/AhRFnaUdexczhxHwWe5vSJG6L2gTw68J3O18G47RPxe0FfgQ/bTPnd0HzTRXiJUITTjhlV0opvg==",
            "mode": 420,
            "size": 14593
        },
        "lib/express.js": {
            "checkedAt": 1682147597339,
            "integrity": "sha512-Kt1mtPLozkY0ScqPLqwZNjhEtqsVmkG0IWMCjFfwekJF6+/nWab5DoaFtb0jnJaf6ZNm7/iTeMuLkrinA9rNYQ==",
            "mode": 420,
            "size": 2409
        },
        "index.js": {
            "checkedAt": 1682147597339,
            "integrity": "sha512-dm0uIC3V5SCsIn4o48NZzKGDYFxStOTJXGmCXJKTVs6ncnI6mvSRo2YtPCb3IJ6JzDp69291FlwQRJLcZyiszA==",
            "mode": 420,
            "size": 224
        },
        "lib/router/index.js": {
            "checkedAt": 1682147597342,
            "integrity": "sha512-9jGAy0cYdKzFRLZ0b6voC3OU/VUYBn4UvW6euskIr2RfwWHrIAkkLYshUFtFYfH6FrwdWJ8JjQOoXF3K7xbcTQ==",
            "mode": 420,
            "size": 15124
        },
        "lib/middleware/init.js": {
            "checkedAt": 1682147597342,
            "integrity": "sha512-Z+p9u1yM83tSSyRv7ag5oyioJMTN/Zv5/5f3nyjEUEiz8hPjY7ifK+ODeBXEGQP2LTR527zFqVReiuvyFHRmig==",
            "mode": 420,
            "size": 853
        },
        "lib/router/layer.js": {
            "checkedAt": 1682147597342,
            "integrity": "sha512-fdyrQpqciFe9fpFwyS6/0+PFnOUjIwIbU+JLp3aro6dXCYrp8yZFheT+XzwzHnRzBzATRNwZ6uk0XmhimlxEDA==",
            "mode": 420,
            "size": 3296
        },
        "lib/middleware/query.js": {
            "checkedAt": 1682147597342,
            "integrity": "sha512-ANvW7Jlp6p2Fmp/TAzml3U/HDywY0bSamimDiaRHOo5/Wm+o0qggBTZDwUPXIC39ulkjbhmsKLXBkiXS31Lzhg==",
            "mode": 420,
            "size": 885
        },
        "lib/request.js": {
            "checkedAt": 1682147597347,
            "integrity": "sha512-98zTB35qTR4qKwCLsxVtqI93BbkghrVnVcfslczfH3lYOuQA9Rk5yd5trwPp9mQTcXIBhvGTZsgRHnRxNRYhsQ==",
            "mode": 420,
            "size": 12505
        },
        "lib/response.js": {
            "checkedAt": 1682147597410,
            "integrity": "sha512-8KB9ZAPj9KDCSjOqQ96MAZNU6f3owIRp2de2I8ZIbbxzm8Paumd4oMKnK6rhoC9WQgdGisqmB8pcP0EzHgPS3g==",
            "mode": 420,
            "size": 28014
        },
        "lib/router/route.js": {
            "checkedAt": 1682147597378,
            "integrity": "sha512-kQWGBSz0KAc6lZlqI315ke2o7nYCeF0qxNeNzFrjhq/EMK3vx0cxNK362f6GvB7PtbeGkdpBGozhS5xNMJDM1g==",
            "mode": 420,
            "size": 4281
        },
        "lib/utils.js": {
            "checkedAt": 1682147597378,
            "integrity": "sha512-FOD2s+3Z6l1kyYEnOK5V+MdhNNlmT/G/hNq9VNoPWHhaEcg1zUMLyS/E/9h1diee/aJiBgsMGGYtBkZP0bf6QQ==",
            "mode": 420,
            "size": 5955
        },
        "lib/view.js": {
            "checkedAt": 1682147597419,
            "integrity": "sha512-CqnvnwdpLQwlTN7d6AY7XR3U9agZafUpO3S8GLIWkEGJKhZOPB+9y+Bsiq+r1MKOkHLHVPtqi4M+YVPhw1mIbw==",
            "mode": 420,
            "size": 3325
        },
        "package.json": {
            "checkedAt": 1682147597419,
            "integrity": "sha512-DEjlVbIrdOe+uEMGy1vt6WG4pPIMkJbX9/ENcbQDrwL6vqYRtaf9JIUv329ae8JKTPRZ2j6vE5GpWAfKW7mVCA==",
            "mode": 420,
            "size": 2624
        },
        "History.md": {
            "checkedAt": 1682147597483,
            "integrity": "sha512-yceKA05lnmBVci2j1bzaTVh2RbfnJEAxirUW/3y15ETVNsAhaZl4SKW4obwK5E6dgFaKK2Crzp5reNutCqc8SQ==",
            "mode": 420,
            "size": 113107
        },
        "Readme.md": {
            "checkedAt": 1682147597483,
            "integrity": "sha512-ibR4XKDpueOD/qJ7Ta+XFS4DUjxVVyhNk7aHE6jfG7atULJUnALvtqyyw9unLpJSalyararMv1by0mWCMG1qNA==",
            "mode": 420,
            "size": 5419
        }
    }
}

この中のindex.jsのハッシュ値をさらに調べてみます。

files/e7/f3ec2fa8863dd7d0fe528cd54ba27a5620bf7054a097f3d5a53053dbc767e27b832bf07505c510120421ac5e19fd0621cade013372044c6d6a58ac0dbb8ca9-index.json
{
    "name": "express",
    "version": "4.18.2",
    "files": {
        ...
        "index.js": {
            "checkedAt": 1682147597339,
            "integrity": "sha512-dm0uIC3V5SCsIn4o48NZzKGDYFxStOTJXGmCXJKTVs6ncnI6mvSRo2YtPCb3IJ6JzDp69291FlwQRJLcZyiszA==",
            "mode": 420,
            "size": 224
        }
	...
    }
}

先ほどのパース処理を使います。

node index.js sha512-dm0uIC3V5SCsIn4o48NZzKGDYFxStOTJXGmCXJKTVs6ncnI6mvSRo2YtPCb3IJ6JzDp69291FlwQRJLcZyiszA==
hex:  766d2e202dd5e520ac227e28e3c359cca183605c52b4e4c95c69825c929356cea772723a9af491a3662d3c26f7209e89cc3a7af76f75165c104492dc6728accc

ファイルの中身を見てます。ちゃんとexpressのコードが書かれていました。

$ cd /Users/ユーザー名/Library/pnpm/store/v3/files/76
$ cat 6d2e202dd5e520ac227e28e3c359cca183605c52b4e4c95c69825c929356cea772723a9af491a3662d3c26f7209e89cc3a7af76f75165c104492dc6728accc
/*!
 * express
 * Copyright(c) 2009-2013 TJ Holowaychuk
 * Copyright(c) 2013 Roman Shtylman
 * Copyright(c) 2014-2015 Douglas Christopher Wilson
 * MIT Licensed
 */

'use strict';

module.exports = require('./lib/express');

ちなみにこのファイルのinode番号を確認してみます。

$ cd /Users/ユーザー名/Library/pnpm/store/v3/files/76
$ ls -i1
...
49618440 6d2e202dd5e520ac227e28e3c359cca183605c52b4e4c95c69825c929356cea772723a9af491a3662d3c26f7209e89cc3a7af76f75165c104492dc6728accc
...

49618440でした。

hello-pnpm1プロジェクトにインストールしたexpressのハードリンクのinode番号を確認してみます。

$ cd /Users/ユーザー名/Documents/study/pnpm/hello-pnpm1/node_modules/.pnpm/express@4.18.2/node_modules/express
$ ls -i1
49618530 History.md
49618435 LICENSE
49618531 Readme.md
49618440 index.js
49619151 lib
49618494 package.json

49618440 index.js
グローバルストアと同じinode番号です。

試しに、hello-pnpm1と同じ内容のhello-pnpm2プロジェクトを作成してみます。
同様にハードリンクのinode番号を確認してみます。

$ cd /Users/ユーザー名/Documents/study/pnpm/hello-pnpm2/node_modules/.pnpm/express@4.18.2/node_modules/express
$ ls -i1
49618530 History.md
49618435 LICENSE
49618531 Readme.md
49618440 index.js
54405782 lib
49618494 package.json

49618440 index.js
こちらもグローバルストアと同じinode番号です。

これで新規のプロジェクトのパッケージもグローバルストアに保存されたファイルのハードリンクが使われていることが確認できました。つまり全て同じファイルデータを参照しているということがわかります。

最後に

個人的には、node_modules配下が実際に使ってるパッケージのシンボリックリンクだけになるのはわかりやすくていいなと思います。それからgitでworktreeを使うことが多いので容量節約になるのがとても助かります。
あとはターミナルのインストール結果表示も見やすい気がします。

「pnpm i」したターミナルの表示結果
$ pnpm i
Packages: +154
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Packages are hard linked from the content-addressable store to the virtual store.
  Content-addressable store is at: /Users/shiratoritakashi/Library/pnpm/store/v3
  Virtual store is at:             node_modules/.pnpm
Downloading registry.npmjs.org/@swc/core-darwin-arm64/1.3.62: 12.1 MB/12.1 MB, done
Progress: resolved 184, reused 139, downloaded 15, added 154, done
node_modules/.pnpm/@swc+core@1.3.62/node_modules/@swc/core: Running postinstall script, done in 847ms
node_modules/.pnpm/esbuild@0.17.19/node_modules/esbuild: Running postinstall script, done in 936ms

dependencies:
+ react 18.2.0
+ react-dom 18.2.0

devDependencies:
+ @types/react 18.0.28 (18.0.38 is available)
+ @types/react-dom 18.0.11
+ @typescript-eslint/eslint-plugin 5.57.1 (5.59.0 is available)
+ @typescript-eslint/parser 5.57.1 (5.59.0 is available)
+ @vitejs/plugin-react-swc 3.0.0 (3.3.0 is available)
+ eslint 8.38.0
+ eslint-plugin-react-hooks 4.6.0
+ eslint-plugin-react-refresh 0.3.4
+ typescript 5.0.2 (5.0.4 is available)
+ vite 4.3.0 (4.3.1 is available)

Done in 8.4s

参考

Discussion