Skip to content
HN On Hacker News ↗

Deep dive into the JS/TS toolchain: How source maps fall short where it matters most · Traceway

▲ 6 points 0 comments by dusanstanojevic 2w ago HN discussion ↗

Pangram verdict · v3.3

We believe that this document is primarily AI-generated with some human-written content

87 %

AI likelihood · overall

AI
9% human-written 91% AI-generated
SEGMENTS · HUMAN 1 of 7
SEGMENTS · AI 6 of 7
WORD COUNT 1,835
PEAK AI % 99% · §7
Analyzed
Jun 11
backend: pangram/v3.3
Segments scanned
7 windows
avg 262 words each
Distribution
9 / 91%
human / AI fraction
Verdict
AI
Pangram v3.3

Article text · 1,835 words · 7 segments analyzed

Human AI-generated
§1 AI · 96%

What actually happens between your TypeScript and a production stack trace, decoded entirely by hand.I'm building an exception tracking platform (Traceway), and I want its symbolication, the bit that turns a minified production stack trace back into "ah, validateUser, line 8", to be genuinely world-class, not "eh, close enough." So I did the obnoxious thing: I tried to symbolicate a trace entirely by hand, with nothing but the artifacts a browser actually ships me. And about ten minutes in I hit a wall that I now can't stop thinking about: a source map, on its own, literally cannot recover the original function names. Not "it's hard." Not "it's lossy." It can't. It will hand me the exact original file, line, and column of every frame, perfectly, every time, and then confidently give me back the wrong name for all of them. To get the names right I also need the minified bundle itself, parsed. That's the real reason every serious error tracker quietly insists you upload your bundle and your map, and almost nobody can tell you why. So let me show you why, by doing the whole thing by hand on a tiny real program. I decode the source map's mappings field digit by digit, do the position lookups (they're great!), watch the names fall apart (spectacularly), and then fix them with the one extra step that needs the bundle. The end result matches what Sentry produces, byte for byte. It's the same three-step algorithm going into Traceway's final symbolicator. Everything below is real output. I generated every table and trace by running the code on the programs I describe, with esbuild 0.25 and node v24. 1. The program and the crash The smallest program I could write that throws across more than one stack frame is two files.

§2 AI · 88%

