Przejdź do treści
·19 min czytania

Monorepo z Turborepo: Konfiguracja, która naprawdę się skaluje

Jak strukturyzuję monorepo z Turborepo dla wielu aplikacji współdzielących pakiety. Konfiguracja workspace'ów, cachowanie pipeline'ów, współdzielone configi TypeScript i błędy z pierwszego miesiąca.

Udostępnij:X / TwitterLinkedIn

Pierwsze trzy lata kariery spędziłem na kopiowaniu funkcji utility między repozytoriami. Nie przesadzam. Miałem formatDate.ts, który żył w siedmiu różnych projektach, każdy z nieco innymi bugami. Kiedy naprawiłem problem ze strefą czasową w jednym, zapominałem o pozostałych sześciu. W końcu użytkownik z Australii zgłaszał ticket i odkrywałem, że fix nigdy nie trafił na produkcję w tej konkretnej aplikacji.

Monorepo to rozwiązało. Nie dlatego, że to modne, nie dlatego, że Google to robi, ale dlatego, że miałem dość bycia ludzkim rejestrem pakietów. Jedno repozytorium, współdzielony kod, atomowe zmiany we wszystkich aplikacjach, które go konsumują.

Ale monorepo mają swoje własne tryby awarii. Próbowałem trzech różnych narzędzi, zmarnowałem tygodnie na zepsutym cachowaniu, walczyłem z błędami cyklicznych zależności o północy i nauczyłem się, że "po prostu wrzuć wszystko do jednego repo" to rada równie użyteczna jak brak szczegółów.

To jest setup Turborepo, którego naprawdę używam. Obsługuje cztery produkcyjne aplikacje z dwunastoma współdzielonymi pakietami. Buildy zajmują poniżej 90 sekund dzięki zdalnemu cachowaniu. Nowi deweloperzy mogą sklonować i uruchomić pnpm dev i mieć wszystko działające w mniej niż dwie minuty. Dojście tutaj zajęło mi około miesiąca błędów.

Dlaczego w ogóle monorepo#

Bądźmy szczerzy co do kompromisów. Monorepo nie jest za darmo. Wymieniasz jeden zestaw problemów na inny i musisz wiedzieć, co kupujesz.

Co zyskujesz#

Współdzielenie kodu bez publikowania. To jest to duże. Piszesz bibliotekę komponentów React w packages/ui. Twoja aplikacja webowa, panel admina i strona dokumentacji — wszystkie z niej importują. Kiedy zmieniasz komponent przycisku, każda aplikacja to natychmiast podnosi. Bez bumpowania wersji, bez npm publish, bez "którą wersję produkcja teraz używa?"

typescript
// In apps/web/src/components/Header.tsx
import { Button, Avatar, Dropdown } from "@acme/ui";
import { formatDate } from "@acme/utils";
import { SITE_CONFIG } from "@acme/config";

Te importy wyglądają jak zewnętrzne pakiety. Rozwiązują się przez zależności workspace'ów. Ale wskazują na kod źródłowy w tym samym repozytorium.

Atomowe zmiany cross-package. Wyobraź sobie, że musisz dodać prop isLoading do współdzielonego komponentu Button. W świecie polyrepo zmieniłbyś bibliotekę komponentów, opublikował nową wersję, zaktualizował wersję w każdej konsumującej aplikacji, potem otworzył PR-y w każdym repo. To trzy do pięciu PR-ów na jeden prop.

W monorepo to jeden commit:

feat: add isLoading prop to Button component

- packages/ui/src/Button.tsx (add prop, render spinner)
- apps/web/src/pages/checkout.tsx (use isLoading)
- apps/admin/src/pages/users.tsx (use isLoading)

Jeden PR. Jedno review. Jeden merge. Wszystko jest zsynchronizowane, bo fizycznie nie może się rozjechać.

Zunifikowany tooling. Jeden config ESLint. Jeden config Prettier. Jeden bazowy config TypeScript. Jeden pipeline CI. Kiedy upgradujesz ESLint, upgradujesz go wszędzie w jedno popołudnie, nie przez trzy sprinty w ośmiu repozytoriach.

Czego to kosztuje#

Rozmiar repozytorium. Twoje repo jest większe. git clone jest wolniejszy. IDE indeksowanie jest wolniejsze. To realne koszty, ale są łagodzone przez shallow clones i sparse checkouts w CI.

Build complexity. Musisz zrozumieć, które pakiety zależą od których. Kiedy zmienisz packages/ui, które aplikacje wymagają przebudowania? Ta kwestia jest dokładnie powodem, dla którego Turborepo istnieje — zarządza grafem zależności i ponownie buduje tylko to, co się zmieniło.

