rea-reverse

Unminify and Recover JavaScript Source from Bundles with REA

Written for REA 6.4.0Updated 9 min read
On this page

A production JavaScript bundle is one long line of renamed variables. To answer questions like “which module sends this request?” or “what did version 2.3 change?”, you first need modules, imports and call relationships back. REA, the open-source project by morluto, does this from local files without running them. It reads source maps when they exist. It recovers webpack chunks and module factories from the syntax. With a caller-supplied Wakaru binary, it can also unpack a bundle into separate derived modules. Each result is Evidence that you or your agent can check.

Who this is for: front-end developers debugging their own production builds, maintainers auditing what shipped, and learners who want to see how bundlers rewrite code. You should be comfortable with npm and a terminal.

What you will learn:

  1. Build a small bundle you can check against its own source
  2. Read source maps through REA’s application graph
  3. Recover chunk and module boundaries when there are no maps
  4. Unpack a bundle with Wakaru through recover-javascript-sources (Linux x64)
  5. Trace data flow with trace-javascript-semantics
  6. Score the bundle against the original tree with compare-source-to-bundle
  7. Run the whole workflow from an AI agent over MCP

Scope. This article covers files that are already on your disk. To capture scripts from a live page, map a single minified position, or link a network request to its initiator, see Trace website API calls with REA. For Electron archives, preload bridges and IPC, see Reverse engineer an Electron app. Analyze only code you own, open-source code, or code you have permission to examine. See REA’s DISCLAIMER.md.

Pick your path first

What you have What REA can give you Section
Bundle plus .map files Original source names and digests, linked to generated files Step 2
Webpack or Rspack bundle, no maps Chunk registrations, module factories, imports, endpoints Step 3
Any single bundle, no maps, Linux x64 host Derived per-module files from Wakaru, then the same graph Step 4
Original source tree for an older or current build A scored file-to-bundle mapping Step 6

Neither tool restores original variable names. Wakaru rewrites syntax into a more readable form, but REA records the result as derived Evidence. Original names and runtime equivalence remain unknown.

Prerequisites

  1. Node.js and npm, plus REA 6.4.0 or newer. Install the CLI with npm install --global rea-agents, or prefix every command with npx -y rea-agents@latest. To use REA from Claude Code, Cursor or Codex, run npx rea-agents setup and restart the agent (setup guide).
  2. No native analysis engine. Static JavaScript analysis does not use Ghidra, Hopper or IDA.
  3. For Step 4 only: a Linux x64 host (a VM or container works), Wakaru ^1.13.0 (REA verifies against 1.14.0), and util-linux prlimit. REA refuses to start recovery on other platforms.
  4. For Step 6 only: Linux or macOS. Historical-source import returns unsupported_host on native Windows.
  5. jq for slicing large JSON results.

Step 1: Build a bundle you can check

Practice on a bundle whose source you have. Every claim REA makes can then be checked against the source. A tiny project of your own works well:

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 two variants, one with source maps and one without:

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

Why two builds: the same code with and without maps shows what each REA layer adds. The dynamic import() makes webpack emit a separate chunk for export.js, so you also get chunk splitting. Real production builds usually leave concatenateModules on. Expect fewer module boundaries there, because the bundler has already merged them.

Step 2: Read the bundle with source maps

Analyze the directory and save the Evidence to a file:

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

Progress lines go to stderr, so stdout stays a clean JSON document. Then project a summary instead of reading the whole record:

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

Page through modules the same way with view: {kind: "page", collection: "modules", offset: 0, limit: 32}. The electron article shows that pattern in full.

What the source maps add:

  • REA parses each local sourceMappingURL declaration and the map it points to. Then it adds source-map and original-source nodes linked by maps_to edges.

  • Original source names such as webpack://bundle-lab/./src/api.js and optional content digests are recorded. Raw sourcesContent text is not stored in the graph, so read the original text from the map yourself if you need it.

  • To read one entry, use an item view with the exact path or reference you saw on a module page:

    jq -c '{source: {kind: "inline", evidence: .},
            view: {kind: "item", collection: "modules",
                   selector: {path: "PASTE_EXACT_PATH_FROM_PAGE"}}}' \
      maps-evidence.json > one-module.json
    rea inspect-analysis-view one-module.json

