パッケージマネージャを作ってみたい
tyny-package-manager
(以下本パッケージ) とかいう、パッケージマネージャ(e.g. npm or yarn) のミニマムバージョンを作ってみようってリポジトリがあるので、これを参考にしながら作ってみようというスクラップ。
上手く学びを得られたら記事にしたい。
→ 記事化した
README 要約
- 本パッケージは超超シンプルなパッケージマネージャー説明用のデモパッケージ
- 本パッケージの目的はパッケージマネージャーの再開発ではなく、パッケージマネージャーの挙動をコードとコメントを通じて説明すること
- シンプルさを重視しているので、各エッジケースやエラー対応が欠けてるので、興味がある人は npm や yarn のコードを読んでみよう
本パッケージは以下のミニマム機能がある
- パッケージを node_modules ディレクトリにダウンロードする
- シンプルな CLI を提供する
- シンプルな依存関係の解決
- フラットな依存ツリーの生成
- pacakge-lock.json / yarn.lock ライクな lock ファイルの生成
そこそこ楽しめそうなボリュームではある。
本パッケージにはステップバイステップで理解するコンテンツにあたるものはないので、実際に本パッケージを使ってみつつ、挙動を確認してからソースコードの全容を見て、そこから少しずつ写経していくってのが良さそう。
セットアップ
グローバルインストールしてバイナリが入ってること確認。
$ npm install -g tiny-package-manager
$ which tiny-pm
/usr/local/bin/tiny-pm
適当にディレクトリを作成し、 package.json
をミニマムに作成する
{
"name": "tiny-pm-test",
"version": "1.0.0"
}
実行してみる。
$ tiny-pm
[1/2] Finished resolving.
lock ファイルっぽいものが作成される。まだ空だけど。
{}
axios を追加してみる。
axios は本体に加え、いくつかの dependencies
を持ってるので依存ツリーができあがるはず。
見た感じはパッケージを追加するコマンドが CLI に用意されてない。
$ tiny-pm --help
tiny-pm <command> [args]
コマンド:
tiny-pm install Install the dependencies.
tiny-pm Install the dependencies. [デフォルト]
オプション:
--production Install production dependencies only. [真偽]
-v, --version バージョンを表示 [真偽]
-h, --help ヘルプを表示 [真偽]
ので、package.json に直接依存関係を追記しちゃう
{
"name": "tiny-pm-test",
"version": "1.0.0",
"dependencies": {
"axios": "^0.27.2"
}
}
それでインストールを実行する。
$ tiny-pm
[1/2] Finished resolving.
[2/2] Installing [########]
lock ファイルが更新されて、おそらく axios
に必要なファイルが揃ってる。
asynckit@^0.4.0:
version: 0.4.0
url: 'https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz'
shasum: c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79
dependencies: {}
axios@^0.27.2:
version: 0.27.2
url: 'https://registry.npmjs.org/axios/-/axios-0.27.2.tgz'
shasum: 207658cc8621606e586c85db4b41a750e756d972
dependencies:
follow-redirects: ^1.14.9
form-data: ^4.0.0
combined-stream@^1.0.8:
version: 1.0.8
url: 'https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz'
shasum: c3d45a8b34fd730631a110a8a2520682b31d5a7f
dependencies:
delayed-stream: ~1.0.0
delayed-stream@~1.0.0:
version: 1.0.0
url: 'https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz'
shasum: df3ae199acadfb7d440aaae0b29e2272b24ec619
dependencies: {}
follow-redirects@^1.14.9:
version: 1.15.1
url: 'https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz'
shasum: 0ca6a452306c9b276e4d3127483e29575e207ad5
dependencies: null
form-data@^4.0.0:
version: 4.0.0
url: 'https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz'
shasum: 93919daeaf361ee529584b9b31664dc12c9fa452
dependencies:
asynckit: ^0.4.0
combined-stream: ^1.0.8
mime-types: ^2.1.12
mime-db@1.52.0:
version: 1.52.0
url: 'https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz'
shasum: bbabcdc02859f4987301c856e3387ce5ec43bf70
dependencies: null
mime-types@^2.1.12:
version: 2.1.35
url: 'https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz'
shasum: 381a871b62a734450660ae3deee44813f70d959a
dependencies:
mime-db: 1.52.0
さらに node_modules ディレクトリに依存パッケージが含まれている。
$ ls node_modules/
asynckit axios combined-stream delayed-stream follow-redirects form-data mime-db mime-types
試しに axios を使ったコードを実行してみる。
const axios = require('axios').default
axios.get('https://jsonplaceholder.typicode.com/todos/').then(res => {
console.log(res.data.length)
})
$ node main.js
200
動いてそう。
pacakge.json から axios を削除して再度 tiny-pm
を実行しても削除はされない。
本パッケージでは追加しかできないみたい。
/src
) 確認
ソースコード (
cli.ts
- バイナリとして配信されるコード
- CLI 自体は
yargs
で構築されてる - CLI で指定したパラメータは
index.ts
のほうに渡される
index.ts
- CLI からパラメータを受け取って各種ロジックを呼び出すコントローラ的な関数
- ここはコメントが丁寧に書かれてるので読みながら理解深められそう
処理の流れはこんな感じ。
-
package.json
を親ディレクトリに向かって探索し、あれば読み込む - インストールするパッケージ一覧を、
package.json
及びinstall
コマンドの引数から取り出す- 後者については実装されてるように見えるけど動作しない…。 (
tiny-pm install axios
みたいなの)
- 後者については実装されてるように見えるけど動作しない…。 (
-
--production
オプションが付与されている場合は、インストールリストからdevDependencies
を削除する - 現状の lock ファイルを読み込んでおく (
lock.ts
) - インストールするパッケージ一覧の情報を取得する (
list.ts
) - 変更差分を lock ファイルに書き込む (
lock.ts
) - インストールするパッケージに関するログを書き出す (
log.ts
) - パッケージを npm から インストール (
install.ts
)
実際の依存解決をどうやってるのかなぁと思ったけど、npm レジストリからメタデータが取れるのね
全体的な流れはわかったけど、モジュールを分割してる割にモジュールの独立性が低くて、副作用をガンガン起こすしそれに期待するコードがあってあまりクリーンではないように見える (少なくとも好みではない)
4年も前のリポジトリってのもあるし、ここは写経じゃなくてコード読みながら自分なりにゼロベースで作っていく方向が良いかも?
好みの構成でプロジェクトを作ってみる
$ npm install --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint typescript prettier
{
"name": "@s-sasaki-0529/tiny-package-manager",
"version": "0.0.1",
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.35.1",
"@typescript-eslint/parser": "^5.35.1",
"eslint": "^8.23.0",
"prettier": "^2.7.1",
"typescript": "^4.8.2"
},
"eslintConfig": {
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"root": true
},
"prettier": {
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 120,
"arrowParens": "avoid"
}
}
リポジトリ
トップダウンに CLI 部分から作ろうと思うけど、今だと何使うと良いんだろ。
型付きがいいなぁ。
commander が良さそうに見えるので使ってく。
$ npm install commander
以下の感じのコマンドラインオプションを定義する
-
install
:package.json
に基づいてインストール -
install --production
:package.json
に基づいて、dependencies
のみインストール -
install axios vue
:axios
vue
をdependencies
に追加しつつインストール -
install typescript --save-dev
:typescript
をdevDependencies
に追加しつつインストール
import { Command } from 'commander'
const program = new Command()
program
.command('install')
.argument('[packageNames...]')
.action(packageNames => {
const options = program.opts()
console.log({ command: 'install', packageNames, options })
})
program
.option('--production', 'devDependencies のインストールを省略する')
.option('--save-dev', 'パッケージを devDependencies に追加する')
.parse()
コマンド、サブコマンド、オプションを渡せてることを確認。
$ ts-node src/cli.ts install axios vue --save-dev --production
{
command: 'install',
packageNames: [ 'axios', 'vue' ],
options: { saveDev: true, production: true }
}
ヘルプは勝手に作られる
$ ts-node src/cli.ts --help
Usage: cli [options] [command]
Options:
--production devDependencies のインストールを省略する
--save-dev パッケージを devDependencies に追加する
-h, --help display help for command
Commands:
install [packageNames...]
help [command] display help for command
package.json
とその内容を読み書きするモジュールを用意したい。
dependencies モジュールでいいか。
一旦型だけ整理してみた。
type PackageName = string
type Version = string
type DependenciesMap = {
[name: PackageName]: Version
}
type PackageJson = {
dependencies: DependenciesMap
devDependencies: DependenciesMap
}
type LockFile = {
[dependency: `${PackageName}@${Version}`]: {
version: Version
url: string
shasum: string
dependencies: {
[name: PackageName]: Version
}
}
}
キャッシュとかそういうのもあった気がするけど、一旦はこれでヨシとする。
package.json
の読み書きを行う packageJson
モジュールを用意して、package.json
から PackageJson 型のデータを抜き出す関数を定義してく。
$ npm install find-up
import { findUp } from 'find-up'
export async function findPackageJsonPath() {
return findUp('package.json')
}
findPackageJsonPath().then(path => {
console.log(path)
})
正しい動作確認のやり方がわからないけど、とりあえず dev
ディレクトリを切って package.json
を作成して、そのディレクトリからスクリプトを実行したらよい感じに取れてたのでOK.
{
"name": "dev",
"version": "1.0.0",
"dependencies": {
"axios": "^0.18.0",
"dayjs": "^1.0.0"
},
"devDependencies": {
"chalk": "^5.0.0"
}
}
$ node ../dist/src/packageJson.js
/Users/shingo.sasaki/tiny-package-manager/dev/package.json
package.json を読み込んで、dependencies と devDependencies を抜き出していく。
export async function parsePackageJson(path: string): Promise<PackageJson> {
return new Promise(resolve => {
readFile(path)
.then(data => {
const json = JSON.parse(data.toString())
resolve({
dependencies: json.dependencies,
devDependencies: json.devDependencies
})
})
.catch(() => {
resolve({
dependencies: {},
devDependencies: {}
})
})
})
}
ちょっとごちゃっとした。無駄に Promise ネストしてる気がするけど、一旦コレで以下は満たせた。
-
package.json
があれば、 dependencies / devDependencies を抜き出す -
package.json
がなければ dependencies / devDependencies が空のオブジェクトを返す
自身の package.json
に対して実行してみるとこんな感じに。
$ node dist/src/packageJson.js
{
dependencies: { commander: '^9.4.0', 'find-up': '^6.3.0' },
devDependencies: {
'@types/node': '^18.7.13',
'@typescript-eslint/eslint-plugin': '^5.35.1',
'@typescript-eslint/parser': '^5.35.1',
eslint: '^8.23.0',
prettier: '^2.7.1',
typescript: '^4.8.2'
}
}
npm リポジトリ周りの型を整理しよう。
これ公開されてる型パッケージある気がするけど、今回は自分で用意してみる。
コレを参考に
使うとこだけ抜き出す。
type NpmManifest = {
name: PackageName
'dist-tags': {
latest: Version
}
versions: {
[version: Version]: {
dist: {
tarball: string
shasum: string
}
dependencies: DependenciesMap
}
}
}
パッケージ名から npm リポジトリのマニフェストを取り出す関数
export async function fetchPackageManifest(name: PackageName): Promise<NpmManifest> {
const manifestUrl = `${REPOSITORY_URL}/${name}`
const manifest = (await fetch(manifestUrl).then(res => res.json())) as NpmManifest
return manifest
}
マニフェストを元にパッケージをダウンロードする関数
const DEFAULT_DOWNLOAD_DIR = `${process.cwd()}/node_modules`
export async function savePackageTarball(manifest: NpmManifest, version: Version, dir = DEFAULT_DOWNLOAD_DIR) {
const tarballUrl = manifest.versions[version].dist.tarball
const path = `${dir}/${manifest.name}`
mkdir(path, { recursive: true })
const tarResponse = await fetch(tarballUrl)
return tarResponse.body?.pipe(tar.extract({ cwd: path, strip: 1 }))
}
マニフェストから対象バージョンの tar URL を抜き出してダウンロード。
tar を解凍して node_modules ディレクトリに保存する手順は元コードをコピペしただけで良くわかってない。
次にやることメモ
-
package.json
からパッケージリストを抜き出して、全てダウンロードできるようにする - 依存パッケージを再帰的にダウンロードできるようにする
キャッシュとかバージョン衝突とかはまた次のステップでOK
ここまで作ったモジュールを使って、ざーっと書いた。
できてるもの
-
package.json
に含まれている依存パッケージをダウンロードできる - コマンドラインオプションからパッケージを指定してダウンロードできる
-
--production
オプションでdevDependencies
のダウンロードを省略できる
できてなくて実現したいもの
- 依存パッケージが依存するパッケージの再帰的なダウンロード
- 最新バージョンでなく、指定したセマンティックバージョン指定でのダウンロード
-
package.json
の更新 - lock ファイルの作成
- 既にダウンロード済みのパッケージをスキップ
type InstallOption = {
saveDev?: boolean
production?: boolean
}
export async function install(packageNames: PackageName[], option: InstallOption = {}) {
// インストール対象パッケージ一覧を初期化
const dependencyMap: PackageDependencyMap = {
dependencies: {},
devDependencies: {}
}
// package.json を探索し、存在する場合はパッケージ依存関係を読み込む
const packageJsonPath = await findPackageJsonPath()
if (packageJsonPath) {
const packageJson = await parsePackageJson(packageJsonPath)
dependencyMap.dependencies = packageJson.dependencies
dependencyMap.devDependencies = packageJson.devDependencies
}
// 追加インストールするパッケージを dependencies または devDependencies に追加する
packageNames.forEach(packageName => {
if (option.saveDev) {
dependencyMap.devDependencies[packageName] = ''
} else {
dependencyMap.dependencies[packageName] = ''
}
})
// production の指定がある場合は devDependencies のインストールを省略する
if (option.production) {
dependencyMap.devDependencies = {}
}
// npm リポジトリから各パッケージのインストールを行う
// devDependencies については production オプションがある場合は省略
const installPromises = []
installPromises.push(
Object.keys(dependencyMap.dependencies).map(packageName => {
return savePackageTarball(packageName, '') // TODO: latest でなく指定されたバージョンを使う
})
)
if (!option.production) {
installPromises.push(
Object.keys(dependencyMap.devDependencies).map(packageName => {
return savePackageTarball(packageName, '') // TODO: latest でなく指定されたバージョンを使う
})
)
}
return Promise.all(installPromises)
}
セマンティックバージョンの指定からインストール対象のバージョンを特定するのはどうすればいいんだろ。
元ソースを調べてみる。
これ使ってるみたい。
マニフェストから取り出したバージョン一覧に対して、指定されたバージョン式を満たす最大のバージョンを返してる。
こんな感じに、バージョン制約がある場合はその中で最新を、ない場合は最新をインストールするように出来た。
export async function savePackageTarball(name: PackageName, vc?: VersionConstraint, dir = DEFAULT_DOWNLOAD_DIR) {
const manifest = await fetchPackageManifest(name)
const versions = Object.keys(manifest.versions)
const version: Version = vc ? semver.maxSatisfying(versions, vc) : manifest['dist-tags'].latest
const path = `${dir}/${manifest.name}`
mkdir(path, { recursive: true })
const tarballUrl = manifest.versions[version].dist.tarball
const tarResponse = await fetch(tarballUrl)
return tarResponse.body?.pipe(tar.extract({ cwd: path, strip: 1 }))
}
{
"name": "dev",
"version": "1.0.0",
"dependencies": {
"axios": "0.2.0",
"dayjs": "^1.0.0"
},
"devDependencies": {
"chalk": "^4.0.0"
}
}
の場合は
- axios 0.2.0
- dayjs 1.11.5
- chalk 4.1.2
が入ることを確認
依存ツリーに含まれるパッケージをまとめてインストールできるようにしたいけど、ぼちぼち動作確認が大変になってきたから、ログ出力を仕込みたい。
上記の package.json
の場合は以下みたいなログが出ると良いかな。
axios@0.2.0 resolve to 0.2.0
dayjs@^1.0.0 resolve to 1.11.5
chalk@^4.0.0 resolve to 4.1.2
みたいなの出せればいいかな。
元リポジトリでは2種類のパッケージを使ってリッチなログを出してるけど、まぁ今回の本筋じゃないし一旦は普通に console.log でいいかなぁ。
progress のほうは余力があれば使ってみたい
export function resolveLog(name: PackageName, vc: VersionConstraint, version: Version) {
console.log(`${name}@${vc} resolve to ${version}`)
}
$ node ../dist/src/cli.js install vue
dayjs@^1.0.0 resolve to 1.11.5
chalk@^4.0.0 resolve to 4.1.2
axios@0.2.0 resolve to 0.2.0
vue@* resolve to 3.2.37
良さげかな
続けて、依存ツリーに含まれるパッケージを一括インストールするのを頑張ってみる。
元パッケージのコードを参考にしよう。
一旦パッケージの衝突 (異なるバージョンが別経路で要求された場合) は考えないで、 axios のインストールにだけ集中してみよう。
ごりっと再帰的にパッケージを取得し続けるだけのコードから書いてみた。
export async function collectDepsPackageList(
packageName: PackageName,
vc: VersionConstraint,
packageList: Readonly<DependenciesMap>
) {
// 破壊的変更を避けるために新しいパッケージ一覧を複製しておく
let newPackageList = { ...packageList }
// パッケージのマニフェストを取得する
const manifest = await fetchPackageManifest(packageName)
// バージョン制約から最新のバージョンを抜き出す
const version = semver.maxSatisfying(Object.keys(manifest.versions), vc)
if (!manifest.versions[version]) return packageList
// インストールリストに自身を追加する
newPackageList[packageName] = version
resolveLog(packageName, vc, version)
// 依存するパッケージに対して再帰的に同様の操作を行う
if (manifest.versions[version].dependencies) {
for (const [depName, depVersion] of Object.entries(manifest.versions[version].dependencies)) {
newPackageList = await collectDepsPackageList(depName, depVersion, packageList)
}
}
return newPackageList
}
試しに vue
のインストールをすると良い感じ。同じようなパッケージへの依存を繰り返してるからキャッシュは重要だ。
$ node ../dist/src/cli.js install vue
vue@* resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-dom@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/runtime-dom@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/runtime-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/reactivity@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
csstype@^2.6.8 resolve to 2.6.20
@vue/compiler-sfc@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/compiler-dom@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/compiler-ssr@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-dom@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/reactivity-transform@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/shared@3.2.37 resolve to 3.2.37
estree-walker@^2.0.2 resolve to 2.0.2
magic-string@^0.25.7 resolve to 0.25.9
sourcemap-codec@^1.4.8 resolve to 1.4.8
@vue/shared@3.2.37 resolve to 3.2.37
estree-walker@^2.0.2 resolve to 2.0.2
magic-string@^0.25.7 resolve to 0.25.9
sourcemap-codec@^1.4.8 resolve to 1.4.8
source-map@^0.6.1 resolve to 0.6.1
postcss@^8.1.10 resolve to 8.4.16
nanoid@^3.3.4 resolve to 3.3.4
picocolors@^1.0.0 resolve to 1.0.0
source-map-js@^1.0.2 resolve to 1.0.2
@vue/server-renderer@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-ssr@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-dom@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
雑にマニフェストのキャッシュ取るような構造にしたら一気に高速化した。
const MANIFEST_CACHE: Record<PackageName, NpmManifest> = {}
export async function fetchPackageManifest(name: PackageName): Promise<NpmManifest> {
if (!MANIFEST_CACHE[name]) {
const manifestUrl = `${REPOSITORY_URL}/${name}`
const manifest = (await fetch(manifestUrl).then(res => res.json())) as NpmManifest
MANIFEST_CACHE[name] = manifest
}
return MANIFEST_CACHE[name]
}
一応この時点で、(バージョンコンフリクトがない限りは) 必要な全てのパッケージのインストールができるのかな。
実用的な例で試してみる。
{
"name": "dev",
"version": "1.0.0",
"dependencies": {
"vue": "^3.2.37"
},
"devDependencies": {
"@vitejs/plugin-vue": "^3.0.1",
"@vue/tsconfig": "^0.1.3",
"typescript": "~4.7.4",
"vite": "^3.0.4",
"vue-tsc": "^0.39.5"
}
}
vite で Vue アプリ書く際の単純な構成。
ミニマムなコード書く。
<script setup lang="ts">
import { ref } from 'vue'
const inputValue = ref<string>('')
</script>
<template>
<div>
<input v-model="inputValue" />
<p>{{ inputValue }}</p>
</div>
</template>
npm で動作確認
$ npm install
$ node_modules/vite/bin/vite.js
サーバーが起動してアプリケーションが動くことを確認。
これを自作パッケージマネージャで動かすことを目標にしてみよう。
インストール時のログを追加して、 node_modules にダウンロードが完了したら吐くようにする
export function installLog(name: PackageName, version: Version) {
console.log(`${name}@${version} installed`)
}
依存解決した各パッケージをインストールするコードを復活
// npm リポジトリから各パッケージのインストールを行う
const installPromises = []
installPromises.push(
Object.keys(fullDependenciesMap).map(packageName => {
return savePackageTarball(packageName, fullDependenciesMap[packageName])
})
)
return Promise.all(installPromises)
いっけー
$ node ../dist/cli.js install
vue@^3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-dom@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/runtime-dom@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/runtime-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/reactivity@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
csstype@^2.6.8 resolve to 2.6.20
@vue/compiler-sfc@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/compiler-dom@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/compiler-ssr@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-dom@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/reactivity-transform@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/shared@3.2.37 resolve to 3.2.37
estree-walker@^2.0.2 resolve to 2.0.2
magic-string@^0.25.7 resolve to 0.25.9
sourcemap-codec@^1.4.8 resolve to 1.4.8
@vue/shared@3.2.37 resolve to 3.2.37
estree-walker@^2.0.2 resolve to 2.0.2
magic-string@^0.25.7 resolve to 0.25.9
sourcemap-codec@^1.4.8 resolve to 1.4.8
source-map@^0.6.1 resolve to 0.6.1
postcss@^8.1.10 resolve to 8.4.16
nanoid@^3.3.4 resolve to 3.3.4
picocolors@^1.0.0 resolve to 1.0.0
source-map-js@^1.0.2 resolve to 1.0.2
@vue/server-renderer@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-ssr@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-dom@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vitejs/plugin-vue@^3.0.1 resolve to 3.0.3
@vue/tsconfig@^0.1.3 resolve to 0.1.3
typescript@~4.7.4 resolve to 4.7.4
vite@^3.0.4 resolve to 3.0.9
esbuild@^0.14.47 resolve to 0.14.54
@esbuild/linux-loong64@0.14.54 resolve to 0.14.54
esbuild-android-64@0.14.54 resolve to 0.14.54
esbuild-android-arm64@0.14.54 resolve to 0.14.54
esbuild-darwin-64@0.14.54 resolve to 0.14.54
esbuild-darwin-arm64@0.14.54 resolve to 0.14.54
esbuild-freebsd-64@0.14.54 resolve to 0.14.54
esbuild-freebsd-arm64@0.14.54 resolve to 0.14.54
esbuild-linux-32@0.14.54 resolve to 0.14.54
esbuild-linux-64@0.14.54 resolve to 0.14.54
esbuild-linux-arm@0.14.54 resolve to 0.14.54
esbuild-linux-arm64@0.14.54 resolve to 0.14.54
esbuild-linux-mips64le@0.14.54 resolve to 0.14.54
esbuild-linux-ppc64le@0.14.54 resolve to 0.14.54
esbuild-linux-riscv64@0.14.54 resolve to 0.14.54
esbuild-linux-s390x@0.14.54 resolve to 0.14.54
esbuild-netbsd-64@0.14.54 resolve to 0.14.54
esbuild-openbsd-64@0.14.54 resolve to 0.14.54
esbuild-sunos-64@0.14.54 resolve to 0.14.54
esbuild-windows-32@0.14.54 resolve to 0.14.54
esbuild-windows-64@0.14.54 resolve to 0.14.54
esbuild-windows-arm64@0.14.54 resolve to 0.14.54
postcss@^8.4.16 resolve to 8.4.16
nanoid@^3.3.4 resolve to 3.3.4
picocolors@^1.0.0 resolve to 1.0.0
source-map-js@^1.0.2 resolve to 1.0.2
resolve@^1.22.1 resolve to 1.22.1
is-core-module@^2.9.0 resolve to 2.10.0
has@^1.0.3 resolve to 1.0.3
function-bind@^1.1.1 resolve to 1.1.1
path-parse@^1.0.7 resolve to 1.0.7
supports-preserve-symlinks-flag@^1.0.0 resolve to 1.0.0
rollup@>=2.75.6 <2.77.0 || ~2.77.0 resolve to 2.77.3
fsevents@~2.3.2 resolve to 2.3.2
fsevents@~2.3.2 resolve to 2.3.2
vue-tsc@^0.39.5 resolve to 0.39.5
@volar/vue-language-core@0.39.5 resolve to 0.39.5
@volar/code-gen@0.39.5 resolve to 0.39.5
@volar/source-map@0.39.5 resolve to 0.39.5
@volar/source-map@0.39.5 resolve to 0.39.5
@volar/vue-code-gen@0.39.5 resolve to 0.39.5
@volar/code-gen@0.39.5 resolve to 0.39.5
@volar/source-map@0.39.5 resolve to 0.39.5
@volar/source-map@0.39.5 resolve to 0.39.5
@vue/compiler-core@^3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/compiler-dom@^3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/shared@^3.2.37 resolve to 3.2.37
@vue/compiler-sfc@^3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/compiler-dom@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/compiler-ssr@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-dom@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/reactivity-transform@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/shared@3.2.37 resolve to 3.2.37
estree-walker@^2.0.2 resolve to 2.0.2
magic-string@^0.25.7 resolve to 0.25.9
sourcemap-codec@^1.4.8 resolve to 1.4.8
@vue/shared@3.2.37 resolve to 3.2.37
estree-walker@^2.0.2 resolve to 2.0.2
magic-string@^0.25.7 resolve to 0.25.9
sourcemap-codec@^1.4.8 resolve to 1.4.8
source-map@^0.6.1 resolve to 0.6.1
postcss@^8.1.10 resolve to 8.4.16
nanoid@^3.3.4 resolve to 3.3.4
picocolors@^1.0.0 resolve to 1.0.0
source-map-js@^1.0.2 resolve to 1.0.2
@vue/reactivity@^3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@volar/vue-typescript@0.39.5 resolve to 0.39.5
@volar/code-gen@0.39.5 resolve to 0.39.5
@volar/source-map@0.39.5 resolve to 0.39.5
@volar/typescript-faster@0.39.5 resolve to 0.39.5
semver@^7.3.7 resolve to 7.3.7
lru-cache@^6.0.0 resolve to 6.0.0
yallist@^4.0.0 resolve to 4.0.0
@volar/vue-language-core@0.39.5 resolve to 0.39.5
@volar/code-gen@0.39.5 resolve to 0.39.5
@volar/source-map@0.39.5 resolve to 0.39.5
@volar/source-map@0.39.5 resolve to 0.39.5
@volar/vue-code-gen@0.39.5 resolve to 0.39.5
@volar/code-gen@0.39.5 resolve to 0.39.5
@volar/source-map@0.39.5 resolve to 0.39.5
@volar/source-map@0.39.5 resolve to 0.39.5
@vue/compiler-core@^3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/compiler-dom@^3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/shared@^3.2.37 resolve to 3.2.37
@vue/compiler-sfc@^3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/compiler-dom@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/compiler-ssr@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-dom@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/reactivity-transform@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
@vue/compiler-core@3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
@babel/parser@^7.16.4 resolve to 7.18.13
estree-walker@^2.0.2 resolve to 2.0.2
source-map@^0.6.1 resolve to 0.6.1
@vue/shared@3.2.37 resolve to 3.2.37
estree-walker@^2.0.2 resolve to 2.0.2
magic-string@^0.25.7 resolve to 0.25.9
sourcemap-codec@^1.4.8 resolve to 1.4.8
@vue/shared@3.2.37 resolve to 3.2.37
estree-walker@^2.0.2 resolve to 2.0.2
magic-string@^0.25.7 resolve to 0.25.9
sourcemap-codec@^1.4.8 resolve to 1.4.8
source-map@^0.6.1 resolve to 0.6.1
postcss@^8.1.10 resolve to 8.4.16
nanoid@^3.3.4 resolve to 3.3.4
picocolors@^1.0.0 resolve to 1.0.0
source-map-js@^1.0.2 resolve to 1.0.2
@vue/reactivity@^3.2.37 resolve to 3.2.37
@vue/shared@3.2.37 resolve to 3.2.37
typescript@4.7.4 installed
vite@3.0.9 installed
source-map@0.6.1 installed
vue@3.2.37 installed
estree-walker@2.0.2 installed
vue-tsc@0.39.5 installed
esbuild-freebsd-64@0.14.54 installed
sourcemap-codec@1.4.8 installed
magic-string@0.25.9 installed
esbuild-linux-mips64le@0.14.54 installed
esbuild@0.14.54 installed
esbuild-linux-ppc64le@0.14.54 installed
csstype@2.6.20 installed
picocolors@1.0.0 installed
esbuild-android-64@0.14.54 installed
nanoid@3.3.4 installed
esbuild-linux-32@0.14.54 installed
esbuild-windows-32@0.14.54 installed
esbuild-sunos-64@0.14.54 installed
@vue/compiler-sfc@3.2.37 installed
esbuild-darwin-arm64@0.14.54 installed
@vue/compiler-ssr@3.2.37 installed
@vue/shared@3.2.37 installed
@vue/compiler-core@3.2.37 installed
esbuild-linux-riscv64@0.14.54 installed
postcss@8.4.16 installed
esbuild-darwin-64@0.14.54 installed
@vue/runtime-core@3.2.37 installed
@babel/parser@7.18.13 installed
esbuild-freebsd-arm64@0.14.54 installed
has@1.0.3 installed
is-core-module@2.10.0 installed
path-parse@1.0.7 installed
yallist@4.0.0 installed
semver@7.3.7 installed
resolve@1.22.1 installed
esbuild-windows-64@0.14.54 installed
esbuild-netbsd-64@0.14.54 installed
esbuild-linux-s390x@0.14.54 installed
esbuild-linux-arm@0.14.54 installed
@volar/code-gen@0.39.5 installed
@volar/vue-language-core@0.39.5 installed
@vue/tsconfig@0.1.3 installed
@esbuild/linux-loong64@0.14.54 installed
@vue/server-renderer@3.2.37 installed
@vitejs/plugin-vue@3.0.3 installed
@volar/vue-code-gen@0.39.5 installed
@volar/vue-typescript@0.39.5 installed
@volar/source-map@0.39.5 installed
@vue/runtime-dom@3.2.37 installed
@vue/compiler-dom@3.2.37 installed
@volar/typescript-faster@0.39.5 installed
function-bind@1.1.1 installed
supports-preserve-symlinks-flag@1.0.0 installed
lru-cache@6.0.0 installed
source-map-js@1.0.2 installed
fsevents@2.3.2 installed
@vue/reactivity@3.2.37 installed
@vue/reactivity-transform@3.2.37 installed
esbuild-openbsd-64@0.14.54 installed
esbuild-android-arm64@0.14.54 installed
esbuild-windows-arm64@0.14.54 installed
esbuild-linux-arm64@0.14.54 installed
rollup@2.77.3 installed
esbuild-linux-64@0.14.54 installed
いけた。 Vite 起動してアプリケーション動いたぞい。
満を持してロックファイル作るぞ。
ここは改めて元コードのコメントを読みながら整理していく
とりあえず書き込み用オブジェクトに追加する関数を用意
/**
* 更新用の lock ファイルの内容で書き込み用
*/
const newLockFile: LockFile = {}
export async function addLockFile(packageName: PackageName, vc: VersionConstraint, version: Version) {
const manifest = await fetchPackageManifest(packageName)
const info = manifest.versions[version]
newLockFile[`${packageName}@${vc}`] = {
version,
url: info.dist.tarball,
shasum: info.dist.shasum,
dependencies: info.dependencies || {}
}
}
パッケージのインストール時に lock ファイルに書き込む (ここでやることかは微妙なので整理が必要そう)
export async function savePackageTarball(name: PackageName, vc: VersionConstraint = '*') {
const manifest = await fetchPackageManifest(name)
const versions = Object.keys(manifest.versions)
const version = semver.maxSatisfying(versions, vc)
const path = `${DEFAULT_DOWNLOAD_DIR}/${manifest.name}`
mkdir(path, { recursive: true })
const tarballUrl = manifest.versions[version].dist.tarball
const tarResponse = await fetch(tarballUrl)
tarResponse.body?.pipe(tar.extract({ cwd: path, strip: 1 }))
addLockFile(name, vc, version) // 追加
installLog(name, version)
}
全行程完了時に、lock ファイルを書き込めるようにする
export async function writeLockFile() {
const lockFileJson = JSON.stringify(newLockFile, null, 2)
return writeFile(LOCK_FILE_PATH, lockFileJson)
}
lock ファイルが出来上がる!
{
"estree-walker@2.0.2": {
"version": "2.0.2",
"url": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"shasum": "52f010178c2a4c117a7757cfe942adb7d2da4cac",
"dependencies": {}
},
"sourcemap-codec@1.4.8": {
"version": "1.4.8",
"url": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
"shasum": "ea804bd94857402e6992d05a38ef1ae35a9ab4c4",
"dependencies": {}
},
"csstype@2.6.20": {
"version": "2.6.20",
"url": "https://registry.npmjs.org/csstype/-/csstype-2.6.20.tgz",
"shasum": "9229c65ea0b260cf4d3d997cb06288e36a8d6dda",
"dependencies": {}
},
"postcss@8.4.16": {
"version": "8.4.16",
"url": "https://registry.npmjs.org/postcss/-/postcss-8.4.16.tgz",
"shasum": "33a1d675fac39941f5f445db0de4db2b6e01d43c",
"dependencies": {
"nanoid": "^3.3.4",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
}
},
// 以下略
}
あれ、キーにはバージョン制約が入ってほしいのにバージョンが入ってる。
どっかでミスってるので修正しよう。
lock ファイルがあることで、manifest の取得とバージョン解決を省略できるようになるってことでいいのかな。複数人開発の場合は、メンバー間で厳密なバージョンの固定ができるようになって環境差異が生まれなくなると。
一応 npm ドキュメントでロックファイルの目的を再確認
- メンバー、デプロイ、CIそれぞれで常に同一の依存ツリーに基づいてインストールできるようにすること
- node_modules をコミットすることなく、その時点の依存ツリーを履歴として残せるようにすること
- 依存ツリーの変更時に、変更差分を可視化出来るようにすること
- パッケージインストール前のバージョン解決をスキップできること
今回のパッケージマネージャー自作の目的としてはやっぱり最後のヤツだけで良さそうかな。
npm install 時にパッケージのコンフリクトがない場合は node_modules 直下にだけ配置して、コンフリクトがある場合は node_moduels 以下の各パッケージディレクトリにさらに node_modules が掘られてるのか。
で、各パッケージは同ディレクトリから上に向かってモジュールを探索するって感じか。npm install 時にパッケージのコンフリクトがない場合は node_modules 直下にだけ配置して、コンフリクトがある場合は node_moduels 以下の各パッケージディレクトリにさらに node_modules が掘られてるのか。
で、各パッケージは同ディレクトリから上に向かってモジュールを探索するって感じか。
コンフリクト制御が大変そうだけど、一旦それはなしで実装進める。
lock ファイルができてるので、パッケージの依存解決時に、lock ファイルに記載があれば manifest の取得及びバージョンの決定をスキップできるようになった。むしろちゃんとスキップして固定バージョンを利用し続けなければならない。
ファイルを読み込む関数を用意しておく。
export async function readLockFile() {
const buffer = await readFile(LOCK_FILE_PATH, 'utf8').catch(e => {
if (e.code === 'ENOENT') return '{}'
throw e
})
const lockFile = JSON.parse(buffer) as LockFile
Object.assign(currentLockFile, lockFile)
return lockFile
}
型をちょっと整理。
命名が雑になってきてる。
// 解決済みのパッケージ情報
type LockedPackageInfo = {
version: Version
url: string
shasum: string
dependencies: DependenciesMap
}
// 解決済みパッケージ情報一覧(≒ tiny-pm.lock)
type LockFile = {
[dependency: `${PackageName}@${VersionConstraint}`]: LockedPackageInfo
}
lock ファイルから該当のパッケージ情報があれば返す。
ない場合ここでは NULL を返して、呼び出し側のほうでマニフェストの取得と lock ファイルへの追加をやってもらおう。
export function readLockedPackageInfo(name: PackageName, vc: VersionConstraint): LockedPackageInfo | null {
const packageInfo = currentLockFile[`${name}@${vc}`]
if (packageInfo) {
return {
version: packageInfo.version,
url: packageInfo.url,
shasum: packageInfo.shasum,
dependencies: packageInfo.dependencies
}
} else {
return null
}
}
lock ファイルを効率的に活用するの難しいな。
一旦バージョン制約を満たすかじゃなくて、バージョン制約文字列が完全一致かだけで見てみよう。
下手に並列化するとデバッグが大変なので直接で割り切ろう。そういうパフォーマンス改善はあとあと。
Lock ファイルまたは manifest を使って依存解決を行う関数を切り出す
async function resolvePackage(packageName: PackageName, vc: VersionConstraint): Promise<LockedPackageInfo | null> {
// Lock ファイルに既に情報があればそれを利用する
const lockedPackageInfo = readLockedPackageInfo(packageName, vc)
if (lockedPackageInfo) return lockedPackageInfo
// Lock ファイルに情報がなければ、パッケージマニフェストから取得する
// TODO: ここで Lock ファイルの更新も必要?
const manifest = await fetchPackageManifest(packageName)
const version = semver.maxSatisfying(Object.keys(manifest.versions), vc)
if (!manifest.versions[version]) return null
resolveLog(packageName, vc, version)
return {
version,
url: manifest.versions[version].dist.tarball,
shasum: manifest.versions[version].dist.shasum,
dependencies: manifest.versions[version].dependencies || {}
}
}
これで Lock ファイルがある場合はマニフェストを参照することなくダウンロードできるようになった。
ログまわりがわかりづらくなってたので、プレフィックス付きで整理しよう。
以下のイベントについてのログを出せるようにする。
初回
$ node ../dist/cli.js install
[Resolve by manifest] axios@^0.27.0 to 0.27.2
[Resolve by manifest] follow-redirects@^1.14.9 to 1.15.1
[Resolve by manifest] form-data@^4.0.0 to 4.0.0
[Resolve by manifest] asynckit@^0.4.0 to 0.4.0
[Resolve by manifest] combined-stream@^1.0.8 to 1.0.8
[Resolve by manifest] delayed-stream@~1.0.0 to 1.0.0
[Resolve by manifest] mime-types@^2.1.12 to 2.1.35
[Resolve by manifest] mime-db@1.52.0 to 1.52.0
[Installed] axios@0.27.2
[Installed] follow-redirects@1.15.1
[Installed] form-data@4.0.0
[Installed] asynckit@0.4.0
[Installed] combined-stream@1.0.8
[Installed] delayed-stream@1.0.0
[Installed] mime-types@2.1.35
[Installed] mime-db@1.52.0
lockファイル生成後
$ node ../dist/cli.js install
[Resolve by lockfile] axios@^0.27.0 to 0.27.2
[Resolve by lockfile] follow-redirects@^1.14.9 to 1.15.1
[Resolve by lockfile] form-data@^4.0.0 to 4.0.0
[Resolve by lockfile] asynckit@^0.4.0 to 0.4.0
[Resolve by lockfile] combined-stream@^1.0.8 to 1.0.8
[Resolve by lockfile] delayed-stream@~1.0.0 to 1.0.0
[Resolve by lockfile] mime-types@^2.1.12 to 2.1.35
[Resolve by lockfile] mime-db@1.52.0 to 1.52.0
[Installed] axios@0.27.2
[Installed] follow-redirects@1.15.1
[Installed] form-data@4.0.0
[Installed] asynckit@0.4.0
[Installed] combined-stream@1.0.8
[Installed] delayed-stream@1.0.0
[Installed] mime-types@2.1.35
[Installed] mime-db@1.52.0
node_modules の内容が lock ファイルで定義されている前提なら、lock ファイルから取り出したパッケージはダウンロード不要なのかな。
いやそれは嘘か。各環境で新たにインストールする際に lock ファイルを参考にするんだから、そこは対になってない。
でも npm でも2回目以降はすぐにインストール完了するから、 node_modules 内の何かは見てるのか。
あー、それが node_modules/.package-lock.json
なのか。
npm の場合はこれで、yarn の場合は別に手段がありそう。
今回は毎回ダウンロードを受け入れるかなぁ。
ここまで雰囲気で依存解決の実装しちゃってたけど、元パッケージとは実装方法が違うので、もう少し深ぼってく。
おそらくはバージョンのコンフリクトへの対応で必要になるからこういう実装なんだろうな。
元パッケージの実装
登場人物
-
topLevel
:node_modules
直下にインストールするパッケージ一覧 -
unsatisfied
:topLevel
とバージョンコンフリクトを起こしそうなので、各パッケージのnode_modules
以下にインストールするパッケージ
パッケージA について解決する流れ
- Lock ファイルまたは npm リポジトリからAのマニフェストを取得
- マニフェストを元に、バージョン制約を満たす最新バージョンを決定する
- Aが
- topLevel に存在しない場合
- topLevel に追加する
- topLevel に存在するが、バージョン制約を満たしている場合
- dependencies 同士でのコンフリクトがある場合
- 解決不要なので終了
- dependencies 同士でのコンフリクトがある場合
- unsatisfied に追加する
- dependencies 同士でのコンフリクトがある場合
- topLevel に存在し、かつバージョン制約も満たしていない場合
- unsatisfied に追加する
- topLevel に存在しない場合
- A の解決したバージョンを Lock ファイルに記述する
- A に dependencies があれば
- stack に自身を積む
- 各dependencies のうち、循環参照が発生していなければ、同様の解決処理を呼び出す(再帰)
- stack から自身を外す
めちゃくちゃ難しい。
ここで考慮してるケースのうち一部でもサポートできれば一旦OKかもしれない。
↑の実装はラスボスとして、先に package.json を書き換える処理から入ろうかな。
package.json の更新はこれで充分そうかな。
/**
* package.json を依存関係オブジェクトを用いて書き換える
*/
export async function writePackageJson(path: string, dependencyMap: PackageDependencyMap) {
const data = await readFile(path)
const currentJson = JSON.parse(data.toString())
const newJson = {
...currentJson,
dependencies: dependencyMap.dependencies,
devDependencies: dependencyMap.devDependencies
}
if (Object.keys(newJson.dependencies).length === 0) delete newJson.dependencies
if (Object.keys(newJson.devDependencies).length === 0) delete newJson.devDependencies
return writeFile(path, JSON.stringify(newJson, null, 2))
}
追加分だけじゃなくて更新後の状態をまるごと渡す形にすることで、今回対応するかはわからないけどパッケージの削除にも使えるようになる。
ややこしくなるから、ここでは package.json は必ず存在することにしよう。
/**
* package.json を探索し、そのパスを返す
*/
export async function findPackageJsonPath() {
const path = await findUp('package.json')
if (!path) throw new Error('package.json not found')
return path
}
node ../dist/cli.js install axios
node ../dist/cli.js install typescript --save-dev
で
{
"name": "dev",
"version": "1.0.0",
"dependencies": {
"axios": "*"
},
"devDependencies": {
"typescript": "*"
}
}
が生成されるようになったけど、*
じゃためよね。 *
が来たら最新版にあわせたバージョン制約作らなきゃ。
ちょっとゴチャッたけど、 install コマンドでパッケージを追加する場合は resolver で最新バージョンを取得して、それを用いたバージョン制約に書き換えるように。
// 追加インストールするパッケージを dependencies または devDependencies に追加する
// バージョン指定がない場合は、最新バージョンを確認してそれを使用する
// TODO: バージョン指定がある場合の対応
for (const packageName of packageNames) {
const latestPackageInfo = await resolvePackage(packageName, '*')
const latestVersion = latestPackageInfo?.version
if (!latestVersion) throw new Error(`Package not found: ${packageName}`)
if (option.saveDev) {
dependencyMap.devDependencies[packageName] = `^${latestVersion}`
} else {
dependencyMap.dependencies[packageName] = `^${latestVersion}`
}
}
さっきと同じコマンド実行したら、ちゃんと最新版になった。
{
"name": "dev",
"version": "1.0.0",
"dependencies": {
"axios": "^0.27.2"
},
"devDependencies": {
"typescript": "^4.8.2"
}
}
TODO コメント書いてる通り、バージョン指定インストールも実装する。
install axios
なら最新版を
install axios@0.26.0
ならそのバージョン制約に従うようにする。
バリデーションとかまで面倒見ずに、 @
があればそこで区切るだけにしよう。
最新バージョンを取り出す関数を切り出しとく
/**
* パッケージの最新バージョンを返す
*/
export async function resolvePackageLatestVersion(packageName: string) {
const latestPackageInfo = await resolvePackage(packageName, '*')
if (!latestPackageInfo) throw new Error(`Package not found: ${packageName}`)
return latestPackageInfo.version
}
バージョン制約があればそれを、なければ↑を使うように修正
// 追加インストールするパッケージを dependencies または devDependencies に追加する
// バージョン指定がない場合は、最新バージョンを確認してそれを使用する
for (const packageName of packageNames) {
const hasConstraint = packageName.includes('@')
const name = hasConstraint ? packageName.split('@')[0] : packageName
const constraint = hasConstraint ? packageName.split('@')[1] : `^${await resolvePackageLatestVersion(name)}`
if (option.saveDev) {
dependencyMap.devDependencies[name] = constraint
} else {
dependencyMap.dependencies[name] = constraint
}
}
これで
$ node ../dist/cli.js install axios@0.26.0
ならちゃんと
{
"name": "dev",
"version": "1.0.0",
"dependencies": {
"axios": "0.26.0"
}
}
になるように。
まだまだガバガバだけど、一旦バージョンコンフリクトの対処以外はここまででヨシとしようかな。
改めて npm におけるバージョンコンフリクトの対応についての確認。
良さそうな記事があったので読んでみる。
バージョンコンフリクトが発生する例が一つ欲しいな。
Vite を入れれば何かで発生した気がする。
webpack 一つ入れるだけで estraverse
でバージョンコンフリクト起こってくれた。
- webpack -> eslint-scope -> estraverse@^4.1.1
- webpack -> eslint-scope -> esrecurse -> estraverse@^5.2.0
これでさらに直下に estraverse@^3.0.0 なんて追加すれば3バージョンコンフリクトが起こるから動作確認としては良さそう。
{
"name": "dev",
"version": "1.0.0",
"dependencies": {
"webpack": "^5.74.0",
"estraverse": "^3.0.0"
}
}
これで npm install すると3バージョンはいる。良い例だ。
$ find node_modules -name estraverse
node_modules/eslint-scope/node_modules/estraverse
node_modules/esrecurse/node_modules/estraverse
node_modules/estraverse
リポジトリ直下の方を ^3.0.0 じゃなくて ^5.0.0 にすればそっちで解決されるようになるし、動作確認の例としては充分そう。
現状の自作パッケージマネージャでこれをインストールすると1個しか入らない。
$ find node_modules -name estraverse
node_modules/estraverse
version は 3.1.
当然先勝ちになるので直下で指定してるバージョンが優先されちゃう。
別に webpack いらないな。
eslint-scope 直接で充分だわ。
{
"name": "dev",
"version": "1.0.0",
"dependencies": {
"eslint-scope": "^5.0.0",
"estraverse": "^3.0.0"
}
}
インストールするパッケージが最小限になったから動作確認しやすくなった。
$ node ../dist/cli.js install
[Resolve by manifest] eslint-scope@^5.0.0 to 5.1.1
[Resolve by manifest] esrecurse@^4.3.0 to 4.3.0
[Resolve by manifest] estraverse@^5.2.0 to 5.3.0
[Resolve by manifest] estraverse@^4.1.1 to 4.3.0
[Resolve by manifest] estraverse@^3.0.0 to 3.1.0
[Installed] eslint-scope@5.1.1
[Installed] estraverse@3.1.0
[Installed] esrecurse@4.3.0
何気に Lock ファイル見るとちゃんと3バージョン入ってるんだな。
{
"eslint-scope@^5.0.0": {
"version": "5.1.1",
"url": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
"shasum": "e786e59a66cb92b3f6c1fb0d508aab174848f48c",
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
}
},
"esrecurse@^4.3.0": {
"version": "4.3.0",
"url": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"shasum": "7ad7964d679abb28bee72cec63758b1c5d2c9921",
"dependencies": {
"estraverse": "^5.2.0"
}
},
"estraverse@^5.2.0": {
"version": "5.3.0",
"url": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"shasum": "2eea5290702f26ab8fe5370370ff86c965d21123",
"dependencies": {}
},
"estraverse@^4.1.1": {
"version": "4.3.0",
"url": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
"shasum": "398ad3f3c5a24948be7725e83d11a7de28cdbd1d",
"dependencies": {}
},
"estraverse@^3.0.0": {
"version": "3.1.0",
"url": "https://registry.npmjs.org/estraverse/-/estraverse-3.1.0.tgz",
"shasum": "15e28a446b8b82bc700ccc8b96c78af4da0d6cba",
"dependencies": {}
}
}
コード的に、解決結果を Lock ファイルに書き出しはしてるけど、インストールリストへの追加時にパッケージ名で上書きしてるからか。
// 解決結果を Lock ファイルに書き出す
addLockFile(packageName, vc, packageInfo.version)
// インストールリストに自身を追加する
packageList[packageName] = packageInfo.version
すんごい実装になってしまった気がする。
/**
* パッケージのバージョン解決を再帰的に行い、依存パッケージの依存パッケージまで深さ優先で解決していく
* @argument topLevelList node_modules 直下にインストール可能なパッケージリスト
* @argument conflictedList バージョン衝突のため、直下でなく各パッケージ以下の node_modules にインストールするパッケージリスト
* @argument dependencyStack 現在解決中のパッケージに依存するパッケージ名のスタック
*/
export async function collectDepsPackageList(
name: PackageName,
vc: VersionConstraint,
rootDependenciesMap: Readonly<DependenciesMap>,
topLevelList: DependenciesMap,
conflictedList: ConflictedPackageInfo[],
dependencyStack: PackageName[]
) {
// 自身のパッケージ名を依存スタックに積み上げる
dependencyStack.push(name)
// 解決後のパッケージ情報を取得する
const packageInfo = await resolvePackage(name, vc)
// 解決結果を Lock ファイルに書き出す
addLockFile(name, vc, packageInfo.version)
// どこにインストールするかのヒント
const topLevelExists = !!topLevelList[name]
const isCompatibleToTopLevel = topLevelExists && semver.satisfies(topLevelList[name], vc)
const isRootDependency = dependencyStack.length === 1
// 1. 初出パッケージで、自身がルートからの依存であればトップレベルに追加
if (!topLevelExists && isRootDependency) {
topLevelList[name] = packageInfo.version
}
// 2. 初出パッケージで、ルートからの依存が存在しなければ自身をトップレベルに追加
else if (!topLevelExists && !rootDependenciesMap[name]) {
topLevelList[name] = packageInfo.version
}
// 3. 初出パッケージで、ルートからの依存が存在するが
else if (!topLevelExists && rootDependenciesMap[name]) {
const rootDependencyVersion = (await resolvePackage(name, rootDependenciesMap[name])).version
// 3-1. 自身と互換性のあるバージョンであればスルー
if (semver.satisfies(rootDependencyVersion, vc)) {
// 何もしない
}
// 3-2. 自身と互換性がないバージョンであれば、衝突リストに追加
else {
conflictLog(name, vc, rootDependenciesMap[name])
conflictedList.push({ name, version: packageInfo.version, parent: dependencyStack[dependencyStack.length - 2] })
}
}
// 4. 既出パッケージで、自身と互換性のあるバージョンであればスルー
else if (topLevelExists && isCompatibleToTopLevel) {
// 何もしない
}
// 5. 既出パッケージで、自身と互換性がないバージョンであれば、衝突リストに追加
else {
conflictLog(name, vc, rootDependenciesMap[name])
conflictedList.push({ name, version: packageInfo.version, parent: dependencyStack[dependencyStack.length - 2] })
}
// 自身が依存するパッケージに対して再帰的に同様の操作を行う
for (const [depName, depVersion] of Object.entries(packageInfo.dependencies)) {
await collectDepsPackageList(
depName,
depVersion,
rootDependenciesMap,
topLevelList,
conflictedList,
dependencyStack
)
}
// 自身の解決は全て完了したのでスタックから取り除く
dependencyStack.pop()
}
でもちゃんと動く。
$ node ../dist/cli.js install
[Resolve by manifest] eslint-scope@^5.0.0 to 5.1.1
[Resolve by manifest] esrecurse@^4.3.0 to 4.3.0
[Resolve by manifest] estraverse@^5.2.0 to 5.3.0
[Resolve by manifest] estraverse@^3.0.0 to 3.1.0
[Conflict with root] estraverse@^5.2.0 is conflicted with ^3.0.0
[Resolve by manifest] estraverse@^4.1.1 to 4.3.0
[Resolve by manifest] estraverse@^3.0.0 to 3.1.0
[Conflict with root] estraverse@^4.1.1 is conflicted with ^3.0.0
[Resolve by manifest] estraverse@^3.0.0 to 3.1.0
[Installed] eslint-scope@5.1.1 > node_modules/eslint-scope
[Installed] esrecurse@4.3.0 > node_modules/esrecurse
[Installed] estraverse@3.1.0 > node_modules/estraverse
[Installed] estraverse@5.3.0 > node_modules/esrecurse/node_modules/estraverse
[Installed] estraverse@4.3.0 > node_modules/eslint-scope/node_modules/estraverse
$ find node_modules -name estraverse
node_modules/eslint-scope/node_modules/estraverse
node_modules/esrecurse/node_modules/estraverse
node_modules/estraverse
これまでの Vite や axios のパターンも再度動作確認した限りは大丈夫そう。
これで一旦終わりかなぁ。リファクタしていこう。
そこそこリファクタしたのでコード書くのはここまで。
次はビルド周り整備して、npm publish していきたい。 cli をリリースするのは初めてだ。
諸々あってリポジトリ名、パッケージ名を変えて無事に npm で公開。
npm 経由でインストールする
$ sudo npm install --global sasaki-package-manager
実行ファイルができてる
$ which sasaki-pm
/usr/local/bin/sasaki-pm
できてるけど、マジックコメントが入ってないぞ。
#!/usr/bin/env node
これどうやって付与するんだ…?
元パッケージの方でビルド試してみたけど、別にビルドで勝手に挿入されるってわけじゃないのか。
別にプロセスが必要っぽいけど今回は dist/cli.js に手動で付けてみるか。
この辺知る必要がありそうなのでメモメモ
動きそう
$ sasaki-pm
Usage: sasaki-pm [options] [command]
Options:
--production install only dependencies (not devDependencies)
--save-dev Package will appear in your devDependencies
-h, --help display help for command
Commands:
install [packageNames...]
help [command] display help for command
完璧!
$ sasaki-pm install axios
[Resolve by manifest] axios@* to 0.27.2
[Resolve by manifest] axios@^0.27.2 to 0.27.2
[Resolve by manifest] follow-redirects@^1.14.9 to 1.15.1
[Resolve by manifest] form-data@^4.0.0 to 4.0.0
[Resolve by manifest] asynckit@^0.4.0 to 0.4.0
[Resolve by manifest] combined-stream@^1.0.8 to 1.0.8
[Resolve by manifest] delayed-stream@~1.0.0 to 1.0.0
[Resolve by manifest] mime-types@^2.1.12 to 2.1.35
[Resolve by manifest] mime-db@1.52.0 to 1.52.0
[Installed] axios@0.27.2 > node_modules/axios
[Installed] follow-redirects@1.15.1 > node_modules/follow-redirects
[Installed] form-data@4.0.0 > node_modules/form-data
[Installed] asynckit@0.4.0 > node_modules/asynckit
[Installed] combined-stream@1.0.8 > node_modules/combined-stream
[Installed] delayed-stream@1.0.0 > node_modules/delayed-stream
[Installed] mime-types@2.1.35 > node_modules/mime-types
[Installed] mime-db@1.52.0 > node_modules/mime-db
$ cat sasaki-pm.lock.json
{
"asynckit@^0.4.0": {
"version": "0.4.0",
"url": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"shasum": "c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79",
"dependencies": {}
},
"axios@^0.27.2": {
"version": "0.27.2",
"url": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
"shasum": "207658cc8621606e586c85db4b41a750e756d972",
"dependencies": {
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
}
},
"combined-stream@^1.0.8": {
"version": "1.0.8",
"url": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"shasum": "c3d45a8b34fd730631a110a8a2520682b31d5a7f",
"dependencies": {
"delayed-stream": "~1.0.0"
}
},
"delayed-stream@~1.0.0": {
"version": "1.0.0",
"url": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"shasum": "df3ae199acadfb7d440aaae0b29e2272b24ec619",
"dependencies": {}
},
"follow-redirects@^1.14.9": {
"version": "1.15.1",
"url": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
"shasum": "0ca6a452306c9b276e4d3127483e29575e207ad5",
"dependencies": {}
},
"form-data@^4.0.0": {
"version": "4.0.0",
"url": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"shasum": "93919daeaf361ee529584b9b31664dc12c9fa452",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
},
"mime-db@1.52.0": {
"version": "1.52.0",
"url": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"shasum": "bbabcdc02859f4987301c856e3387ce5ec43bf70",
"dependencies": {}
},
"mime-types@^2.1.12": {
"version": "2.1.35",
"url": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"shasum": "381a871b62a734450660ae3deee44813f70d959a",
"dependencies": {
"mime-db": "1.52.0"
}
}
}
頑張って記事公開したのでクローズ