Tooling lock-in. Kiedy zaczynasz z Turborepo (albo Nx, albo Lerna), struktura twojego projektu jest powiązana z tym narzędziem. Migracja między narzędziami do monorepo nie jest trywialna. Wybierz ostrożnie.

Governance. Kto jest właścicielem czego? Kto może mergować zmiany w packages/database? W polyrepo masz granice repozytoriów. W monorepo potrzebujesz CODEOWNERS i konwencji.

Kiedy NIE używać monorepo#

Nie kładź wszystkiego w monorepo, bo możesz. Monorepo ma sens, kiedy:

  • Wiele aplikacji współdzieli znaczącą ilość kodu
  • Chcesz atomowych zmian między pakietami
  • Masz zespół (nie freelancera), który korzysta ze wspólnego toolingu

Nie używaj monorepo, kiedy:

  • Twoje aplikacje są naprawdę niezależne (brak współdzielonego kodu)
  • Masz ścisłe granice bezpieczeństwa między projektami (monorepo = współdzielony dostęp)
  • Twoja organizacja nie może zainwestować w konfigurację CI/CD (monorepo CI jest bardziej złożone)

Struktura Turborepo#

Oto struktura katalogów, na której się ustabilizowałem. Przetrwała cztery wersje produkcyjne i jest jedyną, która nie sprawiała mi problemów:

my-monorepo/
├── apps/
│   ├── web/                  # Main web application (Next.js)
│   ├── admin/                # Admin dashboard (Next.js)
│   ├── docs/                 # Documentation site (Next.js)
│   └── api/                  # Backend API (Express/Fastify)
├── packages/
│   ├── ui/                   # Shared React components
│   ├── utils/                # Shared utility functions
│   ├── config-eslint/        # Shared ESLint configuration
│   ├── config-typescript/    # Shared TypeScript configs
│   ├── config-tailwind/      # Shared Tailwind configuration
│   ├── database/             # Database client, schemas, migrations
│   ├── auth/                 # Shared authentication logic
│   ├── email/                # Email templates and sending
│   ├── logger/               # Shared logging setup
│   ├── validation/           # Shared Zod schemas
│   ├── types/                # Shared TypeScript types
│   └── testing/              # Shared test utilities
├── turbo.json
├── package.json
├── pnpm-workspace.yaml
└── .github/
    └── workflows/
        └── ci.yml

Dwie najważniejsze decyzje:

apps/ vs packages/ — Aplikacje to rzeczy, które się deployują. Pakiety to współdzielone biblioteki, które aplikacje konsumują. To rozróżnienie wygląda oczywisto, ale widziałem ludzi kładących współdzielone pakiety w apps/ i zastanawiających się, dlaczego ich grafy zależności były szalone.

Pakiety configconfig-eslint, config-typescript, config-tailwind to pakiety, które eksportują pliki konfiguracyjne. To jest wzorzec, który powoduje, że konfiguracja jednego narzędzia działa identycznie w każdej aplikacji i pakiecie.

Konfiguracja workspace'ów z pnpm#

Używam pnpm. Nie npm, nie yarn. pnpm ma natywne wsparcie workspace'ów, jest szybszy i ma bardziej rygorystyczne rozwiązywanie zależności (co łapie problemy, zanim dotrą na produkcję).

Konfiguracja root workspace:

yaml
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

Root package.json:

json
{
  "name": "my-monorepo",
  "private": true,
  "scripts": {
    "dev": "turbo dev",
    "build": "turbo build",
    "lint": "turbo lint",
    "test": "turbo test",
    "format": "prettier --write \"**/*.{ts,tsx,md,mdx,json}\""
  },
  "devDependencies": {
    "turbo": "^2.4.0",
    "prettier": "^3.5.0"
  },
  "packageManager": "pnpm@9.15.0"
  }

Uwaga: root package.json nie ma żadnych zależności runtime. To czysta orkiestracja. turbo to task runner, prettier obsługuje formatowanie (bo to jedno narzędzie, które nie potrzebuje konfiguracji per-pakiet), a typescript jest podniesiony, żeby każdy pakiet używał tej samej wersji.

Konwencje nazewnictwa#

Każdy pakiet dostaje scoped name: @acme/ui, @acme/utils, @acme/config-typescript. Scope zapobiega kolizjom z pakietami npm i od razu widać w każdym imporcie, czy używasz wewnętrznego czy zewnętrznego kodu.

typescript
// External package - from npm
import { clsx } from "clsx";
 
// Internal package - from our monorepo
import { Button } from "@acme/ui";

