Das Symptom
Ein Skript, ein Build-Werkzeug oder ein Testlauf bricht mit einem Stapel ab, der mit Error [ERR_REQUIRE_ESM] beginnt. Meist steckt dahinter ein Paket, das in einer neuen Hauptversion auf ES-Module umgestellt wurde, während der eigene Code oder ein Werkzeug dazwischen es noch mit require() lädt. Die üblichen Ratschläge lauten: Paket auf die letzte CommonJS-Version zurückpinnen, alles auf import umschreiben, oder package.json mit type: module versehen und dann die halbe Werkzeugkette hinterherziehen.
Das Verwirrende: Auf dem Rechner des Kollegen läuft derselbe Code ohne Fehler. In der CI fällt er um. Oder umgekehrt. Niemand hat den Code angefasst, und doch verhält sich dasselbe require() auf zwei Maschinen verschieden.
Die „Layer 8“-Wahrheit
Der Unterschied liegt nicht im Code, sondern in der Node-Version. Die aktuelle Fehlerreferenz von Node.js führt ERR_REQUIRE_ESM mit der Einstufung Stability: 0 - Deprecated und erklärt: „This error has been deprecated since require() now supports loading synchronous ES modules.“ Die Versionsgeschichte des Eintrags nennt die Grenze: „v23.0.0, v22.12.0, v20.19.0 require() now supports loading synchronous ES modules by default.“ Ein Node 20.18 oder 22.11 wirft den Fehler noch, 20.19 und 22.12 nicht mehr. Wer im September die Regel gelernt hat, dass CommonJS keine ES-Module laden kann, hat eine Regel gelernt, die in aktuellem Node nicht mehr gilt.
Die Modulreferenz beschreibt die neue Fähigkeit ausführlich. Sie war ab v22.0.0 und v20.17.0 hinter einem Schalter vorhanden, fiel dann in drei Zweigen aus dem Experimentalstatus, und seit v25.4.0 heißt es schlicht: „This feature is no longer experimental.“ Dazu gehört eine Bedingung, die die eigentliche Grenze zieht: Das zu ladende Modul muss vollständig synchron sein, darf also kein top-level await enthalten, weder selbst noch in einem seiner Importe. Ist das verletzt, kommt kein ERR_REQUIRE_ESM, sondern ERR_REQUIRE_ASYNC_MODULE, und die Dokumentation sagt klar, was dann zu tun ist: das Modul per import() laden. Für die Fehlersuche gibt es seit v26.5.0 die Eigenschaft requireStack, die die Kette der beteiligten Module enthält, und den Schalter –experimental-print-required-tla, der die Fundstellen der top-level awaits auf stderr schreibt.
Zweite Fußangel: Was require() zurückgibt, ist der Modul-Namensraum, nicht der Standardexport. Ein ES-Modul mit export default landet als Objekt mit den Eigenschaften default und __esModule: true im aufrufenden Code. Bibliotheksautoren können das ändern, indem sie den gewünschten Wert unter dem Exportnamen module.exports exportieren; die Modulreferenz zeigt das am Beispiel einer Klasse Point und weist darauf hin, dass benannte Exporte dann für CommonJS-Nutzer verloren gehen, wenn sie nicht als Eigenschaften am Standardexport hängen. Wer also nach dem Node-Update statt einer Funktion plötzlich ein Objekt mit default in der Hand hält, sieht korrektes Verhalten.
Und für den Fall, dass das neue Verhalten etwas kaputtmacht, ist die Rückfahrkarte dokumentiert: –no-require-module schaltet die Fähigkeit ab, –trace-require-module zeigt, wo sie benutzt wird, und process.features.require_module verrät zur Laufzeit, ob sie vorhanden ist. Die Entscheidung Zurückpinnen, Umschreiben oder Node aktualisieren ist damit keine Glaubensfrage mehr. Erst die Version prüfen, dann den Code anfassen. Meistens ist nur das Erste nötig.