12k
All articles

La fin des builds CJS/ESM doubles dans Node.js

Node.js prend désormais en charge require(esm), faisant dESM-only le choix par défaut pour beaucoup de bibliothèques. Quand quitter CJS, éviter le top-level await et migrer sans risque.

OpenReplay Team
OpenReplay Team
La fin des builds CJS/ESM doubles dans Node.js

À partir de juin 2026, chaque version de Node.js encore supportée peut exécuter require() sur un module ES, ce qui supprime la principale raison pour laquelle la plupart des bibliothèques ont historiquement livré des builds doubles CommonJS/ESM — pour une part large et croissante des packages, l’ESM-only est désormais le choix par défaut le plus approprié. L’asymétrie qui a défini une décennie de galères de packaging — CommonJS ne pouvait ni import ni require quoi que ce soit depuis l’univers ESM — ne s’applique plus sur aucun runtime que vous devriez encore cibler. La map exports double, les sorties parallèles tsup/unbuild, la jonglerie entre déclarations .d.cts/.d.ts : la majeure partie de cette mécanique existe pour résoudre un problème que Node a maintenant résolu en natif.

Cet article présente l’argument de 2026 que les anciens guides de build double ne peuvent pas formuler : voici la chronologie exacte des versions où l’asymétrie a disparu, ce que require(esm) fait réellement et la seule limite stricte qu’il impose, ainsi qu’un cadre décisionnel pour déterminer si vous avez encore besoin d’un build CommonJS. La contrainte n’a pas disparu — elle s’est déplacée. Le nouveau contrat de compatibilité n’est plus « livrer deux formats » ; c’est « garder votre chemin de chargement synchrone exempt de top-level await. »

Points clés

  • À partir de Node.js 25.4.0 (publié le 19 janvier 2026), require(esm) est marqué comme stable, et ce même changement a été rétroporté vers les branches LTS actives — ce qui signifie que chaque version de Node.js actuellement supportée intègre la capacité d’exécuter require() sur un module ES.
  • require(esm) est d’abord apparu derrière le flag --experimental-require-module dans Node 22, a été déverrouillé dans Node 23, rétroporté vers LTS dans v22.12.0 (3 décembre 2024) et v20.19.0, puis déclaré stable fin 2025.
  • require(esm) a exactement une limite stricte : il ne peut pas charger un module ES dont le graphe utilise du top-level await, ce qui lève ERR_REQUIRE_ASYNC_MODULE et vous invite à utiliser import() à la place.
  • Pour un auteur ESM-only, le premier top-level await n’importe où dans votre graphe accessible via require constitue un changement cassant pour chaque consommateur CommonJS — traitez-le comme un incrément majeur de semver.
  • Si votre package cible Node 22.12+ et évite le top-level await dans le code que les utilisateurs CJS vont require(), livrer en ESM-only est désormais le choix par défaut le plus approprié ; conservez un build CJS uniquement pour les runtimes antérieurs à 20.19 ou les modules TLA.

Pourquoi les builds CJS/ESM doubles ont existé

Les builds doubles existaient parce que CommonJS ne pouvait pas exécuter require() sur un module ES. Les deux systèmes chargent différemment : require() est synchrone et retourne module.exports dès que l’appel se termine, tandis que l’ESM était traité comme inconditionnellement asynchrone. Un appelant synchrone ne peut pas attendre un chargement asynchrone, donc require('some-esm-package') levait ERR_REQUIRE_ESM. La direction inverse a toujours fonctionné — l’ESM peut import du CommonJS — ce qui a produit la situation déséquilibrée dans laquelle les auteurs de bibliothèques ont vécu pendant des années : livrer l’ESM pour les consommateurs modernes, livrer le CommonJS pour tous ceux qui utilisent encore require(), et relier les deux via des exports conditionnels.

Cela impliquait une vraie surcharge outillage. Des bundlers comme tsup et unbuild émettent les deux formats ; une map exports dans package.json route import vers l’entrée .mjs et require vers l’entrée .cjs ; TypeScript a besoin de déclarations .d.ts et .d.cts colocalisées pour que les deux modes de résolution passent la vérification de types. Le guide de build double de 2021 d’Anthony Fu et le tutoriel de 2023 de Mayank documentent cette mécanique en détail — et tous deux restent exacts sur comment procéder. Ils répondent simplement à une question qui, pour les runtimes actuels, n’a plus besoin d’être posée.

Les builds doubles comportaient également un risque structurel : le dual-package hazard (risque de package double). Lorsqu’un graphe de dépendances charge votre package via import à un endroit et require à un autre, Node peut charger deux copies distinctes — le build ESM et le build CJS — comme des instances de module séparées. Tout singleton, cache, registre ou vérification instanceof se retrouve alors face à deux états divergents. Le build double qui résolvait le problème d’interopérabilité créait silencieusement un problème de duplication d’état.

