pnpm はパッケージをどんな風にストアに保存してるんだろう?

pnpm を触り始めた

ちょっと前に npm のことを勉強したときに、ゆうくさんに pnpm のことを教えてもらって気になってたので、触り始めた。

bufferings.hatenablog.com

pnpm はパッケージをグローバルストアに保存して、各プロジェクトの node_modules ではハードリンクを使用する。だから、ファイルをコピーしなくていいので容量もくわないし、インストールのスピードも速いのか。へー!便利。

ハードリンクを使用するので、プロジェクトとストアが同じディスクにないといけないことを頭の片隅に入れておこうかなってくらい。

ストアの中身

そのグローバルストアのデフォルトの場所は、macOS だと ~/Library/pnpm/store/v3。どんな風に保存されてるんだろう?と思ってのぞいてみたら、こんな感じになってた。途中で切ってるけど2文字のフォルダがたくさんある。16進文字列みたいね。

ls ~/Library/pnpm/store/v3/files | head -20
00
01
02
03
04
05
06
07
08
09
0a
0b
0c
0d
0e
0f
10
11
12
13

00フォルダの中はこうなってた。全部ファイル。

❯ tree ./Library/pnpm/store/v3/files/00
./Library/pnpm/store/v3/files/00
├── 0fcce94a17835a8fd9aa899692ad95ba85ccd2fff9557cbea5586d6d57a1fd3e28b312b8f976bc8e03f27626fe2019b9e16118f5b0a4e46734e49d57010d19
├── 254544f86098392604bf4dc85d19b78cae56dc6cdd53c37dbc4dfd80e59ce1f4db5e11b98560dc38ce5f5e59f75220ef134f6e92f0badcaef61a9fe8d6c238
├── 3cd9ab95fb966c626eaf2d3cfa910b786a5b737133a3d02fec5ba6545c1230413efa11618b72a01da5c8d21b496e7dd2322ac68ed91f7a6ea17183f4fd95cb-exec
├── 879645d028f36f1a1eeed52e47ad8e1c8a689dccf2b3cfe642d26d40735ffe59a624974e85a52c85fd4debf88392da9809b1cfd4eaf96e1db9234dc8c29a90
├── a875501873b239626aec6d2a27f7a44170772c0eb05379e2f8870e6db5e824829596481636025f1e3fc52a1b25f1a56b6ac2addc1330e4fdd1fd5414286935-exec
├── ae061b93bd3f7143a55922083f16ae281852332e5d1cee867417fc1b1189400def1e6700fb03ef304d0899e31c1e23f1d38cfc6c6efa14a9466958650359a7
├── bc2a562156f803af970681ad1d6297ddb5e4658c7e43637b834722ce126c835d6e52190416c50acfadf8355237341d511f3b1c4127631a014a4a1d249bd6b0
├── c9c19e3c1244175e16eb9e35f433afb82ed7312c20f00a84541f7d384bb69386512bb8d59a2a67c52feeda34386f74f63bd290bb0874293a988c3471cecf07
├── dbd6ec9969ea9d859a9fd30339a5dd4fc70f2c18d1b49a9a298389a4473a8e7f5a6fa8d2a820053643c143d7202dfdba59236e19ac28b5c19225d2df52f386
├── e22049009ea62258c0fdc04671b1fb95674eed870587736c63f8e5e2f0d6faf7cc1def64b7b279dd6c0bd8676dc39cf7f4ab33233944f42b906cf8692f59a3-index.json
├── eef7598b0f987a3a52be56a6147bb3301b52e6616c60ed34ae63189bf6d461d0051fb75d4beb5af932e437e68489fccf7df647f161e20926b4abdec637d5af
└── fed0a26ef09448a15dc113524bb0c012095123e10530cc2dd7312ee4e1b500856c10c0d3b794a34a1f9cce126a4e0650706230ecefd85d7dd84fcb42b08864

ためしに、いっこめのファイルを見てみたら、こんな感じでJSのファイルになってた。

