Van az a fajta hiba, amitől az ember előbb a debuggerére gyanakszik, aztán magára. Az err instanceof HttpError false-t ad, pedig a hibaobjektumon ott a status mező, a constructor.name szerint is HttpError, és a node_modules-ban bizonyíthatóan egyetlen verzió van a csomagból – az npm ls sem mutat duplikációt. Verziókonfliktus kizárva, két lockfile kizárva, a kávé elfogyott.
A megfejtés banálisabb, mint a gyanúsítottak: a csomag tényleg egyszer van feltelepítve, de kétszer él a processzben – egyszer ES module-ként, egyszer CommonJS-ként. A Node dokumentáció ezt éveken át külön szekcióban tárgyalta dual package hazard néven, és nem bug: a package.json exports mezőjének dokumentált, szabálykövető viselkedése. Csak éppen senki nem szólt, hogy az instanceof bánja majd.

Két belépő, két modulgráf
A modern csomagok jó része conditional exports-szal jelenik meg: ugyanarra a belépési pontra más fájlt kap az, aki import-tal érkezik, és mást az, aki require-ral.
{
"name": "logkit",
"exports": {
".": {
"import": "./esm/index.js",
"require": "./cjs/index.cjs"
}
}
}
Ártatlanul néz ki, és a szándék is jó: mindenki a saját modulrendszerének megfelelő buildet kapja. Csakhogy a Node modulcache-ének kulcsa a feloldott fájlútvonal, nem a csomagnév. A két belépő két külön fájl, tehát két külön modul, két külön gráffal. Minden, ami a csomagban module szinten él – osztálydefiníció, Map, singleton, Symbol –, kétszer jön létre.
A minimál repró: az app ESM, de az egyik régebbi fájlja még CommonJS, és mindkettő ugyanazt a csomagot használja.
// middleware.cjs
const { HttpError } = require('logkit');
module.exports.auth = () => { throw new HttpError(401, 'nincs token'); };
// app.mjs
import { HttpError } from 'logkit';
import { auth } from './middleware.cjs';
try { auth(); } catch (err) {
console.log(err instanceof HttpError); // false
console.log(err.constructor.name); // 'HttpError'
}
Ha a csomag mindkét belépőjébe teszünk egy naplósort, a kimenet elárulja magát: az inicializálás kétszer fut le, egyszer ESM-ként, egyszer CJS-ként. Az instanceof tehát nem hazudik – tényleg két külön HttpError osztály van a memóriában, és a middleware a másikat dobta, mint amit az app elkap.
Ez nem elméleti probléma. A graphql csomag például kifejezetten őrfeltételt tart a forrásában, ami „Cannot use … from another module or realm” hibával azonnal elhasal, ha két példányban töltődött be – mert a duplikált csomag nála nem furcsa bug, hanem garantáltan törött séma-validáció.
Hogy került két build egy csomagba?
Történelmi okokból: a require() évekig egyszerűen nem volt hajlandó ES module-t betölteni, csak dobta az ERR_REQUIRE_ESM-et. Aki library-szerzőként mindkét világot ki akarta szolgálni, kénytelen volt két buildet szállítani, és az exports mezővel szétosztani őket. A dual package hazard ennek a kompromisszumnak a számlája.
Az exports mező közben mellékesen kapszuláz is: ami nincs benne felsorolva, az kívülről nem elérhető – a mély importokra kapott ERR_PACKAGE_PATH_NOT_EXPORTED nem szívatás, hanem szerződés. És egy apróság, ami sok órányi értetlenkedést spórol meg: a feltételek sorrendje számít. A Node fentről lefelé megy, az első találat nyer, a default-nak a végén a helye. Egy előre csúszott default csendben elnyeli az összes többi ágat.
Közben a require(esm) csendben megérkezett
Amire az egész kettős build épült – hogy require-ral nem lehet ESM-et betölteni –, ma már nem igaz. A Node 22.12.0 óta a require(esm) flag nélkül működik az LTS-vonalon is, futásidőben a process.features.require_module árulja el, hogy él-e. A feltétel annyi, hogy a betöltött modulgráfban ne legyen top-level await – arra ERR_REQUIRE_ASYNC_MODULE jár, mert azt tényleg nem lehet szinkron megvárni. Joyee Cheung, a feature szerzője ezt úgy fogalmazta meg, hogy az ESM valójában csak feltételesen aszinkron – és a gyakorlatban meglepően kevés csomag használ top-level await-et.
Az interop is átgondoltabb, mint várnánk: a require() ilyenkor a module namespace objektumot adja vissza a named exportokkal, de ha a csomag ki akarja szolgálni a régi module.exports-os elvárásokat, van rá egy egzotikus szintaxis: export { fn as 'module.exports' }. Igen, string lehet az exportnév – és a Node ezt kitüntetetten kezeli.
module-sync: egy példány mindenkinek
Ha a require már tud ESM-et tölteni, a két buildre sincs szükség – ezt mondja ki a Node 22.10.0-ban bevezetett module-sync exports feltétel. A fenti csomag javítva:
{
"name": "logkit",
"exports": {
".": {
"module-sync": "./esm/index.js",
"default": "./cjs/index.cjs"
}
}
}
A module-sync csak olyan Node-on aktív, ahol a require(esm) engedélyezett – ott viszont az import és a require ugyanarra az egy ESM fájlra oldódik fel, tehát ugyanazt az egy modulpéldányt kapja. A fenti repró ezzel a package.json-nal egyetlen inicializálást naplóz, és az instanceof visszatér az igazmondáshoz. Régi Node-on a default ág él, ott marad a CJS build és vele a hazard – pont ez a terv: az ESM-first világba úgy lehet átállni, hogy közben senkinek nem törik el semmi.
Mit kezdj vele?
Appfejlesztőként a felismerés a nehezebb rész. A gyanús tünetek: egy module-szintű side effect kétszer fut le, egy „singleton” registry-ből ketten látnak külön állapotot, egy instanceof vagy Symbol-összehasonlítás magyarázhatatlanul bukik. Ilyenkor egyetlen kérdést kell feltenni: a két oldal ugyanarra a fájlra oldja-e fel a csomagot?
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
console.log(import.meta.resolve('logkit'));
// file:///…/node_modules/logkit/esm/index.js
console.log(require.resolve('logkit'));
// /…/node_modules/logkit/cjs/index.cjs ← két külön fájl: megvan a tettes
Library-szerzőként pedig ideje kimondani: 2026-ban új csomagnak dual buildet kiadni felesleges önsanyargatás. A Node 20 áprilisban elérte az élettartama végét, a támogatott vonalak (22, 24) aktuális kiadásai mind flag nélkül tudják a require(esm)-t – egy sima ESM-only csomagot ma már a CommonJS-felhasználók is gond nélkül require-olnak. Meglévő dual csomagnál a module-sync a civilizált kivezetési terv: az új Node-okon megszünteti a kettős betöltést, a régieken nem tör el semmit. Én production-ben ma új library-t ESM-only-ként adnék ki, meglévőnél pedig a module-sync-et venném fel – a két build karbantartása és az ebből fakadó duplapéldányos rejtélyek vadászata pontosan az a fajta munka, amiből kevesebbet szeretne az ember.
Források
- Node.js dokumentáció: Packages – exports, conditional exports
- Node.js v18 dokumentáció: Dual package hazard
- Node.js dokumentáció: Loading ECMAScript modules using require()
- Node.js v22.12.0 release: require(esm) alapértelmezetten engedélyezve
- Node.js v22.10.0 release: a module-sync exports feltétel
- Joyee Cheung: require(esm) in Node.js
- nodejs/node #55085: unflag –experimental-require-module