Accueil / Articles / Mettre en place un monorepo pnpm et Turborepo pour les applications Node.js, étape par étape

Mettre en place un monorepo pnpm et Turborepo pour les applications Node.js, étape par étape

Créez un monorepo TypeScript à partir d’un dossier vide en utilisant pnpm workspaces et Turborepo, puis exécutez, compilez et effectuez des tâches de filtrage pour une application web, une API ainsi que des packages partagés.

1702 mots

Lorsqu’un produit dispose à la fois d’une interface utilisateur, d’un backend et de code, l’utilisation de répertoires séparés devient problématique : les types partagés évoluent de manière incohérente, la configuration est copiée manuellement et une seule modification nécessite plusieurs demandes de fusion. pnpm workspaces associés à Turborepo résolvent ce problème sans devoir fusionner les projets en un seul. Cette démarche guide l’utilisateur depuis un répertoire vide jusqu’à la création de deux applications TypeScript ainsi qu’un package partagé, que l’on peut développer, compiler et gérer depuis une même racine.

Le schéma final :

my-monorepo/
├── apps/
│   ├── web/
│   └── api/
│
├── packages/
│   ├── types/
│   └── eslint-config/
│
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
├── tsconfig.json
└── pnpm-lock.yaml

Ce qu’un monorepo vous offre

frontend-repository
backend-repository
shared-types-repository
ui-library-repository

Dans un monorepo, ces éléments prennent la forme de dossiers, avec du code destiné au déploiement sous apps et du code réutilisable sous packages :

my-monorepo/
├── apps/
│   ├── web/
│   └── api/
│
└── packages/
    ├── types/
    └── ui/

Le code est alors partagé directement au lieu d’être publié d’abord. Un package de types consommé à la fois par le frontend et le backend signifie qu’un changement dans le payload atteint les deux parties en une seule mise à jour :

apps/web
      ↓
packages/types
      ↑
apps/api

Le rôle de Turborepo

Les espaces de travail pnpm relient les packages ; Turborepo décide de la manière dont les tâches s’exécutent entre eux. Il offre une orchestration des tâches, un ordre déterminé en fonction des dépendances, une exécution parallèle, un cache local et distant, des builds incrémentaux ainsi que du support pour les espaces de travail.

Prenons un répertoire contenant trois espaces de travail :

apps/web
apps/api
packages/types

Chacun peut définir les mêmes scripts :

build
lint
test
dev

Au lieu d’entrer dans chaque répertoire dans le bon ordre, vous les exécutez depuis la racine et Turborepo parallélise ce qui est possible. Pour savoir quand cette approche est utile, consultez où Turborepo s’intègre dans un monorepo NestJS et quand l’ignorer.

Prérequis

node -v

Et pnpm :

pnpm -v

Si pnpm est manquant, Corepack, inclus avec Node.js, peut le fournir. Activez-le :

corepack enable

Activez la dernière version de pnpm :

corepack prepare pnpm@latest --activate

Vérifiez :

pnpm -v

Mise en place de l’espace de travail racine

Créez le répertoire :

mkdir my-monorepo
cd my-monorepo

Initialisez Git :

git init

Générez le manifeste racine :

pnpm init

Cela laisse :

my-monorepo/
└── package.json

Installez Turborepo à la racine

Turborepo gère l’ensemble du répertoire, donc --workspace-root l’installe à la racine plutôt que dans un package :

pnpm add turbo --save-dev --workspace-root

Le manifeste de la racine contient des scripts qui font appel à turbo run ; l’attribut private empêche la publication de la racine :

{
  "name": "my-monorepo",
  "private": true,
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint",
    "test": "turbo run test"
  },
  "devDependencies": {
    "turbo": "..."
  }
}

Votre version de turbo dépend du moment de l’installation. Les versions récentes exigent également un champ packageManager dans le fichier package.json de la racine ; si turbo ne parvient pas à détecter votre gestionnaire de paquets, consultez la documentation.

Déclarez les espaces de travail

pnpm trouve les packages à travers un fichier de racine :

pnpm-workspace.yaml

Énumérez les chemins à traiter comme des packages :

