pnpm workspaces: настройка монорепо с нуля
Когда команда растёт и проектов становится больше двух, рано или поздно встаёт вопрос: как управлять зависимостями. Я последние три года езжу на pnpm workspaces и за это время поднял монорепо с нуля раз шесть. Расскажу, как сделать это правильно, чтобы потом не разгребать.
Зачем монорепо
Если у тебя один пакет — никуда не двигайся. Монорепо имеет смысл, когда:
- Несколько связанных проектов делят общий код (UI-кит, утилиты, типы).
- Команды работают над разными частями, но изменения часто синхронные.
- Хочется одной командой собрать всё или запустить тесты на всём.
- Релизы пакетов нужно версионировать вместе.
Альтернатива — отдельные репозитории и публикация через npm/частный реестр. Это проще, но ломает синхронность. Если у вас в одном PR трогаются и UI-кит, и приложение — лучше монорепо.
Установка
Первое — pnpm. Я ставлю через corepack:
corepack enable
corepack prepare pnpm@latest --activateЭто даёт фиксированную версию pnpm, привязанную к проекту через packageManager-поле в корневом package.json. Все участники проекта получают одинаковую версию.
Структура корня
my-mono/
package.json
pnpm-workspace.yaml
pnpm-lock.yaml
tsconfig.base.json
apps/
web/
package.json
admin/
package.json
packages/
ui/
package.json
utils/
package.json
config/
package.jsonКорневой package.json:
{
"name": "my-mono",
"private": true,
"packageManager": "pnpm@9.15.0",
"scripts": {
"dev": "pnpm -r --parallel dev",
"build": "pnpm -r build",
"test": "pnpm -r test",
"lint": "pnpm -r lint"
},
"devDependencies": {
"typescript": "^5.5.0",
"prettier": "^3.3.0"
}
}Поле private: true запрещает случайно опубликовать корень. Devdeps кладу те, что нужны всему репо: TypeScript, Prettier, ESLint, husky.
pnpm-workspace.yaml:
packages:
- 'apps/*'
- 'packages/*'Это говорит pnpm, какие папки считать пакетами. Подходят и более сложные паттерны: 'apps/**', '!packages/legacy/**'.
Внутренние зависимости
Главная сила workspaces — внутренние зависимости через специальный протокол:
{
"name": "@my-mono/web",
"private": true,
"dependencies": {
"@my-mono/ui": "workspace:*",
"@my-mono/utils": "workspace:^"
}
}Префикс workspace: означает «бери из этого репо, не из npm». При публикации (если ты публикуешь) pnpm заменит на конкретную версию.
Варианты:
workspace:*— текущая версия, любая.workspace:^— с caret-диапазоном.workspace:~— с tilde-диапазоном.workspace:1.2.3— точная версия.
Для сугубо внутренних пакетов (не публикуемых) я ставлю workspace:* — это всегда последняя версия из репо, без головной боли с диапазонами.
TypeScript конфигурация
Базовый tsconfig.base.json в корне:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@my-mono/*": ["packages/*/src"]
}
}
}В каждом пакете — свой tsconfig.json с extends:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}Так общие настройки в одном месте, а специфичные — у пакета.
Запуск задач
pnpm имеет богатый CLI для работы с workspaces:
# все пакеты
pnpm -r build
# конкретный пакет
pnpm --filter @my-mono/web dev
# пакет и его зависимости
pnpm --filter @my-mono/web... build
# только то, что изменилось от main
pnpm --filter ...[origin/main] buildПоследняя команда особенно полезна в CI — собирает только изменённые пакеты и их зависимых, экономит время.
Hoisting и его подводные камни
pnpm по умолчанию использует строгий node_modules: каждый пакет видит только то, что объявлено в его dependencies. Это сильно отличается от npm/yarn, где из-за hoisting можно было импортировать что попало.
Плюс — ловятся «забытые зависимости»: если ты импортишь react в пакете, где его нет в зависимостях, pnpm даст ошибку. В классическом npm это работало случайно через hoisting.
Минус — некоторые инструменты (особенно старые webpack-плагины и storybook) ожидают плоское дерево. Для них есть escape-hatch:
// .npmrc
public-hoist-pattern[]=*storybook*
public-hoist-pattern[]=@types/*Я в основном держу shamefully-hoist=false, но иногда приходится разрешать конкретные паттерны.
Версионирование и публикация
Если пакеты публикуются на npm, я использую Changesets:
pnpm add -Dw @changesets/cli
pnpm changeset initWorkflow: разработчик после изменения создаёт changeset (pnpm changeset), описывает изменения и тип релиза. На merge в main запускается pnpm changeset version, который обновляет версии и changelog. Дальше pnpm publish -r публикует все пакеты с обновлениями.
Это хорошо работает в команде из 3-15 человек. Для меньшего размера — overkill, для большего — нужен либо Lerna, либо что-то вроде Nx с собственным релизом.
CI
Базовый pipeline для GitHub Actions:
name: ci
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: pnpm/action-setup@v3
with: { version: 9 }
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter ...[origin/main] build
- run: pnpm --filter ...[origin/main] testГлавное — --frozen-lockfile в CI: лочит установку и не позволит pnpm молча обновить версии.
Кэш CI
pnpm имеет глобальный store, который кэшируется между запусками. На GitHub Actions с cache: pnpm кэш работает автоматически. На своём CI указывай вручную через ~/.pnpm-store.
Грабли, которые я ловил
- Случайные дублирующие версии. Если два пакета зависят от
reactс разными диапазонами, pnpm может поставить две копии. Ставь общие зависимости явно в корень сworkspace:-протоколом или фиксируй точную версию. - peer dependencies. Если у вас в репо есть пакет, который ожидает конкретный peer, и в другой части использует другой — pnpm может запутаться. Помогает
auto-install-peers=trueв.npmrc. - Локальные ссылки в TS. Если редактор не подхватывает обновления в локальном пакете, добавь
composite: trueв егоtsconfig.jsonиreferencesв зависящих пакетах.
Что я бы делал иначе
На первом монорепо я попытался сделать всё сразу: 12 пакетов, 4 приложения, общий ESLint, общий Prettier, общий Husky, Changesets. Закончилось двумя неделями настроек.
На втором — начал с минимума: apps/web, packages/ui, packages/utils. Через месяц добавил config. Через два — Changesets. Через три — Husky. Это работает гораздо плавнее.
Что копать дальше
Когда базовое монорепо заработало, посмотри на Turborepo или Nx. Они дают распределённый кеш сборки и параллелизацию задач, что заметно ускоряет CI на больших проектах. Но это уже следующий шаг — без рабочей основы на pnpm workspaces они тебе ничего не дадут.