A malformed or unreadable map becomes an unknown scope marked unavailable. It is not treated as proof that the sources are absent.

Step 3: Recover module boundaries without maps

Now analyze the build without maps:

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

Without maps, REA still parses every file as inert text and recovers:

  • Webpack and Rspack chunk registrations and module factories when they appear as AST literals. In this build, expect the main bundle’s module table and the separate export.js chunk to appear as their own nodes.
  • Static import, dynamic import(), CommonJS require, workers and service workers.
  • Endpoints, routes, storage and vendor markers. The /api/v1/todos literal becomes an endpoint observation. Since 6.4.0, the analyzer also recognizes tRPC procedure calls and named GraphQL operations. Router and procedure names survive minification as property names, so you can still read them in a minified tRPC client.

Compare the two summaries. Module counts and import edges should match the source closely. File and variable names will not, and that is what the next step addresses.

Bundler notes. Webpack and Rspack keep a visible module table. Rollup, Vite and esbuild production builds usually hoist everything into shared scopes. Once modules are hoisted, the file no longer contains the boundaries, so neither REA nor Wakaru can bring them back with certainty.

Step 4: Unpack modules with Wakaru (Linux x64)

recover-javascript-sources runs a Wakaru binary that you supply and publishes verified output. REA does not install Wakaru. In 6.4.0 the adapter runs only on Linux x64 and refuses to start elsewhere.

Install Wakaru using one of the two methods from REA’s recovery guide:

curl -fL https://github.com/pionxzh/wakaru/releases/download/v1.14.0/wakaru-linux-x64.tar.gz | tar -xz wakaru
export REA_WAKARU_COMMAND="$PWD/wakaru"

or

npm install [email protected]
export REA_WAKARU_COMMAND="$(realpath node_modules/@wakaru/cli-linux-x64/wakaru)"

Point the variable at the platform binary, not the Node.js shim in node_modules/.bin. REA wraps every run in util-linux prlimit (default /usr/bin/prlimit). If yours is elsewhere, set REA_JAVASCRIPT_PRLIMIT_COMMAND to its absolute path.

Run recovery on the main bundle:

rea recover-javascript-sources "$PWD/dist-plain/main.js" "$PWD/recovered-main" \
  --extraction-mode structural --rewrite-level standard --json > recovery.json
  • The output directory must not exist yet, and both paths must be absolute.
  • --extraction-mode is structural (default, Wakaru --unpack=strict), heuristic (--unpack=auto) or inspection (--unpack=inspect, which can return non-executable regions).
  • --rewrite-level is minimal, standard (default) or aggressive. Higher levels rewrite more syntax. Runtime equivalence stays unknown either way.

The published tree contains inputs/bundle.js (the exact snapshot), modules/ (derived modules plus any maps Wakaru emitted), reports/stdout.json and reports/provenance.json (the engine’s own reports), and manifest.json. Each module records its UTF-8 byte range in the original input. That lets you check any recovered function against the original bytes.

Then feed the recovered modules back into the graph:

rea analyze-javascript-application "$PWD/recovered-main/modules" --json > recovered-evidence.json

The inline result also includes an analysis_input object for exactly this call.

Limits to know: one worker, a 1 GiB address-space ceiling, a 120-second deadline, 64 MiB of input and 128 MiB or 10,000 entries of output. Exceeding any limit fails cleanly and rolls back the output. Structural extraction may fall back to a single file. In REA’s own fixtures, an esbuild scope-hoisted bundle stays one unknown-format script. In 6.4.0, the command takes one input file. The --additional-path option in the repository docs, which recovers several code-split chunks together, is only on main and is not in the npm 6.4.0 release.

Step 5: Trace data flow inside the bundle

Module boundaries tell you where code lives. trace-javascript-semantics tells you how values move. It queries a semantic relation graph that analyze-javascript-application stores next to the structural graph. It covers lexical bindings, property reads and writes, closures, uniquely resolved local calls, Promise chains, timers, event emitters, request construction and response consumers.

Build the input from the saved Evidence:

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