packages:
  - "apps/*"
  - "packages/*"

Chaque dossier situé directement en dessous devient un espace de travail :

apps/*
packages/*

Créez les dossiers pour les deux applications ainsi que le package types :

mkdir -p apps/web
mkdir -p apps/api
mkdir -p packages/types

L’arborescence actuelle :

my-monorepo/
├── apps/
│   ├── web/
│   └── api/
│
├── packages/
│   └── types/
│
├── package.json
└── pnpm-workspace.yaml

Ajout des deux applications

L’application web

Afin de se concentrer sur le monorepo, l’application web reste pour l’instant une simple application Node.js. Entrez-la :

cd apps/web

Donnez-lui un manifeste :

pnpm init

La forme cible :

apps/web/
├── package.json
└── src/
    └── index.ts

Créez le fichier d’entrée :

mkdir src
touch src/index.ts

Ajoutez un placeholder :

console.log("Hello from Web application");

Chaque espace de travail a besoin de TypeScript, donc retournez au répertoire racine :

cd ../..

Et installez-le une fois :

pnpm add typescript --save-dev --workspace-root

L’API

Même schéma : entrez et initialisez :

cd apps/api
pnpm init

Créez le fichier d’entrée :

mkdir src
touch src/index.ts

Ajoutez son placeholder :

console.log("Hello from API application");
apps/
├── web/
│   ├── src/
│   │   └── index.ts
│   └── package.json
│
└── api/
    ├── src/
    │   └── index.ts
    └── package.json

Partage de la configuration TypeScript

Retourner à la racine :

cd ../..

Créer une configuration de base :

tsconfig.json

Celle-ci contient les options partagées par tous les espaces de travail : une cible moderne, une résolution NodeNext et des vérifications strictes :

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

Chaque application l’étend et n’ajoute que des paramètres locaux. Pour l’application web, créer :

apps/web/tsconfig.json

Celui-ci fait référence au fichier racine, définit un dossier de sortie et ne compile que le répertoire src :

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "dist"
  },
  "include": ["src"]
}

L’API obtient le même fichier :

apps/api/tsconfig.json

Avec un contenu identique :

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "dist"
  },
  "include": ["src"]
}

Les modifications de strictesse ou de cible ont désormais lieu en un seul endroit.

Fournir des scripts de compilation à chaque espace de travail

Turborepo exécute les scripts définis par les espaces de travail. Ouvrir le manifeste web :

apps/web/package.json

Definir un nom ciblé et trois scripts : build via tsc, dev en mode surveillance, lint via ESLint :

{
  "name": "@repo/web",
  "private": true,
  "scripts": {
    "build": "tsc",
    "dev": "tsx watch src/index.ts",
    "lint": "eslint ."
  }
}

Puis le manifeste de l’API :

apps/api/package.json

Avec son propre nom :

{
  "name": "@repo/api",
  "private": true,
  "scripts": {
    "build": "tsc",
    "dev": "tsx watch src/index.ts",
    "lint": "eslint ."
  }
}

Les filtres et les dépendances de l’espace de travail font référence à ces noms @repo/.... Le script dev a besoin de tsx:

pnpm add tsx --save-dev --workspace-root

Le script lint suppose également que ESLint est installé et configuré ; ajoutez-le ou supprimez le script, sinon pnpm lint échouera.

Configuration du pipeline de tâches

turbo.json décrit le comportement de chaque tâche. Créez-le dans la racine :

turbo.json

Définissez les tâches :

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^lint"]
    }
  }
}

À noter :

  • "dependsOn": ["^build"] fait en sorte qu’un paquet attende la compilation des paquets de l’espace de travail sur lesquels il dépend ; le symbole caret indique des dépendances.
  • outputs indique ce qui doit être mémorisé en cache et restauré, afin que les paquets inchangés ne soient pas reconstruits.
  • dev n’est pas mémorisé en cache et est persistent car un surveillant ne s’arrête jamais.
  • lint exécute d’abord les dépendances.
  • tasks est la clé actuelle ; les versions anciennes utilisaient pipeline, donc certains exemples plus anciens pourraient nécessiter des ajustements.

    Démarrage et construction depuis la racine

    Démarrez tous les processus de développement :

    pnpm dev
    

    Cela fonctionne parce que le script dev de la racine est :

    "dev": "turbo run dev"
    

    Turborepo trouve dev dans chaque espace de travail et les démarre ensemble, en utilisant un seul terminal pour l’application web :

    cd apps/web
    pnpm dev
    

    Et un autre pour l’API :

    cd apps/api
    pnpm dev
    

    Avec une seule commande depuis la racine :

    pnpm dev
    

    Construisez tout de la même manière :

    pnpm build
    

    Turborepo organise les builds en fonction des dépendances des packages, en commençant par les packages partagés :

    pnpm build
          │
          ▼
    turbo run build
          │
          ├── packages/types
          │
          ├── apps/api
          │
          └── apps/web
    

    Cet ordre provient des dépendances déclarées : tant que packages/types ne dispose pas de un package.json et que les applications n’en dépendent pas, Turborepo ne le compilera pas en premier.

    Cibler un seul espace de travail

    Le paramètre --filter de pnpm exécute un script dans un espace de travail, comme le serveur de développement API :

    pnpm --filter @repo/api dev
    

    Ou une compilation web :

    pnpm --filter @repo/web build
    

    Le filtre intégré de Turborepo gère le cache et l’ordre des builds :

    pnpm turbo run build --filter=@repo/api
    

    Commandes que vous utiliserez quotidiennement

    Installer tout le contenu :

    pnpm install
    

    Démarrer le développement :

    pnpm dev
    

    Compiler tout :

    pnpm build
    

    Vérifier la syntaxe de tout :

    pnpm lint
    

    Compiler un seul package :

    pnpm --filter @repo/api build
    

    Exécuter une seule application :

    pnpm --filter @repo/web dev
    

    Ajouter une dépendance à un espace de travail :

    pnpm --filter @repo/api add express
    

    Dépendance à un package local ; workspace:* lie la copie du répertoire plutôt que de télécharger depuis le registre :

    pnpm --filter @repo/api add @repo/types@workspace:*
    

    Pourquoi ne pas s’en tenir aux workspaces de pnpm ?

    On pourrait se fier uniquement aux workspaces :

    apps/
    packages/
    

    Cependant, à mesure que le répertoire grandit, la coordination devient plus manuelle :

    build
    test
    lint
    typecheck
    dev
    dependencies
    task ordering
    caching
    

    Turborepo ajoute une orchestration : une seule commande comprend les relations entre les packages, saute les tâches inchangées et exécute des tâches indépendantes en parallèle :

    pnpm turbo run build
    

    Pour une seule application et un seul package, les workspaces classiques peuvent suffire. Vous hésitez encore à choisir un gestionnaire de packages ? Consultez notre comparaison npm et pnpm.

    En résumé

    Un bon monorepo est un environnement partagé où les applications et les packages évoluent sous le même ensemble d’outils. La pile technique ici est simple :

    pnpm
    +
    Turborepo
    +
    TypeScript
    

    Démarrez avec deux applications :

    apps/
    ├── web/
    └── api/
    

    Évoluez vers davantage de services :

    apps/
    ├── web/
    ├── admin/
    ├── api/
    └── worker/
    

    Soutenu par des paquets partagés :

    packages/
    ├── ui/
    ├── types/
    ├── database/
    ├── auth/
    └── utils/
    

    Le avantage réside dans le partage de code, de types, de configurations et de flux de travail, tout en maintenant une organisation indépendante pour chaque application. Lorsque vous l’élargissez :

    • déclarez les dépendances locales avec workspace:* afin que les compilations se fassent dans le bon ordre
    • listez tous les artefacts dans outputs, sinon les restaurations du cache les manqueront
    • conservez la configuration de base à la racine et étendez-la
    • ajoutez packageManager ainsi que des outils comme ESLint avant de compter sur les scripts racine dans CI

    Références : la documentation de Turborepo, le répertoire GitHub de Turborepo, la documentation de pnpm et la documentation de Node.js.