require(esm) : les versions exactes où l’asymétrie a disparu

Le correctif est venu d’une remise en question d’une hypothèse longtemps tenue pour acquise. Comme l’a documenté Joyee Cheung, contributrice au cœur de Node, l’ESM lui-même n’était pas réellement conçu pour être inconditionnellement asynchrone — il était conçu pour n’être que conditionnellement asynchrone, uniquement lorsque le graphe contient du top-level await, de sorte qu’il semblait naturel que require() prenne au moins en charge les graphes ESM ne contenant pas de top-level await. Cette intuition a rendu possible un require() synchrone de (la plupart des) modules ES, et require(esm) a été construit sur cette base.

Le déploiement s’est déroulé par étapes sur les différentes branches de versions. Voici la chronologie à partir de juin 2026 :

Branche Node.jsStatut de require(esm)Phase de support (juin 2026)
18.xN’a jamais reçu le rétroportageEOL — doit migrer vers 20+
20.xDéverrouillé à partir de v20.19.0EOL le 30 avril 2026
22.xActivé par défaut à partir de v22.12.0 (3 déc. 2024)Maintenance LTS
23.xDéverrouillé (non-LTS)EOL
24.xMarquage stable rétroporté à v24.15.0 (15 avr. 2026)Active LTS
25.xMarqué stable à v25.4.0 (19 jan. 2026)EOL le 1er juin 2026
26.xStableCurrent

