← Курс/TypeScript в монорепозиториях#198 из 257+25 XP

TypeScript в монорепозиториях

Структура монорепо

monorepo/
├── packages/
│   ├── shared/         # Общие типы и утилиты
│   │   ├── src/
│   │   ├── tsconfig.json
│   │   └── package.json
│   ├── api/            # Backend
│   │   ├── src/
│   │   ├── tsconfig.json
│   │   └── package.json
│   └── web/            # Frontend
│       ├── src/
│       ├── tsconfig.json
│       └── package.json
├── tsconfig.base.json  # Общая конфигурация
└── tsconfig.json       # Корневой (project references)

composite: включение project references

Каждый пакет должен иметь composite: true:

// packages/shared/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "rootDir": "./src",
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}

references: связи между пакетами

// packages/api/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true
  },
  "references": [
    { "path": "../shared" }
  ]
}

// packages/web/tsconfig.json
{
  "references": [
    { "path": "../shared" }
  ]
}

// Корневой tsconfig.json
{
  "references": [
    { "path": "packages/shared" },
    { "path": "packages/api" },
    { "path": "packages/web" }
  ],
  "files": []  // пустой — все файлы в пакетах
}

Shared tsconfig base

// tsconfig.base.json
{
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "bundler",
    "resolveJsonModule": true
  }
}

Каждый пакет наследует:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "target": "ES2022"
  }
}

paths для cross-package импортов

// packages/api/tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@monorepo/shared": ["../shared/src/index.ts"],
      "@monorepo/shared/*": ["../shared/src/*"]
    }
  }
}
// packages/api/src/routes/users.ts
import type { User, ApiResponse } from '@monorepo/shared'

package.json для workspace-пакетов

// packages/shared/package.json
{
  "name": "@monorepo/shared",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  }
}
// packages/api/package.json
{
  "dependencies": {
    "@monorepo/shared": "workspace:*"  // pnpm
    "@monorepo/shared": "*"            // npm/yarn
  }
}

Turborepo: оркестрация сборки

// turbo.json
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],  // сначала собрать зависимости
      "outputs": ["dist/**"]
    },
    "typecheck": {
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["build"]
    }
  }
}
# Собрать всё (в правильном порядке):
turbo build

# Только изменённые пакеты:
turbo build --filter=...web  # web и его зависимости

tsc --build для project references

# Собрать всё в правильном порядке:
tsc --build

# С watch:
tsc --build --watch

# Принудительная пересборка:
tsc --build --force

# Очистить выходные файлы:
tsc --build --clean

Примеры

Симуляция монорепо: граф зависимостей пакетов, топологическая сборка, отслеживание изменений как в Turborepo

// Симулируем систему сборки монорепо.
// Демонстрируем: project references, порядок сборки, кэширование как в Turborepo.

// --- Структура монорепо ---
const packages = {
  '@mono/shared': {
    path: 'packages/shared',
    deps: [],           // нет зависимостей
    files: ['types.ts', 'utils.ts', 'constants.ts'],
    built: false,
    hash: null
  },
  '@mono/config': {
    path: 'packages/config',
    deps: [],
    files: ['index.ts'],
    built: false,
    hash: null
  },
  '@mono/api-client': {
    path: 'packages/api-client',
    deps: ['@mono/shared'],
    files: ['client.ts', 'endpoints.ts'],
    built: false,
    hash: null
  },
  '@mono/ui': {
    path: 'packages/ui',
    deps: ['@mono/shared', '@mono/config'],
    files: ['Button.tsx', 'Input.tsx', 'Modal.tsx'],
    built: false,
    hash: null
  },
  '@mono/web': {
    path: 'apps/web',
    deps: ['@mono/ui', '@mono/api-client', '@mono/shared'],
    files: ['App.tsx', 'pages/Home.tsx'],
    built: false,
    hash: null
  },
  '@mono/mobile': {
    path: 'apps/mobile',
    deps: ['@mono/ui', '@mono/api-client'],
    files: ['App.tsx'],
    built: false,
    hash: null
  }
}