cat ~/Library/pnpm/store/v3/files/00/0fcce94a17835a8fd9aa899692ad95ba85ccd2fff9557cbea5586d6d57a1fd3e28b312b8f976bc8e03f27626fe2019b9e16118f5b0a4e46734e49d57010d19
module.exports = { count: 0 };

ふむ。ファイルひとつひとつを、何かを元にファイル名を付けて保存してるみたい。まぁ、内容を元に一意になるようにしてるんだろうな。

ファイルが保存されていることは分かったけど、どうやってパッケージを管理してるんだろう?expressをインストールしたときに「これがexpressのファイルだよ!」ってどうやって持ってるのかなぁというのが気になった。

ぼーっと pnpm のソースを眺めてみた

なんとなく、この辺かなぁみたいなのを、名前から適当に開いてみて、いったりきたりして、「これかぁ」ってとこにたどりついた。

https://github.com/pnpm/pnpm/blob/ee61ca4cb7ce6b3cb177f892940edb55b19b9e17/store/cafs/src/getFilePathInCafs.ts

export function getFilePathInCafs (
  cafsDir: string,
  integrity: string | IntegrityLike,
  fileType: FileType
) {
  return path.join(cafsDir, contentPathFromIntegrity(integrity, fileType))
}

cafsDir には ~/Library/pnpm/store/v3/files のパスが入ってる。そこに、コンテンツの integrityfileType からパスを生成して結合してる。

この関数は、パッケージに含まれるファイル一覧を記録する処理の中で呼び出されてて、パッケージの integrity'index' が引数として渡されている。

んで、contentPathFromIntegrity は、こんな実装になってる↓。

function contentPathFromIntegrity (
  integrity: string | IntegrityLike,
  fileType: FileType
) {
  const sri = ssri.parse(integrity, { single: true })
  return contentPathFromHex(fileType, sri.hexDigest())
}

export function contentPathFromHex (fileType: FileType, hex: string) {
  const p = path.join(hex.slice(0, 2), hex.slice(2))
  switch (fileType) {
  case 'exec':
    return `${p}-exec`
  case 'nonexec':
    return p
  case 'index':
    return `${p}-index.json`
  }
}

ssri パッケージの parse 関数に integrity を渡して、その結果の hexDigest を使ってるってことみたいね。

パースした結果の digest の base64 データを16進文字列にしたものをパスに使ってるってことか。パッケージ内のファイルリストのファイルタイプは 'index' なので、接尾辞として -index.json が付く。

見てみよう

じゃ、実際に見てみよう。integrity は、たぶん、ロックファイルに書かれてるこれかなぁ。

❯ cat pnpm-lock.yaml | tail

  /utils-merge@1.0.1:
    resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
    engines: {node: '>= 0.4.0'}
    dev: false

  /vary@1.1.2:
    resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
    engines: {node: '>= 0.8'}
    dev: false

試しに vary@1.1.2 のを見てみることにする。

❯ node
Welcome to Node.js v18.16.0.
Type ".help" for more information.
> const ssri = require('ssri')
undefined

> let integrity = ssri.parse('sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==', {single: true})
undefined

> integrity
Hash {
  source: 'sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==',
  digest: 'BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==',
  algorithm: 'sha512',
  options: []
}

> integrity.hexDigest()
'04d19b58b7ddd1e50f69b8645d4566d23f2ebaf444c93879a2f45afddca8c3f06a01b649c82fb97d4f88cd03b39802b362a6110084a8461750af778867f3d7aa'

最初の2文字が files 直下のディレクトリ名だから、予想が当たってたら 04 の中に、-index.json つきの名前で存在するはず。

ls ./Library/pnpm/store/v3/files/04/d19b58b7ddd1e50f69b8645d4566d23f2ebaf444c93879a2f45afddca8c3f06a01b649c82fb97d4f88cd03b39802b362a6110084a8461750af778867f3d7aa-index.json
./Library/pnpm/store/v3/files/04/d19b58b7ddd1e50f69b8645d4566d23f2ebaf444c93879a2f45afddca8c3f06a01b649c82fb97d4f88cd03b39802b362a6110084a8461750af778867f3d7aa-index.json

