npm-shrinkwrap.jsonにdiffが出て困ったので、npmのコード読んでみた

現在所属しているチームでは、かつてはbowerを用いJSライブラリを管理していたが、最近は browserify の導入に伴い npm への移行を進めている。
新たにパッケージをインストールして npm-shrinkwrap.json を更新する際、他のパッケージの from フィールドが更新される事があった。

npm-shrinkwrap.json を調べるついでに、せっかくなので npm のコードをちょっとだけ読んでみた。

npm-shrinkwrap.json って?

Node.js のパッケージマネージャ npm には、プロジェクトの依存パッケージを管理する機能がある。
npm install --save or npm install --save-dev でパッケージをインストールすると、package.json 内の dependencies or devDependencies フィールドにパッケージ情報が追加される。

package.json では、依存パッケージのバージョンを固定することは出来るが、「依存パッケージの依存パッケージ」のバージョンを固定する事はできない。
例えば、プロジェクト A の依存パッケージを "B": "0.1.0" として固定しても、B の package.json"C": "*" となっていたら、Cが publish される度に新しいバージョンがインストールされてしまう。
そのため、より厳密にバージョンを固定したいときは npm-shrinkwrap.json を使う。
(Gemfile.lock や cpanfile.snapshot のようなもの??)

from フィールドが更新される

さて、表題の件。

新しいパッケージをインストールして npm-shrinkwrap.json を更新する時、既にインストール済みの他パッケージの from フィールドまで更新されてしまうことがあった。
具体的には、"from": "from@*""from": "(リポジトリのURL)" に変更されていた。

フィールド名からして、おそらく

  • 最初に npm i -S hoge@x.y.z とした時は、from に指定されたバージョン番号を、resolvedリポジトリのURLを記録する
  • ↑で作成した npm-shrinkwrap.json を用い npm install する時は、resolved に記録されたURLからパッケージをインストールし、from を更新する

と想像はできたが、確証が持てなかったのでコードを追ってみた。

現時点でのlatest stableは 101190a4f2 だ。
https://github.com/npm/npm/tree/101190a4f27510d1de988c7f598d7c3bbea6ca8a

lib/shrinkwrap.js

npm shrinkwrapソースコードはこちら
https://github.com/npm/npm/blob/101190a4f27510d1de988c7f598d7c3bbea6ca8a/lib/shrinkwrap.js

from, resolved で検索してもそれらしい箇所は見当たらない。
となると、npm.commands.ls() の時点で既に from, resolved が設定される気がする。

プロジェクトのディレクトリからREPLを開いて試してみる。

> var npm = require('npm');
> npm.load();
> var pkginfo; npm.commands.ls([], true, function(er, _, pkginfo_){ pkginfo = pkginfo_; });

> pkginfo
{ name: 'yo',
  version: '1.0.0',
  dependencies:
   { yay:
      { version: '0.1.0',
        from: 'yay@>=0.1.0 <0.2.0',
        resolved: 'https://registry.npmjs.org/yay/-/yay-0.1.0.tgz' } } }

やはり。
今度は npm ls の方で、node_modules を読み込んだ結果を出力してみる。
https://github.com/npm/npm/blob/101190a4f27510d1de988c7f598d7c3bbea6ca8a/lib/ls.js#L46

こうして

    var bfs = bfsify(data, args)
      , lite = getLite(bfs)
console.log(data);  // 出力してみる
    if (er || silent) return cb(er, data, lite)

こう

$ npm ls | grep _from
        _from: 'https://registry.npmjs.org/yay/-/yay-0.1.0.tgz',

まだ加工してないのに _from, _resolved がある、ということは、npm install した時点で _from, _resolved フィールドが作られてるのか。
node_modules/ 下にあるリポジトリの package.json みたら既に _from, _resolved が存在した……。

lib/install.js

というわけで、インストール時にどうやって _from, _resolved が作られるのか調べたい。
npm install のコードを見てみる。 https://github.com/npm/npm/blob/101190a4f27510d1de988c7f598d7c3bbea6ca8a/lib/install.js

あるパッケージを npm install hoge した時の流れはこんな感じかな

install -> installManyTop -> installManyTop_ -> installMany

installManyの中で呼ばれてる targetResolver って奴が怪しそう。
targetResolverが返す値をみてみよう。

