rea-reverse

How to Reverse Engineer a Webpack Bundle and Recover the Source

Written for REA 6.7.0Published by rea-reverseUpdated 6 min read
On this page

If the bundle ships a source map, the original files are usually sitting in its sourcesContent array, and one command writes them back to disk byte for byte. Without a map, a webpack build still keeps a module table keyed by numeric IDs, so a bundle splitter like Wakaru can cut it back into one readable file per module. What never comes back is local variable names: strings, property names and exported names survive minification, everything else is t, e and n.

This guide does both on a three-file webpack project you build yourself, so every result can be checked against the real source. It also uses REA, a third-party open-source analysis tool, to map chunks, module factories and network requests without running the code. Work on your own code, open-source code, or code you have permission to examine.

Unminify and Recover JavaScript Source from Bundles with REASilent walkthrough with English captions.

Step 1: Build a bundle you can check

mkdir bundle-lab && cd bundle-lab
npm init -y
npm install --save-dev webpack webpack-cli
mkdir src

src/api.js:

export async function loadTodos() {
  const response = await fetch("/api/v1/todos");
  if (!response.ok) throw new Error("HTTP " + response.status);
  return (await response.json()).items;
}

src/export.js:

export function toCsv(todos) {
  return todos.map((t) => [t.id, JSON.stringify(t.title)].join(",")).join("\n");
}

src/index.js:

import { loadTodos } from "./api.js";

document.querySelector("#export").addEventListener("click", async () => {
  const todos = await loadTodos();
  const { toCsv } = await import("./export.js");
  console.log(toCsv(todos));
});

webpack.config.js:

module.exports = {
  entry: "./src/index.js",
  output: { filename: "[name].js", chunkFilename: "[name].chunk.js", clean: true },
  // Production mode normally concatenates ES modules into one scope.
  // Turning it off keeps per-module factories so you can see them.
  optimization: { concatenateModules: false },
};

Build it twice, with and without source maps:

npx webpack --mode production --devtool source-map --output-path "$PWD/dist-maps"
npx webpack --mode production --no-devtool --output-path "$PWD/dist-plain"

With webpack 5.112.2 and webpack-cli 7.3.1 the plain build printed:

asset main.js 2.75 KiB [emitted] [minimized] (name: main)
asset 68.chunk.js 205 bytes [emitted] [minimized]
runtime modules 6.35 KiB 8 modules
cacheable modules 535 bytes
  ./src/index.js 230 bytes [built] [code generated]
  ./src/api.js 191 bytes [built] [code generated]
  ./src/export.js 114 bytes [built] [code generated]

The dynamic import() split export.js into its own chunk, 68.chunk.js. The start of dist-plain/main.js shows what you are up against:

