Skip to main content

Разрабатываем пакетный менеджер на TypeScript

· 16 min read

Привет, друзья!

Вам когда-нибудь хотелось узнать, как под капотом работают пакетные менеджеры (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!