Prefixuję pakiety config z config-, żeby grupowały się wizualnie: @acme/config-typescript, @acme/config-eslint, @acme/config-tailwind. Niektóre zespoły używają @acme/tsconfig, @acme/eslint-config. Oba podejścia działają. Bądź konsekwentny.

Każda aplikacja i pakiet mają swój własny package.json z zależnościami workspace'owymi:

json
{
  "name": "@acme/web",
  "private": true,
  "dependencies": {
    "@acme/ui": "workspace:*",
    "@acme/utils": "workspace:*",
    "@acme/database": "workspace:*",
    "next": "^15.0.0",
    "react": "^19.0.0"
  }
}

workspace:* mówi pnpm: "rozwiąż to do tego, co jest w monorepo, nie z npm registry". Kiedy budujesz do publishowania, pnpm automatycznie zamienia workspace:* na rzeczywiste numery wersji.

Konfiguracja pipeline'u turbo.json#

Tu Turborepo zarabia na swoje utrzymanie. Plik turbo.json definiuje twój pipeline tasków — jakie taski istnieją, od czego zależą i co produkują.

json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": [
    "**/.env.*local"
  ],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
      "inputs": ["src/**", "package.json", "tsconfig.json"]
    },
    "lint": {
      "dependsOn": ["^build"],
      "cache": true
    },
    "test": {
      "dependsOn": ["^build"],
      "cache": true,
      "inputs": ["src/**", "test/**", "**/*.test.ts", "**/*.test.tsx"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "type-check": {
      "dependsOn": ["^build"],
      "cache": true
    }
  }
}

Kluczowe koncepty:

dependsOn: ["^build"] — Daszek (^) oznacza "buduj zależności najpierw". Jeśli apps/web zależy od packages/ui, Turborepo buduje packages/ui zanim buduje apps/web. Bez daszka nie byłoby porządkowania — a próba zbudowania aplikacji przed jej zależnościami kończy się niezdefiniowanymi importami.

outputs — Mówi Turborepo, jakie pliki cache'ować. Dla Next.js to .next/**. Dla bibliotek to zwykle dist/**. Kiedy Turborepo widzi, że inputy się nie zmieniły, przywraca outputy z cache'u zamiast budować ponownie.

inputs — Zawęża to, co Turborepo uznaje za "zmienione". Jeśli tylko src/ wpływa na build, zmiana w README nie powinna inwalidować cache'u.

persistent: true — Dla dev, bo serwery deweloperskie działają w nieskończoność. Bez tego Turborepo czekałby, aż task się zakończy, zanim uruchomiłby zależne taski.

cache: false — Dla dev, bo nie chcesz cache'ować serwera deweloperskiego.

Pakiety — jak je strukturyzuję#

packages/ui — Współdzielone komponenty React#

json
{
  "name": "@acme/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts"
  }
}

Brak kroku budowania. Brak folderu dist. Konsumująca aplikacja potrzebuje transpilePackages w next.config.ts:

typescript
// apps/web/next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  transpilePackages: ["@acme/ui", "@acme/utils"],
};
 
export default nextConfig;

Używam podejścia bez builda dla większości wewnętrznych pakietów. Jest szybsze podczas developmentu i o jedną rzecz mniej do zepsucia. Wyjątkiem są pakiety, które muszą wspierać konsumentów bez TypeScripta lub mają złożone wymagania budowania.

packages/utils — Współdzielone funkcje utility#

json
{
  "name": "@acme/utils",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts",
    "./date": "./src/date.ts",
    "./string": "./src/string.ts",
    "./validation": "./src/validation.ts"
  },
  "dependencies": {
    "@acme/types": "workspace:*"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "typescript": "^5.7.3"
  }
}

Sub-path exports pozwalają konsumującym aplikacjom importować tylko to, czego potrzebują:

typescript
// Only imports date utilities, not the entire package
import { formatRelativeDate, parseISO } from "@acme/utils/date";
 
// Imports everything (barrel export)
import { formatRelativeDate, slugify, validateEmail } from "@acme/utils";

Słowo ostrzeżenia o barrel exports: są wygodne, ale mogą zabijać tree-shaking w niektórych bundlerach. Jeśli apps/web importuje jedną funkcję z @acme/utils, naiwny bundler może załączyć wszystko. Next.js radzi sobie z tym dobrze dzięki wbudowanemu tree-shakingowi, ale jeśli zauważysz problemy z rozmiarem bundle'a, wzorzec sub-path exports jest bezpieczniejszy.

packages/types — Współdzielone typy TypeScript#