705行目あたりでこうして

    asyncMap( what
            , targetResolver(where, context, deps, devDeps)
            , function (er, targets) {
      console.log(targets);  // 出力してみる

こう

$ rm -rf node_modules && npm cache clean && npm i -S yay
[ { name: 'yay',
    version: '0.1.0',
    description: 'Generate random, ridiculous names for anything. Yay!',
    main: 'index.js',
    scripts: { test: 'echo "Error: no test specified" && exit 1' },
    repository: { type: 'git', url: 'https://github.com/divshot/yay.git' },
    keywords:
     [ 'divshot',
       'superstatic',
       'names',
       'generator',
       'silly',
       'random' ],
    author: { name: 'Divshot' },
    license: 'MIT',
    bugs: { url: 'https://github.com/divshot/yay/issues' },
    homepage: 'https://github.com/divshot/yay',
    _id: 'yay@0.1.0',
    dist:
     { shasum: '083dff9823620a4b7dc95461d9c22bf70eb45305',
       tarball: 'http://registry.npmjs.org/yay/-/yay-0.1.0.tgz' },
    _from: 'yay@>=0.1.0 <0.2.0',
    _npmVersion: '1.4.3',
    _npmUser: { name: 'scottcorgan', email: 'scottcorgan@gmail.com' },
    maintainers: [ [Object] ],
    directories: {},
    _shasum: '083dff9823620a4b7dc95461d9c22bf70eb45305',
    _resolved: 'https://registry.npmjs.org/yay/-/yay-0.1.0.tgz' } ]

既に _from, _resolved があることがわかる。

targetResolver は asyncMap に渡す関数を作って返す。
targetResolver が返す resolver のシグネチャはこんな感じ (837行目) 。

  return function resolver (what, cb) {

asyncMap はちょっと変な map だ。
配列の各要素に関数を適用するのは同じだが、その関数の2番目の引数に渡されるコールバックに結果を返す。

asyncMap([1, 2, 3], (x, cb) => cb(null, x * 100), (err, data) => console.log(data))  // [100, 200, 300]

なので、今回は cb() に渡される2番目の引数を見れば良い。
cb() を呼び出している箇所のうち、2番目の引数をちゃんと渡してるのは910行目だけ。
ここで渡してる data は、cache.add のコールバックの引数として与えられるものだ。

    cache.add(what, null, pkgroot, false, function (er, data) {
      if (er && parent && parent.optionalDependencies &&
          parent.optionalDependencies.hasOwnProperty(npa(what).name)) {
        log.warn("optional dep failed, continuing", what)
        log.verbose("optional dep failed, continuing", [what, er])
        return cb(null, [])
      }

      var type = npa(what).type
      var isGit = type === "git" || type === "hosted"

      if (!er &&
          data &&
          !context.explicit &&
          context.family[data.name] === data.version &&
          !npm.config.get("force") &&
          !isGit) {
        log.info("already installed", data.name + "@" + data.version)
        return cb(null, [])
      }

      if (data && !data._from) data._from = what
      if (er && parent && parent.name) er.parent = parent.name
      return cb(er, data || [])
    })

cache.js , cache/add-named.js, cache/add-remote-tarball.js

https://github.com/npm/npm/blob/101190a4f27510d1de988c7f598d7c3bbea6ca8a/lib/cache.js

cache.add() がやってることは単純だ。
- realizePackageSpecifier() で、どの方法でインストールするかの情報を取得する - インストール方法によって addLocal(), addRemoteTarball(), addRemoteGit(), addNamed() のどれかを呼ぶ (279行目あたり)。

  realizePackageSpecifier(spec, where, function (err, p) {
    if (err) return cb(err)

    log.silly("cache add", "parsed spec", p)

    switch (p.type) {
      case "local":
      case "directory":
        addLocal(p, null, cb)
        break
      case "remote":
        // get auth, if possible
        mapToRegistry(spec, npm.config, function (err, uri, auth) {
          if (err) return cb(err)

          addRemoteTarball(p.spec, {name : p.name}, null, auth, cb)
        })
        break
      case "git":
      case "hosted":
        addRemoteGit(p.rawSpec, cb)
        break
      default:
        if (p.name) return addNamed(p.name, p.spec, null, cb)

        cb(new Error("couldn't figure out how to install " + spec))
    }
  })

コンソールから npm install hoge or npm install hoge@x.y.z とした場合は addNamed() が呼ばれるので、add-named.js を見てみる。

https://github.com/npm/npm/blob/101190a4f27510d1de988c7f598d7c3bbea6ca8a/lib/cache/add-named.js

addNamed() はこれまた、addNameVersion(), addNameRange(), addNameTag() に分岐する……のだが、どれもやることは大体おなじで、バージョン番号など必要な情報を取得したのち自分自身を呼び直している。

addNameVersion() では、data が truthy なら fetchit() でパッケージをダウンロードし、data が falsy なら getOnceFromRegistry() を呼び、パッケージのメタ情報を取得してからもう一度 fetchit() を呼ぶ。
試しに29行目でデータを出力してみると、無事パッケージ tarball のURLが入った JSON データが出力された。

function getOnceFromRegistry (name, from, next, done) {
  function fixName(err, data, json, resp) {
    // (中略)
console.log(json);  // 出力してみる
    next(err, data, json, resp)
  }
  // (中略)
}

(ちなみに npm cache clean しないと resp.statusCode === 304 になって結果返してくれない)

fetchit では、得られた tarball のURLに対し addRemoteTarball() を呼ぶ。
addRemoteTarball() を見ると、_from, _resolved に値を入れている事がわかる!!!!!
https://github.com/npm/npm/blob/101190a4f27510d1de988c7f598d7c3bbea6ca8a/lib/cache/add-remote-tarball.js#L19-26

  function cb (er, data) {
    if (data) {
      data._from = u
      data._resolved = u
      data._shasum = data._shasum || shasum
    }
    cb_(er, data)
  }

あれ……??
これでは npm install -S hoge とした場合にも _from, _resolved は両方とも tarball のURLになるはずでは……??

とおもいきや、addNamed() から渡される cb_() の中で _from だけ hoge@x.y.z 形式になるよう再代入されているのだった。

https://github.com/npm/npm/blob/101190a4f27510d1de988c7f598d7c3bbea6ca8a/lib/cache/add-named.js#L52

addNamed() が完了すると、cache.js 側で afterAdd() が呼ばれる。
これによって、_from, _resolved を持った data が package.json に記録される。

というわけで、npm install hoge した場合に from, resolved が記録される仕組みがわかった!!!!!!!!

npm-shrinkwrap.json からインストールした場合

npm-shrinkwrap.json からインストールした場合についても既にわかっている。
cache.add() において realizePackageSpecifier() を行うことは説明したが、 npm-shrinkwrap.json がある場合には type: 'remote' となるため、addNamed() を経由せず、直接 addRemoteTarball() を呼び出す。
結果、addNamed()_from を再代入する処理がスルーされ、_from には tarball のURLが記録されるのだった。

おまけ : インストール方法による realizePackageSpecifier() 結果の違い

npm i -S yay@0.1.0
{ raw: 'yay@0.1.0',
  scope: null,
  name: 'yay',
  rawSpec: '0.1.0',
  spec: '0.1.0',
  type: 'version' }
npm i (package.json からインストール)
{ raw: 'yay@^0.1.0',
  scope: null,
  name: 'yay',
  rawSpec: '^0.1.0',
  spec: '>=0.1.0 <0.2.0',
  type: 'range' }
npm i (npm-shrinkwrap.json からインストール)
{ raw: 'yay@https://registry.npmjs.org/yay/-/yay-0.1.0.tgz',
  scope: null,
  name: 'yay',
  rawSpec: 'https://registry.npmjs.org/yay/-/yay-0.1.0.tgz',
  spec: 'https://registry.npmjs.org/yay/-/yay-0.1.0.tgz',
  type: 'remote' }

better npm-shrinkwrap

npm-shrinkwrapの代替となるラッパー?をみつけたので紹介。

uber/npm-shrinkwrap

github.com

npm shrinkwrap との違いは以下:

  • package.json, npm-shrinkwrap.json, node_modules の一貫性を保証する
    • 素の npm shrinkwrap では、--save のし忘れ等で矛盾が発生したら叱ってくれるが、package.json の tag が変更されても叱ってくれない
  • npm cache clean してくれる
    • まれに cache が原因でエラー出まくるのを防ぐ
  • npm-shrinkwrap.json の resolved フィールドを固定し、from フィールドを削除する
    • 変な diff が出ないようにしてくれる
  • プログラマブルな設定

どういう仕組で動いてるか解説してくれてuberは親切だな〜〜〜〜

あと、APIの型とかをOCamlのファイル?で宣言してある
OCamlのファイル使って型チェックするような仕組みあるのかな?見つけられなかった

mozilla/npm-lockdown

github.com

npm shrinkwrap との違いは2つ:

  • パッケージのSHA1を記録し、ダウンロードしてきたパッケージのSHA1が一致しないとエラー
  • optionalDependencies を持つ

あとマスコットキャラがかわいい。

f:id:amagitakayosi:20150718195643p:plain

mozilla/npm-seal

https://github.com/mozilla/npm-lockdown

npm-lockdownのsha1チェック機能だけ版
マスコットキャラかわい

その他npmパッケージ

iarna/aproba

github.com

シンプルなバリデーションライブラリ

iarna/write-file-atomic

github.com

fs.writeFile() のアトミック版。書き込み中にエラーが起きたらファイルを削除してくれる
あと uid/gid も指定できる

npm/slide-flow-control

github.com

シンプルな実行フロー制御ライブラリ

  • asyncMap : mapした関数の実行が全て終わったあとのコールバックを渡せる
  • chain : async の series みたいな感じ

shesek/iferr

github.com

はい

npmで使われるようなライブラリでも、あんまり☆つかないんだな〜〜〜〜