Egy csomag, két példány: amikor az instanceof jogosan mond nemet

Ha egy npm csomag ESM-ként és CommonJS-ként is megjelenik, a Node mindkét buildet betölti: két osztály, két singleton, bukó instanceof. A dual package hazard anatómiája – és ahogy a require(esm) meg a module-sync feltétel pontot tesz a végére.

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.

Diagram: egy npm csomag import és require ágon két külön példányként töltődik be, alul a module-sync feltétellel mindkét út ugyanabba az egy példányba fut

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

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük