A node --run tízszer gyorsabb, mint az npm run – de csendben kihagyja a pre/post szkriptjeidet

A Node.js 22 óta natívan is le tudja futtatni a package.json szkriptjeit, npm nélkül, és ez mérhetően gyorsabb. A trükk az, hogy amit kihagy belőle, azt csendben hagyja ki.

Ha a package.json-odban van egy build vagy test szkript, valószínűleg soha nem gondolkodtál el azon, mennyi idő megy el azzal, hogy az npm run egyáltalán elindítja a Node-ot, ami aztán elindítja a te szkriptedet. Ez a felesleges kör egy triviális szkript esetén nagyságrendekkel több időt visz el, mint maga a munka. A Node.js 22 óta van erre egy beépített, npm nélküli megoldás – csak épp nem ártalmatlan cserét ajánl.

Diagram: felül az npm run láncolt pre-teszt-post lépéssora egy lassú piros órával a végén, alul a node --run egyetlen közvetlen lépése gyors zöld órával és sebességvonalakkal.

Mi az a node --run?

A Node.js CLI dokumentációja szerint a --run kapcsoló a package.json scripts mezőjéből futtat le egy megnevezett parancsot – gyakorlatilag az npm run egy alternatívája, csomagkezelő nélkül. A funkciót Yagiz Nizipli implementálta, a nodejs/node#52190 PR-ban, és a Node.js 22.0.0-ban jelent meg 2024. április 24-én.

$ node --run test
$ node --run build -- --watch

Az argumentumok a -- után egyszerűen hozzáfűződnek a szkripthez, ugyanúgy, ahogy az npm run-nál megszoktuk. Eddig semmi meglepő. A különbség ott kezdődik, hogy mi történik a motorháztető alatt.

A sebességkülönbség valódi, és mérhető

Az npm run minden egyes híváskor elindít egy npm CLI-t, ami beolvassa a konfigurációt, felépíti a környezeti változók egy egész csokrát, majd shell-en keresztül elindítja a tényleges parancsot. A node --run ezt megkerüli: a Node bináris maga olvassa be a package.json-t, és közvetlenül futtatja a parancsot. Ezt nem elhiszem-alapon állítom – lefuttattam helyben, Node 22.23.2-n, egy 20 iterációs mérésben egy szinte semmit nem csináló szkriptre (node -e "process.exit(0)"):

--- npm run hello x20 ---
real  0m5.195s   (≈260 ms / hívás)

--- node --run hello x20 ---
real  0m0.509s   (≈25 ms / hívás)

Tízszeres különbség egy olyan szkriptnél, ami gyakorlatilag semmit nem csinál – vagyis a mért idő szinte tisztán az indítási overhead. Ez nem számít sokat, ha egyszer futtatod le a build-et. De egy monorepóban, ahol egy CI pipeline percenként tucatnyi run hívást indít workspace-enként, vagy egy fájlváltozásra újrainduló watch-szkriptnél ez a különbség összeadódik. Ez a motiváció is áll a funkció mögött: a dokumentáció kifejezetten „top performance for the most common use cases” célt említ.

Amit szándékosan kihagy – és csendben teszi

A dokumentáció egy külön szakaszt szentel ennek, „Intentional limitations” címmel. Két dolgot említ explicit módon: nem futtatja le a pre/post életciklus-szkripteket, és nem állítja be a csomagkezelőre jellemző környezeti változókat. Ez így, önmagában absztraktnak hangzik – úgyhogy leteszteltem, mit jelent ez a gyakorlatban.

Egy pretest/test/posttest hármassal az npm run test mindhármat lefuttatja sorban – ezt az npm dokumentációja is így írja le. A node --run test viszont csak a test szkriptet futtatja le, a pretest és posttest szó nélkül kimarad – nincs figyelmeztetés, nincs hibaüzenet, egyszerűen nem fut le. Ha valaki a pretest-re bízza a lint vagy típusellenőrzés lefuttatását, és csak lecseréli a CI-ban az npm run test-et node --run test-re a sebesség kedvéért, az a lépés némán kiesik a pipeline-ból.

A másik, ennél is alattomosabb rész: az npm egy sor npm_* előtagú környezeti változót állít be minden szkript futtatásakor – ilyen a npm_package_version vagy a npm_lifecycle_event, amiket rengeteg build-szkript használ verziószám vagy release-címke beégetéséhez. Ezt is leteszteltem ugyanazzal a package.json-nal:

