Привет, друзья!
Вам когда-нибудь хотелось узнать, как под капотом работают пакетные менеджеры (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!
ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/662830/

Добавить комментарий