json
{
  "name": "@acme/types",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts",
    "./api": "./src/api.ts",
    "./user": "./src/user.ts"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "typescript": "^5.7.3"
  }
}

Ten pakiet ma zero zależności runtime. Zawiera tylko typy i interfejsy TypeScript. Nigdy nie musi być budowany, bo typy są usuwane w czasie kompilacji.

typescript
// packages/types/src/user.ts
export interface User {
  id: string;
  email: string;
  name: string;
  role: "admin" | "user" | "viewer";
  createdAt: Date;
  updatedAt: Date;
}
 
export interface CreateUserInput {
  email: string;
  name: string;
  role?: User["role"];
}
 
export interface UserFilters {
  role?: User["role"];
  search?: string;
  page?: number;
  limit?: number;
}
typescript
// packages/types/src/api.ts
export interface ApiResponse<T> {
  data: T;
  meta?: {
    page: number;
    limit: number;
    total: number;
  };
}
 
export interface ApiError {
  code: string;
  message: string;
  details?: Record<string, string[]>;
}
 
export type ApiResult<T> =
  | { success: true; data: T }
  | { success: false; error: ApiError };

packages/config-typescript — Współdzielone configi TypeScript#

Ten jest zwodniczo ważny. Konfiguracja TypeScript w monorepo to miejsce, gdzie szybko robi się ciężko.

json
{
  "name": "@acme/config-typescript",
  "version": "0.0.0",
  "private": true,
  "files": [
    "base.json",
    "nextjs.json",
    "node.json",
    "react-library.json"
  ]
}

Bazowa konfiguracja, którą wszystkie pakiety rozszerzają:

json
// packages/config-typescript/base.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true
  },
  "exclude": ["node_modules"]
}

Config specyficzny dla Next.js:

json
// packages/config-typescript/nextjs.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "./base.json",
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "noEmit": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      { "name": "next" }
    ]
  }
}

Config Node.js dla pakietów backendowych:

json
// packages/config-typescript/node.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "./base.json",
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

Potem każda aplikacja lub pakiet rozszerza odpowiedni preset:

