Работа с монорепозиториями и workspace

Монорепозиторий — это единое хранилище исходного кода, содержащее несколько приложений, библиотек, сервисов или пакетов. В экосистеме JavaScript подобная архитектура особенно распространена благодаря поддержке package manager workspace-механизмов и возможностям Webpack по работе с зависимостями, alias, symlink и многопакетными сборками.

Типичная структура:

project/
├─ apps/
│  ├─ admin/
│  ├─ client/
│  └─ mobile/
├─ packages/
│  ├─ ui/
│  ├─ utils/
│  └─ api/
├─ package.json
├─ webpack.config.js
└─ node_modules/

В такой модели:

  • apps содержит конечные приложения;
  • packages содержит переиспользуемые модули;
  • все пакеты работают внутри одного git-репозитория;
  • зависимости могут быть общими;
  • workspace автоматически связывает локальные пакеты.

Workspace в npm, Yarn и pnpm

npm workspaces

Поддержка workspace появилась в npm начиная с версии 7.

Пример:

{
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ]
}

Каждый пакет содержит собственный package.json.

Пример:

{
  "name": "@project/ui",
  "version": "1.0.0"
}

После установки npm создаёт симлинки между пакетами.


Yarn workspaces

Yarn workspace предоставляет аналогичный механизм:

{
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ]
}

Yarn активно используется совместно с:

  • Turborepo;
  • Nx;
  • Lerna;
  • Storybook;
  • Babel monorepo.

pnpm workspace

pnpm использует отдельный файл:

packages:
  - 'apps/*'
  - 'packages/*'

Особенность pnpm — нестандартная структура node_modules.

Пакеты хранятся централизованно:

node_modules/.pnpm/

После чего создаются symlink-связи.

Это уменьшает размер диска и ускоряет установку зависимостей.


Webpack в монорепозитории

Основные проблемы

Webpack в монорепозитории сталкивается с несколькими типовыми задачами:

  • разрешение локальных пакетов;
  • обработка symlink;
  • единый Babel/TypeScript;
  • дублирование зависимостей;
  • multiple React instance;
  • кэширование;
  • HMR между workspace;
  • производительность сборки.

Разрешение локальных workspace-пакетов

Предположим, имеется пакет:

packages/ui

и приложение:

apps/admin

Импорт:

import { Button } from '@project/ui';

Webpack должен:

  1. найти workspace-пакет;
  2. корректно разрешить symlink;
  3. обработать исходники;
  4. включить код в bundle.

Базовая настройка resolve

const path = require('path');

module.exports = {
  resolve: {
    extensions: ['.js', '.ts', '.tsx']
  }
};

Однако этого недостаточно для монорепозитория.


Настройка alias для workspace

Использование resolve.alias

Наиболее распространённый подход:

const path = require('path');

module.exports = {
  resolve: {
    alias: {
      '@project/ui': path.resolve(__dirname, '../. ./packages/ui/src'),
      '@project/utils': path.resolve(__dirname, '../. ./packages/utils/src')
    }
  }
};

Преимущества:

  • быстрый import;
  • отсутствие лишнего поиска;
  • контроль версии;
  • удобство IDE;
  • независимость от symlink.

Централизованный alias-конфиг

При большом количестве пакетов alias лучше генерировать автоматически.

Пример:

const fs = require('fs');
const path = require('path');

const packagesDir = path.resolve(__dirname, 'packages');

const aliases = {};

for (const dir of fs.readdirSync(packagesDir)) {
  aliases[`@project/${dir}`] = path.join(
    packagesDir,
    dir,
    'src'
  );
}

module.exports = aliases;

Использование:

const aliases = require('./aliases');

module.exports = {
  resolve: {
    alias: aliases
  }
};

Symlink и resolve.symlinks

Поведение Webpack

Workspace-пакеты обычно подключаются через symbolic links.

Webpack по умолчанию:

resolve: {
  symlinks: true
}

Это означает:

  • Webpack раскрывает symlink;
  • реальный путь используется вместо symbolic link;
  • модуль может оказаться вне текущего package root.

Основные последствия:

  • дублирование React;
  • duplicate module instance;
  • конфликт peerDependencies;
  • неправильная работа HMR;
  • потеря singleton.

React duplication

Классическая ошибка:

Invalid hook call

Причина:

apps/admin/node_modules/react
packages/ui/node_modules/react

В результате:

  • приложение использует одну копию React;
  • библиотека использует другую.

Hooks перестают работать корректно.


Решение через alias

resolve: {
  alias: {
    react: path.resolve(__dirname, 'node_modules/react'),
    'react-dom': path.resolve(__dirname, 'node_modules/react-dom')
  }
}

Теперь все workspace используют единый экземпляр React.


Иногда используется:

resolve: {
  symlinks: false
}

Это заставляет Webpack сохранять symlink-путь вместо реального пути.

Преимущества:

  • корректный module identity;
  • улучшенная работа HMR;
  • меньше duplicate package;
  • предсказуемый cache.

