open atlas
↑ К треку
Next.js с нуля до senior NEXT · 08 · 02

Механика монорепо: pnpm workspaces, граф задач Turborepo и пакеты, заслужившие свою границу

pnpm workspaces линкуют внутренние пакеты; Turborepo хеширует входы задач и проигрывает кеш — 38 минут холодного CI превращаются в считанные минуты тёплого. transpilePackages отдаёт ui исходниками; changesets нужны только при публикации. God-пакет utils стирает весь выигрыш.

NEXT Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Три Next.js-репозитория — web, admin, docs — и скопипащенная между ними дизайн-система. Security-фикс общей обёртки fetch (она логировала заголовки Authorization при ошибке) приземляется в два репозитория из трёх; третий пять недель сливает токены в лог-агрегатор, пока пентест не находит утечку. Разбор инцидента предписывает монорепо: один фикс, один коммит, все приложения. Через полгода монорепо — новый инцидент: каждый PR, включая опечатку в docs, гоняет 38 минут CI. Turborepo установлен, кеш настроен, hit rate — 4 процента, потому что миграция породила packages/shared — свалку утилит, которую импортируют все восемь пакетов и которая меняется в семи PR из десяти. Одна строка в shared перехеширует каждую задачу графа. Лечением оказался не инструмент, а граница: распил shared на сфокусированные доменные пакеты поднял hit rate до 85 процентов и опустил медианный CI PR с 38 минут до 6. Монорепо никогда не было целью. Целью был граф.

pnpm workspaces: ссылки, а не копии

Прежде чем ставить Turborepo, стоит понять, что вообще даёт настоящие границы — а не просто организацию папок. Ответ — субстрат пакетного менеджера.