(()=>{"use strict";var t={672(t,e,o){async function n(){const t=await fetch("/api/v1/todos");if(!t.ok)throw new Error("HTTP "+t.status);return(await t.json()).items}o.d(e,{y:()=>n})}};const e={};function o(n){ /* …webpack runtime… */

var t={672(...){...}} is the module table: api.js became factory 672, and its export loadTodos was renamed to y. The chunk file registers factory 68 with self.webpackChunkbundle_lab.push([[68],{68(n,e,i){…}}]). index.js has no factory of its own; webpack inlined it into the runtime as the entry. Those numeric IDs and the push call are the boundaries every tool below relies on.

Real production builds usually leave concatenateModules on. Expect fewer boundaries there, because the bundler has already merged modules into one scope.

Step 2: With a source map, extract the original files

The map is the fastest route. Wakaru 1.14.0 has an extract subcommand for exactly this (the macOS and Linux binaries come with npm install wakaru):

wakaru extract dist-maps/main.js.map -o from-map

It wrote:

from-map/bundle-lab/src/api.js
from-map/bundle-lab/src/index.js
from-map/bundle-lab/webpack/bootstrap
from-map/bundle-lab/webpack/runtime/define property getters
… (7 more webpack/runtime/* files)

diff against the real src/api.js and src/index.js came back empty: they are the original files, comments and names included. export.js is missing because it lives in the chunk’s own map, 68.chunk.js.map; extract each .map, not just the main one.

No install? The browser-based JS bundle and source map restorer rebuilds the same tree as a .zip from sourcesContent and maps a line:column or pasted stack trace back to the original file. It runs locally and uploads nothing.

A map without sourcesContent only gives you file names and positions, not code. In that case, treat the bundle as map-less and continue with Step 3.

Step 3: No map? Split the module table with Wakaru

Pass the main bundle and its chunks together so cross-chunk require calls resolve:

wakaru dist-plain/main.js dist-plain/68.chunk.js --unpack=strict --provenance -o unpacked

Output: entry.js, module-672.js, module-68.js and provenance.json (byte ranges of each module in the original input). The recovered files, unedited:

// unpacked/entry.js
import { y } from "./module-672.js";
document.querySelector("#export").addEventListener("click", async ()=>{
    const t = await y();
    const { toCsv } = await require.e(68).then(()=>require("./module-68.js"));
    console.log(toCsv(t));
});

// unpacked/module-672.js
export async function y() {
    const t = await fetch("/api/v1/todos");
    if (!t.ok) {
        throw new Error(`HTTP ${t.status}`);
    }
    return (await t.json()).items;
}

// unpacked/module-68.js
export function toCsv(n) {
    return n.map((n)=>[
            n.id,
            JSON.stringify(n.title)
        ].join(",")).join("\n");
}

Compare with Step 1. The structure is fully back: three modules, the static import, the lazy chunk load, the fetch and its error path. toCsv kept its name because it is a property name on the chunk’s export object. loadTodos did not: webpack had already shortened that export to y, and nothing in the bundle remembers the original. Renaming y to loadTodos is your job, and the /api/v1/todos string is usually the clue.

--unpack=strict uses structural detection only (webpack, browserify, esbuild). --unpack=auto adds a heuristic fallback for scope-hoisted bundles, and --unpack=inspect keeps finer boundaries that may not be executable. Rollup, Vite and esbuild production builds usually hoist everything into shared scopes; once the boundaries are gone from the bytes, no tool can restore them with certainty.

Step 4: Map chunks, modules and requests with REA

Splitting gives you files to read. To ask questions across them (“which module sends this request?”, “which source file became this chunk?”), REA builds a static graph of the bundle without executing it. With REA 6.7.0 installed as the rea command (setup):

rea analyze-javascript-application "$PWD/dist-plain" --json > plain-evidence.json
rea analyze-javascript-application "$PWD/dist-maps" --json > maps-evidence.json

Progress lines go to stderr, so stdout stays clean JSON. The record is large: 1,350,372 bytes for this 3 KB bundle. Count what it found:

jq -r '.normalized_result.graph.nodes[].kind' plain-evidence.json | sort | uniq -c
   1 artifact
   2 javascript-asset
   2 javascript-chunk
   4 javascript-module

The two chunks are webpack-bootstrap@0 (the runtime in main.js) and webpackChunkbundle_lab:68. The four modules are the two files plus factories 672 and 68, and a loads edge with "kind":"bundler-entry-module","module_key":"672","resolution_status":"resolved" records that the entry calls factory 672. That matches what we read by hand in Step 1, without anyone executing the bootstrap.

The source-map build adds 2 source-map nodes, 2 maps_to edges and 12 source-module nodes, one per original source name in the maps: webpack://bundle-lab/./src/api.js, ./src/export.js, ./src/index.js and nine webpack/runtime/* entries. REA records names and digests only, not the sourcesContent text, so you still use Step 2 to get the code.

For a large bundle, project a summary instead of reading the whole record:

jq -c '{source: {kind: "inline", evidence: .}, view: {kind: "summary"}}' \
  plain-evidence.json > plain-summary.json
rea inspect-analysis-view plain-summary.json

Swap the view for {kind: "page", collection: "modules", offset: 0, limit: 32} to page through modules. Note what the summary’s statistics.modules counts: on this build it said 2 (the two parsed files), not the four module nodes in the graph.

Feed Wakaru’s output back in. Analyzing the unpacked/ folder from Step 3 gives 5 modules, a static-import edge to ./module-672.js, a require edge to ./module-68.js, a calls edge with "mechanism":"call:fetch", and an endpoint node for /api/v1/todos. The endpoint is visible as its own node only after unpacking; in the raw bundle it shows up in the semantic graph as a fetch request and a .json() response consumer.

REA can also run Wakaru for you with rea recover-javascript-sources, which snapshots the input and records byte ranges in a manifest. In 6.7.0 that adapter is Linux x64 only. On macOS arm64 it refused before doing anything:

{"code":"capability_unavailable","category":"unsupported_provider","message":"Provide an absolute REA_WAKARU_COMMAND pointing to a Wakaru ^1.13.0 Linux x64 binary (verified with 1.14.0); no tool is installed by REA. …"}

On other hosts, run Wakaru directly as in Step 3 and analyze its output folder.

Step 5: Trace where a request’s data goes

trace-javascript-semantics follows values through a semantic graph that the analysis stores next to the structural one. Seed it with the endpoint:

node --input-type=module -e '
import { readFileSync, writeFileSync } from "node:fs";
const application = JSON.parse(readFileSync("plain-evidence.json", "utf8"));
writeFileSync("semantic-input.json", JSON.stringify({
  application,
  query: { seed: { kind: "endpoint", value: "/api/v1/todos" }, direction: "forward-influence" }
}));'
rea trace-javascript-semantics ./semantic-input.json --json > trace.json
jq -c '.normalized_result | {status, summary}' trace.json
jq -c '.normalized_result.nodes[] | {kind, label, at: .identity.source_range.start}' trace.json
{"status":"ambiguous","summary":{"total_seed_matches":2,"traversed_nodes":3,"traversed_relations":1,"traversed_functions":1,"traversed_modules":1,"relevant_unknowns":0}}
{"kind":"response","label":"json","at":{"line":1,"column":149}}
{"kind":"call-site","label":"call","at":{"line":1,"column":149}}
{"kind":"request","label":"fetch","at":{"line":1,"column":70}}

Two things to read correctly. ambiguous here only means the seed matched two nodes (the request and its response consumer), not that the trace is unreliable. And the trace stops at .json(): it links the fetch at column 70 to the response read at column 149 with one consumed-by relation, but does not follow .items out of the function into toCsv. A missing relation is not proof the data goes nowhere. Other seed kinds are literal, property, event, function, boundary-field, semantic-node and application-node; other directions are backward-provenance, callers and ownership.

Step 6: Check a bundle against a source tree

When you have a source tree (an open-source release tag, or your own older checkout), compare-source-to-bundle scores which source file landed where:

rea import-reference-source "$PWD" --json > reference.json
node --input-type=module -e '
import { readFileSync, writeFileSync } from "node:fs";
const reference = JSON.parse(readFileSync("reference.json", "utf8"));
const application = JSON.parse(readFileSync("maps-evidence.json", "utf8"));
writeFileSync("stb-input.json", JSON.stringify({ reference, application }));'
rea compare-source-to-bundle ./stb-input.json --json > stb-maps.json
jq -c '.normalized_result.summary' stb-maps.json

Run it once with maps-evidence.json and once with plain-evidence.json:

unchanged duplicated unknown
With maps 3 2 3
Without maps 0 2 6

With maps, each src/*.js file came back unchanged, confidence exact, score 195: exact-source-digest (100) + source-map-original-path (70) + basename-match (20) + language-extension (5). Without maps, all three are unknown with no candidates. Hashing the source cannot match minified bytes, so this check depends on the maps.

Gotcha: the import only skips well-known generated paths like dist/ and node_modules/. Our dist-maps/ and dist-plain/ folders were imported as “source” too, and they are the two duplicated rows: each bundle file matched its own bytes in the analyzed build twice, once as an asset node and once as a module node. Point import-reference-source at src/ or a clean checkout. It also needs Linux or macOS; native Windows returns unsupported_host.

To diff two builds of the bundle itself, use rea compare-application-versions.

Large bundles and running it from an agent

Each source file is analyzed in its own worker, by default with 1024 MiB of V8 heap and 300000 ms. A file that exceeds the budget becomes an explicit unknown and the rest continues. We hit this on a real 13 MB bundle in the Electron walkthrough; raise the limits with --max-heap-mb and --analysis-timeout-ms, and check the summary’s limitations before trusting counts.

After npx rea-agents setup, the same operations are MCP tools in Claude Code, Cursor or Codex (analyze_javascript_application, inspect_analysis_view, trace_javascript_semantics, compare_source_to_bundle). Ask the agent to cite the Evidence ID for each claim: a module boundary should come from the graph and a value flow from the semantic trace, not from the model’s guess about what a minified function “probably does”.