Но некоторые loader и plugin могут ожидать реальные пути.


Babel в монорепозитории

Единый Babel config

Обычно Babel-конфиг размещается в корне:

babel.config.js

Пример:

module.exports = {
  presets: [
    '@babel/preset-env',
    '@babel/preset-react',
    '@babel/preset-typescript'
  ]
};

Почему не .babelrc

.babelrc действует локально внутри пакета.

В монорепозитории это приводит к:

  • конфликтам конфигурации;
  • разным preset;
  • различному transpilation;
  • сложному cache.

babel.config.js работает глобально.


Обработка workspace-пакетов

Стандартная ошибка:

Module parse failed

Webpack не транспилирует код из packages.


include вместо exclude

Плохой вариант:

exclude: /node_modules/

Workspace-пакеты могут оказаться внутри node_modules через symlink.

Правильный подход:

const path = require('path');

module.exports = {
  module: {
    rules: [
      {
        test: /\.[jt]sx?$/,
        include: [
          path.resolve(__dirname, 'src'),
          path.resolve(__dirname, '../. ./packages')
        ],
        use: 'babel-loader'
      }
    ]
  }
};

TypeScript и workspace

tsconfig base

Общий конфиг:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "baseUrl": ".",
    "paths": {
      "@project/ui": [
        "packages/ui/src"
      ],
      "@project/utils": [
        "packages/utils/src"
      ]
    }
  }
}

Наследование конфигурации

Внутри пакета:

{
  "extends": "../. ./tsconfig.base.json"
}

Синхронизация paths и alias

Одна из частых проблем:

  • TypeScript видит модуль;
  • Webpack не видит модуль.

Необходимо синхронизировать:

  • tsconfig paths;
  • webpack resolve.alias.

TsconfigPathsPlugin

Автоматическая интеграция:

npm install tsconfig-paths-webpack-plugin
const TsconfigPathsPlugin =
  require('tsconfig-paths-webpack-plugin');

module.exports = {
  resolve: {
    plugins: [
      new TsconfigPathsPlugin()
    ]
  }
};

Теперь Webpack использует paths автоматически.


Shared dependencies

Общие зависимости

В монорепозитории зависимости часто располагаются в корне:

project/node_modules

Это уменьшает:

  • размер проекта;
  • количество duplicate package;
  • время установки.

Hoisting

Hoisting — поднятие зависимостей вверх.

Пример:

packages/ui/node_modules/react

перемещается в:

project/node_modules/react

Проблемы hoisting

Иногда пакет случайно использует dependency, не указанную в package.json.

Локально всё работает благодаря hoisting, но CI или публикация ломаются.


Строгий dependency isolation

pnpm особенно полезен тем, что:

  • запрещает скрытые зависимости;
  • жёстко контролирует graph;
  • уменьшает dependency leakage.

Module Federation и монорепозитории

Использование shared

При Module Federation особенно важно избегать duplicate dependency.

Пример:

new ModuleFederationPlugin({
  shared: {
    react: {
      singleton: true
    },
    'react-dom': {
      singleton: true
    }
  }
});

Singleton dependency

Singleton гарантирует:

  • единственный экземпляр библиотеки;
  • отсутствие duplicate React;
  • единое состояние context;
  • стабильную работу hooks.

HMR между workspace-пакетами

Проблема watch

Webpack dev server может не отслеживать изменения в соседних пакетах.

Особенно часто проблема возникает:

  • в Docker;
  • WSL;
  • pnpm;
  • symlink;
  • network filesystem.

watchOptions

module.exports = {
  watchOptions: {
    ignored: /node_modules/
  }
};

Но workspace-пакеты могут находиться внутри node_modules.


Исключение workspace из ignored

watchOptions: {
  ignored: [
    '**/node_modules/**',
    '!**/node_modules/@project/**'
  ]
}

Иногда требуется:

resolve: {
  symlinks: false
}

иначе HMR не замечает изменения.


Кэширование

Filesystem cache

Webpack 5:

module.exports = {
  cache: {
    type: 'filesystem'
  }
};

В монорепозитории это особенно важно.


Build dependencies

cache: {
  type: 'filesystem',
  buildDependencies: {
    config: [__filename]
  }
}

Общий cache

Иногда кэш выносится в корень:

cache: {
  type: 'filesystem',
  cacheDirectory:
    path.resolve(__dirname, '../. ./.cache/webpack')
}

Это позволяет:

  • переиспользовать cache;
  • ускорять CI;
  • уменьшать cold build.

Multiple Webpack Config

Раздельные сборки

В монорепозитории часто существуют:

  • client webpack;
  • server webpack;
  • library webpack;
  • mobile webpack.

Multi compiler mode

module.exports = [
  clientConfig,
  serverConfig,
  adminConfig
];

Webpack запускает несколько compiler одновременно.


Сборка библиотек внутри workspace

Library package

Пример структуры:

packages/ui
├─ src
├─ dist
├─ package.json
└─ webpack.config.js

Output library

