Skip to content

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 :

  1. Déterminisme / reproductibilité — est-ce que install produit exactement le même node_modules sur 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.
  2. 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.
  3. 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 :

ChampRôleSans lui
Version exacte résolue (4.17.21)Fige le semver range ^4.17.0Chaque 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 packageDependency confusion : un autre registry répond
Arbre transitif completFige toutes les sous-depsUne 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 :

bash
# Via Corepack (bundled avec Node 16.10+, mais à activer explicitement)
corepack enable
corepack prepare pnpm@latest --activate

# Ou directement
npm i -g pnpm

Commandes courantes :

bash
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 :

yaml
# 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
json
// 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.

bash
curl -fsSL https://bun.sh/install | bash
bash
bun 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 :

bash
bun install --save-text-lockfile   # convertit bun.lockb → bun.lock
rm bun.lockb                        # puis supprime l'ancien

Vitesse : 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.

bash
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 react

Workspaces npm :

json
// package.json racine
{
  "name": "repo",
  "private": true,
  "workspaces": ["packages/*"]
}
bash
npm install                    # installe tous les workspaces
npm run build --workspace=api
npm run test --workspaces       # toutes

npm 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).

bash
corepack prepare yarn@stable --activate
yarn init -2
yarn add fastify
yarn install
yaml
# .yarnrc.yml
nodeLinker: node-modules     # ou : pnp
enableImmutableInstalls: true   # CI default

PnP 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é.

bash
# CI strict — bloque si le lockfile bouge
pnpm install --frozen-lockfile
npm ci
yarn install --immutable
bun install --frozen-lockfile

Rè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.lock ensemble = explosion garantie). Choisis-en un, ajoute les autres à .gitignore au 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 :

bash
# 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
json
// package.json — npm/pnpm overrides
{
  "overrides": {
    "lodash@<4.17.21": ">=4.17.21"
  },
  "pnpm": {
    "overrides": {
      "lodash@<4.17.21": ">=4.17.21"
    }
  }
}
bash
# 3. Outils externes
npx socket@latest npm install  # audit dynamique au moment de l'install
npx snyk test
npx better-npm-audit audit

3. .npmrc — config sûre par défaut

Pour les nouveaux projets :

ini
# .npmrc
save-exact=true                  # pas de ^ ou ~, exact
audit=true
fund=false
package-lock=true
ignore-scripts=false             # discutable selon contexte

Pour pnpm, .npmrc partagé + options spécifiques :

ini
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=true ET strict-peer-dependencies=true ensemble se marchent dessus. Le premier installe les peers manquantes silencieusement, le second échoue sur un mismatch. Choisis ton modèle mental :

  • App finaleauto-install-peers=true, strict-peer-dependencies=false (tu veux que ça marche, les peers c'est du bruit).
  • Lib publiée / monorepo de libsauto-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.

ini
# .npmrc avec pnpm — config stricte (recommandée)
hoist=false              # rien n'est hoisté à la racine

Avec 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 :

ini
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.
bash
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)

OutilCold installWarm installDisk usage
bun4s0.5sbas
pnpm12s1.5strès bas (hardlinks)
yarn 4 PnP8s1sbas (zips)
yarn 4 node-modules18s3sélevé
npm 1122s4sé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 NodePackage managers
18npm 9, pnpm 8/9, yarn 4, bun 1
20npm 10, pnpm 9, yarn 4, bun 1 (corepack disponible)
22npm 10/11, pnpm 9, yarn 4, bun 1
24npm 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 :

json
{
  "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 install sans --save-exact : avec les ranges (^1.2.3), un autre dev installe 1.3.0 qui 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.
  • peerDependencies mal résolues : en pnpm strict, une lib qui déclare react en 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 postinstall malicieux : un package compromis peut exécuter du code arbitraire à l'install. Mitigation : --ignore-scripts par défaut, pnpm trust ou bun pm trust pour autoriser explicitement.
  • node_modules checked-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 .npmrc partout, idéalement strict.
  • Bun runtime ≠ Node runtime : bun install marche pour des deps Node, mais bun run exécute avec le runtime bun (souvent compatible, parfois pas). Pour un projet Node, utilise bun comme PM, mais node pour 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 de package.json plutôt que du lockfile.
  • Cache CI pas configuré : chaque CI réinstalle tout. Configure actions/cache ou é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

bash
# 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: :

yaml
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.0

Dans 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".

yaml
# 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
json
// 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"]
    }
  }
}
json
// 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
  }
}
yaml
# .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
json
// .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) :

yaml
# 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_modules directement avec pnpm : ce sont des symlinks vers le store, un cache de node_modules sans 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étriqueCommentSeuil d'alerte
Durée install CI (froid/chaud)timing du steprégression > 20 % = cache cassé
Nombre de deps transitivespnpm ls -r --depth Infinity | wc -lcroissance soudaine = audit
Vulns high/criticalpnpm audit --json en CI nightly> 0 critical = bloquant
Deps inutiliséesknip / depcheckdette à nettoyer trimestriellement
Drift packageManager vs installécorepack comparemismatch = 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

PMQuandÉviter quand
pnpmMonorepo, vitesse, sécurité (strict)Compat absolue avec outils anciens qui veulent du hoisting
bunScripts, microservices, vitesse maximaleLib publiée (pas universel encore)
npmLibs publiées, compat universelle, simplicitéMonorepo gros (lent, moins de features)
yarn 4Codebase yarn historique, PnP religionGreenfield (pnpm fait mieux pour la plupart)

🛠️ package.json — champs essentiels en 2026

json
{
  "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" : remplace main et types. 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.

bash
# 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
ini
# .npmrc — désactiver scripts globalement (controversé)
ignore-scripts=true

Audit signé

npm audit signatures (npm 10+) vérifie que les packages installés ont des signatures valides du registry npm :

bash
npm audit signatures

C'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.

ini
# .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é) :

json
{
  "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

bash
# 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

🏋️ 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
json
{ "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.

Bibliothèque tech perso — Achref