json
// apps/web/tsconfig.json
{
  "extends": "@acme/config-typescript/nextjs",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
json
// apps/api/tsconfig.json
{
  "extends": "@acme/config-typescript/node",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

Alias @/*#

Każda aplikacja dostaje ten sam alias @/* wskazujący na swój własny katalog src/. To oznacza, że @/components/Header w apps/web rozwiązuje się do apps/web/src/components/Header, a @/components/Header w apps/admin rozwiązuje się do apps/admin/src/components/Header.

To są ścieżki lokalne dla aplikacji. Nie przekraczają granic pakietów. Dla importów między pakietami zawsze używasz nazwy pakietu: @acme/ui, a nie @/../../packages/ui. Jeśli kiedykolwiek widzisz relatywny import, który wychodzi powyżej roota pakietu (../../packages/something), to jest code smell. Użyj zależności workspace zamiast tego.

composite i referencje projektów#

Referencje projektów TypeScript (composite: true) pozwalają tsc budować pakiety inkrementalnie i rozumieć zależności między projektami. Jest to opcjonalne z Turborepo — Turborepo sam obsługuje orkiestrację budowania — ale może przyspieszyć sprawdzanie typów w twoim IDE.

Będę szczery: referencje projektów dodają złożoność i usunąłem je z większości moich setupów. --filter i dependsOn w Turborepo już obsługują kolejność budowania. Dodaję composite: true tylko wtedy, gdy wydajność IDE staje się problemem (zwykle przy 15+ pakietach).

packages/config-eslint — Współdzielony linting#

javascript
// packages/config-eslint/base.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
 
export default tseslint.config(
  js.configs.recommended,
  ...tseslint.configs.recommended,
  {
    rules: {
      "@typescript-eslint/no-unused-vars": [
        "error",
        { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }
      ],
      "@typescript-eslint/no-explicit-any": "warn",
      "@typescript-eslint/consistent-type-imports": [
        "error",
        { prefer: "type-imports" }
      ],
    },
  }
);
javascript
// packages/config-eslint/next.js
import baseConfig from "./base.js";
import nextPlugin from "@next/eslint-plugin-next";
import reactPlugin from "eslint-plugin-react";
import hooksPlugin from "eslint-plugin-react-hooks";
 
export default [
  ...baseConfig,
  {
    plugins: {
      "@next/next": nextPlugin,
      "react": reactPlugin,
      "react-hooks": hooksPlugin,
    },
    rules: {
      ...nextPlugin.configs.recommended.rules,
      ...nextPlugin.configs["core-web-vitals"].rules,
      "react-hooks/exhaustive-deps": "warn",
    },
  },
];

Każda aplikacja odwołuje się do swojego configu:

javascript
// apps/web/eslint.config.mjs
import nextConfig from "@acme/config-eslint/next";
 
export default [
  ...nextConfig,
  {
    ignores: [".next/"],
  },
];

Zdalne cachowanie#

Lokalne cachowanie jest fajne — zapobiega ponownemu budowaniu niezmienonych pakietów podczas developmentu. Ale zdalne cachowanie jest transformatywne. Oznacza, że twój serwer CI korzysta z twoich lokalnych buildów i vice versa.

Jak to działa#

Kiedy Turborepo uruchamia task, oblicza hash na podstawie:

  • Plików źródłowych (content hash)
  • Konfiguracji inputs tasku
  • Zmiennych środowiskowych
  • Hashy upstream zależności

Jeśli hash pasuje do wcześniej cache'owanego wyniku, Turborepo przywraca outputs z cache'u zamiast uruchamiać task. Z zdalnym cachowaniem te cache'owane outputy są przechowywane we współdzielonej lokalizacji, do której każda maszyna — twój laptop, laptop kolegi, CI — ma dostęp.

Vercel Remote Cache#

Najprostszy setup. Vercel utrzymuje infrastrukturę cache za darmo (z limitami):

bash
# Login to Vercel (one-time setup)
npx turbo login
 
# Link your repo to a Vercel project
npx turbo link

To wszystko. Turborepo będzie teraz pushować i pullować artefakty cache'u z serwerów Vercel. Na CI ustawiasz zmienne środowiskowe:

yaml
# .github/workflows/ci.yml
env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Self-hosted remote cache#

Jeśli nie możesz używać Vercel (air-gapped environment, wymagania rezydencji danych), możesz hostować samodzielnie. API zdalnego cache Turborepo jest dobrze udokumentowane i istnieje kilka implementacji open-source:

bash
# docker-compose.yml for self-hosted cache
services:
  turbo-cache:
    image: ducktors/turborepo-remote-cache:latest
    ports:
      - "3000:3000"
    environment:
      TURBO_TOKEN: "your-secret-token"
      STORAGE_PROVIDER: "s3"
      S3_ACCESS_KEY: "${AWS_ACCESS_KEY_ID}"
      S3_SECRET_KEY: "${AWS_SECRET_ACCESS_KEY}"
      S3_REGION: "eu-central-1"
      S3_ENDPOINT: "https://s3.eu-central-1.amazonaws.com"
      S3_BUCKET: "turbo-cache"

Wskaźniki trafień cache'u#

Z mojego doświadczenia, dobrze skonfigurowane Turborepo osiąga 80-95% cache hit rates w CI. To oznacza, że tylko 5-20% tasków faktycznie się uruchamia w danym pipeline'ie CI. Reszta jest przywracana z cache'u w sekundy.

Klucze do wysokich cache hit rates:

  1. Zawęź swoje inputs. Jeśli tylko src/ wpływa na build, nie pozwól, żeby zmiana README inwalidowała cache.
  2. Nie wrzucaj zmiennych danych do globalDependencies. Plik .env zmieniający się przy każdym deploymencie zabija twój cache.
  3. Przypnij swoje zależności. Zmiany lockfile'a inwalidują wszystko downstream.
  4. Trzymaj pakiety małe i skupione. Gigantyczne packages/utils z 200 plikami będzie miało cache inwalidowany często, bo każda zmiana jakiejkolwiek utility rozbija cały pakiet.

Wpływ wydajności#

Oto prawdziwe liczby z mojego setupu:

ScenariuszBez remote cacheZ remote cache
Pełny build (CI, cold)4m 20s4m 20s
Pełny build (CI, po 1 zmianie pliku)4m 20s47s
Deweloper pull + build3m 10s32s
Deweloper po zmianie 1 pakietu1m 45s12s

"47 sekund" zamiast "4 minut 20 sekund" — to nie optymalizacja. To zmiana kategorii. Pipeline CI, który kończył się za 5 minut, teraz kończy się za minutę. Feedback loop deweloperski, który zabierał minuty, teraz jest prawie natychmiastowy.

CI/CD z Turborepo#

Oto workflow GitHub Actions, którego używam. Był dopracowywany przez dziesiątki iteracji.

yaml
# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}
 
jobs:
  ci:
    name: Build, Lint, Test
    runs-on: ubuntu-latest
    timeout-minutes: 15
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 2   # Need parent commit for change detection
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Build
        run: pnpm turbo build
 
      - name: Lint
        run: pnpm turbo lint
 
      - name: Type Check
        run: pnpm turbo type-check
 
      - name: Test
        run: pnpm turbo test

Flaga --filter#

Flaga --filter to sposób uruchamiania tasków dla konkretnych pakietów lub na podstawie tego, co się zmieniło. Jest niezbędna dla wydajnego CI:

bash
# Build only the web app and its dependencies
pnpm turbo build --filter=apps/web...
 
# Build only packages that changed since main
pnpm turbo build --filter=...[main]
 
# Build only @acme/ui and everything that depends on it
pnpm turbo build --filter=...@acme/ui
 
# Build only packages in the packages/ directory
pnpm turbo build --filter="./packages/*"

Składnia z trzema kropkami jest ważna:

  • --filter=@acme/ui... — Pakiet i wszystkie jego zależności (rzeczy, których potrzebuje)
  • --filter=...@acme/ui — Pakiet i wszyscy jego zależni (rzeczy, które go potrzebują)
  • --filter=@acme/ui — Tylko sam pakiet

Wykrywanie affected pakietów#

Dla pull requestów prawdopodobnie nie chcesz budować wszystkiego. Chcesz budować tylko to, co jest dotknięte przez zmiany:

yaml
# Only build/test what changed compared to main
- name: Build affected
  run: pnpm turbo build --filter="...[origin/main]"
 
- name: Test affected
  run: pnpm turbo test --filter="...[origin/main]"

Składnia [origin/main] mówi Turborepo, żeby porównał aktualny commit z origin/main i uruchomił taski tylko dla pakietów, które mają zmiany. W połączeniu ze zdalnym cachowaniem, większość pipeline'ów PR kończy się w mniej niż 2 minuty nawet dla dużych monorepo.

Strategia matrix dla deploymentów#

Jeśli każda aplikacja deployuje się niezależnie, użyj strategii matrix:

yaml
deploy:
  needs: ci
  runs-on: ubuntu-latest
  if: github.ref == 'refs/heads/main'
  strategy:
    matrix:
      app: [web, admin, docs, api]
  steps:
    - name: Checkout
      uses: actions/checkout@v4
 
    - name: Setup pnpm
      uses: pnpm/action-setup@v4
 
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: 20
        cache: "pnpm"
 
    - name: Install dependencies
      run: pnpm install --frozen-lockfile
 
    - name: Build
      run: pnpm turbo build --filter=apps/${{ matrix.app }}...
 
    - name: Deploy ${{ matrix.app }}
      run: ./scripts/deploy.sh ${{ matrix.app }}
      env:
        DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Każdy matrix job buduje jedną aplikację i jej zależności. Dzięki zdalnemu cachowaniu współdzielone pakiety są budowane raz i cache'owane — każdy następny matrix job przywraca je z cache'u.

Częste pułapki#

Poświęcam temu całą sekcję, bo trafiłem na każdą z nich w pierwszym miesiącu. Uczenie się z dokumentacji jest wydajne. Uczenie się z moich błędów jest darmowe.

1. Cykliczne zależności#

Graf zależności musi być DAG (directed acyclic graph). Jeśli @acme/ui importuje z @acme/utils i @acme/utils importuje z @acme/ui, masz cykl. Turborepo będzie narzekać i odmówi budowania.

Fix prawie zawsze polega na wyodrębnieniu współdzielonego kodu do trzeciego pakietu:

PRZED (zepsute):
@acme/ui → @acme/utils → @acme/ui  ← cykl!

PO (naprawione):
@acme/ui     → @acme/types
@acme/utils  → @acme/types

Przenieś współdzielone typy lub stałe do @acme/types i niech oba pakiety od niego zależą.

Inna częsta przyczyna: wrzuciłeś React hook do @acme/utils, który importuje komponent z @acme/ui. Hooki zależące od komponentów UI należą do @acme/ui, a nie do @acme/utils. Utility powinny być framework-agnostyczne, kiedy to możliwe.

2. Brakujące outputs inwalidujące cache#

To jest najbardziej frustrujący bug. Wszystko wydaje się działać lokalnie. CI buildy przechodzą. Ale każdy build zajmuje pełny czas, bo nic nie jest cache'owane.

Scenariusz: twój pakiet buduje do build/ zamiast dist/, ale twój turbo.json wymienia tylko dist/** w outputach:

json
// turbo.json
"build": {
  "outputs": ["dist/**"]  // but your package builds to build/
}

Turborepo cache'uje task (bo się udał), ale nie przechowuje żadnych plików wyjściowych. Następnym razem dostaje cache "hit" i przywraca nic. Konsumująca aplikacja nie może znaleźć outputu buildu i failuje, albo — co gorsza — cicho używa starych plików.

Fix: przejrzyj katalog wyjściowy każdego pakietu i upewnij się, że turbo.json je wszystkie pokrywa:

json
"build": {
  "outputs": ["dist/**", "build/**", ".next/**", "!.next/cache/**"]
}

3. Koordynacja serwera deweloperskiego#

Uruchomienie turbo dev startuje wszystkie serwery deweloperskie jednocześnie. To jest w porządku, dopóki twoje aplikacje nie spróbują użyć tego samego portu. Jawne przypisanie portów jest obowiązkowe:

json
// apps/web/package.json
"scripts": {
  "dev": "next dev --port 3000"
}
 
// apps/admin/package.json
"scripts": {
  "dev": "next dev --port 3001"
}
 
// apps/docs/package.json
"scripts": {
  "dev": "next dev --port 3002"
}
 
// apps/api/package.json
"scripts": {
  "dev": "tsx watch src/index.ts"  // Uses port 4000 from config
}

Inny problem: hot module replacement dla współdzielonych pakietów. Kiedy edytujesz plik w packages/ui, zmiana musi się propagować do działającego serwera deweloperskiego Next.js. Jeśli używasz podejścia "internal packages" (bez build stepu, bezpośrednie importy TypeScript), to działa automatycznie — Next.js obserwuje pliki źródłowe przez symlink w node_modules.

4. Dryft wersji między pakietami#

To jest subtelne i niebezpieczne. Jeśli apps/web używa React 19, a apps/admin przypadkowo używa React 18, możesz tego nie zauważyć, dopóki komponent z @acme/ui nie zacznie się zachowywać inaczej w każdej aplikacji.

Rozwiązanie: zarządzaj współdzielonymi zależnościami na poziomie roota. Pole overrides pnpm w root package.json wymusza jedną wersję wszędzie:

json
// Root package.json
{
  "pnpm": {
    "overrides": {
      "react": "^19.0.0",
      "react-dom": "^19.0.0",
      "typescript": "^5.7.3"
    }
  }
}

Regularnie uruchamiaj pnpm ls react --recursive, żeby weryfikować, że wszystko używa tej samej wersji.

5. Zmienne środowiskowe we współdzielonych pakietach#

Współdzielone pakiety nie powinny czytać process.env bezpośrednio. Jeśli @acme/utils czyta process.env.API_URL, tworzy niewidoczne sprzężenie z środowiskiem konsumującej aplikacji. Zamiast tego przekazuj konfigurację jawnie:

typescript
// BAD: packages/utils/src/api.ts
const API_URL = process.env.API_URL;  // What env? Which app?
 
export function fetchData(path: string) {
  return fetch(`${API_URL}${path}`);
}
 
// GOOD: packages/utils/src/api.ts
export function createApiClient(baseUrl: string) {
  return {
    fetch: (path: string) => fetch(`${baseUrl}${path}`),
  };
}

Konsumująca aplikacja dostarcza konfigurację:

typescript
// apps/web/src/lib/api.ts
import { createApiClient } from "@acme/utils";
 
export const api = createApiClient(process.env.NEXT_PUBLIC_API_URL!);

To utrzymuje pakiety czyste i testowalne.

6. Ghost dependencies#

pnpm jest domyślnie rygorystyczny co do zależności — nie podnosi pakietów tak jak npm. To jest feature, nie bug. Oznacza to, że jeśli @acme/ui importuje clsx, ale nie wymienia go w swoim package.json, pnpm rzuci błąd. npm cicho by to rozwiązał z parent node_modules.

Zawsze deklaruj każdy import w package.json konsumującego pakietu. Żadnego polegania na hoistingu.

7. Degradacja wydajności IDE#

Z 15+ pakietami twój language server TypeScript może zacząć mieć problemy. Kilka wskazówek:

  • Dodaj "exclude": ["node_modules", "**/dist/**"] do root tsconfig.json
  • Użyj "Files: Exclude" w VS Code, żeby ukryć foldery dist/, .next/ i .turbo/
  • Rozważ "disableSourceOfProjectReferenceRedirect": true w tsconfig, jeśli Go to Definition wysyła cię do plików .d.ts głęboko w node_modules

Skalowanie ponad podstawy#

Kiedy przejdziesz początkową konfigurację, kilka wzorców pojawia się w miarę jak monorepo rośnie.

Generatory pakietów#

Po dziesiątym pakiecie tworzenie nowego przez kopiowanie i edycję robi się nużące. Stwórz skrypt:

typescript
// scripts/create-package.ts
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
 
const name = process.argv[2];
if (!name) {
  console.error("Usage: tsx scripts/create-package.ts <package-name>");
  process.exit(1);
}
 
const dir = join(process.cwd(), "packages", name);
mkdirSync(join(dir, "src"), { recursive: true });
 
writeFileSync(
  join(dir, "package.json"),
  JSON.stringify(
    {
      name: `@acme/${name}`,
      version: "0.0.0",
      private: true,
      exports: { ".": "./src/index.ts" },
      scripts: {
        lint: "eslint src/",
        "type-check": "tsc --noEmit",
      },
      devDependencies: {
        "@acme/config-typescript": "workspace:*",
        typescript: "^5.7.3",
      },
    },
    null,
    2
  )
);
 
writeFileSync(join(dir, "src", "index.ts"), "// Entry point\n");
 
console.log(`Created packages/${name}`);
console.log("Run: pnpm install");
bash
npx tsx scripts/create-package.ts email
# Creates packages/email with all the boilerplate

Wizualizacja grafu zależności workspace#

Turborepo ma wbudowane polecenie grafowe:

bash
pnpm turbo build --graph=graph.svg

To generuje SVG twojego grafu zależności. Uruchamiam to przed dużymi refaktorami, żeby zrozumieć blast radius zmiany. Jeśli dotknięcie @acme/types przebudowuje 12 pakietów, może czas podzielić go na @acme/types-user i @acme/types-billing.

Pruning dla Dockera#

Kiedy deployujesz jedną aplikację z monorepo, nie chcesz kopiować całego repo do obrazu Docker. Turborepo ma polecenie prune:

bash
pnpm turbo prune --scope=apps/web --docker

To generuje katalog out/ zawierający tylko pliki potrzebne dla apps/web i jego zależności. Twój Dockerfile używa tego przyciętego outputu:

dockerfile
FROM node:20-alpine AS base
 
FROM base AS builder
WORKDIR /app
COPY out/json/ .
COPY out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN corepack enable pnpm && pnpm install --frozen-lockfile
 
COPY out/full/ .
RUN pnpm turbo build --filter=web...
 
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
 
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/public ./apps/web/public
 
EXPOSE 3000
CMD ["node", "apps/web/server.js"]

Obraz Docker zawiera tylko aplikację webową i jej zależności. Żadnej aplikacji admina, żadnego serwera API, żadnych nieużywanych pakietów. Rozmiary obrazów pozostają rozsądne nawet w miarę wzrostu monorepo.

Miesiąc później: Co zrobiłbym inaczej#

Gdybym zaczynał od nowa, wiedząc to, co wiem teraz:

  1. Zacznij od pakietów wewnętrznych (bez build stepu). Zmarnowałem dwa tygodnie na konfigurowanie buildów tsup dla pakietów, które konsumowały tylko moje aplikacje Next.js. transpilePackages w konfiguracji Next.js jest prostsze i szybsze. Dodaj build step dopiero, kiedy go potrzebujesz.

  2. Utrzymuj niską liczbę packages/ na początku. Podzieliłem zbyt agresywnie za wcześnie. packages/utils, packages/types i packages/ui wystarczą na start. Zawsze możesz podzielić później, kiedy pakiet stanie się nieporęczny. Przedwczesne dzielenie oznacza więcej plików package.json do utrzymania i więcej krawędzi w grafie zależności.

  3. Skonfiguruj zdalne cachowanie od pierwszego dnia. npx turbo login && npx turbo link zajmuje 30 sekund. Czas zaoszczędzony na buildach w samym pierwszym tygodniu to uzasadnia.

  4. Udokumentuj komendy workspace. Nowi deweloperzy nie wiedzą, że pnpm --filter @acme/ui add lodash instaluje do konkretnego pakietu, ani że pnpm turbo build --filter=apps/web... buduje tylko to, co potrzeba. Prosty "Monorepo Cheatsheet" w contributing guide oszczędza wszystkim czas.

  5. Egzekwuj kierunek zależności od pierwszego dnia. Jeśli pozwolisz choćby na jeden import z aplikacji do pakietu, granica szybko się rozmywa. Dodaj regułę lintingu albo check w CI. Kierunek to apps → packages → packages. Nigdy odwrotnie.

Monorepo nie jest celem. Celem jest shippowanie funkcji bez walki z własnym codebase'em. Turborepo to najlżejsze narzędzie, jakie znalazłem do tego, żeby to działało. Robi graf tasków, robi cachowanie i nie wchodzi ci w drogę przy reszcie.

Zacznij prosto. Dodawaj złożoność, kiedy repo tego wymaga. I przypnij swoje porty.

Powiązane wpisy