Package managers Node 2026 — npm, pnpm, bun, yarn
TL;DR — Quatre gestionnaires de packages sérieux en 2026. pnpm 9 est le défaut pragmatique pour la majorité (rapide, content-addressable store, workspaces solides, catalog pour monorepo). bun 1.x est le plus rapide pour install et boot, idéal pour scripts et microservices. npm 11 reste partout, bon pour les libs publiées et la compatibilité absolue. yarn 4 vit encore principalement chez les utilisateurs de Plug'n'Play. Le débat dépasse la vitesse : il s'agit de structure du
node_modules, de sécurité supply chain, et de discipline monorepo.
🧠 Mental model — ASCII + analogie
Tous les package managers répondent aux mêmes questions, différemment :
install
│
▼
┌─────────────────────────────────────────┐
│ 1. Résoudre le DAG de dépendances │
│ → lockfile │
├─────────────────────────────────────────┤
│ 2. Télécharger les tarballs │
│ → cache local │
├─────────────────────────────────────────┤
│ 3. Construire le node_modules │
│ → flat (npm), nested (pnpm), │
│ virtual (yarn PnP), bun-style │
├─────────────────────────────────────────┤
│ 4. Lancer les lifecycle scripts │
│ → postinstall, prepare │
└─────────────────────────────────────────┘La grande différence : comment est structuré node_modules.
npm / bun (flat hoisted) :
node_modules/
├── react/
├── lodash/
└── express/
(hoist agressif, fantômes possibles)
pnpm (nested + symlinks) :
node_modules/
├── .pnpm/
│ ├── [email protected]/
│ ├── [email protected]/
│ └── [email protected]/
├── react → .pnpm/[email protected]/node_modules/react
├── lodash → .pnpm/[email protected]/node_modules/lodash
└── express → .pnpm/[email protected]/node_modules/express
(uniquement les deps déclarées sont visibles)
yarn PnP (zéro node_modules) :
.yarn/cache/*.zip
.pnp.cjs (résolveur runtime)Analogie : node_modules est une bibliothèque. npm range tout sur une grande étagère plate (rapide à parcourir mais des doublons et des fantômes). pnpm range chaque livre dans un coffre numéroté et met des étiquettes (symlinks) sur les coffres utilisés. yarn PnP zippe tous les livres et donne au lecteur un index unique. bun ressemble à npm mais avec un moteur dix fois plus rapide.
Comment un staff engineer raisonne sur le choix
Le débat "lequel est le plus rapide" est une diversion de junior. Un staff engineer optimise trois variables couplées, dans cet ordre :
- Déterminisme / reproductibilité — est-ce que
installproduit exactement le mêmenode_modulessur ma machine, chez le collègue, et en CI, aujourd'hui et dans 6 mois ? C'est la propriété qui fait ou casse un build. Le lockfile en est le garant, mais il n'est suffisant que combiné àpackageManager(corepack) + mode strict en CI. - Surface d'attaque supply chain — combien de code arbitraire peut s'exécuter à l'install (
postinstall), combien de deps transitives non auditées, et quelle facilité à forcer une version patchée (overrides). Une banque optimise ça avant la vitesse. - Vitesse / disque — vient en dernier, parce que la CI met en cache et que l'install à froid n'arrive qu'une fois par dev. Gagner 15s sur un install qui tourne 3×/jour ne rembourse jamais une nuit perdue à débugger un phantom dependency.
Le vrai différenciateur entre managers n'est pas la vitesse mais la rigueur du graphe : npm/bun (flat hoisted) acceptent les phantom deps — ton import marche par accident ; pnpm (isolated) les interdit par construction. Choisir pnpm, c'est choisir d'attraper la classe de bug "ça marche chez moi" à l'install plutôt qu'en prod.
Anatomie d'un lockfile — ce qu'il garantit vraiment
Un lockfile n'est pas "la liste des versions installées". C'est un graphe résolu et figé qui encode quatre choses :
| Champ | Rôle | Sans lui |
|---|---|---|
Version exacte résolue (4.17.21) | Fige le semver range ^4.17.0 | Chaque install peut prendre une version différente |
integrity (SHA-512 / SRI) | Vérifie que le tarball n'a pas été altéré | Un registry compromis sert un tarball trafiqué sans détection |
resolved (URL) | Source exacte du package | Dependency confusion : un autre registry répond |
| Arbre transitif complet | Fige toutes les sous-deps | Une sous-dep patch peut introduire un bug ou un malware |
Conséquence pratique : modifier package.json à la main sans relancer install casse le contrat — le range et le lockfile divergent. En CI, --frozen-lockfile / npm ci détectent exactement cette divergence et échouent, ce qui est le comportement voulu. Un lockfile non commité, c'est une appli sans garantie de reproductibilité : à proscrire absolument.
🛠️ Code minimal — pnpm
Installation :
# Via Corepack (bundled avec Node 16.10+, mais à activer explicitement)
corepack enable
corepack prepare pnpm@latest --activate
# Ou directement
npm i -g pnpmCommandes courantes :
pnpm init # crée package.json
pnpm add fastify zod # ajoute deps
pnpm add -D vitest # devDeps
pnpm add -g typescript # global
pnpm install # installe selon le lockfile
pnpm install --frozen-lockfile # CI : refuse de modifier le lockfile
pnpm update # respecte semver ranges
pnpm outdated # liste les MAJ disponibles
pnpm audit # vulnérabilités
pnpm dedupe # dédoublonne
pnpm why react # qui dépend de react ?Workspaces et catalog
Le tueur de pnpm en monorepo, c'est le catalog (introduit en pnpm 9), qui centralise les versions communes :
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'
catalog:
react: ^18.3.1
zod: ^3.23.0
typescript: ^5.6.0
catalogs:
testing:
vitest: ^2.0.0
'@testing-library/react': ^16.0.0// packages/web/package.json
{
"dependencies": {
"react": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"vitest": "catalog:testing"
}
}Une seule MAJ dans pnpm-workspace.yaml propage à tout le monorepo. Plus jamais de package qui traîne avec React 17 dans un coin.
🛠️ Code minimal — bun
Bun fait tout d'un coup : runtime, package manager, bundler, test runner.
curl -fsSL https://bun.sh/install | bashbun init # initialise
bun add fastify # install
bun add -d vitest # dev deps
bun install # respecte bun.lock (texte) ou bun.lockb (binaire legacy)
bun install --frozen-lockfile # CI : refuse de toucher au lockfile
bun update # update
bun pm trust # gère les scripts d'install (post-install)
bun why react # qui dépend de react ?Lockfile : jusqu'à bun 1.1 le lockfile était binaire (bun.lockb), illisible en review et source de conflits Git ingérables. Depuis bun 1.2 (jan. 2025) le format texte bun.lock est le défaut — diffable, mergeable. Si tu migres un vieux repo :
bun install --save-text-lockfile # convertit bun.lockb → bun.lock
rm bun.lockb # puis supprime l'ancienVitesse : bun install est typiquement 3-10× plus rapide que pnpm sur un cold install, similaire sur un warm install. La rapidité vient de trois choix : install écrit en Zig (pas de runtime Node à démarrer), syscalls optimisés par OS (clonefile sur macOS, hardlinks sur Linux), et un résolveur qui parallélise agressivement le téléchargement. Le piège : cette vitesse cache le coût réel — un bun install qui exécute des postinstall malveillants le fait aussi 10× plus vite. La vitesse n'est pas une feature de sécurité.
🛠️ Code minimal — npm 11
npm reste la référence, présent partout, parfaitement compatible avec tous les outils.
npm init -y
npm install fastify
npm install --save-dev vitest
npm ci # CI : install propre depuis le lockfile
npm outdated
npm audit
npm audit fix
npm dedupe
npm ls react # arbre des deps qui amènent reactWorkspaces npm :
// package.json racine
{
"name": "repo",
"private": true,
"workspaces": ["packages/*"]
}npm install # installe tous les workspaces
npm run build --workspace=api
npm run test --workspaces # toutesnpm 11 apporte un meilleur support des overrides et un comportement plus prévisible des fixes audit.
🛠️ Code minimal — yarn 4
Yarn 4 (Berry) supporte deux modes : PnP (zéro node_modules, résolution runtime) ou nodeLinker: node-modules (compat classique).
corepack prepare yarn@stable --activate
yarn init -2
yarn add fastify
yarn install# .yarnrc.yml
nodeLinker: node-modules # ou : pnp
enableImmutableInstalls: true # CI defaultPnP est puissant (zéro fantôme, install rapide) mais demande à chaque outil de connaître l'API PnP. En 2026, c'est encore un point de friction pour certains plugins.
🎯 Patterns courants
1. Discipline lockfile
Le lockfile est la source de vérité pour la reproductibilité.
# CI strict — bloque si le lockfile bouge
pnpm install --frozen-lockfile
npm ci
yarn install --immutable
bun install --frozen-lockfileRègles d'or :
- Commit le lockfile, toujours.
- En CI, utilise le mode strict (sinon la CI peut installer des versions différentes du local).
- Ne mélange pas plusieurs package managers (
package-lock.json+pnpm-lock.yaml+bun.lockensemble = explosion garantie). Choisis-en un, ajoute les autres à.gitignoreau cas où.
2. Supply chain — verrouiller et auditer
Les attaques supply chain (typosquatting, dependency confusion, malwares dans des libs populaires) sont devenues quotidiennes. Stratégie pragmatique :
# 1. Audit régulier
pnpm audit --prod # vulns connues
pnpm audit --json | jq '.advisories[] | select(.severity == "high" or .severity == "critical")'
# 2. Overrides ciblés pour patch d'urgence// package.json — npm/pnpm overrides
{
"overrides": {
"lodash@<4.17.21": ">=4.17.21"
},
"pnpm": {
"overrides": {
"lodash@<4.17.21": ">=4.17.21"
}
}
}# 3. Outils externes
npx socket@latest npm install # audit dynamique au moment de l'install
npx snyk test
npx better-npm-audit audit3. .npmrc — config sûre par défaut
Pour les nouveaux projets :
# .npmrc
save-exact=true # pas de ^ ou ~, exact
audit=true
fund=false
package-lock=true
ignore-scripts=false # discutable selon contextePour pnpm, .npmrc partagé + options spécifiques :
auto-install-peers=true # installe automatiquement les peer deps manquantes
strict-peer-dependencies=false # warning au lieu d'erreur sur conflit de peer
shamefully-hoist=false # le défaut, ne pas changer
node-linker=isolated # ou hoisted pour compat
package-import-method=hardlink # rapide, peu d'espace disque
dedupe-peer-dependents=true # dédoublonne quand des peers compatibles existent⚠️ Piège classique de config :
auto-install-peers=trueETstrict-peer-dependencies=trueensemble se marchent dessus. Le premier installe les peers manquantes silencieusement, le second échoue sur un mismatch. Choisis ton modèle mental :
- App finale →
auto-install-peers=true,strict-peer-dependencies=false(tu veux que ça marche, les peers c'est du bruit).- Lib publiée / monorepo de libs →
auto-install-peers=false,strict-peer-dependencies=true(tu veux voir les peers manquantes, car tes consommateurs les verront aussi). La rigueur paie : une peer non satisfaite chez toi devient un bug chez tes utilisateurs.
4. Hoisting policies
Le hoisting (remonter des deps profondes dans le node_modules racine) crée les fameux phantom dependencies : ton code marche parce qu'une lib hoistée se trouve là par hasard, jusqu'au jour où elle ne s'y trouve plus.
# .npmrc avec pnpm — config stricte (recommandée)
hoist=false # rien n'est hoisté à la racineAvec ce mode, chaque package ne voit que ce qu'il a déclaré dans son package.json. Plus de phantom. Mais certains outils legacy (jest, sequelize-cli) peuvent avoir besoin d'hoisting spécifique :
public-hoist-pattern[]='*types*'
public-hoist-pattern[]='*eslint*'5. Monorepo — release et publish
Pour un monorepo, deux écoles :
- changesets (vercel, populaire en 2026) : chaque PR ajoute un changeset, un workflow CI génère le changelog et bump les versions.
pnpm add -D @changesets/cli
pnpm changeset init
pnpm changeset # crée un .changeset/foo.md
pnpm changeset version # bump packages
pnpm changeset publish # publie sur npm- nx ou turborepo : plus de features (cache build, task runner), aussi capable de release.
6. Comparaison perf typique (install à froid, ~100 deps)
| Outil | Cold install | Warm install | Disk usage |
|---|---|---|---|
| bun | 4s | 0.5s | bas |
| pnpm | 12s | 1.5s | très bas (hardlinks) |
| yarn 4 PnP | 8s | 1s | bas (zips) |
| yarn 4 node-modules | 18s | 3s | élevé |
| npm 11 | 22s | 4s | élevé |
Mais : la vitesse d'install n'est qu'un facteur. La structure du node_modules, les workspaces, les overrides comptent plus dans le quotidien.
🔄 Versions — Node 18 / 20 / 22 / 24
| Version Node | Package managers |
|---|---|
| 18 | npm 9, pnpm 8/9, yarn 4, bun 1 |
| 20 | npm 10, pnpm 9, yarn 4, bun 1 (corepack disponible) |
| 22 | npm 10/11, pnpm 9, yarn 4, bun 1 |
| 24 | npm 11, pnpm 9, yarn 4, bun 1.1+ |
Corepack est livré avec Node (à activer via corepack enable) : la version du package manager est alors encodée dans package.json et respectée automatiquement :
{
"packageManager": "[email protected]+sha512.xxxxxx"
}C'est la bonne pratique : chaque dev et la CI utilisent automatiquement la même version, sans installation manuelle.
⚠️ Pitfalls
- Plusieurs lockfiles dans un même repo :
package-lock.json+pnpm-lock.yaml= comportement imprévisible selon qui installe. Supprime ceux qui ne sont pas utilisés. npm installsans--save-exact: avec les ranges (^1.2.3), un autre dev installe1.3.0qui contient un bug. Le lockfile règle le problème en CI mais pas au premier install d'un nouveau dev tant qu'il n'a pas le lock. Pin tes versions critiques.peerDependenciesmal résolues : en pnpm strict, une lib qui déclarereacten peer mais où ton app ne l'a pas → erreur. C'est correct ! Soit tu installes react, soit la lib n'est pas pertinente.- Scripts
postinstallmalicieux : un package compromis peut exécuter du code arbitraire à l'install. Mitigation :--ignore-scriptspar défaut,pnpm trustoubun pm trustpour autoriser explicitement. node_moduleschecked-in : ça arrive encore. Supprime, ajoute à.gitignore, formate le repo.- Différences pnpm strict vs hoisted : ton code marche en local (
shamefully-hoist=true) mais casse en CI (shamefully-hoist=false). Aligne les.npmrcpartout, idéalement strict. - Bun runtime ≠ Node runtime : bun install marche pour des deps Node, mais
bun runexécute avec le runtime bun (souvent compatible, parfois pas). Pour un projet Node, utilise bun comme PM, maisnodepour exécuter. - Yarn PnP cassé avec un outil legacy : tu peux passer en
nodeLinker: node-modules, perdre la vitesse mais gagner la compat. - Lockfile diff énorme en PR : un upgrade de lodash modifie 50 lignes du lockfile. Bruyant en review. Solutions : grouper les bumps (Renovate, Dependabot avec
groups), reviewer le diff depackage.jsonplutôt que du lockfile. - Cache CI pas configuré : chaque CI réinstalle tout. Configure
actions/cacheou équivalent pour le~/.local/share/pnpm/store(pnpm),~/.npm(npm),~/.bun/install/cache(bun). Gain : 1-2 min par job.
🧪 Testing — vérifier la santé du repo
# 1. Lockfile à jour
pnpm install --frozen-lockfile
# 2. Pas de vulns critiques
pnpm audit --audit-level=high
# 3. Pas de dupes inutiles
pnpm dedupe --check
# 4. Pas de phantom deps (deps utilisées non déclarées)
npx depcheck
# 5. Toutes les deps installées sont utilisées (l'inverse)
npx knip
# 6. Versions alignées dans monorepo
pnpm catalog check # depuis pnpm 9.5🎬 Cas d'usage concrets
Scénario 1 — SaaS RH "PaySimple" monorepo pnpm workspace multi-app
PaySimple a un monorepo pnpm avec 4 apps Node (api-core, api-payslips, api-leaves, worker-notifications) et 9 packages partagés (@paysimple/db, @paysimple/auth, @paysimple/schemas, @paysimple/ui, etc.). Avant 2024, ils utilisaient npm workspaces + Lerna : install à 6 minutes, hoisting cassé (un dev pouvait importer react-dom dans worker-notifications sans l'avoir déclaré), et les builds CI flakys parce que les dependencies n'étaient pas isolées.
Migration à pnpm en mars 2024 : install passé de 6 min à 1 min 20 (cache content-addressable + hardlinks), strict node_modules par défaut (impossible d'importer une dep non déclarée), et pnpm catalog: (introduit en pnpm 9.5) qui centralise les versions des libs partagées (React, TypeScript, Vitest) dans un seul endroit. Quand l'équipe bump TypeScript de 5.4 à 5.5, ils modifient une seule ligne dans pnpm-workspace.yaml, et tous les workspaces sont à jour. Gain CI : 4 minutes par build. Gain équipe : zéro dépendance "fantôme", refactor plus facile parce que les graphes d'imports sont explicites. pnpm dlx remplace npx avec un cache plus malin.
Scénario 2 — Banque "NeoCrédit" supply chain audit avec socket.dev
NeoCrédit gère 18 microservices Node avec ~ 1200 dépendances transitives au total. Le risque supply chain (un package npm compromis qui exfiltre des données ou installe un crypto-miner) est une menace existentielle pour une banque. Après l'incident ua-parser-js en 2021 et plusieurs autres en 2023-2024, l'équipe sécurité a mis en place trois défenses.
(1) socket.dev intégré comme GitHub App qui scanne chaque PR et bloque le merge si une nouvelle dep introduit du risque (telemetry, network call inattendu, install script, obfuscated code). (2) npm audit signatures (depuis npm 11.0) qui vérifie que chaque package est signé avec sigstore par son maintainer GitHub — protège contre les hijacks de compte npm. (3) pnpm.allowedDeprecatedVersions et overrides qui force certaines versions patchées au niveau du workspace (utile quand une dépendance transitive a un CVE non patché et que le maintainer ne réagit pas). Bénéfice : 4 incidents supply chain détectés en 2024-2025 (PR rejetées avant merge), zéro arrivé en prod. Coût : 30 min par dev par mois à reviewer les flags socket.dev, jugé acceptable.
Scénario 3 — Cabinet juridique "LexFidens" pnpm catalog centralisé
LexFidens (le cabinet, monorepo avec 8 apps + 12 packages) utilise pnpm catalog: pour centraliser les versions des libs critiques. Avant : chaque sous-package avait sa propre version de React, TypeScript, Zod, etc. Conséquence : un sous-package en React 18, un autre en React 19, l'UI design system cassait dans l'un mais pas l'autre. Mise à jour pénible : modifier 20 package.json, run pnpm install partout.
Avec pnpm-workspace.yaml + catalog: :
packages:
- 'apps/*'
- 'packages/*'
catalog:
react: ^19.1.0
react-dom: ^19.1.0
typescript: ^5.6.0
zod: ^4.0.0
vitest: ^2.0.0
'@types/node': ^22.0.0
catalogs:
legacy:
react: ^18.3.0
react-dom: ^18.3.0Dans chaque sous-package, on déclare "react": "catalog:". Un seul endroit pour bumper React partout. L'app mobile React Native qui ne peut pas encore passer en React 19 utilise "react": "catalog:legacy". Bénéfice : 95 % de réduction du temps passé à gérer les versions transverses, et fin des incohérences entre packages.
🛠️ Exemple end-to-end
Cas d'usage : "monorepo LexFidens — pnpm workspace + catalog + scripts orchestrés via turbo, hooks de sécurité, audit CI, et release npm de @lexfidens/sdk-client avec changesets".
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
- '!**/dist/**'
catalog:
react: ^19.1.0
react-dom: ^19.1.0
typescript: ^5.6.0
zod: ^4.0.0
vitest: ^2.0.0
fastify: ^5.0.0
'@types/node': ^22.0.0
catalogs:
rn:
react: ^18.3.0
react-dom: ^18.3.0// package.json racine
{
"name": "@lexfidens/monorepo",
"private": true,
"packageManager": "[email protected]",
"engines": { "node": ">=22.0.0", "pnpm": ">=9.0.0" },
"scripts": {
"build": "turbo run build",
"test": "turbo run test",
"lint": "biome check .",
"audit": "pnpm audit --audit-level=high && pnpm dlx better-npm-audit audit --level high",
"audit:signatures": "npm audit signatures",
"deps:check": "pnpm dlx knip && pnpm dlx depcheck",
"deps:update": "pnpm update --interactive --recursive",
"release": "changeset publish"
},
"devDependencies": {
"@biomejs/biome": "^2.0.0",
"@changesets/cli": "^2.27.0",
"turbo": "^2.0.0"
},
"pnpm": {
"overrides": {
"semver@<7.5.2": ">=7.5.2",
"got@<11.8.5": ">=11.8.5"
},
"auditConfig": {
"ignoreCves": []
},
"peerDependencyRules": {
"ignoreMissing": ["react-native"]
}
}
}// packages/sdk-client/package.json
{
"name": "@lexfidens/sdk-client",
"version": "2.4.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist", "README.md"],
"scripts": {
"build": "tsup src/index.ts --format esm --dts",
"test": "vitest run",
"prepublishOnly": "pnpm build && pnpm test"
},
"dependencies": {
"zod": "catalog:"
},
"peerDependencies": {
"@lexfidens/schemas": "workspace:^"
},
"devDependencies": {
"typescript": "catalog:",
"vitest": "catalog:",
"tsup": "^8.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}# .github/workflows/audit.yml — exécuté quotidiennement + à chaque PR
name: supply-chain-audit
on:
schedule: [{ cron: '0 6 * * *' }]
pull_request:
paths: ['**/package.json', 'pnpm-lock.yaml']
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: pnpm audit
run: pnpm audit --audit-level=high --prod
- name: npm audit signatures (sigstore)
run: npm audit signatures
- name: Socket security
uses: socketdev/socket-github-app@v1
with:
api_token: ${{ secrets.SOCKET_TOKEN }}
- name: Unused deps
run: pnpm deps:check// .changeset/config.json
{
"$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@lexfidens/dashboard", "@lexfidens/signature-app"]
}Cette config combine huit patterns seniors pnpm + monorepo : (1) catalog: qui centralise React, TS, Zod, Fastify (un bump = une ligne), (2) catalogs.rn séparé pour le mobile React Native qui ne peut pas suivre la version principale, (3) packageManager champ qui force pnpm 9.12 partout (anti-works on my machine), (4) overrides qui patche des CVE dans des deps transitives sans attendre le maintainer, (5) publishConfig.provenance: true qui signe les packages publiés via sigstore (audit chain de bout en bout), (6) workflow audit quotidien + sur chaque PR (socket.dev + npm audit signatures), (7) knip + depcheck qui détectent les deps inutilisées (gain : -25 deps en moyenne par audit), (8) changesets pour le versioning sémantique + changelog auto + publish coordonné. Sur le monorepo LexFidens (8 apps, 12 packages), pnpm install froid prend 80 secondes, hot 4 secondes. La release @lexfidens/sdk-client v2.4 a pris 12 minutes du git push au npm install réussi côté client.
🔭 Production & observabilité — ce qui casse à l'échelle
Sur 5 services c'est invisible ; sur 50 services et 30 devs, la discipline package manager devient une feature de fiabilité.
CI : le cache mal câblé est le coût n°1
Le piège n°1 en CI n'est pas la vitesse d'install brute mais le cache de store mal configuré. Câble-le par manager (la clé doit dépendre du lockfile) :
# pnpm — le store est content-addressable, on cache le STORE pas node_modules
- uses: actions/cache@v4
with:
path: ~/.local/share/pnpm/store # `pnpm store path` pour le chemin exact
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-⚠️ Ne jamais cacher
node_modulesdirectement avec pnpm : ce sont des symlinks vers le store, un cache denode_modulessans le store derrière donne des liens cassés. Cache le store (~/.local/share/pnpm/store), npm cache~/.npm, bun cache~/.bun/install/cache. Gain typique : 1-2 min par job.
Métriques à instrumenter
Un staff engineer ne pilote pas la santé des deps à l'instinct, il la mesure :
| Métrique | Comment | Seuil d'alerte |
|---|---|---|
| Durée install CI (froid/chaud) | timing du step | régression > 20 % = cache cassé |
| Nombre de deps transitives | pnpm ls -r --depth Infinity | wc -l | croissance soudaine = audit |
| Vulns high/critical | pnpm audit --json en CI nightly | > 0 critical = bloquant |
| Deps inutilisées | knip / depcheck | dette à nettoyer trimestriellement |
Drift packageManager vs installé | corepack compare | mismatch = build non reproductible |
| Taille du lockfile (diff) | git diff --stat sur le lock | +500 lignes inattendu = enquête |
Discipline de version du manager lui-même
Le bug le plus pénible à diagnostiquer : deux devs avec deux versions de pnpm qui produisent deux lockfiles incompatibles, chacun écrasant celui de l'autre à chaque PR. La défense est packageManager + corepack (voir plus bas) — non négociable au-delà de 3 personnes sur un repo.
🔁 Quand utiliser / éviter
| PM | Quand | Éviter quand |
|---|---|---|
| pnpm | Monorepo, vitesse, sécurité (strict) | Compat absolue avec outils anciens qui veulent du hoisting |
| bun | Scripts, microservices, vitesse maximale | Lib publiée (pas universel encore) |
| npm | Libs publiées, compat universelle, simplicité | Monorepo gros (lent, moins de features) |
| yarn 4 | Codebase yarn historique, PnP religion | Greenfield (pnpm fait mieux pour la plupart) |
🛠️ package.json — champs essentiels en 2026
{
"name": "@org/orders-api",
"version": "1.4.2",
"description": "Orders API service",
"private": true,
"type": "module",
"packageManager": "[email protected]+sha512.abcdef...",
"engines": {
"node": ">=22.0.0",
"pnpm": ">=9.0.0"
},
"scripts": {
"dev": "node --watch --experimental-strip-types src/server.ts",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/server.js",
"test": "vitest",
"test:ci": "vitest run --coverage",
"lint": "biome check .",
"format": "biome format --write .",
"typecheck": "tsc --noEmit"
},
"dependencies": { "fastify": "^5.0.0" },
"devDependencies": { "vitest": "^3.0.0", "typescript": "^5.6.0" },
"exports": {
".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
"./types": { "types": "./dist/types.d.ts" }
},
"files": ["dist", "README.md", "LICENSE"]
}Points cruciaux :
"type": "module": tout est ESM par défaut. Préférable pour Node 22+."packageManager": verrouille la version exacte du PM utilisée. Corepack respecte ce champ."engines": avertit (ou bloque) si tu installes avec une mauvaise version Node."exports": remplacemainettypes. Permet de contrôler ce qui est exposé publiquement (encapsulation)."files": ce qui sera publié sur npm. Sans ça, tout est publié, y compris secrets potentiels.
🛠️ Sécurité — éviter les pièges supply chain
Désactiver les scripts par défaut
Une lib malicieuse peut exécuter du code via postinstall. En 2026, c'est devenu le vecteur d'attaque numéro 1.
# pnpm 9+
pnpm install --ignore-scripts
# Puis autoriser explicitement les scripts pour les packages de confiance
pnpm approve-builds
# Ouvre un éditeur, tu choisis quels packages peuvent run leurs scripts# .npmrc — désactiver scripts globalement (controversé)
ignore-scripts=trueAudit signé
npm audit signatures (npm 10+) vérifie que les packages installés ont des signatures valides du registry npm :
npm audit signaturesC'est un cran au-dessus de npm audit qui ne vérifie que les CVE connues.
Dependency confusion
Si ton org publie des packages privés (@myorg/internal-lib), un attaquant peut publier sur le registry public sous le même nom avec un higher version. Si ta config n'est pas stricte, npm installe la version publique malicieuse.
# .npmrc — scope à un registry privé
@myorg:registry=https://npm.myorg.io/
//npm.myorg.io/:_authToken=${NPM_TOKEN}🛠️ Cas particulier : publier une lib
Si tu publies sur npm, viser une publication propre dual ESM/CJS si possible (mais en 2026, ESM-only est de plus en plus accepté) :
{
"name": "my-cool-lib",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
"prepublishOnly": "pnpm build && pnpm test"
}
}tsup (basé sur esbuild) génère ESM + CJS + .d.ts en une commande. Alternative : unbuild, tsc direct.
Tester la publication avant
# Pack en local, voir ce qui serait publié
npm pack --dry-run
# Tester via npm link ou pnpm pack
pnpm pack
# génère my-cool-lib-1.0.0.tgz, à installer dans un autre projet pour tester🔗 Liens
- pnpm : https://pnpm.io/
- bun : https://bun.sh/
- npm docs : https://docs.npmjs.com/
- yarn 4 : https://yarnpkg.com/
- changesets : https://github.com/changesets/changesets
- Socket : https://socket.dev/
- npm audit signatures : https://docs.npmjs.com/cli/v10/commands/npm-audit
- Corepack : https://nodejs.org/api/corepack.html
🏋️ Exercices
Progression d'implémentation → production → casse-puis-répare. Fais-les dans un dossier jetable.
1. Reproduire et tuer une phantom dependency
Objectif : prouver qu'une phantom dep existe en flat hoisted et qu'elle disparaît en pnpm strict.
Crée un projet avec npm, installe une lib qui dépend transitivement de chalk (ex. une lib quelconque), puis require('chalk') dans ton code sans l'avoir déclarée. Ça marche (phantom). Migre le même projet en pnpm (node-linker=isolated, le défaut), relance — ça casse au runtime.
Indice / Solution
npm i some-lib-using-chalk, puis un index.js avec const c = require('chalk'). Marche grâce au hoisting. Avec pnpm : supprime node_modules + package-lock.json, pnpm import puis pnpm install, relance → Cannot find module 'chalk'. Fix correct : pnpm add chalk (déclarer la dep) — pas shamefully-hoist=true qui ne fait que masquer le problème. La leçon : pnpm a transformé un bug runtime aléatoire en erreur déterministe à l'install.
2. Mettre en place un monorepo pnpm avec catalog
Objectif : un workspace 2 packages + 1 app partageant React/TS via catalog:, bumpés en une ligne.
Crée pnpm-workspace.yaml avec un catalog:, deux packages/* qui consomment react: "catalog:", vérifie qu'un seul package physique de React existe (pnpm why react), puis bump React d'une version en changeant une seule ligne.
Indice / Solution
Reprends la structure du Scénario 3. Après install, pnpm why react doit montrer une seule entrée résolue. Change react: ^18 → ^19 dans le catalog, pnpm install, et observe que les deux packages bougent ensemble. Bonus : ajoute catalogs.legacy et fais pointer un seul package dessus pour simuler le cas React Native bloqué.
3. Lockfile strict en CI — casser puis réparer
Objectif : comprendre pourquoi --frozen-lockfile échoue et ce que ça protège.
Édite package.json à la main (change ^4.17.0 en ^4.18.0) sans relancer install, puis lance pnpm install --frozen-lockfile. Observe l'échec. Répare proprement.
Indice / Solution
L'échec est voulu : le range de package.json ne correspond plus au lockfile figé. C'est exactement le filet que npm ci / --frozen-lockfile tend en CI. Fix : pnpm install (sans frozen) en local pour régénérer le lock, commit les deux fichiers ensemble. La PR doit toujours contenir package.json + lockfile dans le même commit, sinon la CI rejette.
4. Bloquer un postinstall malveillant (production-grade)
Objectif : neutraliser la classe d'attaque postinstall sans casser les builds natifs légitimes.
Configure pnpm install --ignore-scripts par défaut, puis utilise pnpm approve-builds pour autoriser explicitement uniquement les packages de confiance (ex. esbuild, better-sqlite3 qui compilent du natif). Vérifie qu'un package non approuvé ne lance pas son script.
Indice / Solution
pnpm 9+ ne lance plus les build scripts par défaut et te demande approbation. pnpm approve-builds écrit onlyBuiltDependencies: [...] dans la config. Crée une fausse lib locale avec un postinstall qui console.log('PWNED') (ou touch /tmp/pwned), installe-la — le script ne tourne pas tant que non approuvé. Production : combine avec socket.dev en GitHub App pour bloquer en amont au niveau PR.
5. Patcher un CVE transitif sans attendre le maintainer (break-then-fix)
Objectif : forcer une version patchée d'une dep transitive vulnérable via overrides.
Installe une lib dont une dépendance transitive a un CVE connu (ex. une vieille semver < 7.5.2). pnpm audit doit la signaler. Ajoute un pnpm.overrides pour forcer >=7.5.2, relance l'audit → propre. Vérifie que rien ne casse au runtime.
Indice / Solution
{ "pnpm": { "overrides": { "semver@<7.5.2": ">=7.5.2" } } }Après pnpm install, pnpm why semver confirme la version forcée, pnpm audit ne la signale plus. Subtilité senior : un override est un patch d'urgence, pas une solution permanente — ouvre une issue chez le maintainer et retire l'override quand la vraie version est publiée, sinon tu accumules une dette de versions figées invisibles.
6. Publier une lib dual ESM/CJS avec provenance (architecte)
Objectif : publier @toi/lib avec exports conditionnels, tsup, et provenance sigstore.
Build ESM+CJS+.d.ts avec tsup, configure exports (import/require/types), files, publishConfig.provenance: true, teste avec npm pack --dry-run que rien de sensible ne fuit, puis publie depuis un workflow CI (la provenance exige un runner CI OIDC).
Indice / Solution
Reprends le bloc "publier une lib". npm pack --dry-run doit lister uniquement dist/ + README (pas src/, pas .env). La provenance ne marche que depuis un CI supporté (GitHub Actions) avec id-token: write — pas en local. Vérifie après publication que la page npm affiche le badge "Provenance". Piège classique : oublier "type": "module" ou inverser l'ordre des clés dans exports (types doit être en premier).
🎤 En entretien
Q : Pourquoi pnpm est-il plus sûr que npm vis-à-vis des phantom dependencies, concrètement ? Parce que son node_modules est isolé : chaque package ne voit via symlinks que les deps qu'il a déclarées, les sous-deps vivent dans .pnpm/ non accessibles. Un import non déclaré échoue à l'install au lieu de marcher par accident grâce au hoisting de npm. pnpm transforme une bombe à retardement runtime en erreur déterministe.
Q : Que garantit exactement un lockfile, et pourquoi npm install ne suffit pas en CI ? Le lockfile fige le graphe transitif complet, les versions exactes, et l'integrity (SHA des tarballs) — donc reproductibilité et détection d'altération. npm install peut modifier le lock (résoudre de nouveaux ranges) ; en CI on veut npm ci / --frozen-lockfile qui échoue si package.json et le lock divergent, garantissant que la CI installe exactement ce qui a été reviewé.
Q : Un dev importe une lib qui marche en local mais casse en CI. Comment tu diagnostiques ? D'abord suspecter une phantom dependency (hoisting local permissif vs CI strict) ou un drift de version du package manager (lockfiles incompatibles). Je vérifie : .npmrc identiques partout, packageManager figé via corepack, et que la dep est bien déclarée (depcheck). Fix durable : aligner sur pnpm strict + --frozen-lockfile, pas masquer avec shamefully-hoist.
Q : Comment réduis-tu la surface d'attaque supply chain sur un monorepo de 1000+ deps transitives ? Quatre couches : (1) --ignore-scripts par défaut + approbation explicite des build scripts ; (2) npm audit signatures (sigstore) qui détecte les hijacks de compte maintainer, pas juste les CVE ; (3) un scanner comportemental type socket.dev en GitHub App qui bloque au niveau PR sur network/install-script inattendu ; (4) overrides pour forcer les versions patchées sans attendre les maintainers. La vitesse d'install passe après — un install rapide exécute aussi du malware rapidement.
🗓️ Récap final
En 2026, le débat n'est plus "npm vs yarn" mais "quel équilibre vitesse / sécurité / écosystème pour ton contexte". pnpm est le choix par défaut pour les apps et monorepos (rapide, strict, catalog), bun pour la vitesse maximale, npm pour les libs publiées, yarn 4 pour les héritages. Au-delà du choix : commit le lockfile, audit régulièrement, verrouille packageManager dans package.json via Corepack, et fais de la supply chain une discipline (overrides, socket.dev, scripts désactivés par défaut). Le PM lui-même importe moins que la rigueur autour.