module.exports = {
  output: {
    library: {
      type: 'module'
    }
  },
  experiments: {
    outputModule: true
  }
};

External dependencies

Исключение зависимостей

Для библиотек часто используется:

externals: {
  react: 'react',
  'react-dom': 'react-dom'
}

Это предотвращает:

  • duplicate React;
  • увеличение bundle size;
  • конфликты версий.

Tree shaking между workspace

Side effects

Для корректного tree shaking:

{
  "sideEffects": false
}

Barrel files

Неудачный пример:

export * from './Button';
export * from './Modal';
export * from './Chart';

Такие barrel-файлы иногда ухудшают tree shaking.


Granular exports

Лучше:

export { Button } from './Button';
export { Modal } from './Modal';

Performance оптимизация

Ограничение transpilation

Плохой вариант:

include: path.resolve(__dirname, '../. ./')

Webpack начнёт обрабатывать весь монорепозиторий.


Точное include

Лучше:

include: [
  path.resolve(__dirname, 'src'),
  path.resolve(__dirname, '../. ./packages/ui/src')
]

Thread loader

Для крупных монорепозиториев:

{
  loader: 'thread-loader'
}

Позволяет распараллелить Babel-transpilation.


Persistent cache

Webpack 5 значительно ускоряет rebuild благодаря persistent cache:

cache: {
  type: 'filesystem'
}

Особенно заметный эффект:

  • при большом количестве workspace;
  • TypeScript;
  • Babel;
  • React;
  • Module Federation.

Публикация workspace-пакетов

Подготовка dist

Библиотека обычно публикует только:

dist/

package.json exports

{
  "exports": {
    ".": "./dist/index.js"
  }
}

Main fields

{
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts"
}

Nx, Turborepo и Webpack

Nx

Nx предоставляет:

  • dependency graph;
  • affected build;
  • distributed cache;
  • incremental rebuild.

Webpack интегрируется через:

@nx/webpack

Turborepo

Turborepo ускоряет:

  • build;
  • lint;
  • test;
  • cache.

Часто используется совместно с:

  • Next.js;
  • Webpack;
  • Vite;
  • pnpm workspace.

Yarn PnP и Webpack

Plug’n’Play

Yarn PnP полностью убирает node_modules.

Webpack требует специального resolver:

yarn add pnp-webpack-plugin

Настройка

const PnpWebpackPlugin =
  require('pnp-webpack-plugin');

module.exports = {
  resolve: {
    plugins: [
      PnpWebpackPlugin
    ]
  },
  resolveLoader: {
    plugins: [
      PnpWebpackPlugin.moduleLoader(module)
    ]
  }
};

Частые ошибки

Module not found

Причины:

  • неверный alias;
  • отсутствующий workspace;
  • broken symlink;
  • неправильный main;
  • ошибка exports.

Duplicate React

Симптомы:

Invalid hook call

Решение:

  • alias;
  • singleton;
  • peerDependencies;
  • shared config.

Unexpected token export

Причина:

Webpack не транспилирует workspace-пакет.

Решение:

include: [
  path.resolve(__dirname, '../. ./packages')
]

Медленный rebuild

Причины:

  • слишком широкий include;
  • отключён cache;
  • огромный dependency graph;
  • отсутствие thread-loader.

Рекомендуемая структура

Root

project/
├─ apps/
├─ packages/
├─ node_modules/
├─ package.json
├─ tsconfig.base.json
├─ babel.config.js
└─ webpack/

Shared webpack config

webpack/
├─ webpack.common.js
├─ webpack.dev.js
├─ webpack.prod.js
└─ aliases.js

Package naming

Рекомендуемый namespace:

@project/ui
@project/utils
@project/config

Это:

  • уменьшает конфликты;
  • упрощает импорт;
  • делает dependency graph понятнее.

Практический пример

Root package.json

{
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ]
}

App import

import { Button } from '@project/ui';

Webpack config

const path = require('path');

module.exports = {
  resolve: {
    alias: {
      '@project/ui': path.resolve(
        __dirname,
        '../. ./packages/ui/src'
      )
    },
    symlinks: false
  },

  module: {
    rules: [
      {
        test: /\.[jt]sx?$/,
        include: [
          path.resolve(__dirname, 'src'),
          path.resolve(__dirname, '../. ./packages')
        ],
        use: 'babel-loader'
      }
    ]
  },

  cache: {
    type: 'filesystem'
  }
};

Преимущества монорепозитория

Повторное использование кода

Workspace позволяет:

  • использовать единые UI-компоненты;
  • делить utility-модули;
  • переиспользовать типы;
  • централизовать бизнес-логику.

Единый tooling

Монорепозиторий обеспечивает:

  • единый ESLint;
  • единый Babel;
  • общий TypeScript;
  • единый CI/CD;
  • централизованный dependency management.

Ускорение разработки

Общие workspace уменьшают:

  • количество duplicate package;
  • overhead публикации;
  • сложность синхронизации версий;
  • время интеграции изменений.