Van az a pillanat, amikor beesik a hibakövetőbe egy TypeError, a stack trace pedig ennyit mond: at r (app.js:1:48213). Egy sor, negyvennyolcezredik oszlop, egy r nevű függvény. Ilyenkor két dolog történhet: vagy van source maped, és a DevTools szó nélkül visszafordítja az egészet olvasható kódra, vagy nincs, és elkezded grepelni a bundle-t. A visszafordítást egy olyan formátum végzi, amit naponta használ nagyjából minden webfejlesztő – és ami 2024 decemberéig hivatalosan nem is létezett. Tizenhárom éven át egy Google Docban élt „Source Map Revision 3 Proposal” címmel, és a bundlerek meg a böngészők ebből a megosztott dokumentumból dolgoztak. Aztán a TC39 külön munkacsoportot (TG4) állított rá, és a formátumból ECMA-426 néven szabvány lett. Jó alkalom, hogy egyszer tényleg belenézzünk, mi van abban a .map fájlban.

Négy tömb és egy nagyon sűrű string
Írtam egy pár soros checkout.js-t, átengedtem az esbuilden (--minify --sourcemap), és ezt kaptam mellé:
{
"version": 3,
"sources": ["checkout.js"],
"sourcesContent": ["// checkout.js – …a teljes eredeti forráskód, egyben…"],
"mappings": "AACA,SAASA,EAAkBC,EAAMC,EAAQ,CACvC,GAAI,CAACA,…",
"names": ["calculateDiscount", "cart", "coupon", "checkout", "discount"]
}
A version a szabvány szerint mindig 3 – az ECMA-426 első kiadása is ezt írja elő, vagyis a vadonatúj szabvány hivatalosan is a 2011-es harmadik revíziót rögzíti. A sources az eredeti fájlok listája, a sourcesContent pedig maga a teljes eredeti forráskód, beágyazva. Ezért tud a Sentry vagy a DevTools forráskódot mutatni olyan fájlból, ami soha nem került fel a szerverre – és ezért nő a map fájl simán a bundle többszörösére. A names az eredeti azonosítók listája: ebből lesz a minifier által r-re keresztelt függvényből újra calculateDiscount. Az igazi munka viszont a mappings mezőben van, abban az első ránézésre véletlenszerű betűhalmazban.
A mappings megfejtése kézzel
A mappings pontosvesszőkkel generált sorokra, vesszőkkel szegmensekre tagolódik. Egy szegmens 1, 4 vagy 5 számot kódol: a generált oszlopot, a forrásfájl indexét, az eredeti sort és oszlopot, plusz opcionálisan egy indexet a names tömbbe. A számok base64 VLQ-ban vannak: minden karakter 6 bit, amiből a felső bit azt jelzi, jön-e még folytatás, az első számjegy legalsó bitje pedig az előjel. És ami a méretet igazán leszorítja: szinte minden érték az előző előfordulásához képest relatív. A dekóder elfér húsz sorban, könyvtár nélkül:
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
function decodeVLQ(str, pos) {
let result = 0, shift = 0, digit;
do {
digit = B64.indexOf(str[pos++]);
result += (digit & 31) << shift; // az alsó 5 bit az adat
shift += 5;
} while (digit & 32); // a 6. bit a folytatásjelző
const negative = result & 1; // az előjel a legalsó bit
result >>>= 1;
return [negative ? -result : result, pos];
}
A fenti map első szegmense, az AACA így négy szám: az A nulla, a C pedig 2, amiből az előjelbit levágása után 1 marad – tehát [0, 0, 1, 0]: a generált 0. oszlop a 0-s forrásfájl második sorának elejére mutat (az első sor komment volt, azt az esbuild eldobta). A delták felgöngyölítése után ilyen táblázat esik ki a teljes stringből:
gen 1: 9 -> checkout.js 2:9 (calculateDiscount)
gen 1: 64 -> checkout.js 4:10
gen 1:148 -> checkout.js 10:19 (calculateDiscount)
Ellenőrzésképp el is dobattam egy kivételt a minifikált modulból, source mappel és anélkül – a Node --enable-source-maps kapcsolója pontosan oda jutott, ahová a kézi dekóderem:
$ node crash.mjs
Error: A kupon lejárt: NYAR20
at r (checkout.min.js:1:65)
$ node --enable-source-maps crash.mjs
Error: A kupon lejárt: NYAR20
at calculateDiscount (checkout.js:4:11)
Az apró szépséghiba, hogy a stack trace 65-öt ír, a map 64-et tárol: a V8 1-től, a source map 0-tól számozza az oszlopokat. Ha valaha írsz stack trace-feldolgozót, ez az off-by-one órákat fog elvinni az életedből.
A relatív kódolásnak van egy kevésbé nyilvánvaló következménye is. Új generált sornál csak a generált oszlop nullázódik; a forrásindex, az eredeti sor/oszlop és a névindex a teljes fájlon átívelően deltákban folytatódik. Vagyis nincs véletlen hozzáférés: ha arra vagy kíváncsi, mi van az 1:48213 pozíción, elvileg az első karaktertől kell dekódolnod az egész stringet. A DevTools és a hibakövetők ezért egyszer végigdekódolják, és indexet építenek belőle – egy nagy bundle-nél ez az a pár másodperc, amíg a betöltött map után „gondolkodik” a debugger.
Régi sebek, amiket a szabvány is megörökölt
Az ECMA-426 szövege tele van olyan mondatokkal, amikből régi háborúk olvashatók ki. A map hivatkozása ma //# sourceMappingURL=… formában történik, de eredetileg //@ volt – amíg ki nem derült, hogy az Internet Explorer feltételes fordítása (/*@cc_on) félreérti, és élesben törtek el tőle oldalak. A generátoroknak azóta tilos //@-ot kibocsátani, a fogyasztóknak viszont a szabvány szerint ma is kötelező mindkettőt elfogadni. A szerver küldheti a map helyét SourceMap HTTP-fejlécben is (a régi X-SourceMap már deprecated), és kevesen tudják: ha mindkettő jelen van, a fejléc nyer a kódba írt komment felett.
A kedvencem viszont ez: a szabvány előírja, hogy ha egy HTTP-n érkező source map első sora )]}' karakterekkel kezdődik, azt a sort a fogyasztónak némán el kell dobnia. Ez egy XSSI (cross-site script inclusion) elleni védelem a 2010-es évek elejéről – a Google JSON-válaszai kezdődtek így, hogy <script>-ként betöltve szintaktikai hibát okozzanak. A támadási forma azóta gyakorlatilag kihalt, a prefix viszont most már nemzetközi szabványban rögzített kövület. És ha már modern kor: a Chrome DevTools x_google_ignoreList kísérleti mezőjéből ignoreList néven lett hivatalos mező – ezzel jelöli a bundler, hogy mely forrásfájlok „nem a te kódod” (jellemzően a node_modules), és a debugger ezeket alapból átugorja lépkedésnél.
Amire a formátum ma is kevés
A names tömb tokenekhez rendel neveket, nem hívási lánchoz. Amikor a DevTools egy minifikált frame-hez „eredeti függvénynevet” mutat, az részben pozícióalapú találgatás – inline-olt vagy átstrukturált kódnál rendszeresen mellé is lő. Ezen dolgozik a TG4 proposal-listájának legérettebb tétele, a Scopes (Stage 3), ami scope- és változókötési információt ágyazna a mapbe, hogy a debugger valódi eredeti hívási vermet és változóneveket tudjon rekonstruálni. Mellette érik a Range Mappings (Stage 2) és a Debug ID (Stage 2) – utóbbi a Sentry-féle, nagyon is földhözragadt problémára válasz: éles környezetben a legtöbb „nem működik a source map” hiba valójában párosítási hiba, a hibakövető nem találja meg, melyik deploy melyik bundle-jéhez melyik map tartozik. Egy beágyazott, egyedi azonosító ezt oldaná meg URL-bűvészkedés helyett.
Használnám-e élesben?
A kérdés source mapnél nem az, hogy legyen-e – hanem hogy hová kerüljön. Node backendnél a --enable-source-maps nálam alapfelszerelés: a fordítás árát a dokumentáció szerint akkor fizeted, amikor valaki lekéri az Error.stack-et – ha nem loggolsz stack trace-t forró útvonalon, ez olvasható hibákért cserébe olcsó ár. Frontendnél a mapet generálom, de nem teszem ki publikus CDN-re, ha a forrás nem publikus amúgy is: a sourcesContent szó szerint a teljes forráskódod, kommentekkel együtt, egyetlen guesselhető URL-en. A hibakövetőbe feltöltött, kifelé nem szolgáltatott map mindkét igényt kielégíti. Amit viszont nem csinálnék: kikapcsolni a sourcesContent-et pár megabájt spórolásért, mert azzal pont azt a tulajdonságát veszíted el, hogy a map önhordó – a hibakövetőd a hat hónappal ezelőtti deploy forrását is látja, ami a gitből már rég nem checkoutolható ki egy mozdulattal.
És közben érdemes egy pillanatra értékelni a helyzetet: a webes toolchain egyik legfontosabb formátuma 2009-ben született a Closure Toolsban, 2011-től egy Google Docból üzemelt, és csak 2024 decemberében lett belőle szabvány – nagyjából akkor, amikorra mindenki elfelejtette, hogy nem volt az. A version: 3 pedig marad, örök mementóként.
Források
- ECMA-426: Source map format specification (élő draft)
- Ecma International: ECMA-426, 1st edition (2024. december)
- Bloomberg JS Blog: Source Maps – Shipping Features Through Standards
- tc39/ecma426 – a spec repója és az aktív proposalok (Scopes, Range Mappings, Debug ID)
- Chrome DevTools: x_google_ignoreList / ignoreList
- web.dev: What are source maps?