お。あった。中身を見てみる。

cat ./Library/pnpm/store/v3/files/04/d19b58b7ddd1e50f69b8645d4566d23f2ebaf444c93879a2f45afddca8c3f06a01b649c82fb97d4f88cd03b39802b362a6110084a8461750af778867f3d7aa-index.json | jq
{
  "name": "vary",
  "version": "1.1.2",
  "files": {
    "LICENSE": {
      "checkedAt": 1681561992291,
      "integrity": "sha512-QOMBdEM0CODi7UbSQ3OxLe9H9UXZGDt7zijU3djIu1KAdcfyDhGPN2YdufG7o1iZnYGhRCXrPgpKIIZd/LUxgg==",
      "mode": 438,
      "size": 1094
    },
    "package.json": {
      "checkedAt": 1681561992534,
      "integrity": "sha512-n8fJ5vEp1Tg5mYrBFSP3tMR8VB99SCO0xwI1hFKmzJA6+lRilxUflHiBwtnTcD0IY6I4elslwnbOdL6R663EOw==",
      "mode": 438,
      "size": 1215
    },
    "README.md": {
      "checkedAt": 1681561992534,
      "integrity": "sha512-qRNRG5kAwXHBYc3zD0lI77dY9jI7eWMX4yyMVV6g8zn1O3grqozmSdIL/7xduHXQyF+qictsEqlKtubFQkSOZA==",
      "mode": 438,
      "size": 2716
    },
    "index.js": {
      "checkedAt": 1681561992535,
      "integrity": "sha512-Pvci03sBbGOsASbP3Oy21xQGGdDPSZWJjAu9lweVFYFSem3LeKw16UjCb8pTuKGZz1oo6PQYIfDVthfbVLq9QQ==",
      "mode": 438,
      "size": 2930
    },
    "HISTORY.md": {
      "checkedAt": 1681561992535,
      "integrity": "sha512-UcUbV+Xd1MZn7/txs1S7HClmVKgLtSeYM393lxhf/A3M+0SLQLWszq84EgKQ4dD1QHMF+BtdSdMs3WGQpLkrIA==",
      "mode": 438,
      "size": 792
    }
  }
}

ちゃんと vary@1.1.2 の情報だ。わーい。ファイル一覧が入ってる。ディレクトリがある場合が気になったので、別のも見てみたらこんな感じだった。

...

    "typings/parse-graphql-json.d.ts": {
      "checkedAt": 1681519087270,
      "integrity": "sha512-WjddWh1/1lf2BMEEV2l0zx3rOH6+a9iUgAfXN7yAc5oqGhPXTbBCG5AfNcpLgOSU2wAdDNLU9MLP5dTWQedLww==",
      "mode": 420,
      "size": 261
    },
    "typings/selectionSets.d.ts": {
      "checkedAt": 1681519087270,
      "integrity": "sha512-gvYkrbbd+CuShehbJiR3jQw2yLsRFqIz5NF8FyVMfHBoPeZSmZHJf3iLKoaoj23GrBGd6+K//4HU9cTjp3shlA==",
      "mode": 420,
      "size": 213
    },
    "typings/visitResult.d.ts": {
      "checkedAt": 1681519087270,
      "integrity": "sha512-lcoPJGj+GoPdunsS/G5h4mqxCDYbvWvPNzNYDWOLToN6/+OUt9aisgkROczA95GhOq+cjUxOD0z+58vvhnG6Tg==",
      "mode": 420,
      "size": 1029
    }
  }
}

ディレクトリ名も含めて、ファイルのパスがJSONのキーになってた。

すっきりした

パッケージの integrity からファイル一覧のJSONファイルを作ってて、そこにそのパッケージのファイル一覧が書かれているから、そのパッケージに含まれているファイルを引いてこれるんだね。すっきりした。