Lesson 12 / 14
Source Maps
Mapping from a generated position to a source position, the map file's fields, how the mapping string is encoded, hand-building a map, and the decision to publish it in production.
Contents
The previous lesson stopped at the point where transpilation separates source from output. The output’s lines may have shifted, its constructs may have changed. When an error occurs, the line number in the stack trace belongs to the generated file and does not match the source the developer has open.
A source map is a data file that carries the position mapping between these two files. It is not code and is not executed; it holds only the information that “this line and column of the generated file correspond to this line and column of the source file.”
What the Mapping Covers
The mapping works at the level of position, not file. Given a generated position, the corresponding source file, line, and column are found. This level of detail is necessary: a single generated line can come from more than one source line.
Three operations use the mapping: resolving stack traces back to the source, placing breakpoints in the debugger at the source file, and writing performance-measurement durations back to source functions. All three let the tools introduced in the debugging and profiling lessons of the Asynchronous JavaScript course work against generated code.
The Fields of the Map File
The map is an object in JSON form:
| Field | Content |
|---|---|
version |
The format’s version number |
file |
The name of the generated file the map belongs to |
sources |
The paths of the source files |
sourcesContent |
The content of the source files; optional |
names |
The original names of identifiers that were renamed |
mappings |
The encoded form of the position mappings |
The sourcesContent field lets the source be embedded inside the map file itself. If
it is embedded, a tool does not have to look for the source elsewhere; if it is not,
the tool tries to read from the sources paths and cannot display the source if it
fails to find it.
The generated file is linked to its map by a comment on its last line. This comment tells the runtime and debugging tools where to look for the map.
The Shape of the Mapping String
The mappings field carries every mapping in a single string. The structure has three
levels:
- A semicolon separates generated lines. Two semicolons in a row mean that line has no mapping at all.
- A comma separates the segments on the same line.
- Each segment consists of four or five numbers: generated column, source file index, source line, source column, and a name index if one applies.
The numbers are not written directly. Two compressions apply: each number is kept as a delta relative to the previous one, and the deltas are written with variable-length quantity encoding. The encoding splits the number into five-bit groups, puts a sign bit in the lowest bit, and marks continuing groups with the sixth bit; each group is written as one letter from a 64-character alphabet.
The result is a string that is small but not human-readable. A thousand-line file’s mapping, written out without compression, would run to hundreds of thousands of characters; this encoding brings it down to a tenth of that.
Producing the Map
The shortest way to understand the encoding is to write a working generator. The transform below is deliberately plain: it adds a three-line banner to the top of the source. By the end of the transform, every source line has shifted down by three lines, and that shift cannot be tracked without a map.
// file: source/measure.mjs export function measure(text) { if (typeof text !== 'string') { throw new TypeError('expected a string'); } return text.split(/\s+/).length; } console.log(measure('one two three')); console.log(measure(42));
// file: generate.mjs import { mkdir, readFile, writeFile } from 'node:fs/promises'; const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; // Variable-length quantity encoding: sign bit in the lowest bit, 5-bit groups. function vlq(number) { let value = number < 0 ? (-number << 1) | 1 : number << 1; let output = ''; do { let chunk = value & 0b11111; value >>>= 5; if (value > 0) chunk |= 0b100000; output += ALPHABET[chunk]; } while (value > 0); return output; } await mkdir('output', { recursive: true }); const sourceName = 'source/measure.mjs'; const source = await readFile(sourceName, 'utf8'); const lines = source.split('\n'); const banner = ['/* generated file */', '/* do not edit by hand */', '']; const output = [...banner, ...lines]; // Banner lines have no mapping; every generated line looks at its source counterpart. const segments = banner.map(() => ''); let previousSourceLine = 0; for (let i = 0; i < lines.length; i += 1) { const indent = lines[i].length - lines[i].trimStart().length; segments.push([vlq(indent), vlq(0), vlq(i - previousSourceLine), vlq(indent)].join('')); previousSourceLine = i; } const map = { version: 3, file: 'measure.mjs', sources: ['../source/measure.mjs'], sourcesContent: [source], names: [], mappings: segments.join(';'), }; await writeFile('output/measure.mjs', output.join('\n') + '\n//# sourceMappingURL=measure.mjs.map\n'); await writeFile('output/measure.mjs.map', JSON.stringify(map)); console.log('mappings:', map.mappings.slice(0, 60));
$ node generate.mjs mappings: ;;;AAAA;AACA;EACE;IACI;EACE;EACE;AACA;AACA;AACA;AACA;AACA
The three semicolons at the start of the string say the banner lines have no mapping.
The fourth line’s segment is AAAA — four zeros: generated column 0, source file 0,
source line delta 0, source column 0. The fifth line’s segment reads AACA: the third
field is no longer zero, a delta showing the source line advanced by one. On indented
lines the first and last fields also move off zero; the segment EACE means generated
column 2 and source column 2.
The generated file’s first and last lines show how the link is established:
$ head -5 output/measure.mjs
/* generated file */
/* do not edit by hand */
// file: source/measure.mjs
export function measure(text) {
$ tail -2 output/measure.mjs
//# sourceMappingURL=measure.mjs.map
Tracing Back
When the generated file is run, the error reports the generated position:
$ node output/measure.mjs 2>&1 | grep 'at measure' | sed "s|file://||; s|$(pwd -P)/||"
at measure (output/measure.mjs:7:11)
The same file, run with the runtime’s source-map support turned on:
$ node --enable-source-maps output/measure.mjs 2>&1 | grep 'at measure' | sed "s|$(pwd -P)/||"
at measure (source/measure.mjs:4:7)
The stack trace now shows the source file and the source line. The line number is
exact; the column number is approximate, because this generator wrote one segment per
line. Real tools write a segment for every token, and the column becomes exact too. The
sed calls only shorten the absolute path in the output.
Chaining and Integrity
A source can go through more than one transform: transpiled first, then bundled, then minified. Each step produces its own map, and the last one looks back at the one before it. The tools have to merge this chain into a single map; a step that does not merge makes the whole chain unusable.
This explains the following observation: a stack trace sometimes lands not on the source but on an intermediate output. The reason is usually that the map was not carried through one link of the chain.
The Map in Production
If the map is published, the entire source is effectively published along with it —
especially if the sourcesContent field is filled in. The decision is between three
options.
Do not publish. The map is produced but not published alongside the output. The source stays protected; in exchange, stack traces coming from production have to be resolved by hand.
Publish. The map sits next to the output. Debugging is at its easiest; the source code is open to everyone.
Publish somewhere separate. The map is uploaded to an error-tracking system; it is not present next to the output. Stack traces are resolved inside that system, the source is not exposed. This needs extra infrastructure.
The choice depends on how private the source is. If the source is already open, there is no reason not to publish the map. If it is closed, the third option strikes the balance between the other two. Whichever option is used, producing the map is not optional: a map that was never produced cannot be obtained afterward.
Summary
- A source map is a data file that maps positions in the generated file to positions in the source file; it contains no code and is not executed.
- Mappings are held in a single string, separated into lines and segments, with each number kept as a delta and compressed with variable-length quantity encoding.
- The generated file is linked to its map by a comment on its last line; the source text can be embedded in the map.
- Once the runtime’s source-map support is turned on, stack traces show source positions; column precision depends on how often segments are written.
- In a multi-step transform, each step has to merge its map with the one before it; a step that does not merge breaks the chain.
- Publishing the map means publishing the source too; uploading to a separate error-tracking system balances that trade-off.
Next Step
The tools covered so far transformed code. Another class of tool does not transform code at all — it only reads it and judges it against a rule set: which constructs are a sign of a defect, which formatting is inconsistent. The next lesson covers this check performed without running the code, and why formatting is kept separate from it.
To keep your progress and take notes, Log in
My notes
Log in to take notes.