lenec ru

← все посты

pnpm workspaces: настройка монорепо с нуля

10K

Когда команда растёт и проектов становится больше двух, рано или поздно встаёт вопрос: как управлять зависимостями. Я последние три года езжу на 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 init

Workflow: разработчик после изменения создаёт 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 они тебе ничего не дадут.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.