Éviter les boucles de reconstruction pour les paquets de monorepo avec des exportations conditionnelles
Le fichier package.json définit les exportations en TypeScript dans un environnement de développement, permettant ainsi à Vite, Node et Webpack d’effectuer une mise à jour en temps réel de l’interface utilisateur sans devoir recompiler chaque package interne.
Sources: Avoiding repeatedly building internal packages in a Monorepo
Le principe de base
Imaginez un monorepo TypeScript Node.js géré à l’aide des espaces de travail Yarn. Il peut contenir une application Vite principale, une application Next.js secondaire, ainsi que des packages pour une configuration Tailwind partagée, une interface utilisateur JSX commune, des icônes et d’autres bibliothèques similaires. Turborepo relie tous ces éléments afin que les dépendances internes soient compilées dans le bon ordre :
// Tip: name your turbo config file 'turbo.jsonc' instead of 'turbo.json'. This lets you write comments.
{
"tasks": {
// App 1 and it's three internal dependencies
"@repo/vite-app#build": {
"outputs": ["dist/**"],
"dependsOn": [
"@repo/icons#build",
"@repo/ui#build",
"@repo/tailwind-config#build"
]
},
// App 2 and it's three internal dependencies
"@repo/next-app#build": {
"outputs": [".next/**"],
"dependsOn": [
"@repo/icons#build",
"@repo/ui#build",
"@repo/tailwind-config#build"
]
},
// Package 1, depends on @repo/icons
"@repo/ui#build": {
"outputs": ["dist/**"],
"dependsOn": ["@repo/icons#build"]
},
// Package 2
"@repo/icons#build": {
"outputs": ["dist/**"]
},
// Package 3
"@repo/tailwind-config#build": {
"outputs": ["theming.css", "postcss.config.js"]
},
// End of build tasks.
//
// The following two tasks are for when the app is started in dev mode.
"@repo/vite-app#dev": {
// '^build' below is a way of saying -- this task (@repo/vite-app#dev) depends on the #build tasks of ALL of the packages 'vite-app' depends on.
"dependsOn": ["^build"],
"persistent": true,
"cache": false
},
"@repo/next-app#dev": {
"dependsOn": ["^build"],
"persistent": true,
"cache": false
}
}
}
Chaque package effectue sa propre compilation, génère des artefacts et les annonce via package.json afin que les applications puissent les importer. Les outils mentionnés ci-dessus ne sont qu’un exemple concret. Ce même problème se pose dans presque tous les monorepos Node qui compilent des bibliothèques internes avant que les applications ne les utilisent.
Le problème
Dans le cadre du développement, les modifications effectuées à l’intérieur de l’application Vite sont immédiates : on lance une tâche turbo, HMR transmet les changements et le navigateur se met à jour. En revanche, les modifications apportées à un package d’interface utilisateur partagé importé par l’application Vite se comportent différemment. L’application en cours d’exécution continue de charger le résultat déjà généré par ce package, ce qui fait que l’interface affichée reste obsolète jusqu’à ce que le package soit reconstruit.
Les équipes ont souvent recours à la surveillance des dossiers de package et à l’enchaînement de scripts de reconstruction. Cette méthode restaure la correction des fonctionnalités, mais au prix d’un temps d’attente lié aux compilateurs, qui ne devraient pas faire partie du chemin critique lors d’une simple modification d’une propriété. Les exportations conditionnelles agissent plutôt en inversant la direction des dépendances : elles permettent à l’application de lire le code source pendant que l’on itère sur les modifications.
Cette boucle n’est pas surprenante si on la compare aux packages npm publics : les auteurs modifient le code source, le compilent, puis publient les artefacts. Les packages partagés localement conservent néanmoins la même configuration par défaut de « consommation d’artefacts », même si l’arbre de source se trouve dans un répertoire voisin. L’expérience de développement souhaitée est plutôt la suivante : lors de l’itération, importer le code source ; lors du déploiement, importer les fichiers compilés en qui le pipeline de production a déjà confiance.
Les export conditionnels à la rescousse
Le package.json d’un package constitue le contrat que voient les utilisateurs. Plus précisément, le champ exports détermine quels fichiers concrets sont retournés pour une demande d’import donnée. Selon la documentation des packages Node.js, les export conditionnels orientent vers des chemins différents en fonction de conditions prédéfinies :
{
"exports": {
"import": "./index-module.js",
"require": "./index-require.cjs"
},
"type": "module"
}
// ... rest of the package.json
Cela signifie qu’un seul package logique peut exposer des implémentations alternatives. Les conditions intégrées courantes incluent :
- L’instruction ESM
importrenvoyant à./index-module.js - L’instruction CJS
require()renvoyant à./index-require.cjs
Le même mécanisme peut prendre en compte des conditions liées à l’environnement, ce qui permet d’optimiser l’utilisation d’un monorepo.
C’est excellent, mais en quoi cela concerne-t-il mon problème de monorepo ?
Les packages publiés ont rarement besoin de plus que des instructions import, require, et éventuellement default, toutes pointant vers des artefacts compilés. Un package interne peut ajouter une condition spécifique qui fait référence à du code TypeScript ou JSX et n’est sélectionnée que pendant le développement local.
Commencez par une carte typique destinée uniquement à la compilation pour les dossiers components/ et utilities/ :
"exports": {
"./components/*": {
"default": "./dist/components/*.js" // 'default' export condition only, just for simplicity
},
"./utilities/*": {
"default": "./dist/utilities/*.js"
}
}
Puisque le paquet se trouve à côté de son code source dans le monorepo, étendez la carte avec une branche development (et souvent une branche types qui lit également le code source) :
"exports": {
"./components/*": {
// Custom conditions can be named whatever, I've named it
// 'development' because we'd want to use it when the app
// is run in dev mode.
// This file path points to the raw source files.
"development": "./lib/components/*/index.jsx",
"default": "./dist/components/*.js" // unchanged from above
},
"./utilities/*": {
"development": "./lib/utilities/*/index.jsx",
"default": "./dist/utilities/*.js" // unchanged from above
}
}
"exports": {
"./components/*": {
"types": "./lib/components/*/index.tsx", // one additional 'types' condition that lets the consuming app access the types directly from the source
"development": "./lib/components/*/index.tsx",
"default": "./dist/components/*.js"
},
"./utilities/*": {
"types": "./lib/utilities/*/index.ts",
"development": "./lib/utilities/*/index.ts",
"default": "./dist/utilities/*.js"
}
},
Important : l’ordre des clés à l’intérieur de exports est crucial. Node donne la priorité aux entrées les plus précoces. Placez la branche default générale en dernier. Si default est placée en premier, elle l’emporte à chaque fois et les branches spécialisées n’ont plus d’importance.
Avec cette carte en place, le travail restant consiste à indiquer à chaque consommateur quelles conditions sont actives dans quel mode.
Génial ! Mais comment faire en sorte que mon application/paquet consommateur prenne en compte cette nouvelle condition ?
Le comportement souhaité est facile à décrire. Lors du développement local, l’application doit privilégier l’export development afin que le code source soit chargé directement, comme s’il se trouvait à l’intérieur de l’application. Lors des builds en production, l’application doit privilégier default (ou production) et ne doit en aucun cas sélectionner par erreur development.
Differentes environnements d’exécution permettent de faire ce choix de manière différente.
Applications Vanilla NodeJS
Les applications qui s’exécutent sans bundler peuvent utiliser le flag --conditions de Node lors du démarrage du processus :
node --conditions=development index.js # Or whatever you've named your condition
Tout package importé par ce processus considérera development comme une condition autorisée et utilisera l’export correspondant s’il est disponible. Pour les builds en production, il suffit d’omettre ce flag afin que seules les conditions habituelles liées à la publication restent applicables.
Vite
Vite dispose déjà d’une liste des conditions autorisées dérivée du mode :
- Au mode dev :
['module', 'browser', 'development'] - Au mode build :
['module', 'browser', 'production']
Ainsi, un paquet qui déclare development est sélectionné automatiquement lors des builds en mode développement avec vite et ignoré lors des builds en production, où production s’appliquerait si défini. Vous pouvez personnaliser cette liste via resolve.conditions, mais les valeurs par défaut correspondent déjà au schéma de monorepo décrit ici.
Remarque : tant pour Node classique que pour Vite, l’ordre des éléments dans export-map décide en cas d’égalité. La première condition présente à la fois dans le map et autorisée par l’environnement de exécution l’emporte.
Webpack
Webpack peut également personnaliser les conditions de résolution, mais il n’inclut pas par défaut la valeur development de la même manière que Vite. Configurez explicitement resolve.conditionNames pour cet exemple :
resolve: {
conditionNames: ["development", "..."],
},
Lorsque les utilisateurs résolvent des sources en mode développement, Turborepo n’a plus besoin de recompiler chaque package interne avant que l’application ne reflète un simple ajustement d’interface. Les pipelines de développement peuvent ainsi éviter l’attente longue liée à la compilation de ces packages :
// Tip: name your turbo config file 'turbo.jsonc' instead of 'turbo.json'. This lets you write comments.
{
"tasks": {
// App 1 and it's three internal dependencies
"@repo/vite-app#build": {
"outputs": ["dist/**"],
"dependsOn": [
"@repo/icons#build",
"@repo/ui#build",
"@repo/tailwind-config#build"
]
},
// App 2 and it's three internal dependencies
"@repo/next-app#build": {
"outputs": [".next/**"],
"dependsOn": [
"@repo/icons#build",
"@repo/ui#build",
"@repo/tailwind-config#build"
]
},
// Package 1, depends on @repo/icons
"@repo/ui#build": {
"outputs": ["dist/**"],
"dependsOn": ["@repo/icons#build"]
},
// Package 2
"@repo/icons#build": {
"outputs": ["dist/**"]
},
// Package 3
"@repo/tailwind-config#build": {
"outputs": ["theming.css", "postcss.config.js"]
},
// End of build tasks.
//
// The following two tasks are for when the app is started in dev mode.
"@repo/vite-app#dev": {
// #dev tasks no longer depend on the #build tasks of the dependencies.
"persistent": true,
"cache": false
},
"@repo/next-app#dev": {
"persistent": true,
"cache": false
}
}
}
Le résultat est un contrat à double mode : les imports en développement donnent l’impression d’éditer une seule base de code, tandis que la production continue de compiler les artefacts. Cette séparation suffit généralement à rétablir le cycle de feedback HMR pour les interfaces partagées, sans pour autant renoncer à la discipline de compilation dont a besoin la production.
Quelques détails opérationnels empêchent que ce schéma ne surprenne les collègues. Documentez le nom de la condition choisie (development est conventionnel mais pas magique) dans le README du monorepo afin que les nouveaux packages reproduisent la même structure d’export. Assurez-vous que les extensions de fichiers soient reconnaissables par le transformateur du consommateur : le pipeline JS/TS de Vite comprend déjà .tsx, tandis qu’un processus Node classique peut avoir besoin d’un chargeur. Enfin, n’oubliez pas que l’omission des compilations de packages en mode développement ne supprime pas la nécessité de ces compilations en CI : les images de production et les artefacts publiables doivent toujours compiler les bibliothèques internes afin que les hôtes en temps de exécution ne dépendent pas du TypeScript brut.
Lorsqu’ils sont utilisés ensemble, les exports conditionnels et un graphique turbo légèrement plus souple transforment à nouveau les packages partagés en simples dossiers de source lors du travail quotidien sur l’interface utilisateur, ce qui est généralement le but principal de placer ces packages dans le même répertoire.