При 125 пакетах полный pnpm -r run build занимает около десяти минут. Для CI — нормально. Для разработчика, который поменял одну строку в библиотеке утилит и хочет увидеть результат — нет. Мы написали kb-devkit — Go-бинарь — чтобы это исправить.
Почему не Turborepo?
Turborepo хорошо делает своё дело. Но у нашей монорепы есть особенности, ради которых кастомное решение оказалось оправданным:
- Go-бинари живут рядом с TypeScript-пакетами — у них нет
package.json, и npm-ориентированные инструменты их просто не видят - Нам нужны упорядоченные категории (библиотеки перед приложениями, Go перед Node) с семантикой «первый совпавший выигрывает»
- Хотелось content-addressable кэша под полным нашим контролем, а не удалённого кэша с оплатой
- Оценка здоровья воркспейса (покрытие линтером, отсутствующие конфиги) — фича, которую не предоставляет ни один существующий инструмент
Как работает kb-devkit
Обнаружение пакетов
Вместо обхода всей файловой системы kb-devkit разворачивает паттерны из pnpm-workspace.yaml уровень за уровнем через os.ReadDir(). Сложность — O(dirs_per_level × depth) вместо O(total_files). Ограничение maxDepth: 3 предотвращает бесконечную рекурсию.
Каждый найденный пакет классифицируется в категорию по совпадению относительного пути с glob-паттернами. Категории — упорядоченный список, первое совпадение выигрывает. Это важно, потому что паттерны ts-app и ts-lib пересекаются, а библиотеки должны собираться раньше приложений.
# devkit.yaml — категории (порядок важен)
categories:
- name: ts-lib
match: ['core/*/packages/**', 'plugins/*/packages/**']
- name: ts-app
match: ['core/*/apps/**', 'plugins/*/apps/**']
- name: go-binary
match: ['tools/kb-dev', 'tools/kb-devkit'] # литеральные пути, без package.jsonПланирование через DAG
kb-devkit строит направленный ациклический граф из пар (пакет, задача) с помощью алгоритма Кана. Спецификация зависимости deps: ["^build"] означает «сначала выполни build для всех зависимостей из воркспейса». Планировщик выполняет слой за слоем: все задачи внутри слоя идут параллельно (до NumCPU - 1), но слои — последовательно, чтобы соблюдать порядок.
Content-addressable кэш
Вот откуда берётся цифра в 15 секунд. Перед запуском задачи kb-devkit вычисляет SHA256-хэш по всем входным файлам (отсортированным, с автоматическим исключением node_modules, .git, dist, coverage). Если хэш совпадает с кэшированным манифестом, результаты мгновенно восстанавливаются из content-addressable хранилища в .kb/devkit/objects/.
# Вычисление ключа кэша (упрощённо)
hash = SHA256(
for each file matching task.inputs:
relative_path + '\x00' + file_content + '\x00'
)
# Структура хранилища
.kb/devkit/
objects/<ab>/<cdef...> # контент-адресуемые блобы
tasks/<pkg>/<task>/<hash>.json # манифесты (код выхода, stdout, ссылки на файлы)У каждой задачи независимый кэш. Запуск build не заполняет кэш lint — у них разные входы и выходы. Одинаковые выходные файлы из разных пакетов дедуплицируются (хранятся по одному экземпляру по хэшу содержимого).
Неочевидные края
- Go-пакеты без package.json. В воркспейсе три Go-бинари (kb-devkit, kb-dev, kb-create). Их нельзя обнаружить через npm workspace protocol. Решение: сопоставление литеральных путей в категориях, минуя проверку
hasPackageJSON. - Итерация по map в Go недетерминирована. Категории должны вычисляться в порядке объявления (первый совпавший выигрывает), но Go итерирует
mapв случайном порядке. Решение: кастомныйyaml.NodeUnmarshalYAMLдля сохранения порядка. - Порядок DTS. Файлы TypeScript-деклараций должны эмититься в порядке зависимостей — пакет downstream не может пройти type-check, пока не существуют
.d.tsфайлы его зависимостей. Именно поэтомуpnpm -r run buildнередко выдаёт ошибки типов, которые исчезают при повторном запуске. DAG-планировщик решает это по конструкции.
Результаты
Полная холодная сборка: ~158 пакетов за 26 секунд (Go-бинарь, параллельные слои, без кэша). Инкрементальная пересборка после изменения одного пакета: 2–15 секунд в зависимости от fan-out зависимостей. Худший случай в 15 секунд — изменение core-types, которое тригерит пересборку большей части графа.
В типичном случае — изменение исходников плагина — это меньше 3 секунд. Разница между «сохранил и проверил» и «сохранил, пошёл за кофе, проверил».