Субстрат — pnpm-workspace.yaml, объявляющий apps/* и packages/*. Каждый пакет держит настоящий package.json, а внутренние зависимости используют workspace-протокол — "@acme/ui": "workspace:*", — который pnpm разрешает в симлинк на локальный пакет и никогда — в реестр. Два свойства делают это субстратом границ, а не удобством. Первое: каждый пакет объявляет зависимости явно — package.json есть контракт границы, машинно проверяемый при установке. Второе: строгий, неплоский node_modules pnpm означает, что пакет может разрешить только то, что объявил, — классическая фантомная зависимость (импорт пакета, который случайно поставил поднятый сосед) падает сразу, вместо того чтобы работать в dev и взрываться позже при другом порядке установки. Монорепо на пакетном менеджере с хостингом — это папки; монорепо на pnpm — это проверяемые рёбра.

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
// apps/web/package.json — ребро объявлено, а не подразумевается
{
  "name": "web",
  "dependencies": {
    "@acme/ui": "workspace:*",
    "@acme/billing": "workspace:*",
    "next": "15.1.0"
  }
}

Граф задач — это граф кеша

Turborepo читает turbo.json и строит направленный граф задач: "build": { "dependsOn": ["^build"] } значит «моей сборке сначала нужны сборки моих зависимостей» — топологический порядок получается сам. Рычаг — в том, что происходит до запуска задачи: Turborepo считает контент-хеш по её входам — исходники пакета, хеши выходов задач-зависимостей, объявленные переменные окружения, релевантный срез lockfile и конфигурацию самой задачи. Хеш уже встречался: работа пропускается, кешированные выходы и захваченные логи проигрываются байт в байт. Хеш новый: задача выполняется и кешируется. Поскольку хеш выхода зависимости — вход хеша каждого зависимого, инвалидация распространяется ровно по стрелкам зависимостей — и больше никуда.

Честные цифры из репозитория в прологе: холодная полная сборка (девять пакетов, два Next.js-приложения) идёт 20–35 минут в CI. С удалённым кешем артефакты, ключованные хешами, живут в общем хранилище: CI собирает один раз, каждый другой раннер — и каждый ноутбук — проигрывает. Тёплый медианный PR: 4–7 минут, потому что выполняется только изменённый подграф; PR, не трогающий ничего собираемого, проигрывает всё меньше чем за минуту. Добавьте --filter=...[origin/main] — и CI вообще не рассматривает задачи вне затронутого подграфа. Ничто из этого не требует умного кеша. Это требует разрезаемого графа — а это свойство ваших границ пакетов, не Turborepo.

Викторина

Вы меняете одну строку в packages/utils, от которого зависят ui и оба приложения (ui тоже зависит от utils). turbo run build выполняется с тёплым удалённым кешем. Что запустится?

Извлечение пакетов: ui, config, домен

Что заслуживает извлечения из приложения: код, потребляемый двумя и более приложениями, тестируемый изолированно, владеющий своими зависимостями. Три архетипа покрывают большинство репозиториев. У packages/ui — дизайн-системы — есть настоящее решение: отдавать исходниками (пакет экспортирует сырые .tsx, и каждое Next.js-приложение указывает его в transpilePackages, чтобы компилятор приложения обработал его как собственный код) или пребилдом (пакет получает задачу сборки tsup/rollup, эмитящую dist/). Исходники выигрывают в скорости итерации: ноль конфигурации бандлера в пакете, и правка кнопки мгновенно хот-релоадится в каждом запущенном приложении. Цена симметрична: каждое потребляющее приложение перекомпилирует пакет при каждой сборке, и эта компиляция кешируется как часть сборки приложения, а не как собственная задача. Пребилд переворачивает обмен — сборка пакета кешируется и шарится, сборки приложений быстрее, внешняя публикация становится возможной — ценой конфигурации бандлера, оркестрации watch-режима и класса багов «в приложении A работает, в B — протухший dist». По умолчанию — исходники плюс transpilePackages; переключайтесь, когда заставит время сборки приложений или внешняя публикация.

// apps/web/next.config.js
module.exports = {
  transpilePackages: ['@acme/ui'], // компилировать внутренние пакеты-исходники
};

packages/config держит базы eslint-config и tsconfig, которые расширяет каждый пакет, — один PR обновления двигает весь репозиторий, дрейф становится структурно невозможным. Доменные пакеты в духе packages/billing — извлечение с наибольшей отдачей: чистый TypeScript без React, так что проверка типов и тесты бизнес-правил больше вообще не требуют сборки приложения.

Расставь шаги по порядку

Разработчик изменяет одну строку в packages/utils. Упорядочите цепочку инвалидации кеша Turborepo:

  1. 1 Turborepo пересчитывает контент-хеш packages/utils — исходники изменились, промах кеша
  2. 2 packages/ui и packages/billing пересчитывают свои хеши — хеш выхода их зависимости (utils) является входом, поэтому они тоже промахиваются
  3. 3 apps/web и apps/admin пересчитывают свои хеши — они зависят от ui и billing, чьи хеши выходов только что изменились
  4. 4 Turborepo выполняет build/typecheck для всех промахнувших узлов; незатронутые пакеты (например, packages/config) проигрываются из удалённого кеша без изменений

Честно о версионировании

Внутренним пакетам версии не нужны. workspace:* означает, что версия — каждый коммит: потребители всегда компилируются против HEAD, нечего публиковать, нечего поднимать, не о чем спорить про semver на ревью. Changesets отрабатывают свою церемонию ровно в одном случае — когда пакет публикуется наружу (npm или приватный реестр) для потребителей вне workspace. Принятие changesets для чисто внутренних пакетов порождает PR с поднятием версий, которые не потребляет ни человек, ни машина: чистый процессный театр. Честное правило: workspace:* до дня, когда появится внешний потребитель, и changesets с этого дня — только для публикуемых пакетов.

Викторина

packages/ui экспортирует сырые .tsx без шага сборки. apps/web импортирует оттуда Button, и сборка падает на неожиданном синтаксисе. Какое решение поддерживается официально и сколько оно стоит?

Отдача — и god-пакет, который её стирает

Отдача границ накапливается в CI: turbo run typecheck test --filter=...[origin/main] означает, что PR в packages/billing гоняет проверки биллинга плюс двух приложений, которые его импортируют, — приложение docs и пять остальных пакетов проигрываются из кеша. Время проверки типов перестаёт расти с размером репозитория и начинает расти с радиусом поражения.

Провал — тот самый, из пролога, и его механизм стоит проговорить: packages/shared — utils, helpers, «common» — импортируется каждым пакетом. Его позиция в графе означает, что его хеш питает все остальные хеши; его природа свалки означает, что он меняется постоянно (70 процентов PR в репозитории из инцидента). Перемножьте: почти нулевые попадания в кеш, пересборки всего графа на каждое изменение и команда с выводом «монорепо замедлило CI», хотя замедлила топология графа. Обнаружение механическое — отрендерите граф пакетов (turbo умеет; dependency-cruiser — тоньше) и отслеживайте hit rate кеша на PR как первоклассную метрику CI. Лечение — распил по домену и частоте изменений: стабильные листья (@acme/result, @acme/dates) отдельно от горячей доменной логики, чтобы изменение биллинга перехешировало зависимых биллинга и никого больше. Культурное лечение важнее: больше никогда не позволяйте пакету называться shared или utils — таким именам нечем отказать новому экспорту. Теперь, когда CI монорепо начнёт ползти вверх, сначала отрисуйте граф зависимостей — скорее всего, один пакет лежит под всем деревом и меняется в каждом втором PR.

Вспомните перед уходом
  1. 01
    Как Turborepo решает пропустить задачу и почему настоящая отдача — в удалённом кеше?
  2. 02
    Когда внутренним пакетам нужны changesets и в чём провал god-utils вместе с лечением?
Итог

Инцидент с копипастой — фикс утечки токенов, доехавший до двух репозиториев из трёх, — покупает монорепо, а 38-минутный CI покупает настоящий урок: монорепо окупается только как граф. pnpm workspaces дают субстрат: workspace:* линкует внутренние пакеты симлинками, каждый package.json объявляет свои рёбра явно, а строгий node_modules превращает фантомные зависимости из латентных продакшен-багов в мгновенные падения при установке. Turborepo превращает граф зависимостей в кеш: каждая задача ключуется контент-хешем по исходникам, хешам выходов зависимостей, переменным окружения, срезу lockfile и конфигурации — попадание проигрывает выходы и логи, промах выполняет и сохраняет, а удалённый кеш шарит артефакты, чтобы CI собирал один раз, а остальные проигрывали. Цифры, которые стоит запомнить: холодные сборки в 20–35 минут становятся тёплыми медианными PR в 4–7 минут, а фильтрация по затронутому исключает остальной граф целиком. У извлечения три архетипа: ui исходниками с transpilePackages (мгновенный кросс-app HMR, но каждое приложение его перекомпилирует) против пребилда (собственная кешируемая задача сборки ценой конфигурации бандлера и багов протухшего dist); config-пакеты, делающие дрейф eslint/tsconfig невозможным; и чистые TS-доменные пакеты — самый ценный разрез, потому что бизнес-логике для проверки типов перестаёт быть нужна сборка приложения. Внутреннее не версионируйте — workspace:* означает, что версия это HEAD, а changesets суть церемония, пока пакет не публикуется на самом деле. И провал, стирающий весь выигрыш: god-пакет utils, лежащий под всем графом и меняющийся в большинстве PR, гонит hit rate к 4 процентам. Пилите по домену и частоте изменений, следите за hit rate кеша на PR как за метрикой CI и никогда не называйте пакет shared. Теперь, когда CI монорепо начнёт замедляться, первый вопрос — не «купить ли более быстрые раннеры», а «какой пакет лежит под всем графом и как часто он меняется».

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.