Seeds can be literal, endpoint, property, event, boundary-field, function (by fingerprint), semantic-node or application-node. Directions are backward-provenance (where did this value come from), forward-influence (what does it affect), callers and ownership. Here, forward influence from the endpoint should lead from the fetch request to the code that reads .items.

How to read the result: status is found, no-match, ambiguous, partial or unsupported. resolved means the static target was unique, not that the path ran. Ambiguous dynamic edges are excluded unless you set include_ambiguous_dynamic_edges: true. A missing relation is never proof that the behavior is absent.

Step 6: Check the bundle against the source

When you have a source tree, whether yours, an open-source release tag, or an older version, you can measure how well the bundle corresponds to it. First import the tree as a historical reference:

rea import-reference-source "$PWD" --json > reference.json

The import records file hashes and import relationships, honors .gitignore, and skips common generated paths such as node_modules/ and dist/ by default. Then compare:

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

Each source file gets a status: unchanged, modified, removed, split, merged, duplicated or unknown. Each status comes with the signals and weights behind it: an exact source digest (100), a source-map original path (70), an exact current path (60), a path suffix (40), a basename (20) and the language extension (5). Run it once against maps-evidence.json and once against plain-evidence.json. That shows how much of the mapping depends on the maps. removed requires complete inventories on both sides, so with partial coverage absence stays unknown.

To compare two builds of the bundle itself rather than source against bundle, use compare-application-versions. It is covered in Evidence, snapshots and version comparison.

Step 7: Large bundles

Since 6.4.0, each JavaScript source is analyzed in an isolated process with its own budget:

rea analyze-javascript-application "$PWD/dist-plain" \
  --max-heap-mb 4096 --analysis-timeout-ms 600000 --json > plain-evidence.json

The defaults are 1024 MiB of V8 old-space and 300000 ms per source. When a source runs out of budget, REA keeps the facts it already collected, marks that source as an explicit unknown and continues with the rest. Read the summary’s limitations before trusting counts from a very large bundle.

Step 8: Run it from your agent (JavaScript reverse engineering over MCP)

After npx rea-agents setup, the same operations are MCP tools: analyze_javascript_application, inspect_analysis_view, recover_javascript_sources, trace_javascript_semantics, compare_source_to_bundle, compare_application_versions and trace_application_feature. A prompt that works:

Use REA on /absolute/path/bundle-lab/dist-plain. Analyze it as a JavaScript
application, show me the module and chunk structure from the summary, then
trace forward influence from the endpoint "/api/v1/todos". Cite the Evidence
ID for every claim and list anything that stayed unknown or partial.

Over MCP, the agent can pass a retained Evidence ID ({"kind":"retained-evidence","evidence_id":"..."}) instead of the full record, when the connected server supports retained references. If you see a capability_unavailable error for recovery, the server host is not Linux x64 or REA_WAKARU_COMMAND was not visible to the server process. Set it in the MCP server’s environment, not only in your shell.

Check the answer the same way you check any REA result. A module boundary should come from the application graph, a value flow from the semantic trace, and a source correspondence from compare-source-to-bundle. An agent’s guess about what a minified function “probably does” is not Evidence.

Troubleshooting

recover-javascript-sources returns capability_unavailable. Check three things: the host is Linux x64, REA_WAKARU_COMMAND is an absolute path to the platform binary, and prlimit exists at /usr/bin/prlimit or at the path in REA_JAVASCRIPT_PRLIMIT_COMMAND.

“Unsupported Wakaru version”. REA accepts ^1.13.0. Other releases in that range work, and the result notes that the report parser was verified only against 1.14.0.

Recovery produced one module. The bundle was scope-hoisted or used an unknown format. Try --extraction-mode heuristic. If that also yields one file, the boundaries are not in the bytes.

The CLI rejects --additional-path as an unknown option. That option is on repository main only. In 6.4.0, recover each chunk separately.

No source-map nodes appear. The build has no sourceMappingURL comment, or the map file is not next to the bundle. Copy the .map files into the analyzed directory.

import-reference-source fails on Windows. It needs safe no-follow file opens, which are available on Linux and macOS only. Run it on one of those hosts.

Next steps