Understanding the sources and sourcesContent Fields
The sources and sourcesContent arrays are the two halves of how a source map answers the question “which original file, and what did it look like?” — and when they disagree, your symbolicated stack trace shows the right file name but no surrounding code. This how-to explains the strict index-parallel relationship between the two arrays, and when you must embed original code inline versus resolving it from disk. It builds on Understanding the Source Map v3 Specification & Formats and fits inside the broader work of Source Map Generation & Stack Trace Debugging. If you have not yet decoded a mappings string, the decoding VLQ mappings by hand walkthrough shows where the sourceIndex that keys into sources actually comes from.
Symptom / Trigger
A frame resolves to a real file path, but the code snippet is empty, and the resolver logs a lookup miss:
TypeError: Cannot read properties of undefined (reading 'total')
at checkout (app.min.js:1:24187)
# After symbolication:
at checkout (src/checkout/cart.ts:42:15) ← file + line resolved
# but the context viewer shows:
sourcesContent[2] === undefined
ENOENT: no such file or directory, open '/build/src/checkout/cart.ts'
# The name is correct; the SOURCE TEXT is missing.
The originalPositionFor call returns the right source and line, yet sourceContentFor throws or returns null. The map knows where the code came from but not what it said. In an error dashboard this shows up as a stack trace whose frames each have a clickable file and line number, but whose expandable code preview is empty or shows a loading spinner that never resolves. Developers assume the map is broken; in fact it is doing exactly what it was built to do — it simply was not built to carry the original text.
Root Cause Explanation
In the v3 format, sources and sourcesContent are two independent arrays that the spec treats as index-parallel: for any integer i, sources[i] is the path of the original file and sourcesContent[i] is that file’s full text. There is no key, no filename match, no path resolution — the only thing that binds a path to its content is the shared array position. When a decoded mapping yields sourceIndex = 2, the resolver reads sources[2] for the label and sourcesContent[2] for the snippet.
sourcesContent is optional. A perfectly valid map can omit it entirely, or provide an array with null in some slots and strings in others. When a slot is null or the whole array is absent, the consumer is expected to fetch the original text itself using the sources path (resolved against sourceRoot). That fetch is exactly what fails in a production symbolication server that has the map but not the original repository checked out on disk.
// The broken assumption: that a resolved source path means the text is available.
const pos = consumer.originalPositionFor({ line: 1, column: 24187 });
// pos.source === 'src/checkout/cart.ts' — resolved fine
const code = consumer.sourceContentFor(pos.source); // returns null!
renderSnippet(code.split('\n')[pos.line - 1]); // throws: code is null
The map author (your build tool) decides whether content ships inside the map. If the tool was configured to exclude sourcesContent, the map is smaller but only symbolicates fully on a machine that can read the original files. The mismatch — right path, missing text — is not a bug in your resolver; it is a map that was never told to carry its own source text.
It helps to picture the two arrays as columns in the same table, joined on row number rather than on any value. The sources column holds strings like webpack://app/src/checkout/cart.ts — display paths, often prefixed with a scheme that never corresponds to a real filesystem location. The sourcesContent column holds the entire original file as a single string with embedded newlines. Because the join is positional, reordering one array without reordering the other silently corrupts every mapping that references a shifted index: the frame will now show file A’s path beside file B’s code. This is why post-processing tools that “clean up” or sort sources are dangerous unless they renumber the mappings too. The safest mental model is that neither array is ever meaningful on its own — a sourceIndex is a coordinate into a pair of columns, and the two columns must always move together.
Step-by-Step Fix
1. Confirm the two arrays are the same length and index-aligned
// A well-formed map keeps sources and sourcesContent the same length.
function assertParallel(map) {
if (!map.sourcesContent) return false; // content simply not embedded
if (map.sourcesContent.length !== map.sources.length) {
throw new Error(`length mismatch: ${map.sources.length} vs ${map.sourcesContent.length}`);
}
return true; // safe to index sourcesContent[i] alongside sources[i]
}
2. Look content up by index, never by filename match
// sourceIndex comes from the decoded mapping segment, not from a path string.
function contentForIndex(map, sourceIndex) {
const text = map.sourcesContent?.[sourceIndex]; // may be a string, null, or undefined
return typeof text === 'string' ? text : null; // normalise absent slots to null
}
3. Fall back to a disk or network read only when the slot is null
// Embedded content wins; otherwise resolve the path against sourceRoot and fetch.
async function resolveSource(map, sourceIndex, readFile) {
const embedded = contentForIndex(map, sourceIndex);
if (embedded !== null) return embedded; // no I/O needed — inline text present
const path = (map.sourceRoot || '') + map.sources[sourceIndex]; // join sourceRoot
return readFile(path); // last resort: read the original file yourself
}
4. Re-run the build with content embedding turned on
# Webpack: choose a devtool variant that inlines sourcesContent
# 'source-map' embeds sourcesContent by default; verify it is not stripped.
npx webpack --mode production --devtool source-map
# esbuild: sourcesContent is on by default — keep it enabled explicitly
npx esbuild app.ts --bundle --sourcemap --sources-content=true --outfile=app.min.js
5. Repair an existing map by injecting content from source
// Backfill sourcesContent for a shipped map when you still have the originals.
const fs = require('fs');
function embedContent(map, readFile) {
map.sourcesContent = map.sources.map((rel) => {
try { return readFile((map.sourceRoot || '') + rel); } // read each path in order
catch { return null; } // preserve the slot so indexes stay aligned
});
return map; // now self-contained; index i still lines up with sources[i]
}
const map = JSON.parse(fs.readFileSync('app.min.js.map', 'utf8'));
fs.writeFileSync('app.min.js.map', JSON.stringify(embedContent(map, (p) => fs.readFileSync(p, 'utf8'))));
Backfilling content works only while you still hold the exact originals that produced the bundle. If a dependency was bumped or a file was edited after the release shipped, the text you inject will not match the line and column coordinates baked into the mappings, and your snippets will drift by a few lines. For anything you deploy, the reliable pattern is to embed sourcesContent at build time and archive the map alongside the release artifact, so the map and the exact source it describes travel together forever. Treat a map without embedded content the way you would treat a debug symbol file with no matching binary: technically parseable, but only useful next to the precise inputs that created it.
Verification
const { SourceMapConsumer } = require('source-map');
const fs = require('fs');
async function verify() {
const raw = fs.readFileSync('app.min.js.map', 'utf8');
const map = JSON.parse(raw);
// 1. Arrays are index-parallel.
console.assert(map.sourcesContent.length === map.sources.length, 'array lengths differ');
// 2. Every referenced source has non-null embedded text.
const consumer = await new SourceMapConsumer(raw);
for (const src of map.sources) {
const text = consumer.sourceContentFor(src, /* returnNullOnMissing */ true);
console.assert(text && text.length > 0, `missing content for ${src}`);
}
consumer.destroy();
console.log('All sources carry embedded content — offline symbolication is safe');
}
verify();
// Expected: "All sources carry embedded content — offline symbolication is safe"
Edge Cases & Gotchas
- A
nullslot is legal, not a bug. The spec explicitly allowssourcesContent[i]to benullfor a source whose text was intentionally not embedded. Your resolver must treatnullas “fetch it yourself”, not “the map is corrupt”. sourceRootprefixessources, notsourcesContent. ThesourceRootstring is prepended to eachsourcespath when resolving a URL, butsourcesContentis looked up purely by index and never touched bysourceRoot. Applying the prefix to the content lookup is a common mistake.- Embedding original code leaks it. Inlining
sourcesContentputs your unminified TypeScript inside a.mapfile. If that map is publicly reachable next to the bundle, anyone can read your source. Ship maps to your error tracker privately rather than to a public path — see converting eval source maps to inline format for the inline-content tradeoffs. - Duplicate paths in
sourcesstill need distinct indexes. Two entries can share a path (e.g. a virtual module included twice), and each keeps its ownsourcesContentslot. Never deduplicatesourcesafter the fact — it renumbers every downstreamsourceIndex. - Scheme-prefixed paths are display labels, not fetch URLs. Webpack writes paths like
webpack://project/./src/app.ts; esbuild and Vite use their own prefixes. WhensourcesContentis present you never need to resolve these, which is another reason embedding is the more robust choice — you avoid guessing how each tool’s virtual path maps back to a real file. Strip or rewrite the prefix only for display, never as the key into either array. - Windows and POSIX path separators can diverge. A map generated on one platform may list
src\checkout\cart.tswhile your resolver looks forsrc/checkout/cart.ts. BecausesourcesContentis keyed by index rather than by the path string, embedding sidesteps this class of separator mismatch entirely.
FAQ
Why does my frame resolve to a file name but show no code?
The mapping decoded a valid sourceIndex, so sources[i] gave a path, but sourcesContent[i] was null or absent and the resolver had no original file to read. Rebuild with content embedding enabled, or point the resolver at a directory that contains the original sources at the paths listed in sources.
Is sourcesContent required by the v3 spec?
No. It is optional, and individual slots may be null. When it is missing, a consumer is expected to fetch the text using the sources path resolved against sourceRoot. Embedding it simply makes the map self-contained so no fetch is needed.
Can sources and sourcesContent ever have different lengths?
A conformant map keeps them the same length so index i lines up in both. Some tools emit a shorter sourcesContent and rely on trailing slots being treated as absent, but you should normalise to equal length and align by index before indexing either array.
Related
- Understanding the Source Map v3 Specification & Formats — the parent reference covering every v3 field.
- Decoding VLQ Mappings by Hand — where the
sourceIndexthat keys these arrays comes from. - Converting eval Source Maps to Inline Format — inline-content tradeoffs for eval-devtool builds.
- Source Map Generation & Stack Trace Debugging — the overview for the whole symbolication workflow.