Schritt für Schritt: Aufbau eines pnpm- und Turborepo-basierten Monorepos für Node.js-Anwendungen
Erstellen Sie ein TypeScript-Monorepo aus einem leeren Ordner mithilfe von pnpm workspaces und Turborepo, und führen Sie anschließend Aufgaben für eine Webanwendung, eine API sowie gemeinsame Pakete aus, kompilieren und filtern Sie diese.
Sobald ein Produkt eine Frontend-, Backend-Struktur sowie entsprechenden Code benötigt, führen getrennte Repositorien zu Problemen: Gemeinsame Typen entwickeln sich auseinander, Konfigurationen werden manuell kopiert und eine einzige Änderung erfordert mehrere Pull Requests. pnpm Workspaces zusammen mit Turborepo lösen dieses Problem, ohne die Projekte in ein einziges zu verschmelzen. In dieser Anleitung entwickeln, kompilieren und verwalten Sie von einem einzigen Wurzelverzeichnis aus zwei TypeScript-Anwendungen sowie ein gemeinsames Paket.
Das fertige Layout:
my-monorepo/
├── apps/
│ ├── web/
│ └── api/
│
├── packages/
│ ├── types/
│ └── eslint-config/
│
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
├── tsconfig.json
└── pnpm-lock.yaml
Was ein Monorepo bietet
Ein Monorepo ist ein Git-Repository, das mehrere Anwendungen und Pakete enthält. Die Alternative besteht darin, ein eigenes Repository für jedes Anliegen zu verwenden:
frontend-repository
backend-repository
shared-types-repository
ui-library-repository
In einem Monorepo werden diese in Ordner unterteilt, wobei der für die Veröffentlichung bestimmte Code in apps und der wiederverwendbare Code in packages gespeichert wird:
my-monorepo/
├── apps/
│ ├── web/
│ └── api/
│
└── packages/
├── types/
└── ui/
Der Code wird anschließend direkt geteilt, anstatt erst veröffentlicht zu werden. Ein Types-Paket, das sowohl vom Frontend als auch vom Backend genutzt wird, bedeutet, dass Änderungen am Payload in einem einzigen Commit auf beide Seiten übertragen werden:
apps/web
↓
packages/types
↑
apps/api
Wo Turborepo hineinpasst
pnpm-Workspaces verknüpfen Pakete miteinander; Turborepo entscheidet, wie Aufgaben zwischen ihnen ausgeführt werden. Es bietet Task-Orchestrierung, abhängigkeitsbasierte Sortierung, parallele Ausführung, lokalen und entfernten Caching, inkrementelle Builds sowie Unterstützung für Workspaces.
Nehmen wir ein Repository mit drei Workspaces:
apps/web
apps/api
packages/types
Jeder kann dieselben Skripte definieren:
build
lint
test
dev
Anstatt jeden Verzeichnisordner in der richtigen Reihenfolge einzugeben, werden sie vom Wurzelverzeichnis aus ausgeführt und Turborepo parallelisiert, wo immer es möglich ist. Welche Situationen dafür geeignet sind, erfahren Sie unter wo Turborepo in einem NestJS Monorepo Platz findet und wann man es überspringen sollte.
Voraussetzungen
Ihr benötigen Node.js, pnpm, Git sowie einen Editor. Überprüfen Sie Node.js:
node -v
Und pnpm:
pnpm -v
Falls pnpm fehlt, kann Corepack, das mit Node.js geliefert wird, es bereitstellen. Aktivieren Sie es:
corepack enable
Aktivieren Sie die neueste Version von pnpm:
corepack prepare pnpm@latest --activate
Bestätigen Sie:
pnpm -v
Einrichtung des Wurzelarbeitsbereichs
Erstellen Sie das Verzeichnis:
mkdir my-monorepo
cd my-monorepo
Initialisieren Sie Git:
git init
Erstellen Sie das Wurzelmanifest:
pnpm init
Dadurch bleibt:
my-monorepo/
└── package.json
Installieren Sie Turborepo in der Wurzel
Turborepo bedient das gesamte Repository, weshalb --workspace-root es in der Wurzel statt in einem Paket installiert:
pnpm add turbo --save-dev --workspace-root
Das Wurzelmanifest enthält Skripte, die auf turbo run verweisen; private verhindert, dass die Wurzel veröffentlicht wird:
{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test"
},
"devDependencies": {
"turbo": "..."
}
}
Ihre turbo-Version hängt vom Installierzeitpunkt ab. Neuere Versionen erwarten außerdem ein packageManager-Feld im Wurzel-package.json; wenn turbo Ihren Paketmanager nicht erkennen kann, lesen Sie die Dokumentation.
Deklarieren Sie die Workspaces
pnpm findet Pakete über eine Wurzeldatei:
pnpm-workspace.yaml
Listen Sie die Glob-Angaben auf, die als Pakete behandelt werden sollen:
packages:
- "apps/*"
- "packages/*"
Jeder direkt darunterliegende Ordner wird zu einem Workspace:
apps/*
packages/*
Erstellen Sie die Verzeichnisse für beide Apps sowie das types-Paket:
mkdir -p apps/web
mkdir -p apps/api
mkdir -p packages/types
Der Baum bis jetzt:
my-monorepo/
├── apps/
│ ├── web/
│ └── api/
│
├── packages/
│ └── types/
│
├── package.json
└── pnpm-workspace.yaml
Hinzufügen der beiden Anwendungen
Die Web-App
Um die Aufmerksamkeit auf das Monorepo zu richten, ist die Web-App vorerst eine einfache Node.js-Anwendung. Geben Sie sie ein:
cd apps/web
Geben Sie ihr eine Manifest-Datei:
pnpm init
Die gewünschte Struktur:
apps/web/
├── package.json
└── src/
└── index.ts
Erstellen Sie die Eingabedatei:
mkdir src
touch src/index.ts
Fügen Sie einen Platzhalter hinzu:
console.log("Hello from Web application");
Jedes Arbeitsumfeld benötigt TypeScript, daher kehren Sie zum Wurzelverzeichnis zurück:
cd ../..
Und installieren Sie es einmal:
pnpm add typescript --save-dev --workspace-root
Die API
Dasselbe Muster: Eingabe und Initialisierung:
cd apps/api
pnpm init
Erstellen Sie die Eingabedatei:
mkdir src
touch src/index.ts
Fügen Sie ihren Platzhalter hinzu:
console.log("Hello from API application");
Sowohl Apps entsprechen nun einander:
apps/
├── web/
│ ├── src/
│ │ └── index.ts
│ └── package.json
│
└── api/
├── src/
│ └── index.ts
└── package.json
Teilen der TypeScript-Konfiguration
Zur Wurzel zurückkehren:
cd ../..
Eine Basiskonfiguration erstellen:
tsconfig.json
Diese enthält Optionen, die alle Arbeitsbereiche teilen: ein modernes Ziel, NodeNext-Lösung sowie strenge Überprüfungen:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
Jede Anwendung erweitert diese und fügt nur lokale Einstellungen hinzu. Für die Webanwendung erstellen Sie:
apps/web/tsconfig.json
Diese weist auf die Wurzeldatei hin, legt ein Ausgabeverzeichnis fest und kompiliert nur src:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}
Die API erhält dieselbe Datei:
apps/api/tsconfig.json
Mit identischem Inhalt:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}
Änderungen bezüglich Strenge oder Ziel finden nun an einem Ort statt.
Erlauben von Build-Skripten für jeden Arbeitsbereich
Turborepo führt die von den Arbeitsbereichen definierten Skripte aus. Öffnen Sie das Web-Manifest:
apps/web/package.json
Legen Sie einen begrenzten Namen sowie drei Skripte fest: build über tsc, dev im Überwachungsmodus und lint über ESLint:
{
"name": "@repo/web",
"private": true,
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"lint": "eslint ."
}
}
Dann das API-Manifest:
apps/api/package.json
Mit eigenem Namen:
{
"name": "@repo/api",
"private": true,
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"lint": "eslint ."
}
}
Filter und Abhängigkeiten des Arbeitsbereichs beziehen sich auf diese @repo/...-Namen. Das dev-Skript benötigt tsx:
pnpm add tsx --save-dev --workspace-root
Auch das lint-Skript setzt voraus, dass ESLint installiert und konfiguriert ist; fügen Sie es hinzu oder entfernen Sie das Skript, sonst wird pnpm lint fehlschlagen.
Konfigurieren der Task-Pipeline
turbo.json beschreibt, wie sich jede Aufgabe verhält. Erstellen Sie es im Wurzelverzeichnis:
turbo.json
Definieren Sie die Aufgaben:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
}
}
}
Was zu beachten ist:
"dependsOn": ["^build"]lässt ein Paket auf die Builds der von ihm abhängigen Arbeitsbereichspakete warten; das Bindestrichzeichen steht für Abhängigkeiten.
outputs listet auf, was gespeichert und wiederhergestellt werden soll, sodass unveränderte Pakete nicht neu erstellt werden.dev ist ungespeichert und persistent, weil ein Watcher niemals beendet wird.lint führt zunächst auch die Abhängigkeiten aus.tasks ist der aktuelle Schlüssel; ältere Versionen verwendeten pipeline, weshalb ältere Beispiele angepasst werden müssen.
Ausführen und Kompilieren von der Wurzel aus
Starten Sie alle Entwicklungsprozesse:
pnpm dev
Das funktioniert, weil das Wurzel-dev-Skript folgendes ist:
"dev": "turbo run dev"
Turborepo findet dev in jedem Arbeitsbereich und startet sie gemeinsam, wobei ein Terminal für die Webanwendung verwendet wird:
cd apps/web
pnpm dev
Und ein weiteres für die API:
cd apps/api
pnpm dev
Mit einem einzigen Befehl von der Wurzel aus:
pnpm dev
Alles wird auf dieselbe Weise kompiliert:
pnpm build
Turborepo ordnet die Kompilierungen nach den Paketabhängigkeiten an, wobei gemeinsam genutzte Pakete zuerst kommen:
pnpm build
│
▼
turbo run build
│
├── packages/types
│
├── apps/api
│
└── apps/web
Diese Reihenfolge ergibt sich aus den deklarierten Abhängigkeiten: Solange packages/types kein package.json besitzt und die Anwendungen nicht davon abhängen, wird Turborepo es nicht zuerst kompilieren.
Zielen auf einen einzelnen Workspace
pnpms --filter führt ein Skript in einem Workspace aus, wie zum Beispiel den API-Entwicklungsserver:
pnpm --filter @repo/api dev
Oder eine Web-Kompilierung:
pnpm --filter @repo/web build
Turborepos eigener Filter sorgt für Caching und Sortierung:
pnpm turbo run build --filter=@repo/api
Befehle, die Sie täglich verwenden werden
Alles installieren:
pnpm install
Entwicklung starten:
pnpm dev
Alles kompilieren:
pnpm build
Alles linten:
pnpm lint
Ein Paket kompilieren:
pnpm --filter @repo/api build
Eine Anwendung ausführen:
pnpm --filter @repo/web dev
Eine Abhängigkeit in einen Workspace hinzufügen:
pnpm --filter @repo/api add express
Abhängigkeit von einem lokalen Paket; workspace:* verknüpft stattdessen eine Kopie des Repositories, anstatt aus dem Registry-Server zu laden:
pnpm --filter @repo/api add @repo/types@workspace:*
Warum nicht bei pnpm Workspaces bleiben?
Man könnte sich allein auf Workspaces verlassen:
apps/
packages/
Doch je größer das Repository wird, desto mehr muss man manuell koordinieren:
build
test
lint
typecheck
dev
dependencies
task ordering
caching
Turborepo bietet Orchestrierungsfunktionen: Ein Befehl versteht die Beziehungen zwischen Paketen, überspringt unveränderte Arbeitsschritte und führt unabhängige Aufgaben parallel aus:
pnpm turbo run build
Für eine Anwendung und ein Paket reichen einfache Workspaces möglicherweise aus. Wählen Sie immer noch einen Paketmanager? Schauen Sie sich unseren Vergleich von npm und pnpm an.
Zusammenfassung
Ein gutes Monorepo ist eine gemeinsame Umgebung, in der Anwendungen und Pakete unter einem Toolset entwickelt werden. Der hier verwendete Stack ist überschaubar:
pnpm
+
Turborepo
+
TypeScript
Beginnen Sie mit zwei Apps:
apps/
├── web/
└── api/
Erweitern Sie sie zu mehr Diensten:
apps/
├── web/
├── admin/
├── api/
└── worker/
Unterstützt durch gemeinsame Pakete:
packages/
├── ui/
├── types/
├── database/
├── auth/
└── utils/
Der Vorteil besteht darin, Code, Typen, Konfigurationen und Arbeitsabläufe zu teilen, während jede App unabhängig organisiert bleibt. Wenn Sie sie erweitern:
- deklarieren Sie lokale Abhängigkeiten mit
workspace:*, damit die Kompilierungen korrekt geordnet werden - listet man jedes Artefakt in
outputsauf, sonst werden Ladevorgänge davon ausgeschlossen - halten Sie die Basiskonfiguration im Wurzelverzeichnis und erweitern Sie sie
- fügen Sie vor der Verwendung von Skripten in der CI einen
packageManagersowie Tools wie ESLint hinzu
Referenzen: Die Turborepo-Dokumentation, das Turborepo-Repository, die pnpm-Dokumentation sowie die Node.js-Dokumentation.