How to reconstruct original source code from a sourcemap
A sourcemap looks like metadata, but it's actually a zip of source files hiding in JSON. The `sources[]` array is a list of original paths (usually `webpack://_N_E/./src/components/Button.tsx` or similar). The `sourcesContent[]` array is the matching original text for each. Reconstructing the project tree means writing each content string to its normalized path. The tricky part is the normalization.
Background
The first time you try this, you realize the paths inside a sourcemap don't look like paths you'd see in a real file system. Webpack uses prefixes like `webpack://`, `webpack:///` and often a project-specific namespace like `_N_E/` (Next.js). Angular uses `ng://`. Rollup uses `rollup://`. Vite uses its own prefix conventions. On top of that, webpack emits 'synthetic' module entries for its own runtime bookkeeping — paths that look like ` lazy ./components async ^\.\/.*$`, which is not a real file and is not meant to be written to disk. A naive 'just write every sources[i] to disk' script will end up with weird directories like `(webpack)/`, files with `!` and `|` characters, and a broken mess. Doing this right means normalizing each path, filtering synthetics, deduplicating across chunks, and handling the case where the same path shows up with different content in different bundles. None of the steps are hard, but you only get them right after you've been burned by each edge case once.
Why this matters
The whole value of a sourcemap is in its sources. The `mappings` field (the VLQ-encoded byte-level mapping) only matters if you're building a stack-trace de-minifier. For a human who wants to read the source, the `sources[]` + `sourcesContent[]` pair is everything. Reconstructing the tree properly converts a 12 MB JSON blob into a browsable 8 MB `src/` folder you can open in VS Code. Reconstructing it wrong gives you an unusable dump with inconsistent paths and missing files.
Prerequisites
- A `.map` file in hand. See the 'download sourcemaps' guide if you don't have one yet.
- Node.js 18+ for scripting.
- A filesystem that handles long paths well — Linux and macOS are fine; Windows hits path-length limits on large trees.
Step-by-step
- 1
Load the sourcemap
Sourcemaps are JSON. Load with `fs.readFileSync` and `JSON.parse`. You'll be working with `map.sources` (array of paths) and `map.sourcesContent` (array of strings, matched by index). Skip `mappings` (VLQ) and `names` unless you need stack-trace decoding.
const fs = require('node:fs'); const map = JSON.parse(fs.readFileSync('bundle.js.map', 'utf8')); console.log(`${map.sources.length} source files, content present: ${map.sourcesContent ? map.sourcesContent.length : 0}`); - 2
Normalize each source path
For each `sources[i]`: strip known prefixes (`webpack://`, `webpack:///`, `ng://`, `rollup://`), decode with `decodeURIComponent`, drop any `?query#hash`, and collapse `.` / `..`. Skip entries that look like webpack synthetic modules (paths containing ` lazy `, ` sync `, `!`, `|`, `(webpack)`, `namespace object`, `chunkName`). These synthetics don't correspond to any real file on the author's disk.
function normalize(raw) { let p = raw.replace(/^(webpack:\/+|ng:\/+|rollup:\/+)/, ''); p = decodeURIComponent(p); p = p.split(/[?#]/)[0]; if (/\s(lazy|sync)\s|[!|]|\(webpack\)|chunkName|namespace object/.test(p)) return null; // collapse . and .. const parts = []; for (const seg of p.split('/')) { if (seg === '' || seg === '.') continue; if (seg === '..') { parts.pop(); continue; } parts.push(seg); } return parts.join('/'); } - 3
Deduplicate across bundles
A real production site ships many bundles, and they share modules. `node_modules/react/index.js` appears in every chunk that imports React. Hash the content (FNV-1a, MD5 or SHA-1) and dedupe by `(normalizedPath, contentHash)`. If the same normalized path shows up with different content in different bundles (can happen with tree-shaking or per-chunk polyfills), keep both — suffix the second one with `.<8-char-hash>` so nothing is silently dropped.
Tip: FNV-1a 64-bit is fast enough to be a non-issue on 500 MB of source content. SHA-1 is overkill but similarly cheap in Node.
- 4
Write the tree
For each surviving `(path, content)`, write to disk creating directories as needed. Use `fs.promises.writeFile` with `fs.promises.mkdir({recursive: true})` on the parent directory. On a full site reconstruction you'll write hundreds of files; batching with `Promise.all` is fine.
const path = require('node:path'); for (let i = 0; i < map.sources.length; i++) { const clean = normalize(map.sources[i]); if (!clean) continue; const content = map.sourcesContent?.[i]; if (content == null) continue; const out = path.join('./recovered', clean); fs.mkdirSync(path.dirname(out), { recursive: true }); fs.writeFileSync(out, content); } - 5
Or use the extension
Sourcemap Explorer runs this entire pipeline automatically across every sourcemap on a site, deduplicates cross-bundle, skips webpack synthetics, handles same-path-different-content correctly, and exports a single `.zip` ready to open in your editor. Install it, browse the target site, click Download — you're done. The extension uses an offscreen document to build the zip blob, so multi-hundred-MB projects zip cleanly (the MV3 service worker on its own can't reliably build big blob URLs).
Real-world example
Troubleshooting
I see file names like `(webpack)/buildin/global.js`.
Webpack synthetic. Add `(webpack)` to your skip patterns. These aren't real files.
Files with `%20` or URL-encoded characters in the path.
Missing `decodeURIComponent`. Decode after stripping the prefix.
Same file overwritten with empty content.
Your script is writing `sources[i]` with `sourcesContent[i]` from a different index. Loop index carefully and check `i` lines up across both arrays.
Enormous `.map` blows up memory.
Stream-parse with `stream-json` or similar instead of `JSON.parse`. Or use `jq` to extract just `sources` and `sourcesContent` before your script loads them.
Caveats
What to do next
With the tree reconstructed, the typical next step is to iterate the `package.json` files in `node_modules/` to build a list of bundled libraries and their versions (see the 'find exact npm versions' guide). Or to read the author's own source for learning or auditing purposes. Or to diff recovered trees across visits to see what changed.
FAQ
Why do I see `node_modules/` in the reconstructed tree?
Most bundlers include third-party code as sources. React, Next, webpack runtime, Tailwind processed CSS — everything gets a `sources[]` entry. That's why you can extract the exact version of a library from the reconstructed `node_modules/<pkg>/package.json`.
Can I tell which sources are the author's own code vs libraries?
Usually yes — webpack prefixes author code with `_N_E/./src/...` (Next.js) or `./src/...` and everything else with `./node_modules/...`. Once the paths are normalized, you can filter `node_modules/` out if you only care about the application code.
Do I need the `mappings` field?
No, unless you're writing a stack-trace de-minifier. `sources` + `sourcesContent` are sufficient to reconstruct the source tree. `mappings` encodes byte-level position mapping for runtime use.
Can I reconstruct CSS from a `.css.map`?
Yes, the schema is the same — `sources[]` and `sourcesContent[]`. CSS maps often include PostCSS/Sass/Tailwind original files as sources, so you can recover the original stylesheets.
How does Sourcemap Explorer deduplicate across many bundles?
Same path + same content hash → one file. Same path + different content → suffix second with an 8-char hash to keep both. The logic lives in `src/lib/project.ts` (the `collectProject` function) and is the single source of truth for both the popup preview and the zip export.
Related
Skip the manual steps.
Sourcemap Explorer automates every workflow in this guide — free, local, no sign-up.