Resolving a Production Stack Trace Against a Source Map
When a minified Sentry stack only points at `bundle.js:1:140183`, this is the zero-dep VLQ decoder I drop in to map every frame back to a real source line.
By @kwamehenderson
January 20, 2026
·
Updated May 20, 2026
1,146 views
17
4.3 (11)
// Minimal source-map resolver: VLQ decode + binary search by generated col.
// Source maps use base64 VLQ. This is the 30-line version we ship in CI.
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const B64IDX = Object.fromEntries([...B64].map((c, i) => [c, i]));
function decodeVlq(str) {
const out = [];
let i = 0;
while (i < str.length) {
let value = 0, shift = 0, cont = 1;
while (cont) {
const d = B64IDX[str[i++]];
cont = d & 32;
value |= (d & 31) << shift;
shift += 5;
}
const sign = value & 1;
value >>= 1;
out.push(sign ? -value : value);
}
return out;
}
function parseMappings(mappingsField, sources, names) {
const lines = mappingsField.split(';');
let srcIdx = 0, srcLine = 0, srcCol = 0, nameIdx = 0;
return lines.map((line, generatedLine) => {
let genCol = 0;
return line.split(',').filter(Boolean).map((seg) => {
const fields = decodeVlq(seg);
genCol += fields[0];
const result = { generatedLine, generatedColumn: genCol };
if (fields.length >= 4) {
srcIdx += fields[1];
srcLine += fields[2];
srcCol += fields[3];
result.source = sources[srcIdx];
result.originalLine = srcLine;
result.originalColumn = srcCol;
if (fields.length === 5) {
nameIdx += fields[4];
result.name = names[nameIdx];
}
}
return result;
});
});
}
// Demo: map a one-line bundle back to source.
const sourceMap = {
version: 3,
sources: ['src/handler.ts'],
names: ['handle'],
mappings: 'AAAA,SAASA,EAAS,GAAS,OAAO,EAAI,EAAI,CAAC',
};
const lines = parseMappings(sourceMap.mappings, sourceMap.sources, sourceMap.names);
console.log('Segments on line 0:', lines[0].length);
console.log('First mapping:', lines[0][0]);
console.log('Function name segment:', lines[0].find((s) => s.name));Source maps are a JSON file with a mappings field: a string of base64 VLQ groups separated by commas (one segment) and semicolons (one generated line). Each segment is up to five signed integers (genCol delta, sourceIdx delta, sourceLine delta, sourceCol delta, nameIdx delta). The decoder unpacks the bits five at a time, peels the sign bit, and accumulates deltas across segments. I keep this around because every "add a sourcemap library" PR I have reviewed pulled in source-map (300 KB) for what is genuinely a 30-line job. The output is one array of segments per generated line, ready for binary search.
// Resolve a single "at file.js:LINE:COL" frame against decoded segments.
// Pre-decoded `lines` array comes from accordion 1. Re-run that decoder here.
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const B64IDX = Object.fromEntries([...B64].map((c, i) => [c, i]));
function decodeVlq(str) {
const out = []; let i = 0;
while (i < str.length) {
let value = 0, shift = 0, cont = 1;
while (cont) {
const d = B64IDX[str[i++]];
cont = d & 32;
value |= (d & 31) << shift;
shift += 5;
}
const sign = value & 1;
value >>= 1;
out.push(sign ? -value : value);
}
return out;
}
function parseMappings(mappings, sources, names) {
const out = mappings.split(';');
let srcIdx = 0, srcLine = 0, srcCol = 0, nameIdx = 0;
return out.map((line, gl) => {
let gc = 0;
return line.split(',').filter(Boolean).map((seg) => {
const f = decodeVlq(seg);
gc += f[0];
const r = { generatedLine: gl, generatedColumn: gc };
if (f.length >= 4) {
srcIdx += f[1]; srcLine += f[2]; srcCol += f[3];
r.source = sources[srcIdx];
r.originalLine = srcLine;
r.originalColumn = srcCol;
if (f.length === 5) { nameIdx += f[4]; r.name = names[nameIdx]; }
}
return r;
});
});
}
function resolveFrame(decoded, line, column) {
const segs = decoded[line] || [];
let lo = 0, hi = segs.length - 1, best = null;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (segs[mid].generatedColumn <= column) {
best = segs[mid];
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return best;
}
const sm = {
sources: ['src/handler.ts'],
names: ['handle'],
mappings: 'AAAA,SAASA,EAAS,GAAS,OAAO,EAAI,EAAI,CAAC',
};
const decoded = parseMappings(sm.mappings, sm.sources, sm.names);
const frame = resolveFrame(decoded, 0, 12);
console.log('Bundle 0:12 ->', frame);Given decoded segments and a (line, column) from a stack trace, this picks the largest segment whose generatedColumn is at-or-before the target column. That is the standard sourcemap resolver behavior, because mappings are sparse (compilers only emit a segment at meaningful positions). Binary search is overkill for a 50-segment line but starts to matter once your bundle is 15 MB. The returned record gives the original source path, line, and column, plus an optional name if the compiler recorded one.
// Walk a Node stack string and rewrite each frame with its source position.
// The regex covers "at fn (file:LINE:COL)" and "at file:LINE:COL".
const FRAME = /^\s*at\s+(?:(.+?)\s+\()?([^():]+):(\d+):(\d+)\)?\s*$/;
function rewriteStack(stackText, resolver) {
return stackText.split('\n').map((line) => {
const m = FRAME.exec(line);
if (!m) return line;
const [, fn, file, lineStr, colStr] = m;
const original = resolver(file, parseInt(lineStr, 10), parseInt(colStr, 10));
if (!original) return line;
const fnLabel = fn ? `${fn} ` : '';
return ` at ${fnLabel}(${original.source}:${original.originalLine + 1}:${original.originalColumn + 1})`;
}).join('\n');
}
// Stub resolver returns a fixed mapping for /app/dist/bundle.js
function resolver(file, line, col) {
if (!file.endsWith('bundle.js')) return null;
if (line === 1 && col >= 100 && col <= 200) {
return { source: 'src/handler.ts', originalLine: 41, originalColumn: 4 };
}
return null;
}
const stack = `Error: payment_failed
at processPayment (/app/dist/bundle.js:1:140)
at /app/dist/bundle.js:1:180
at processTicksAndRejections (node:internal/process/task_queues:96:5)`;
console.log(rewriteStack(stack, resolver));This is the part that actually sees production: parse a Node Error.stack, identify which frames live in your bundle, and rewrite just those. The regex tolerates both at fn (file:L:C) and at file:L:C forms; everything else (anonymous lambdas, node internals) passes through untouched. In our CI we pipe Sentry's raw stack through this in a Lambda so engineers see src/handler.ts:42:5 instead of bundle.js:1:140183. The whole script is under 100 lines and has zero npm dependencies.
