Turborepo monorepa: Setup, který skutečně škáluje
Jak strukturuji Turborepo monorepa pro více aplikací sdílejících balíčky. Konfigurace workspace, pipeline caching, sdílené TypeScript configy a chyby, které jsem udělal v prvním měsíci.
Prvních tři roky své kariéry jsem strávil kopírováním utilit mezi repozitáři. Nepřeháním. Měl jsem soubor formatDate.ts, který žil v sedmi různých projektech, každý s mírně odlišnými chybami. Když jsem opravil problém s časovým pásmem v jednom, zapomněl jsem na ostatních šest. Nakonec uživatel z Austrálie nahlásil ticket a já zjistil, že oprava se nikdy nedostala do produkce té konkrétní aplikace.
Monorepo to vyřešilo. Ne proto, že je to trendy, ne proto, že to dělá Google, ale protože mě přestalo bavit být lidským registrem balíčků. Jeden repozitář, sdílený kód, atomické změny napříč každou aplikací, která ho spotřebovává.
Ale monorepa mají své vlastní chybové stavy. Vyzkoušel jsem tři různé nástroje, ztratil jsem týdny na rozbitém cachování, bojoval jsem s chybami cyklických závislostí o půlnoci a naučil jsem se, že „prostě dej všechno do jednoho repa" je asi tak užitečná rada, jaká jen může být, bez detailů.
Tohle je ten Turborepo setup, který skutečně používám. Provozuje čtyři produkční aplikace s dvanácti sdílenými balíčky. Buildy trvají pod 90 sekund díky vzdálenému cachování. Noví vývojáři mohou klonovat a spustit pnpm dev a mít vše funkční za méně než dvě minuty. Trvalo mi asi měsíc chyb, než jsem se sem dostal.
Proč vůbec monorepo#
Buďme upřímní ohledně kompromisů. Monorepo není zadarmo. Vyměňujete jednu sadu problémů za jinou a potřebujete vědět, co kupujete.
Co získáte#
Sdílení kódu bez publikování. To je ta hlavní výhoda. Napíšete knihovnu React komponent v packages/ui. Vaše webová aplikace, admin dashboard a dokumentační stránka z ní všechny importují. Když změníte komponentu tlačítka, každá aplikace to okamžitě převezme. Žádné zvyšování verzí, žádný npm publish, žádné „jakou verzi vlastně používáme v produkci?"
// 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";Tyto importy vypadají jako externí balíčky. Resolvují se přes workspace závislosti. Ale ukazují na zdrojový kód ve stejném repozitáři.
Atomické změny napříč balíčky. Představte si, že potřebujete přidat prop isLoading do sdílené komponenty Button. Ve světě polyrepa byste změnili knihovnu komponent, publikovali novou verzi, aktualizovali verzi v každé konzumující aplikaci a pak otevřeli PR v každém repu. To je tři až pět PR pro jednu prop.
V monorepu je 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. Všechno zůstává synchronizované, protože fyzicky nemůže divergovat.
Sjednocené nástroje. Jedna ESLint konfigurace. Jedna Prettier konfigurace. Jedna základní TypeScript konfigurace. Jeden CI pipeline. Když upgradujete ESLint, upgradujete ho všude během jednoho odpoledne, ne během tří sprintů napříč osmi repozitáři.
Čím zaplatíte#
Složitost v kořenu. Váš package.json v kořenu se stane infrastrukturou. Váš CI pipeline potřebuje rozumět tomu, které balíčky se změnily. Vaše IDE může mít problémy s obrovským počtem souborů. Git operace se zpomalují, jak repozitář roste (i když u většiny týmů to trvá roky).
Čas CI se může nafouknout. Pokud buildujete vše na každém commitu, vyplýtváte obrovské množství výpočetního výkonu. Potřebujete nástroj, který rozumí grafu závislostí a builduje jen to, co se změnilo. To je celý důvod existence Turborepa.
Tření při onboardingu. Noví vývojáři potřebují pochopit workspaces, hoisting, interní balíčky, build pipeline. Není to jen „klonuj a spusť." No, nakonec by to tak mělo být, ale dostat se tam vyžaduje cílenou námahu.
Upřímné hodnocení: pokud máte jednu aplikaci bez sdíleného kódu, monorepo je režie bez přínosu. Pokud máte dvě nebo více aplikací, které sdílejí cokoliv — komponenty, utility, typy, konfigurace — monorepo se zaplatí během prvního měsíce.
Turborepo vs Nx vs Lerna#
Používal jsem všechny tři. Zde je stručná verze.
Lerna byl původní monorepo nástroj pro JavaScript. Spravuje verzování a publikování. Ale nerozumí build pipeline ani cachování. Byl opuštěný, oživený Nx, a nyní působí spíše jako kompatibilní vrstva než samostatný nástroj. Pokud potřebujete publikovat balíčky na npm, Lerna + Nx to zvládnou. Ale pro interní monorepa, kde jen sdílíte kód mezi vlastními aplikacemi, je to více ceremoniálu, než potřebujete.
Nx je mocný. Opravdu mocný. Má generátory, pluginy pro každý framework, vizuální graf závislostí, distribuované spouštění úloh. Má také křivku učení, která vypadá jako kolmá stěna. Viděl jsem týmy strávit dva týdny jen konfigurací Nx, než napsaly jakýkoliv produkční kód. Pokud jste ve firmě s 50+ vývojáři a stovkami balíčků, Nx je pravděpodobně správná volba. Pro mé použití je to buldozer, když potřebuji lopatu.
Turborepo dělá tři věci dobře: rozumí vašemu grafu závislostí, cachuje výstupy buildů a spouští úlohy paralelně. To je vše. Celá konfigurace je jeden soubor turbo.json. Můžete si ho celý přečíst za dvě minuty. Negeneruje kód, nemá pluginy, nesnaží se nahradit váš build nástroj. Je to task runner, který je velmi, velmi dobrý v tom, co potřebuje spustit a co může přeskočit.
Vybral jsem si Turborepo, protože jsem dokázal vysvětlit celý setup novému členovi týmu za 15 minut. S Nx ten samý rozhovor trval hodinu a stále měli otázky.
Feature | Turborepo | Nx | Lerna
------------------+---------------+-----------------+----------------
Config complexity | Low (1 file) | High (multiple) | Medium
Learning curve | ~1 day | ~1 week | ~2 days
Build caching | Yes (remote) | Yes (remote) | No (native)
Task orchestration| Yes | Yes | Basic
Code generation | No | Yes (extensive) | No
Framework plugins | No | Yes | No
Best for | Small-medium | Large orgs | Publishing
Struktura repozitáře#
Zde je skutečný adresářový strom. Není to příklad „první kroky" — takhle vypadá produkční monorepo po šesti měsících.
acme-monorepo/
├── apps/
│ ├── web/ # Main customer-facing app
│ │ ├── src/
│ │ ├── public/
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── admin/ # Internal admin dashboard
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── docs/ # Documentation site
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── api/ # Express/Fastify API server
│ ├── src/
│ ├── package.json
│ └── tsconfig.json
├── packages/
│ ├── ui/ # Shared React component library
│ │ ├── src/
│ │ │ ├── components/
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── Input.tsx
│ │ │ │ ├── Modal.tsx
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── utils/ # Shared utility functions
│ │ ├── src/
│ │ │ ├── date.ts
│ │ │ ├── string.ts
│ │ │ ├── validation.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── types/ # Shared TypeScript types
│ │ ├── src/
│ │ │ ├── user.ts
│ │ │ ├── api.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── config-typescript/ # Shared tsconfig presets
│ │ ├── base.json
│ │ ├── nextjs.json
│ │ ├── node.json
│ │ └── package.json
│ ├── config-eslint/ # Shared ESLint configurations
│ │ ├── base.js
│ │ ├── next.js
│ │ ├── node.js
│ │ └── package.json
│ └── config-tailwind/ # Shared Tailwind presets
│ ├── tailwind.config.ts
│ └── package.json
├── turbo.json
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json
Rozdělení apps/ vs packages/#
Toto je základní organizační princip. apps/ obsahuje nasaditelné věci — webové aplikace, API, cokoliv s příkazem dev nebo start. packages/ obsahuje knihovny — kód, který existuje, aby ho konzumovaly aplikace nebo jiné balíčky.
Pravidlo je jednoduché: Aplikace konzumují balíčky. Balíčky nikdy neimportují z aplikací. Balíčky mohou importovat z jiných balíčků.
Pokud balíček začne importovat z aplikace, máte graf závislostí pozpátku a Turborepo to zachytí jako cyklus.
Konfigurace workspace#
Kořenový soubor pnpm-workspace.yaml říká pnpm, kde hledat balíčky:
packages:
- "apps/*"
- "packages/*"To je celý soubor. pnpm proskenuje tyto adresáře, přečte každý package.json a vytvoří mapu workspace. Když apps/web deklaruje "@acme/ui": "workspace:*" jako závislost, pnpm ji propojí s lokálním packages/ui místo hledání v npm registru.
Kořenový package.json#
{
"name": "acme-monorepo",
"private": true,
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"test": "turbo test",
"clean": "turbo clean",
"format": "prettier --write \"**/*.{ts,tsx,md,json}\""
},
"devDependencies": {
"prettier": "^3.4.2",
"turbo": "^2.4.4",
"typescript": "^5.7.3"
},
"packageManager": "pnpm@9.15.4",
"engines": {
"node": ">=20"
}
}Všimněte si: kořenový package.json nemá žádné runtime závislosti. Je to čistě orchestrace. turbo je task runner, prettier zajišťuje formátování (protože je to jeden nástroj, který nepotřebuje konfiguraci per balíček) a typescript je hoistován, aby každý balíček používal stejnou verzi.
Konvence pojmenování#
Každý balíček dostane scoped jméno: @acme/ui, @acme/utils, @acme/config-typescript. Scope zabraňuje kolizím s npm balíčky a v jakémkoliv import příkazu je okamžitě zřejmé, zda používáte interní nebo externí kód.
// External package - from npm
import { clsx } from "clsx";
// Internal package - from our monorepo
import { Button } from "@acme/ui";Konfigurační balíčky prefixuji s config-, aby se vizuálně seskupily: @acme/config-typescript, @acme/config-eslint, @acme/config-tailwind. Některé týmy používají @acme/tsconfig, @acme/eslint-config. Obojí funguje. Jen buďte konzistentní.
Konfigurace pipeline v turbo.json#
Zde si Turborepo zaslouží své místo. Soubor turbo.json definuje váš task pipeline — jaké úlohy existují, na čem závisí a co produkují.
{
"$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
},
"clean": {
"cache": false
},
"type-check": {
"dependsOn": ["^build"],
"cache": true
}
}
}Pojďme rozebrat každé pole, protože tady se většina lidí ztrácí.
dependsOn a notace ^#
"dependsOn": ["^build"] znamená: „Před spuštěním této úlohy v balíčku X nejdříve spusť build v každém balíčku, na kterém X závisí."
Symbol ^ znamená „upstream závislosti." Bez něj by "dependsOn": ["build"] znamenalo „spusť build ve stejném balíčku nejdříve" — užitečné, pokud vaše úloha test potřebuje, aby se build provedl nejdříve ve stejném balíčku.
Zde je konkrétní příklad. Váš graf závislostí vypadá takto:
apps/web → @acme/ui → @acme/utils
→ @acme/types
Když spustíte turbo build, Turborepo vyřeší graf:
- Build
@acme/types(žádné závislosti) - Build
@acme/utils(žádné závislosti) - Build
@acme/ui(závisí na types a utils — čeká na ně) - Build
apps/web(závisí na ui — čeká na něj)
Kroky 1 a 2 běží paralelně. Krok 3 čeká na oba. Krok 4 čeká na 3. Turborepo to zjistí z vašich deklarací závislostí v package.json. Nemusíte pořadí specifikovat ručně.
outputs#
To je klíčové pro cachování. Když Turborepo cachuje build, ukládá soubory uvedené v outputs. Když dostane cache hit, obnoví tyto soubory. Pokud zapomenete uvést výstupní adresář, cache „uspěje", ale vaše soubory se neobjeví. To byla moje největší chyba v prvním týdnu — zapomněl jsem .next/** a nemohl jsem přijít na to, proč se moje Next.js aplikace stále přestavovala.
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]Vyloučení !.next/cache/** je důležité. Next.js má svou vlastní cache uvnitř .next/cache/. Nechcete ukládat cache cache — nafoukne to vaše vzdálené úložiště cache a nepomůže to.
inputs#
Ve výchozím nastavení Turborepo hashuje každý soubor v balíčku, aby zjistilo, zda se změnil. Pole inputs to zužuje. Pokud build výstup ovlivňují pouze soubory v src/, změna README.md by neměla invalidovat cache.
"inputs": ["src/**", "package.json", "tsconfig.json"]Buďte s tímto opatrní. Pokud přidáte konfigurační soubor, který ovlivňuje váš build (řekněme postcss.config.js) a nezahrnete ho do inputs, dostanete zastaralý cachovaný výstup. V případě pochybností nechte inputs nenastavené a nechte Turborepo hashovat vše.
globalDependencies#
Soubory uvedené zde invalidují cache pro každý balíček, když se změní. Soubory prostředí jsou klasickým příkladem — pokud se změní váš .env.local, vše, co by mohlo číst z process.env, se musí přestavět.
"globalDependencies": ["**/.env.*local"]Přidávám sem také tsconfig.json na kořenové úrovni, protože moje základní TypeScript konfigurace ovlivňuje všechny balíčky:
"globalDependencies": [
"**/.env.*local",
"tsconfig.json"
]persistent a dev#
Úloha dev má "cache": false a "persistent": true. Cachování dev serveru nedává smysl — je to dlouho běžící proces. Příznak persistent říká Turborepu, že tato úloha nekončí, takže by na ni nemělo čekat před spuštěním dalších úloh.
Když spustíte turbo dev, Turborepo nastartuje všechny dev servery paralelně — vaši Next.js aplikaci, váš API server, vaši dokumentační stránku — vše v jednom terminálu s prokládaným výstupem. Každý řádek má prefix s názvem balíčku, abyste je mohli rozlišit.
Sdílené balíčky v detailu#
packages/ui — Knihovna komponent#
Toto je balíček, který každý tým staví jako první. Sdílené React komponenty používané napříč všemi frontendovými aplikacemi.
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./styles.css": "./src/styles.css"
},
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --external react",
"dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch",
"clean": "rm -rf dist",
"lint": "eslint src/",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@acme/utils": "workspace:*",
"@acme/types": "workspace:*"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"@acme/config-eslint": "workspace:*",
"tsup": "^8.3.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
}Několik věcí k povšimnutí:
"version": "0.0.0" — Interní balíčky nepotřebují skutečné verze. Protokol workspace:* říká pnpm, aby vždy používal lokální kopii. Čísla verzí jsou irelevantní.
"private": true — Tento balíček se nikdy nebude publikovat na npm. Pokud ho budete chtít někdy publikovat, odstraňte tento příznak a nastavte řádnou strategii verzování.
Pole exports — Toto je moderní způsob definice vstupních bodů balíčku. Nahrazuje pole main, module a types. Export "." je výchozí importní cesta. Můžete přidat sub-path exporty pro granulární importy:
{
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./Button": {
"types": "./src/components/Button.tsx",
"default": "./src/components/Button.tsx"
},
"./styles.css": "./src/styles.css"
}
}Důležité rozhodnutí: buildovat nebo ne? Některé týmy přeskočí krok buildu pro interní balíčky úplně. Místo kompilace s tsup nasměrují exports přímo na TypeScript zdrojový kód a nechají bundler konzumující aplikace (Next.js, Vite) zvládnout kompilaci. V terminologii Turborepa se tomu říká „interní balíčky" a je to jednodušší:
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
}
}Žádný krok buildu. Žádná složka dist. Soubor next.config.ts konzumující aplikace potřebuje transpilePackages:
// apps/web/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@acme/ui", "@acme/utils"],
};
export default nextConfig;Pro většinu interních balíčků používám přístup bez buildu. Je rychlejší během vývoje a je to o jednu věc méně, co se může rozbít. Výjimkou jsou balíčky, které potřebují podporovat konzumenty bez TypeScriptu nebo mají složité požadavky na build.
packages/utils — Sdílené utility#
{
"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 exporty umožňují konzumujícím aplikacím importovat pouze to, co potřebují:
// 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";Barrel export v src/index.ts re-exportuje vše:
// packages/utils/src/index.ts
export * from "./date";
export * from "./string";
export * from "./validation";Upozornění ohledně barrel exportů: jsou pohodlné, ale mohou zabít tree-shaking u některých bundlerů. Pokud apps/web importuje jednu funkci z @acme/utils, naivní bundler může zahrnout vše. Next.js to zvládá dobře se svým vestavěným tree-shakingem, ale pokud si všimnete problémů s velikostí bundlu, vzor sub-path exportů je bezpečnější.
packages/types — Sdílené TypeScript typy#
{
"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"
}
}Tento balíček nemá žádné runtime závislosti. Obsahuje pouze TypeScript typy a rozhraní. Nikdy nepotřebuje být buildován, protože typy jsou odstraněny při kompilaci.
// 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;
}// 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 — Sdílené TSConfigy#
Tenhle je klamně důležitý. Konfigurace TypeScriptu v monorepu je místo, kde věci rychle začnou být komplikované.
{
"name": "@acme/config-typescript",
"version": "0.0.0",
"private": true,
"files": [
"base.json",
"nextjs.json",
"node.json",
"react-library.json"
]
}Základní konfigurace, kterou všechny balíčky rozšiřují:
// 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"]
}Konfigurace specifická pro Next.js:
// 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" }
]
}
}Konfigurace Node.js pro backendové balíčky:
// 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"
}
}Nyní každá aplikace nebo balíček rozšiřuje příslušný preset:
// 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"]
}// apps/api/tsconfig.json
{
"extends": "@acme/config-typescript/node",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}// packages/ui/tsconfig.json
{
"extends": "@acme/config-typescript/base",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}Alias cesty @/*#
Každá aplikace dostane stejný alias cesty @/* ukazující na svůj vlastní adresář src/. To znamená, že @/components/Header v apps/web se vyřeší na apps/web/src/components/Header a @/components/Header v apps/admin se vyřeší na apps/admin/src/components/Header.
Jsou to app-lokální cesty. Nepřekračují hranice balíčků. Pro cross-package importy vždy používáte název balíčku: @acme/ui, ne @/../../packages/ui. Pokud někdy vidíte relativní import, který jde nahoru za kořen balíčku (../../packages/something), je to code smell. Použijte raději workspace závislost.
composite a projektové reference#
Projektové reference TypeScriptu (composite: true) umožňují tsc buildovat balíčky inkrementálně a rozumět cross-projektovým závislostem. S Turborepem je to volitelné — Turborepo zvládá orchestraci buildu samo — ale může to zrychlit typovou kontrolu ve vašem IDE.
// packages/ui/tsconfig.json (with composite)
{
"extends": "@acme/config-typescript/base",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": ["src"],
"references": [
{ "path": "../types" },
{ "path": "../utils" }
]
}Budu upřímný: projektové reference přidávají složitost a z většiny svých setupů jsem je vyřadil. --filter a dependsOn v Turborepu už řeší pořadí buildu. Přidávám composite: true pouze tehdy, když se výkon IDE stane problémem (obvykle při 15+ balíčcích).
packages/config-eslint — Sdílený linting#
// 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" }
],
},
}
);// 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ždá aplikace odkazuje na svou konfiguraci:
// apps/web/eslint.config.mjs
import nextConfig from "@acme/config-eslint/next";
export default [
...nextConfig,
{
ignores: [".next/"],
},
];Vzdálené cachování#
Lokální cachování je fajn — zabraňuje přestavbě nezměněných balíčků během vývoje. Ale vzdálené cachování je transformativní. Znamená to, že váš CI server těží z vašich lokálních buildů a naopak.
Jak to funguje#
Když Turborepo spustí úlohu, vypočítá hash na základě:
- Zdrojových souborů (hash obsahu)
- Konfigurace
inputsúlohy - Proměnných prostředí
- Hashů upstream závislostí
Pokud hash odpovídá dříve cachovanému výsledku, Turborepo obnoví outputs z cache místo spouštění úlohy. Se vzdáleným cachováním jsou tyto cachované výstupy uloženy na sdíleném místě, ke kterému má přístup jakýkoliv stroj — váš notebook, notebook kolegy, CI.
Vzdálená cache Vercelu#
Nejjednodušší setup. Vercel spravuje infrastrukturu cache zdarma (s limity):
# Login to Vercel (one-time setup)
npx turbo login
# Link your repo to a Vercel project
npx turbo linkTo je vše. Turborepo nyní bude odesílat a stahovat artefakty cache ze serverů Vercelu. Na CI nastavíte proměnné prostředí:
# .github/workflows/ci.yml
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Token vygenerujte z dashboardu Vercelu v nastavení vašeho týmu. TURBO_TEAM je slug vašeho týmu.
Vlastní vzdálená cache#
Pokud nemůžete použít Vercel (air-gapped prostředí, požadavky na data residency), můžete si hostovat vlastní. API vzdálené cache Turborepa je dobře zdokumentované a existuje několik open-source implementací:
- ducktors/turborepo-remote-cache — Node.js server, který ukládá artefakty do S3, GCS nebo lokálního souborového systému
- fox1t/turborepo-remote-cache — Podobný, s podporou Azure Blob
# 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"Poté nakonfigurujte svůj monorepo, aby ji používal:
// .turbo/config.json
{
"teamId": "team_acme",
"apiUrl": "https://turbo-cache.internal.acme.com"
}Poměr úspěšnosti cache#
Podle mých zkušeností dobře nakonfigurované Turborepo dosahuje 80–95% poměru úspěšnosti cache v CI. To znamená, že pouze 5–20 % úloh se skutečně spustí na jakémkoliv daném CI pipeline. Zbytek je obnoven z cache za pár sekund.
Klíče k vysokému poměru úspěšnosti cache:
- Zúžte své
inputs. Pokud build ovlivňují pouze soubory vsrc/, nenechte změnu README invalidovat cache. - Nedávejte volatilní data do
globalDependencies. Soubor.env, který se mění při každém nasazení, zabíjí vaši cache. - Připněte své závislosti. Změny lockfilu invalidují vše downstream.
- Udržujte balíčky malé a zaměřené. Obří
packages/utilss 200 soubory bude mít cache invalidovanou často, protože jakákoliv změna jakékoliv utility zničí celý balíček.
CI/CD s Turborepem#
Zde je workflow pro GitHub Actions, který používám. Byl vyladěn přes desítky iterací.
# .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 testPříznak --filter#
Příznak --filter slouží ke spouštění úloh pro konkrétní balíčky nebo na základě toho, co se změnilo. Je to nezbytné pro efektivní CI:
# 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/*"Syntaxe tří teček je důležitá:
--filter=@acme/ui...— Balíček a všechny jeho závislosti (věci, které potřebuje)--filter=...@acme/ui— Balíček a všechny jeho dependenty (věci, které ho potřebují)--filter=@acme/ui— Pouze samotný balíček
Detekce ovlivněných balíčků#
Pro pull requesty pravděpodobně nechcete buildovat vše. Chcete buildovat pouze to, co je ovlivněno změnami:
# 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]"Syntaxe [origin/main] říká Turborepu, aby porovnalo aktuální commit s origin/main a spouštělo úlohy pouze pro balíčky, které mají změny. V kombinaci se vzdáleným cachováním většina PR pipeline skončí za méně než 2 minuty i pro velká monorepa.
Maticová strategie pro nasazení#
Pokud se každá aplikace nasazuje nezávisle, použijte maticovou strategii:
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ždý maticový job builduje jednu aplikaci a její závislosti. Díky vzdálenému cachování jsou sdílené balíčky buildovány jednou a cachovány — každý následující maticový job je obnoví z cache.
Časté úskalí#
Této sekci věnuji celou kapitolu, protože jsem narazil na každý jeden z těchto problémů v prvním měsíci. Učit se z dokumentace je efektivní. Učit se z mých chyb je zadarmo.
1. Cyklické závislosti#
Graf závislostí musí být DAG (directed acyclic graph — orientovaný acyklický graf). Pokud @acme/ui importuje z @acme/utils a @acme/utils importuje z @acme/ui, máte cyklus. Turborepo si bude stěžovat a odmítne buildovat.
Oprava je téměř vždy extrahovat sdílený kód do třetího balíčku:
BEFORE (broken):
@acme/ui → @acme/utils → @acme/ui ← cycle!
AFTER (fixed):
@acme/ui → @acme/types
@acme/utils → @acme/types
Přesuňte sdílené typy nebo konstanty do @acme/types a nechte oba balíčky na něm záviset.
Další častá příčina: vložíte React hook do @acme/utils, který importuje komponentu z @acme/ui. Hooky, které závisí na UI komponentách, patří do @acme/ui, ne do @acme/utils. Utility by měly být pokud možno framework-agnostické.
2. Chybějící outputs invalidující cache#
Toto je nejfrustrujúcnější chyba. Vše zdánlivě funguje lokálně. CI buildy procházejí. Ale každý build trvá plnou dobu, protože nic není cachováno.
Scénář: váš balíček builduje do build/ místo dist/, ale váš turbo.json uvádí v outputs pouze dist/**:
// turbo.json
"build": {
"outputs": ["dist/**"] // but your package builds to build/
}Turborepo cachuje úlohu (protože úspěšně proběhla), ale neuloží žádné výstupní soubory. Příště dostane cache „hit" a neobnoví nic. Konzumující aplikace nemůže najít výstup buildu a selže, nebo — hůře — tiše použije zastaralé soubory.
Oprava: proveďte audit výstupního adresáře buildu každého balíčku a ujistěte se, že turbo.json pokrývá všechny:
"build": {
"outputs": ["dist/**", "build/**", ".next/**", "!.next/cache/**"]
}Nebo použijte per-package přepsání v turbo.json:
{
"tasks": {
"build": {
"outputs": ["dist/**"]
}
},
// Per-package overrides
"packages": {
"apps/web": {
"build": {
"outputs": [".next/**", "!.next/cache/**"]
}
},
"apps/api": {
"build": {
"outputs": ["build/**"]
}
}
}
}3. Koordinace dev serverů#
Spuštění turbo dev nastartuje všechny dev servery současně. To je v pořádku, dokud se vaše aplikace nepokusí použít stejný port. Explicitní přiřazení portů je povinné:
// 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
}Další problém: hot module replacement pro sdílené balíčky. Když upravíte soubor v packages/ui, změna se musí propagovat do běžícího Next.js dev serveru. Pokud používáte přístup „interních balíčků" (žádný krok buildu, přímé TypeScript importy), funguje to automaticky — Next.js sleduje zdrojové soubory přes symlinkované node_modules.
Pokud má váš balíček krok buildu, potřebujete, aby dev skript balíčku běžel v režimu sledování:
// packages/ui/package.json
"scripts": {
"dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch"
}Turborepo spouští všechny dev úlohy paralelně, takže se balíček přestaví při změně a konzumující aplikace převezme nový výstup.
4. Drift verzí mezi balíčky#
Toto je nenápadné a nebezpečné. Pokud apps/web používá React 19 a apps/admin náhodou používá React 18, možná si toho nevšimnete, dokud se komponenta z @acme/ui nezačne chovat v každé aplikaci jinak.
Řešení: spravujte sdílené závislosti na kořenové úrovni. Pole overrides v pnpm v kořenovém package.json vynutí jednu verzi všude:
// Root package.json
{
"pnpm": {
"overrides": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
}
}
}Pravidelně spouštějte pnpm ls react --recursive pro ověření, že vše používá stejnou verzi.
5. Proměnné prostředí ve sdílených balíčcích#
Sdílené balíčky by neměly přímo číst process.env. Pokud @acme/utils čte process.env.API_URL, vytváří neviditelnou vazbu na prostředí konzumující aplikace. Místo toho předávejte konfiguraci explicitně:
// 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}`),
};
}Konzumující aplikace poskytne konfiguraci:
// apps/web/src/lib/api.ts
import { createApiClient } from "@acme/utils";
export const api = createApiClient(process.env.NEXT_PUBLIC_API_URL!);Tím udržíte balíčky čisté a testovatelné.
6. Fantomové závislosti#
pnpm je ve výchozím nastavení přísný ohledně závislostí — nehoistuje balíčky tak, jak to dělá npm. To je vlastnost, ne chyba. Znamená to, že pokud @acme/ui importuje clsx, ale neuvádí ho ve svém package.json, pnpm vyhodí chybu. npm by ho tiše vyřešil z nadřazeného node_modules.
Vždy deklarujte každý import v package.json konzumujícího balíčku. Žádné spoléhání na hoisting.
7. Degradace výkonu IDE#
S 15+ balíčky může váš TypeScript language server začít mít potíže. Několik tipů:
- Přidejte
"exclude": ["node_modules", "**/dist/**"]do kořenovéhotsconfig.json - Použijte „Files: Exclude" ve VS Code pro skrytí složek
dist/,.next/a.turbo/ - Zvažte
"disableSourceOfProjectReferenceRedirect": truev tsconfig, pokud vás Go to Definition posílá na.d.tssoubory hluboko vnode_modules
Startovací struktura: vše pohromadě#
Pojďme to všechno dát dohromady. Zde je každý soubor, který potřebujete pro bootstrap Turborepo monorepa se dvěma Next.js aplikacemi a třemi sdílenými balíčky.
Kořen#
// package.json
{
"name": "acme-monorepo",
"private": true,
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"test": "turbo test",
"type-check": "turbo type-check",
"clean": "turbo clean",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\""
},
"devDependencies": {
"prettier": "^3.4.2",
"turbo": "^2.4.4",
"typescript": "^5.7.3"
},
"packageManager": "pnpm@9.15.4",
"engines": {
"node": ">=20"
},
"pnpm": {
"overrides": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
}# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [
"**/.env.*local",
"tsconfig.json"
],
"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",
"vitest.config.ts"
]
},
"type-check": {
"dependsOn": ["^build"],
"cache": true
},
"dev": {
"cache": false,
"persistent": true
},
"clean": {
"cache": false
}
}
}// tsconfig.json (root — for IDE only, not used by builds)
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"exclude": ["node_modules", "**/dist/**", "**/.next/**"]
}apps/web#
// apps/web/package.json
{
"name": "web",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf .next"
},
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/utils": "workspace:*",
"@acme/types": "workspace:*",
"next": "^15.2.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"@acme/config-eslint": "workspace:*",
"@types/node": "^22.13.5",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"typescript": "^5.7.3",
"vitest": "^3.0.7"
}
}// apps/web/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@acme/ui", "@acme/utils"],
reactStrictMode: true,
};
export default nextConfig;// 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"]
}apps/admin#
// apps/admin/package.json
{
"name": "admin",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start --port 3001",
"lint": "next lint",
"type-check": "tsc --noEmit",
"clean": "rm -rf .next"
},
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/utils": "workspace:*",
"@acme/types": "workspace:*",
"next": "^15.2.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"@acme/config-eslint": "workspace:*",
"@types/node": "^22.13.5",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"typescript": "^5.7.3"
}
}packages/ui#
// packages/ui/package.json
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
},
"scripts": {
"lint": "eslint src/",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@acme/utils": "workspace:*",
"@acme/types": "workspace:*"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"@acme/config-eslint": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
}// packages/ui/src/index.ts
export { Button } from "./components/Button";
export { Input } from "./components/Input";
export { Modal } from "./components/Modal";
export { Card } from "./components/Card";
export { Badge } from "./components/Badge";
// Re-export types
export type { ButtonProps } from "./components/Button";
export type { InputProps } from "./components/Input";
export type { ModalProps } from "./components/Modal";// packages/ui/src/components/Button.tsx
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cn } from "@acme/utils";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "outline" | "ghost" | "destructive";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "primary", size = "md", isLoading, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(
"inline-flex items-center justify-center rounded-lg font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
{
"bg-blue-600 text-white hover:bg-blue-700": variant === "primary",
"bg-gray-100 text-gray-900 hover:bg-gray-200": variant === "secondary",
"border border-gray-300 bg-transparent hover:bg-gray-100": variant === "outline",
"bg-transparent hover:bg-gray-100": variant === "ghost",
"bg-red-600 text-white hover:bg-red-700": variant === "destructive",
},
{
"h-8 px-3 text-sm": size === "sm",
"h-10 px-4 text-sm": size === "md",
"h-12 px-6 text-base": size === "lg",
},
className
)}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<>
<svg
className="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{children}
</>
) : (
children
)}
</button>
);
}
);
Button.displayName = "Button";packages/utils#
// packages/utils/package.json
{
"name": "@acme/utils",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./date": "./src/date.ts",
"./string": "./src/string.ts",
"./cn": "./src/cn.ts"
},
"scripts": {
"lint": "eslint src/",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"clsx": "^2.1.1",
"tailwind-merge": "^3.0.1"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"typescript": "^5.7.3",
"vitest": "^3.0.7"
}
}// packages/utils/src/cn.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}// packages/utils/src/date.ts
export function formatRelativeDate(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSeconds < 60) return "just now";
if (diffMinutes < 60) return `${diffMinutes}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
export function formatDate(date: Date, locale = "en-US"): string {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
}// packages/utils/src/string.ts
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-");
}
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength).trimEnd() + "...";
}
export function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}// packages/utils/src/index.ts
export { cn } from "./cn";
export { formatRelativeDate, formatDate } from "./date";
export { slugify, truncate, capitalize } from "./string";packages/types#
// packages/types/package.json
{
"name": "@acme/types",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./user": "./src/user.ts",
"./api": "./src/api.ts"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"typescript": "^5.7.3"
}
}.gitignore#
# Dependencies
node_modules/
# Build outputs
dist/
build/
.next/
out/
# Turborepo
.turbo/
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/settings.json
.idea/
# OS
.DS_Store
Thumbs.db
Začínáme od nuly#
Pokud začínáte úplně od začátku, zde je přesná sekvence příkazů:
# Create the repo
mkdir acme-monorepo && cd acme-monorepo
git init
# Initialize the root package
pnpm init
# Create the workspace file
echo 'packages:\n - "apps/*"\n - "packages/*"' > pnpm-workspace.yaml
# Install Turborepo
pnpm add -D turbo -w
# Create directories
mkdir -p apps/web apps/admin
mkdir -p packages/ui/src packages/utils/src packages/types/src
mkdir -p packages/config-typescript packages/config-eslint
# Initialize apps (from their directories)
cd apps/web && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../admin && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../..
# Add workspace dependencies
cd packages/ui && pnpm add @acme/utils@workspace:* @acme/types@workspace:*
cd ../..
cd apps/web && pnpm add @acme/ui@workspace:* @acme/utils@workspace:* @acme/types@workspace:*
cd ../..
# Install everything
pnpm install
# Verify it works
pnpm turbo build
pnpm turbo devPrvní pnpm turbo build bude pomalý — vše se builduje od nuly. Druhý by měl být téměř okamžitý, pokud se nic nezměnilo. To je cache v akci.
Škálování za základy#
Jakmile se dostanete za počáteční setup, s rostoucím monorepem se objeví několik vzorů.
Generátory balíčků#
Po desátém balíčku se vytváření nového kopírováním a úpravami stává únavným. Vytvořte si skript:
// 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, "tsconfig.json"),
JSON.stringify(
{
extends: "@acme/config-typescript/base",
compilerOptions: {
outDir: "./dist",
rootDir: "./src",
},
include: ["src"],
exclude: ["node_modules", "dist"],
},
null,
2
)
);
writeFileSync(join(dir, "src", "index.ts"), "// Entry point\n");
console.log(`Created packages/${name}`);
console.log("Run: pnpm install");npx tsx scripts/create-package.ts email
# Creates packages/email with all the boilerplateVizualizace grafu závislostí workspace#
Turborepo má vestavěný příkaz pro graf:
pnpm turbo build --graph=graph.svgToto vygeneruje SVG vašeho grafu závislostí. Spouštím to před většími refaktoringy, abych pochopil dopadový radius změny. Pokud změna @acme/types přestaví 12 balíčků, možná je čas ji rozdělit na @acme/types-user a @acme/types-billing.
Ořezávání pro Docker#
Při nasazení jedné aplikace z monorepa nechcete kopírovat celý repozitář do vašeho Docker image. Turborepo má příkaz prune:
pnpm turbo prune --scope=apps/web --dockerToto vygeneruje adresář out/ obsahující pouze soubory potřebné pro apps/web a její závislosti. Váš Dockerfile používá tento ořezaný výstup:
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"]Docker image obsahuje pouze webovou aplikaci a její závislosti. Žádná admin aplikace, žádný API server, žádné nepoužívané balíčky. Velikosti image zůstávají rozumné, i když monorepo roste.
Po měsíci: co bych udělal jinak#
Kdybych začínal znovu s tím, co teď vím:
-
Začněte s interními balíčky (bez kroku buildu). Ztratil jsem dva týdny konfigurací tsup buildů pro balíčky, které spotřebovávaly pouze moje Next.js aplikace.
transpilePackagesv konfiguraci Next.js je jednodušší a rychlejší. Krok buildu přidejte teprve tehdy, když ho potřebujete. -
Držte počet
packages/zpočátku nízký. Příliš brzy jsem rozděloval příliš agresivně.packages/utils,packages/typesapackages/uina začátek stačí. Později můžete vždy rozdělit, když se balíček stane nezvladatelným. Předčasné rozdělení znamená více souborůpackage.jsonk údržbě a více hran ve vašem grafu závislostí. -
Nastavte vzdálené cachování od prvního dne.
npx turbo login && npx turbo linkzabere 30 sekund. Čas ušetřený na buildech jen v prvním týdnu to ospravedlní. -
Zdokumentujte příkazy workspace. Noví vývojáři nevědí, že
pnpm --filter @acme/ui add lodashinstaluje do konkrétního balíčku, nebo žepnpm turbo build --filter=apps/web...builduje jen to, co je potřeba. Jednoduchá sekce „Monorepo tahák" ve vašem contributing guide ušetří všem čas. -
Vynuťte směr závislostí od prvního dne. Pokud povolíte byť jen jeden import z aplikace do balíčku, hranice se rychle rozpadne. Přidejte lint pravidlo nebo CI kontrolu. Směr je
apps → packages → packages. Nikdy naopak.
Monorepo není cíl. Cílem je dodávat funkce bez boje s vlastním kódem. Turborepo je nejlehčí nástroj, který jsem našel pro to, aby to fungovalo. Zvládá task graf, zvládá cachování a u všeho ostatního vám nepřekáží.
Začněte jednoduše. Přidávejte složitost, až si to repozitář vyžádá. A připněte si porty.