現在所属しているチームでは、かつては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
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
npm shrinkwrap との違いは2つ:
あとマスコットキャラがかわいい。
mozilla/npm-seal
https://github.com/mozilla/npm-lockdown
npm-lockdownのsha1チェック機能だけ版
マスコットキャラかわい
その他npmパッケージ
iarna/aproba
シンプルなバリデーションライブラリ
iarna/write-file-atomic
fs.writeFile() のアトミック版。書き込み中にエラーが起きたらファイルを削除してくれる
あと uid/gid も指定できる
npm/slide-flow-control
シンプルな実行フロー制御ライブラリ - asyncMap : mapした関数の実行が全て終わったあとのコールバックを渡せる - chain : async の series みたいな感じ
shesek/iferr
はい
npmで使われるようなライブラリでも、あんまり☆つかないんだな〜〜〜〜