--- npm run showvars ---
npm_package_version=1.2.3
npm_lifecycle_event=showvars

--- node --run showvars ---
npm_package_version=undefined
npm_lifecycle_event=undefined

Na, ezt nem tudtam: a node --run alatt ezek a változók nem hiányoznak hibával, hanem csendben undefined-ra állnak. Egy Docker image-taggelő vagy Sentry release-jelölő szkript, ami process.env.npm_package_version-re épít, nem fog elszállni – csak egy undefined stringgel taggeli meg a build-et. Ez pontosan az a fajta hiba, ami nem esik le code review-n, csak élesben, amikor már valaki a hibás verziójelzés miatt keresi a rossz image-et.

Amit mégis kapsz cserébe

A hiányzó npm-változók helyett a Node.js 22.3.0-tól (nodejs/node#53032 és #53058) két saját változót ad: NODE_RUN_SCRIPT_NAME (a futtatott szkript neve) és NODE_RUN_PACKAGE_JSON_PATH (a feloldott package.json elérési útja). Ez helyben is így viselkedik:

$ node --run test
TEST-RAN
NODE_RUN_SCRIPT_NAME=test
NODE_RUN_PACKAGE_JSON_PATH=/tmp/noderun-test/package.json

Ez nem pótolja az npm teljes változókészletét, de a leggyakoribb esetre – „melyik szkript fut, honnan” – ad egy stabil, dokumentált API-t.

A másik hasznos, kevésbé reklámozott képesség ugyanabból a release-ből (nodejs/node#53154): a --run felfelé bejárja a könyvtárfát a gyökérig, amíg talál egy package.json-t, és minden érintett szülőkönyvtár node_modules/.bin mappáját hozzáfűzi a PATH-hoz. Ez konkrétan monorepókban számít: egy workspace-csomagból kiadott node --run hívás eléri a gyökér node_modules/.bin-jébe hoisztolt bináris eszközöket is, akkor is, ha te magad egy mélyebb alkönyvtárból dolgozol. Ezt is leteszteltem – egy gyökér node_modules/.bin-be tett bináris egy két szinttel lejjebb lévő csomag szkriptjéből simán elérhető volt node --run-nal, közvetlen PATH-módosítás nélkül.

Egy csendes, releváns rész: az --env-file

Ha a --env-file kapcsolóval töltesz be környezeti változókat, és utána --run-nal futtatsz egy szkriptet, a dokumentáció külön kiemeli: az onnan betöltött változók nem öröklődnek a --run által indított folyamatba. Ezt is leellenőriztem – egy .env fájlból betöltött változó a --run-nal indított szkriptben undefined maradt, miközben a node --env-file önmagában (egy sima node script.js előtt) helyesen belövi. Ha valaki lokális titkokat egy .env fájlból akar egy npm-szkriptnek átadni --run-on keresztül, csalódni fog.

Mikor van értelme, mikor nem

A node --run ott ér igazán sokat, ahol sok, gyakran ismételt, egyszerű szkriptindítás történik: fájlváltozásra újrainduló dev-loopban, vagy egy CI mátrixban, ahol ugyanaz a lépés workspace-enként sokszor lefut. Ott a 200+ milliszekundumos npm-overhead valódi, összeadódó költség.

Éles projektben én csak ott cserélném le vele az npm run-t, ahol előbb végigmentem a szkriptláncon, és tudom, hogy: nincs pre/post hook, amire számítok; a szkript nem használ npm_* változót; és nem .env-ből várja a titkait. Ez a három feltétel egy egyszerű build- vagy teszt-szkriptnél könnyen teljesül, egy örökölt, sok éve épülő npm-szkript-erdőnél viszont pont a kivétel szokott lenni a szabály. A dokumentáció maga is világosan leszögezi: ez szándékosan nem az npm run (vagy más csomagkezelők run parancsának) helyettesítője, hanem egy szűkebb, gyorsabb eszköz egy szűkebb feladatra. Ha ezt a határvonalat tiszteletben tartod, jó eszköz; ha vakon lecseréled vele a CI-t, a hiba nem a build logban jelenik meg, hanem egy hibás verziójelzésű image-ben vagy egy lemaradt lint-lépésben, jóval később.

Források

Leave a Reply

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