src/user.tsexport interface User { name: string; } export function validateUser(user: User): User { const trimmed = user.name.trim(); if (trimmed.length === 0) { throw new Error("user has no name"); // line 8 } return { name: trimmed }; } src/index.tsimport { validateUser, type User } from "./user"; function handleSignup(form: User): User { return validateUser({ name: form.name }); // line 4 } handleSignup({ name: " " }); // line 7 I bundled and minified it with esbuild src/index.ts --bundle --format=iife --minify --sourcemap --outfile=dist/app.min.js. The whole program collapsed onto one line. And look what the minifier did to the names. This is the crime scene: validateUser became n, handleSignup became t, and both parameters became e: (()=>{function n(e){let r=e.name.trim();if(r.length===0)throw new Error("user has no name");return{name:r}}function t(e){return n({name:e.name})}t({name:" "});})(); //# sourceMappingURL=app.min.js.map I ran it. It crashed. Here's the raw stack trace, exactly what Traceway (or Sentry, or anything) gets from a browser in production: Error: user has no name at n (app.min.js:1:63) at t (app.min.js:1:129) at app.min.js:1:146 at app.min.js:1:164 Four frames. Each one is a line:column into the bundle, plus a minified name when V8 has one; that bottom frame is the top-level call of the IIFE wrapper itself, the () at the very end of the line. That's it. That's the entire amount of information I get to work with. (The columns are 1-based, V8's convention. Frame 1 is column 63. File that away, because the off-by-one bit me later.)

§3 AI · 99%

What I want, what a great symbolicator coughs up, is this: Error: user has no name at validateUser (src/user.ts:8:11) at handleSignup (src/index.ts:4:10) at <global> (src/index.ts:7:1) at <global> (src/index.ts:7:29) Names back, real .ts files, real lines. Let's earn it. 2. Anatomy of the source map The .map file is just JSON. Here's the real one, formatted, with sourcesContent truncated: { "version": 3, "sources": ["../src/user.ts", "../src/index.ts"], "sourcesContent": ["export interface User {\n name: string;\n}\n...", "import { validateUser..."], "mappings": "MAIO,SAASA,EAAaC,EAAkB,CAC7C,IAAMC,EAAUD,EAAK,KAAK,KAAK,EAC/B,GAAIC,...", "names": ["validateUser", "user", "trimmed", "handleSignup", "form", "validateUser"] } Five fields matter: version: always 3 today. sources: the original files, referenced later by index. Index 0 is ../src/user.ts, index 1 is ../src/index.ts. (The ../ is just esbuild recording the path relative to dist/; a symbolicator normalizes it back to src/user.ts.) sourcesContent: the full original text of each source. Optional, but almost always there. This is why DevTools can show you your real code without the .ts files being on the server. names: a flat pool of original identifier strings, referenced by index. And here's the first "huh": "validateUser" shows up twice, at index 0 and index 5. No deduplication. And, crucially, there is no "minified → original" dictionary anywhere in here. The array is purely positional. Hold that thought, it's about to become the whole story. mappings: the heart of it. A compressed list of point mappings, one per interesting token: "the token at this bundle column came from this source, this original line, this original column, and (optionally) was spelled this name."

§4 AI · 93%

And that's the thing that genuinely surprised me, the load-bearing fact for everything below: a source map is a list of points, not ranges. It records "column X maps to original Y." It never, ever records "columns X through Z are the body of function F." That one design decision is the reason I'm about to need the bundle. 3. Decoding mappings by hand: the VLQ format The mappings string looks like a cat walked across the keyboard. It is not. It's three layers of structure stacked on top of each other: Semicolons (;) separate generated lines. My bundle is one line, so this map has zero semicolons. It's all one group. (In a normal multi-line bundle, each ; bumps you to the next output line.) Commas (,) separate segments within a line. One segment = one token. Each segment is 1, 4, or 5 numbers, Base64-VLQ encoded: FieldsMeaning of each field (every one is a delta from the previous segment)1[generatedColumn]4[generatedColumn, sourceIndex, originalLine, originalColumn]5[generatedColumn, sourceIndex, originalLine, originalColumn, nameIndex] Everything is a delta, never an absolute. That's the trick that keeps the string short. The values accumulate left to right. One gotcha I had to nail down: generatedColumn resets to 0 at every new line (every ;), but sourceIndex, originalLine, originalColumn, and nameIndex keep accumulating across the entire file. Base64-VLQ in one paragraph Each number is one or more Base64 digits. Take the alphabet ABCD…abcd…0123…+/ (A=0, B=1, … /=63). For each char, grab its 6-bit value. The top bit (value 32) is a continuation flag: set means another digit follows with higher-order bits. The low 5 bits are payload, shifted 5 per continuation. Once you've got the full integer, the lowest bit is the sign (1 = negative) and the rest is magnitude: value = (n & 1) ? -(n >>> 1) : (n >>> 1).

§5 Human · 23%

Here's the whole decoder, no dependencies (the decoder is not optimal, it's just a quick and dirty example): const ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; function decodeSegment(seg) { const vals = []; let shift = 0, cur = 0; for (const c of seg) { const d = ALPHA.indexOf(c); cur |= (d & 31) << shift; // low 5 bits are payload if (d & 32) { // bit 32 = "continuation, more digits follow" shift += 5; } else { vals.push(cur & 1 ? -(cur >>> 1) : cur >>> 1); // low bit = sign cur = 0; shift = 0; } } return vals; } One segment, all the way down Let me take the second segment of my map, SAASA, and grind through it character by character. CharBase64 valueBinary (6 bit)Continuation?Payload (low 5)S18010010no (bit 32 clear)10010 = 18A0000000no0A0000000no0S18010010no18A0000000no0 No continuation bits, so each char is its own number. Apply the sign rule (value = n&1 ? -(n>>1) : n>>1): 18 → 18 >>> 1 = 9 0 → 0 0 → 0 18 → 9 0 → 0 So SAASA decodes to [9, 0, 0, 9, 0]. Five fields → it carries a name. Apply the deltas to the running totals (after the first segment MAIO, those stood at generatedColumn 6, sourceIndex 0, originalLine 4, originalColumn 7, nameIndex 0): generatedColumn: 6 + 9 = 15 sourceIndex:

§6 AI · 98%

0 + 0 = 0 → ../src/user.ts originalLine: 4 + 0 = 4 originalColumn: 7 + 9 = 16 nameIndex: 0 + 0 = 0 → names[0] = "validateUser" Out loud: bundle column 15 maps to user.ts line 4, column 16 (0-based), originally named validateUser. Bundle column 15 is the n in function n(, i.e. the minified validateUser. The map recorded the rename at the exact column where the token physically sits. Not near it. At it. Remember that, because it's the whole trap. Continuation example kBAAkB has a multi-digit number. k = 36 = 100100: bit 32 is set, so it continues. Payload 00100 = 4. Next char B = 1 = 000001, no continuation, payload 00001 = 1. Combine: 4 | (1 << 5) = 36. Sign rule: 36 >>> 1 = 18. So kB is one number, 18, and kBAAkB is [18, 0, 0, 18], four fields, no name. The full decode Doing that for every segment gives the complete mapping table. Here are the rows that matter, in the map's native 0-based line/column, with the bundle text at each column so you can see precisely which token each row pins: bundle col -> original file:line:col name | bundle text at that col 6 -> ../src/user.ts:4:7 | "function n(e){le" 15 -> ../src/user.ts:4:16 validateUser | "n(e){let r=e.nam" <- token `n` (the def of validateUser) 17 -> ../src/user.ts:4:29 user | "e){let r=e.name."

§7 AI · 99%

<- param `e` in n 24 -> ../src/user.ts:5:8 trimmed | "r=e.name.trim();" 62 -> ../src/user.ts:7:10 | "new Error(\"user " <- THROW SITE: no name 107 -> ../src/index.ts:2:0 | "function t(e){re" 116 -> ../src/index.ts:2:9 handleSignup | "t(e){return n({n" <- token `t` (the def of handleSignup) 118 -> ../src/index.ts:2:22 form | "e){return n({nam" <- param `e` in t 128 -> ../src/index.ts:3:9 validateUser | "n({name:e.name})" <- the CALL n(...) inside t 145 -> ../src/index.ts:6:0 handleSignup | "t({name:\" \"});" <- the CALL t(...) at top level 159 -> ../src/index.ts:6:28 | ");})();" <- the map's LAST mapping (file this away too) Two things to stare at before moving on, because the entire failure mode lives in these two lines: The rename n → validateUser is recorded everywhere the token n physically appears, its definition (column 15) and its call site (column 128), and nowhere else. The throw statement at column 62 contains no n, so the map says nothing about the name there. The field is empty. The map isn't being lazy; there was simply no n token to annotate. The same minified name maps to different originals depending on where it sits. e is user at column 17 and form at column 118. There is no global "minified e → original name" lookup, and there cannot be one, because renames only exist positionally. This is why "just reverse the minification" is a fantasy. 4. Resolving locations: the floor lookup (this part is great) For a frame at bundle line:column, how do I find the original location? I don't get an exact hit. Most columns have no mapping at all. So I do a floor lookup: take the mapping with the largest column ≤ my query column. Its source/line/column is the answer.