Привет, друзья!
Вам когда-нибудь хотелось узнать, как под капотом работают пакетные менеджеры (Package Manager, PM) — интерфейсы командной строки (Command Line Interface, CLI) для установки зависимостей проектов наподобие npm или yarn? Если хотелось, тогда эта статья для вас.
В данном туториале мы разработаем простой пакетный менеджер на Node.js и TypeScript. В качестве образца для подражания мы будем использовать yarn. Если вы не знакомы с TS, советую взглянуть на эту карманную книгу.
Наш CLI будет называться my-yarn. В качестве lock-файла (yarn.lock, package-lock.json) он будет использовать файл my-yarn.yml.
Источник вдохновения.
Код проекта.
В процессе разработки CLI
мы будем использовать несколько интересных npm-пакетов
. Давайте начнем наше путешествие с краткого знакомства с ними.
Пакеты
find-up
find-up — это утилита для поиска файла или директории в родительских директориях.
Установка
yarn add find-up
Использование
import { findUp } from 'find-up'
import fs from 'fs-extra'
// находим файл `package.json` (путь к нему)
const filePath = await findUp('package.json')
// читаем его содержимое как `JSON`
const fileContent = await fs.readJson(filePath)
fs-extra
fs-extra — это просто fs на стероидах.
Установка
yarn add fs-extra
js-yaml
js-yaml — это утилита для разбора (парсинга) файла в формате YAML в объект и сериализации объекта обратно в yaml
.
Установка
yarn add js-yaml
Использование
import { findUp } from 'find-up'
import fs from 'fs-extra'
import yaml from 'js-yaml'
const filePath = await findUp('my-yarn.yml')
const fileContent = await fs.readFile(filePath, 'utf-8')
// разбираем файл
// метод `load` принимает строку и опциональный объект с настройками
const fileObj = yaml.load(fileContent)
// сериализуем файл
// метод `dump` принимает объект c содержимым файла и опциональный объект с настройками
await fs.writeFile(
filePath,
yaml.dump(fileObj, { noRefs: true, sortKeys: true })
)
// `noRefs: true` запрещает преобразование дублирующихся объектов в ссылки
// `sortKeys: true` выполняет сортировку ключей объекта при формировании файла
log-update
log-update — это утилита для вывода сообщений в терминал с перезаписью предыдущего вывода. Может использоваться для рендеринга индикаторов прогресса, анимации и др.
Установка
yarn add log-update
Использование
import logUpdate from 'log-update'
export function logResolving(pkgName: string) {
logUpdate(`[1/2] Resolving: ${pkgName}`)
}
node-fetch
node-fetch — это обертка над Fetch API для Node.js
.
Установка
yarn add node-fetch
progress
progress — это утилита для создания индикаторов загрузки, состоящих из ASCII-символов.
Установка
yarn add progress
Использование
import logUpdate from 'log-update'
import ProgressBar from 'progress'
export function prepareInstall(total: number) {
logUpdate('[1/2] Finished resolving.')
// конструктор принимает строку с токенами и опциональный объект с настройками
progress = new ProgressBar('[2/2] Installing [:bar]', {
// символ заполнения
complete: '#',
// общее количество тиков (ticks)
total
})
}
semver
semver — это семантический "версионер" (semantic versioner) для npm
.
Установка
yarn add semver
Использование
import semver from 'semver'
const versions = ['1.0.0', '3.0.0', '5.0.0']
const range = '2.0.0 - 4.0.0'
// возвращает наиболее близкую к диапазону версию или `null`
semver.maxSatisfying(versions, range) // 3.0.0
// возвращает `true`, если версия удовлетворяет диапазону
semver.satisfies(versions[1], range) // true
tar
tar — это обертка над tar для Node.js
.
Установка
yarn add tar
Использование
import fetch from 'node-fetch'
import fs from 'fs-extra'
import tar from 'tar'
// адрес реестра
const REGISTRY_URI = 'https://registry.npmjs.org'
// название пакета
const pkgName = 'nodemon'
// путь к директории для пакета
const dirPath = `${process.cwd()}/node_modules/${pkgName}`
// получаем информацию о пакете
const pkgJson = await (await fetch(`${REGISTRY_URI}/${pkgName}`)).json()
// получаем последнюю версию пакета
const latestVersion = Object.keys(pkgJson.versions).at(-1)
// путь к тарбалу (tarball) пакета
const tarUrl = pkgJson.versions[latestVersion].dist.tarball
// создаем директорию для пакета при отсутствии
if (!(await fs.pathExists(dirPath))) {
await fs.mkdirp(dirPath)
}
// тело ответа представляет собой поток данных (application/octet-stream),
// доступный для чтения
const { body: tarReadableStream } = await fetch(tarUrl)
tarReadableStream
// извлекаем содержимое пакета
// `cwd` - путь к директории
// `strip` - количество ведущих элементов пути (leading path elements) для удаления
.pipe(tar.extract({ cwd: dirPath, strip: 1 }))
.on('close', () =>
console.log(`Package ${pkgName} has been successfully extracted.`)
)
yargs
yargs — это библиотека для разработки CLI
с отличной документацией.
Установка
yarn add yargs
Использование yargs
— тема для отдельной статьи. Я немного расскажу об этом, когда мы дойдем до разработки соответствующей части приложения.
Подготовка и настройка проекта
Создаем директорию для проекта, переходим в нее и инициализируем Node.js-проект:
mkdir ts-package-manager
cd $!
yarn init -y
# or
npm init -y
Устанавливаем зависимости:
# производственные зависимости
yarn add find-up fs-extra js-yaml log-update node-fetch progress semver tar yargs
# or
npm i ...
# зависимости для разработки
# компилятор `tsc` и типы для пакетов
yarn add -D typescript @types/find-up @types/fs-extra @types/js-yaml @types/log-update @types/node-fetch @types/progress @types/semver @types/tar @types/yargs
# or
npm i -D ...
Редактируем файл package.json:
{
"name": "my-yarn",
"private": false,
"license": "MIT",
"version": "0.0.1",
"main": "dist/main.js",
"bin": {
"my-yarn": "dist/cli.js"
},
"files": [
"dist"
],
"engines": {
"node": ">= 13.14.0"
},
"scripts": {
"build": "tsc"
},
"type": "module",
"devDependencies": {
...
},
"dependencies": {
...
}
}
При запуске команды my-yarn
будет выполняться код из файла dist/cli.js.
Создаем файл tsconfig.json с настройками для компиляции TS в JS (настройками, которые будут использоваться tsc при выполнении команды build
):
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"lib": [
"ESNext"
],
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"outDir": "dist"
},
"include": [
"src"
]
}
Не забываем про файл .gitignore:
node_modules
dist
# если вы используете `yarn`
yarn-error.log
# если вы работаете на `mac`
.DS_Store
Все файлы нашего проекта будут находиться в директории src и компилироваться в директорию dist. Структура директории src будет следующей:
- cli.ts
- install.ts
- list.ts
- lock.ts
- log.ts
- main.ts
- resolve.ts
- utils.ts
С подготовкой и настройкой проекта мы закончили. Можно приступать к разработке CLI.
CLI
Начнем с основного файла нашего CLI — main.ts. В этом файле происходит следующее:
- функция main, вызываемая при выполнении команды
my-yarn install <packageName>
, в качестве аргумента принимает массив устанавливаемых пакетов, передаваемый yargs; - находим и читаем файл package.json; предполагается, что он существует в проекте;
- извлекаем из аргумента названия устанавливаемых пакетов и расширяем ими package.json;
- читаем lock-файл; данный файл создается при отсутствии;
- получаем информацию о зависимостях на основе расширенного package.json;
- записываем обновленный lock-файл;
- устанавливаем пакеты;
- записываем обновленный package.json.
import fs from 'fs-extra'
import { findUp } from 'find-up'
import yargs from 'yargs'
// обратите внимание, что мы импортируем `JS-файлы`
import * as utils from './utils.js'
import list, { PackageJson } from './list.js'
import install from './install.js'
import * as log from './log.js'
import * as lock from './lock.js'
export default async function main(args: yargs.Arguments) {
// находим и читаем `package.json`
const jsonPath = (await findUp('package.json'))!
const root = await fs.readJson(jsonPath)
// собираем новые пакеты, добавляемые с помощью `my-yarn install <packageName>`,
// через аргументы `CLI`
const additionalPackages = args._.slice(1)
if (additionalPackages.length) {
if (args['save-dev'] || args.dev) {
root.devDependencies = root.devDependencies || {}
// мы заполним эти объекты после получения информации о пакетах
additionalPackages.forEach((pkg) => (root.devDependencies[pkg] = ''))
} else {
root.dependencies = root.dependencies || {}
additionalPackages.forEach((pkg) => (root.dependencies[pkg] = ''))
}
}
// в продакшне нас интересуют только производственные зависимости
if (args.production) {
delete root.devDependencies
}
// читаем `lock-файл`
await lock.readLock()
// получаем информацию о зависимостях
const info = await list(root)
// сохраняем `lock-файл` асинхронно
lock.writeLock()
/*
* готовимся к установке
* обратите внимание, что здесь мы повторно вычисляем количество пакетов
*
* по причине дублирования
* количество разрешенных пакетов не будет совпадать
* с количеством устанавливаемых пакетов
*/
log.prepareInstall(
Object.keys(info.topLevel).length + info.unsatisfied.length
)
// устанавливаем пакеты верхнего уровня
await Promise.all(
Object.entries(info.topLevel).map(([name, { url }]) => install(name, url))
)
// устанавливаем пакеты с конфликтами
await Promise.all(
info.unsatisfied.map((item) =>
install(item.name, item.url, `/node_modules/${item.parent}`)
)
)
// форматируем `package.json`
beautifyPackageJson(root)
// сохраняем `package.json`
fs.writeJSON(jsonPath, root, { spaces: 2 })
}
// форматируем поля `dependencies` и `devDependencies`
function beautifyPackageJson(packageJson: PackageJson) {
if (packageJson.dependencies) {
packageJson.dependencies = utils.sortKeys(packageJson.dependencies)
}
if (packageJson.devDependencies) {
packageJson.devDependencies = utils.sortKeys(packageJson.devDependencies)
}
}
Рассмотрим утилиты для логгирования (log.ts):
- утилита
logResolving
выводит в терминал название устанавливаемого пакета; - утилита
prepareInstall
сообщает о завершении разрешения устанавливаемых пакетов и создает индикатор прогресса установки; - утилита
tickInstalling
обновляет индикатор прогресса установки после извлечения тарбала пакета.
import logUpdate from 'log-update'
import ProgressBar from 'progress'
let progress: ProgressBar
// разрешаемый модуль
// по аналогии с `yarn`
export function logResolving(name: string) {
logUpdate(`[1/2] Resolving: ${name}`)
}
export function prepareInstall(count: number) {
logUpdate('[1/2] Finished resolving.')
// индикатор прогресса установки
progress = new ProgressBar('[2/2] Installing [:bar]', {
complete: '#',
total: count
})
}
// обновляем индикатор прогресса
// после извлечения `tarball`
export function tickInstalling() {
progress.tick()
}
Рассмотрим утилиты для работы с lock-файлом (lock.ts):
- утилита
updateOrCreate
записывает информацию о пакете в lock; - утилита
getItem
извлекает информацию о пакете по названию и версии; - утилита
readLock
читает lock; - утилита
writeLock
пишет lock.
import { findUp } from 'find-up'
import fs from 'fs-extra'
import yaml from 'js-yaml'
import { Manifest } from './resolve.js'
import { Obj } from './utils.js'
interface Lock {
// название пакета
[index: string]: {
// версия
version: string
// путь к тарбалу
url: string
// хеш-сумма (контрольная сумма) файла
shasum: string
// зависимости
dependencies: { [dep: string]: string }
}
}
// находим `lock-файл`
const lockPath = (await findUp('my-yarn.yml'))!
// зачем нам 2 отдельных `lock`?
// это может быть полезным при удалении пакетов
// при добавлении или удалении пакетов
// `lock` может обновляться автоматически
// старый `lock` предназначен только для чтения
const oldLock: Lock = Object.create(null)
// новый `lock` предназначен только для записи
const newLock: Lock = Object.create(null)
// записываем информацию о пакете в `lock`
export function updateOrCreate(name: string, info: Obj) {
if (!newLock[name]) {
newLock[name] = Object.create(null)
}
Object.assign(newLock[name], info)
}
/*
* извлекаем информацию о пакете по его названию и версии (семантическому диапазону)
* обратите внимание, что мы не возвращаем данные,
* а форматируем их для того,
* чтобы структура данных соответствовала реестру пакетов (`npm`)
* это позволяет сохранить логику функции `collectDeps`
* из модуля `list`
*/
export function getItem(name: string, constraint: string): Manifest | null {
// извлекаем элемент `lock` по ключу,
// формат вдохновлен `yarn.lock`
const item = oldLock[`${name}@${constraint}`]
if (!item) {
return null
}
// преобразуем структуру данных
return {
[item.version]: {
dependencies: item.dependencies,
dist: { tarball: item.url, shasum: item.shasum }
}
}
}
// читаем `lock`
export async function readLock() {
if (await fs.pathExists(lockPath)) {
Object.assign(oldLock, yaml.load(await fs.readFile(lockPath, 'utf-8')))
}
}
// сохраняем `lock`
export async function writeLock() {
// необходимость сортировки ключей обусловлена тем,
// что при каждом использовании менеджера
// порядок пакетов будет разным
//
// сортировка может облегчить сравнение версий `lock` с помощью `git diff`
await fs.writeFile(
lockPath,
yaml.dump(newLock, { sortKeys: true, noRefs: true })
)
}
Утилита для установки пакета (install.ts):
import fetch from 'node-fetch'
import tar from 'tar'
import fs from 'fs-extra'
import * as log from './log.js'
export default async function install(
name: string,
url: string,
location = ''
) {
// путь к директории для устанавливаемого пакета
const path = `${process.cwd()}${location}/node_modules/${name}`
// создаем директории рекурсивно
await fs.mkdirp(path)
const response = await fetch(url)
/*
* тело ответа - это поток данных, доступный для чтения
* (readable stream, application/octet-stream)
*
* `tar.extract` принимает такой поток
* это позволяет извлекать содержимое напрямую -
* без его записи на диск
*/
response
.body!.pipe(tar.extract({ cwd: path, strip: 1 }))
// обновляем индикатор прогресса установки после извлечения тарбала
.on('close', log.tickInstalling)
}
Утилита для сортировки ключей объекта (utils.ts):
export type Obj = { [key: string]: any }
export const sortKeys = (obj: Obj) =>
Object.keys(obj)
.sort()
.reduce((_obj: Obj, cur) => {
_obj[cur] = obj[cur]
return _obj
}, Object.create(null))
Теперь рассмотрим, пожалуй, самое интересное — формирование списка зависимостей верхнего уровня и зависимостей с конфликтами (дубликатов) в файле list.ts.
Импортируем пакеты, определяем типы и переменные:
import semver from 'semver'
import resolve from './resolve.js'
import * as log from './log.js'
import * as lock from './lock.js'
import { Obj } from './utils.js'
type DependencyStack = Array<{
name: string
version: string
dependencies: Obj
}>
export interface PackageJson {
dependencies?: Obj
devDependencies?: Obj
}
// переменная `topLevel` предназначена для выравнивания (flatten)
// дерева пакетов во избежание их дублирования
const topLevel: {
[name: string]: { version: string; url: string }
} = Object.create(null)
// переменная `unsatisfied` предназначена для аккумулирования конфликтов (дублирующихся пакетов)
const unsatisfied: Array<{ name: string; url: string; parent: string }> = []
Определяем функцию для формирования списка зависимостей collectDeps
:
// @ts-ignore
async function collectDeps(
name: string,
constraint: string,
stack: DependencyStack = []
) {
// извлекаем манифест пакета из `lock` по названию и версии
const fromLock = lock.getItem(name, constraint)
// получаем информацию о манифесте
//
// если манифест отсутствует в `lock`,
// получаем его из сети
const manifest = fromLock || (await resolve(name))
// выводим в терминал название разрешаемого пакета
log.logResolving(name)
// используем версию пакета,
// которая ближе всего к семантическому диапазону
//
// если диапазон не определен,
// используем последнюю версию
const versions = Object.keys(manifest)
const matched = constraint
? semver.maxSatisfying(versions, constraint)
: versions.at(-1)
if (!matched) {
throw new Error('Cannot resolve suitable package.')
}
// если пакет отсутствует в `topLevel`
if (!topLevel[name]) {
// добавляем его
topLevel[name] = { url: manifest[matched].dist.tarball, version: matched }
// если пакет имеется в `topLevel` и удовлетворяет диапазону
} else if (semver.satisfies(topLevel[name].version, constraint)) {
// определяем наличие конфликтов
const conflictIndex = checkStackDependencies(name, matched, stack)
// пропускаем проверку зависимостей при наличии конфликта
// это позволяет избежать возникновения циклических зависимостей
if (conflictIndex === -1) return
/*
* из-за особенностей алгоритма, используемого `Node.js`
* для разрешения модулей,
* между зависимостями зависимостей могут возникать конфликты
*
* одним из решений данной проблемы
* является извлечение информации о двух предыдущих зависимостях зависимости,
* конфликтующих между собой
*/
unsatisfied.push({
name,
parent: stack
.map(({ name }) => name)
.slice(conflictIndex - 2)
.join('/node_modules/'),
url: manifest[matched].dist.tarball
})
// если пакет уже содержится в `topLevel`
// но имеет другую версию
} else {
unsatisfied.push({
name,
parent: stack.at(-1)!.name,
url: manifest[matched].dist.tarball
})
}
// не забываем о зависимостях зависимости
const dependencies = manifest[matched].dependencies || null
// записываем манифест в `lock`
lock.updateOrCreate(`${name}@${constraint}`, {
version: matched,
url: manifest[matched].dist.tarball,
shasum: manifest[matched].dist.shasum,
dependencies
})
// собираем зависимости зависимости
if (dependencies) {
stack.push({
name,
version: matched,
dependencies
})
await Promise.all(
Object.entries(dependencies)
// предотвращаем циклические зависимости
.filter(([dep, range]) => !hasCirculation(dep, range, stack))
.map(([dep, range]) => collectDeps(dep, range, stack.slice()))
)
// удаляем последний элемент
stack.pop()
}
// возвращаем семантический диапазон версии
// для добавления в `package.json`
if (!constraint) {
return { name, version: `^${matched}` }
}
}
Определяем 2 вспомогательные функции:
// данная функция определяет наличие конфликтов в зависимостях зависимости
const checkStackDependencies = (
name: string,
version: string,
stack: DependencyStack
) =>
stack.findIndex(({ dependencies }) =>
// если пакет не является зависимостью другого пакета,
// возвращаем `true`
!dependencies[name] ? true : semver.satisfies(version, dependencies[name])
)
// данная функция определяет наличие циклической зависимости
//
// если пакет содержится в стеке и имеет такую же версию
// значит, имеет место циклическая зависимость
const hasCirculation = (name: string, range: string, stack: DependencyStack) =>
stack.some(
(item) => item.name === name && semver.satisfies(item.version, range)
)
Наконец, определяем основную функцию:
// наша программа поддерживает только поля
// `dependencies` и `devDependencies`
export default async function list(rootManifest: PackageJson) {
// добавляем в `package.json` названия и версии пакетов
// обрабатываем производственные зависимости
if (rootManifest.dependencies) {
;(
await Promise.all(
Object.entries(rootManifest.dependencies).map((pair) =>
collectDeps(...pair)
)
)
)
.filter(Boolean)
.forEach(
(item) => (rootManifest.dependencies![item!.name] = item!.version)
)
}
// обрабатываем зависимости для разработки
if (rootManifest.devDependencies) {
;(
await Promise.all(
Object.entries(rootManifest.devDependencies).map((pair) =>
collectDeps(...pair)
)
)
)
.filter(Boolean)
.forEach(
(item) => (rootManifest.devDependencies![item!.name] = item!.version)
)
}
// возвращаем пакеты верхнего уровня и пакеты с конфликтами
return { topLevel, unsatisfied }
}
Определяем интерфейс командной строки (cli.ts):
#!/usr/bin/env node
import yargs from 'yargs'
import main from './main.js'
yargs
// пример использования
.usage('my-yarn <command> [args]')
// получение информации о версии
.version()
// псевдоним
.alias('v', 'version')
// получение информации о порядке использования
.help()
// псевдоним
.alias('h', 'help')
// единственной командной, выполняемой нашим `CLI`,
// будет команда `add`
// данная команда предназначена для установки зависимостей
.command(
'add',
'Install dependencies',
(argv) => {
// по умолчанию устанавливаются производственные зависимости
argv.option('production', {
type: 'boolean',
description: 'Install production dependencies only'
})
// при наличии флагов `save-dev`, `dev` или `D`
// выполняется установка зависимостей для разработки
argv.boolean('save-dev')
argv.boolean('dev')
argv.alias('D', 'dev')
return argv
},
// при выполнении команды `yarn add <packageName>`
// запускается код из файла `main.js`
main
)
// парсим аргументы, переданные `CLI`
.parse()
На этом разработка нашего CLI
завершена. Пришло время убедиться в его работоспособности.
Пример
Выполняем сборку проекта с помощью команды yarn build
или npm run build
.
Это приводит к генерации директории dist с JS-файлами проекта.
Находясь в корневой директории, подключаем наш CLI к npm с помощью команды npm link (данная команда позволяет тестировать разрабатываемые пакеты локально) и получаем список глобально установленных пакетов с помощью команды npm -g list --depth 0
.
Видим в списке глобальных пакетов my-yarn@0.0.1
. Для удаления my-yarn необходимо выполнить команду npm -g rm my-yarn
.
Получаем информацию о версии my-yarn с помощью команды my-yarn -v
и информацию о порядке использования CLI с помощью команды my-yarn -h
.
Разработаем простой сервер на Express, который будет запускаться в режиме для разработки и возвращать некоторую статическую разметку.
Создаем директорию my-yarn, переходим в нее и инициализируем Node.js-проект:
mkdir my-yarn
cd $!
yarn init -yp
# или
npm init -y
Создаем файл index.html следующего содержания:
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MyYarn</title>
</head>
<body>
<h1>MyYarn - простой пакетный менеджер</h1>
</body>
</html>
И такой файл index.js:
// для того, чтобы иметь возможность использовать `ESM`,
// необходимо определить `"type": "module"` в файле `package.json`
import express from 'express'
const app = express()
// возвращаем статику при получении `GET-запроса` по адресу `/my-yarn`
app.get('/my-yarn', (_, res) => {
res.sendFile(`${process.cwd()}/index.html`)
})
const PORT = process.env.PORT || 3124
// запускаем сервер
app.listen(PORT, () => {
console.log(`Server is listening on port ${PORT}`)
})
Для работы сервера требуется пакет express, а для его запуска в режиме для разработки — пакет nodemon. Мы выполним установку этих пакетов с помощью нашего CLI.
Находясь в директории my-yarn, устанавливаем express с помощью команды my-yarn add express
и nodemon с помощью команды my-yarn add -D nodemon
.
Это приводит к генерации директории node_modules
, файла my-yarn.yml и обновлению файла package.json.
Добавляем команду для запуска сервера для разработки в package.json:
"scripts": {
"dev": "node_modules/nodemon/bin/nodemon.js"
}
Обратите внимание: наш CLI не умеет выполнять скрипты, поэтому для запуска команды dev мы будем использовать yarn. Однако, поскольку мы устанавливали зависимости с помощью my-yarn, у нас отсутствует файл yarn.lock, который используется yarn для разрешения путей к пакетам. Это обуславливает необходимость указания полного пути к выполняемому файлу nodemon.
Запускаем сервер для разработки с помощью команды yarn dev
.
Получаем сообщение о готовности сервера к обработке запросов.
Открываем вкладку браузера по адресу http://localhost:3124/my-yarn.
Получаем наш index.html.
Отлично. Приложение работает, как ожидается.
Пожалуй, это все, о чем я хотел рассказать вам в этой статье. Надеюсь, вам было интересно и вы не жалеете потраченного времени.
Благодарю за внимание и happy coding!