// --- Топологическая сортировка ---
function buildOrder(packages) {
  const sorted = []
  const visited = new Set()
  const inProgress = new Set()

  function visit(name) {
    if (visited.has(name)) return
    if (inProgress.has(name)) throw new Error('Circular dependency: ' + name)

    inProgress.add(name)
    const pkg = packages[name]

    for (const dep of pkg.deps) {
      if (!packages[dep]) throw new Error('Unknown dependency: ' + dep)
      visit(dep)
    }

    inProgress.delete(name)
    visited.add(name)
    sorted.push(name)
  }

  Object.keys(packages).forEach(visit)
  return sorted
}

// --- Определение "волн" (параллельная сборка) ---
function buildWaves(packages, order) {
  const waves = []
  const built = new Set()

  while (built.size < order.length) {
    const wave = order.filter(name =>
      !built.has(name) &&
      packages[name].deps.every(dep => built.has(dep))
    )
    if (wave.length === 0) break
    waves.push(wave)
    wave.forEach(name => built.add(name))
  }

  return waves
}

// --- Кэш сборки (как Turborepo) ---
class BuildCache {
  constructor() {
    this.cache = new Map()
    this.hits = 0
    this.misses = 0
  }

  _computeHash(pkgName, packages) {
    const pkg = packages[pkgName]
    // Хэш зависит от файлов и хэшей зависимостей
    const depHashes = pkg.deps.map(dep => packages[dep].hash || 'unbuilt').join(',')
    return pkgName + ':' + pkg.files.join(',') + ':' + depHashes
  }

  shouldRebuild(pkgName, packages) {
    const pkg = packages[pkgName]
    const newHash = this._computeHash(pkgName, packages)

    if (this.cache.get(pkgName) === newHash) {
      this.hits++
      return false  // не нужно пересобирать
    }

    this.misses++
    return true
  }

  markBuilt(pkgName, packages) {
    const hash = this._computeHash(pkgName, packages)
    this.cache.set(pkgName, hash)
    packages[pkgName].hash = hash
    packages[pkgName].built = true
  }

  get stats() {
    return {
      hits: this.hits,
      misses: this.misses,
      total: this.hits + this.misses,
      hitRate: this.hits + this.misses > 0
        ? Math.round(this.hits / (this.hits + this.misses) * 100) + '%'
        : '0%'
    }
  }
}

// --- Симуляция сборки ---
function build(packages, changedPackages = []) {
  const order = buildOrder(packages)
  const waves = buildWaves(packages, order)
  const cache = new BuildCache()

  // Симулируем изменение файлов
  changedPackages.forEach(name => {
    packages[name].hash = null  // инвалидируем кэш
  })

  console.log('Build plan:')
  waves.forEach((wave, i) => {
    console.log(`  Wave ${i + 1} (parallel): ${wave.join(', ')}`)
  })

  console.log('\nBuilding...')
  let rebuilt = 0
  let skipped = 0

  for (const wave of waves) {
    for (const pkgName of wave) {
      const needsRebuild = cache.shouldRebuild(pkgName, packages)
      if (needsRebuild) {
        console.log(`  [BUILD] ${pkgName}`)
        cache.markBuilt(pkgName, packages)
        rebuilt++
      } else {
        console.log(`  [SKIP]  ${pkgName} (cached)`)
        skipped++
      }
    }
  }

  return { rebuilt, skipped, waves: waves.length }
}

// --- Демонстрация ---

// Сбрасываем состояние
Object.values(packages).forEach(p => { p.built = false; p.hash = null })

console.log('=== Первая сборка (без кэша) ===')
const result1 = build(packages)
console.log(`\nResult: ${result1.rebuilt} rebuilt, ${result1.skipped} skipped`)

console.log('\n=== Вторая сборка (без изменений) ===')
// Сбрасываем флаги но хэши остались в packages
Object.values(packages).forEach(p => p.built = false)
const result2 = build(packages)
console.log(`\nResult: ${result2.rebuilt} rebuilt, ${result2.skipped} skipped`)

console.log('\n=== Третья сборка (изменился @mono/shared) ===')
Object.values(packages).forEach(p => p.built = false)
const result3 = build(packages, ['@mono/shared'])  // изменился shared
console.log(`\nResult: ${result3.rebuilt} rebuilt, ${result3.skipped} skipped`)
console.log('(shared изменился → все зависящие тоже пересобираются)')