Startseite / Artikel / Schritt für Schritt: Aufbau eines pnpm- und Turborepo-basierten Monorepos für Node.js-Anwendungen

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.

1702 Wörter

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 outputs auf, 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 packageManager sowie Tools wie ESLint hinzu

    Referenzen: Die Turborepo-Dokumentation, das Turborepo-Repository, die pnpm-Dokumentation sowie die Node.js-Dokumentation.