Le fait marquant : dans la version v25.4.0, le changement « module: mark require(esm) as stable » (PR #60959) a supprimé le marqueur expérimental, et ce même commit a été rétroporté dans la branche LTS à v24.15.0. La fonctionnalité avait été déverrouillée par défaut bien avant la stabilisation : Node 22.12.0 a été la première version LTS à l’activer par défaut, et elle a été rétroportée vers Node 20 à v20.19.0. Node 18 n’a jamais reçu le rétroportage.

Conformément au calendrier des versions Node.js, les branches supportées en juin 2026 sont 22 (Maintenance LTS), 24 (Active LTS, support actif jusqu’au 20 octobre 2026, puis maintenance de sécurité jusqu’au 30 avril 2028) et 26 (Current). Les trois se situent au-dessus du seuil de déverrouillage. Node 18 n’ayant jamais reçu le rétroportage et Node 20 ayant atteint sa fin de vie le 30 avril 2026, la version minimale que tout projet supporté devrait cibler inclut déjà require(esm).

Ce que require(esm) change pour les auteurs de bibliothèques

Un consommateur CommonJS sur une version actuelle de Node peut désormais exécuter require() directement sur un package ESM-only. La justification originale pour livrer un build CJS — que les appelants require() seraient autrement exclus — ne tient plus sur aucun runtime supporté. Comme le décrit la documentation Node.js, si le module ES chargé satisfait les prérequis, require() peut le charger et retourner l’objet namespace du module ; dans ce cas, c’est similaire à import() dynamique, mais exécuté de manière synchrone et retournant directement l’objet namespace.

Cela met également fin au dual-package hazard. Puisqu’un appelant CommonJS charge désormais le module ES réel au lieu d’une copie CJS parallèle, il n’existe qu’une seule instance de module, un seul singleton, un seul cache — le problème d’états divergents qui justifiait les builds doubles soigneux ne se pose tout simplement plus lorsqu’il n’y a qu’un seul build.

Un détail d’interopérabilité est important lorsque vous supprimez le wrapper CJS. require(esm) retourne un objet namespace, et non une valeur brute, donc un export par défaut se trouve sur .default plutôt que d’être la valeur de retour elle-même, à l’instar des résultats retournés par import(). Si vous souhaitez une valeur de retour unique à la manière de CommonJS, le module ES peut exporter la valeur souhaitée en utilisant le nom de chaîne "module.exports" pour personnaliser ce que require(esm) retourne directement.

Vous pouvez détecter la prise en charge au runtime lorsque vous avez besoin d’un chemin de repli en vérifiant si process.features.require_module vaut true.

// Détection de fonctionnalité au runtime — true sur Node 20.19+, 22.12+, et tout 24/26.
if (process.features.require_module) {
  const lib = require("some-esm-only-package");
  // l'export par défaut est sur .default
  const fn = lib.default ?? lib;
}

La seule limite : le top-level await est le nouveau contrat de compatibilité

require(esm) a exactement une limite stricte : il ne peut pas charger un module ES dont le graphe utilise du top-level await. Parce que require() doit rester synchrone, un fichier ESM qui suspend sa propre évaluation sur un await de niveau supérieur ne peut pas être chargé de cette façon. Si le module sur lequel require() est appelé contient du top-level await, ou si le graphe de modules qu’il importe en contient, ERR_REQUIRE_ASYNC_MODULE sera levée, et les utilisateurs devront charger le module asynchrone en utilisant import() à la place. Le message d’erreur est explicite : « require() cannot be used on an ESM graph with top-level await. Use import() instead. »

Le mot critique est graphe. La limite ne concerne pas le fichier que vous require — elle concerne tout ce que ce fichier importe transitivement.

Un incident réel et daté illustre l’étendue des dégâts. En avril 2026, lru-cache@11.3.0 a introduit un top-level await dans son build ESM, ce qui a cassé tout module CJS chargeant transitivement le build ESM de lru-cache, notamment jsdom via @asamuzakjp/css-color (qui est du pur ESM sans point d’entrée CJS). La chaîne était : jsdom (CJS) → un package de couleurs pur ESM → l’entrée ESM de lru-cache, désormais asynchrone. La map exports routait correctement require vers CJS et import vers ESM ; mais lorsque le package CJS exigeait un package pur ESM, Node résolvait le graphe ESM, et dans ce graphe le point d’entrée ESM de lru-cache — contenant désormais du TLA — rendait l’ensemble du graphe impossible à require() de manière synchrone. Le mainteneur a annulé le top-level await dans un patch ultérieur, donc la régression est résolue — mais elle prouve que ce mode d’échec frappe en production. La même cascade ERR_REQUIRE_ASYNC_MODULE a touché Prettier et firebase-tools lorsque Node 22.12.0 a activé la fonctionnalité.

require(esm) recadre l’ensemble du problème : il supprime la raison d’interopérabilité pour les builds doubles, mais il fait de l’absence de TLA un contrat. Pour un auteur ESM-only, le premier top-level await que vous ajoutez n’importe où dans votre graphe accessible via require constitue un changement cassant pour chaque consommateur CommonJS. Comme le soutient Evert Pot, si c’est le premier await, vous pourriez casser par inadvertance les utilisateurs Node.js qui utilisaient require() pour importer votre module — ce qui signifie que le premier top-level await dans votre projet ou dans l’une de vos dépendances pourrait désormais constituer une nouvelle version majeure si vous suivez semver. Traitez-le comme un incrément majeur de semver.

Le top-level await est véritablement peu courant dans le code de bibliothèque. Lorsque Cheung a testé l’implémentation pour la première fois, aucun des ~30 packages ESM-only à fort impact testés ne contenait de top-level await — ce qui explique pourquoi require(esm) synchrone couvre l’écrasante majorité des packages réels.

Avez-vous encore besoin d’un build CJS en 2026 ?

Pour la plupart des nouveaux packages, non. Optez par défaut pour l’ESM-only et n’ayez recours à un build double que lorsqu’une contrainte spécifique l’impose. Répondez à trois questions :

  1. Quelle est votre cible Node minimale ? Si c’est Node 22.12+ (et avec Node 20 désormais en EOL, ce devrait être le cas), chaque consommateur peut exécuter require() sur votre ESM. Livrez en ESM-only. Si vous devez vraiment supporter des runtimes antérieurs à 20.19 encore en circulation, vous avez encore besoin d’un build CJS pour eux.
  2. Votre graphe accessible via require utilise-t-il du top-level await ? Si oui — dans votre code ou dans une dépendance chargée de manière synchrone — les consommateurs CJS se heurteront à ERR_REQUIRE_ASYNC_MODULE. Soit supprimez le TLA (souvent remplaçable par un import() différé plutôt que de niveau supérieur), soit conservez une entrée CJS et documentez que les utilisateurs require() ne sont pas supportés.
  3. Contrôlez-vous vos consommateurs ? Les auteurs d’applications sur un Node actuel épinglé peuvent passer librement à l’ESM-only. Les auteurs de bibliothèques avec des consommateurs en aval inconnus devraient toujours publier une map exports propre et traiter le TLA comme un événement de versionnage.

Si aucune de ces contraintes n’impose un second format, le build double est du poids mort : outillage supplémentaire, CI plus lente, artefact publié plus volumineux, et un dual-package hazard réintroduit sans aucun bénéfice.

Migrer vers l’ESM-only : la checklist

Passer à l’ESM-only se résume principalement à une simplification de package.json et à une syntaxe de module rigoureuse. Les étapes :

  1. Définissez "type": "module" pour que les fichiers .js soient analysés comme de l’ESM.
  2. Simplifiez la map exports vers une seule entrée ESM. La map double devient une seule ligne :
{
  "type": "module",
  "exports": "./dist/index.js",
  "engines": { "node": ">=22.12.0" }
}

La valeur engines recommandée dans la rétrospective est "^20.19.0 || >=22.12.0" ; puisque Node 20 est en EOL, >=22.12.0 seul est défendable.

  1. Utilisez des extensions .js explicites dans les imports relatifs — l’ESM les exige : import { x } from "./util.js", et non "./util".
  2. Définissez "moduleResolution": "NodeNext" dans tsconfig.json pour que TypeScript émette et résolve l’ESM correctement, y compris les extensions obligatoires.
  3. Remplacez les variables globales CommonJS. L’ESM ne dispose pas de __dirname, __filename ni de require. Reconstituez-les à partir de import.meta :
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
  1. Auditez la présence de top-level await dans votre propre code et vos dépendances avant de publier. Si vous envisagez d’utiliser du TLA ultérieurement, planifiez dès maintenant l’incrément de version majeure plutôt que de le livrer comme un patch.

Ce que cela implique pour les auteurs de bibliothèques

Le mur d’interopérabilité qui justifiait les builds CJS/ESM doubles a disparu sur chaque version de Node.js qui mérite d’être supportée : require(esm) est stable à partir de v25.4.0 et présent sur les branches 22, 24 et 26. La contrainte restante est étroite et nommable — gardez le top-level await hors du chemin qu’un appelant require() traversera, et traitez le premier comme un changement cassant. Pour un nouveau package ciblant Node actuel, livrez en ESM-only, simplifiez la map exports, et auditez votre graphe pour détecter le TLA avant de publier.

FAQ

Puis-je exécuter require() sur un package ESM-only dans Node.js 22 ?

Oui. Node 22 a activé require(esm) par défaut à partir de v22.12.0, publié le 3 décembre 2024, donc un fichier CommonJS s'exécutant sur n'importe quelle version 22.12 ou ultérieure peut exécuter require() directement sur un package ESM-only, à condition que le graphe de ce package ne contienne pas de top-level await. La fonctionnalité a ensuite été marquée stable dans Node 25.4.0 et rétroportée vers la branche LTS 24.x à v24.15.0, mais elle est fonctionnelle sur Node 22 depuis la version v22.12.0.

Quelle est la différence entre ERR_REQUIRE_ESM et ERR_REQUIRE_ASYNC_MODULE ?

ERR_REQUIRE_ESM était l'ancienne erreur levée chaque fois que CommonJS tentait d'exécuter require() sur un module ES, et elle ne se produit plus sur les versions Node supportées car require(esm) gère le chargement synchrone de l'ESM. ERR_REQUIRE_ASYNC_MODULE est l'erreur moderne plus ciblée, levée uniquement lorsque le graphe ESM requis contient du top-level await, puisque require() ne peut pas attendre une évaluation asynchrone. Son message vous invite à utiliser import() à la place. La première erreur signifiait que l'ESM n'était pas supporté ; la seconde signifie qu'une fonctionnalité ESM spécifique ne l'est pas.

require(esm) retourne-t-il directement l'export par défaut ?

Non. require(esm) retourne l'objet namespace complet du module, et non une valeur brute, donc un export par défaut se trouve sur la propriété .default plutôt que d'être la valeur de retour elle-même, ce qui correspond au comportement de import() dynamique. Cela diffère d'un module CommonJS traditionnel où require() retourne directement module.exports. Si vous avez besoin d'une valeur de retour unique, un module ES peut l'exporter en utilisant le nom de chaîne 'module.exports', ce qui personnalise ce que require(esm) retourne. Vérifiez toujours .default lors de la migration des consommateurs depuis un wrapper CJS.

Comment vérifier au runtime si require(esm) est disponible ?

Vérifiez si process.features.require_module vaut true. Ce booléen est défini par le runtime Node.js et retourne true sur chaque version qui prend en charge l'exécution de require() sur des modules ES, ce qui inclut Node 20.19 et ultérieur, 22.12 et ultérieur, ainsi que toutes les branches 24 et 26. Utilisez-le pour choisir entre un require() synchrone et un fallback import() asynchrone lorsque vous devez supporter un mélange de runtimes anciens et récents dans la même base de code.

Est-il sûr de livrer en ESM-only si mes dépendances utilisent du top-level await ?

Pas pour les consommateurs CommonJS. La limite de require(esm) s'applique à l'ensemble du graphe accessible via require, et non uniquement à vos propres fichiers, donc un top-level await n'importe où dans une dépendance chargée de manière synchrone lèvera ERR_REQUIRE_ASYNC_MODULE pour quiconque utilise require(). Un incident documenté en 2026 a vu lru-cache ajouter du top-level await à son build ESM et casser jsdom transitivement avant que le mainteneur ne l'annule. Auditez l'intégralité de votre graphe de dépendances avant de passer à l'ESM-only, ou conservez une entrée CJS et marquez les utilisateurs require() comme non supportés.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.