rea-reverse

JS bundle 与 source map 还原

打开 source map、压缩后的 bundle,或两者一起:查看 map 里有什么,用 sourcesContent 重建原始源码目录并打包成 .zip 下载,把 bundle 中的位置或粘贴的报错堆栈映射回原始文件、行号和名称。

文件不会离开你的浏览器. map 和 bundle 在一个禁止联网的 Web Worker 中本地读取,不上传任何内容,文件里的 URL 也不会被加载。 如何自己验证

在单独页面打开工具(无广告、无统计)

生产环境的 JavaScript bundle 往往只有一行或几行很长的代码,变量名也都被改掉了。如果构建时同时产出了 source map(单独的 .map 文件,或者写在 bundle 末尾的内联 data: URL),这个 map 就记录了 bundle 里每个位置对应原始代码的哪个文件、哪一行哪一列。很多 map 还会把原始文件本身塞进一个叫 sourcesContent 的字段里。

这个工具在浏览器里读取 source map,给你三样东西:一份 map 内容报告;用 sourcesContent 重建出来的原始源码目录(可以浏览、预览、单独下载某个文件,或者整体下载成 .zip);以及位置查询——把 bundle 中的位置或粘贴进来的报错堆栈,换算成原始文件、行号、列号和名称。整个过程不上传任何文件。

使用步骤

  1. 拿到 map:bundle 旁边的 .map 文件、bundle 最后一行 sourceMappingURL 注释指向的文件,或者末尾带内联 data: map 的 bundle。
  2. 把 .map、bundle 或两者一起拖到上方的拖放区,或点“选择文件”。报告和原始源码目录会立即出现。
  3. 先看有多少源文件带内容,浏览或打包成 .zip 下载;再把位置或报错堆栈粘贴到查询框,点“映射到源码”。

真实输出示例

我们打开 eventemitter3 npm 包里的 eventemitter3.umd.min.js.map,再粘贴一段堆栈:它是 Node 22 在监听函数于压缩后的 emit 里抛错时打印的(绝对路径缩短成了文件名),然后点“映射到源码”,把页面显示的结果复制下来。排版做了压缩(标签和值放在同一行,省去按钮),所有值都原样保留。

样本: eventemitter3 5.0.4 npm 包(MIT)中的 dist/eventemitter3.umd.min.js 及其 .map(14,599 字节),2026-11-02 获取 (www.npmjs.com/package/eventemitter3/v/5.0.4)

概览
Source map      eventemitter3.umd.min.js.map (上传的文件)
格式            普通 map
file            eventemitter3.umd.min.js
sourceRoot      —
源文件          2 个源文件,2 个带内容,0 个没有
名称            60
映射            1 行生成代码上共 654 个映射段
忽略列表        —
打包工具线索    rollup 1
node_modules    2 个源文件中有 0 个(0%)

位置映射
c.<anonymous> throw.cjs:3:28  其他文件,不是这个 BUNDLE
c.emit eventemitter3.umd.min.js:1:1661  已映射
→ index.js:180:35  名称 call
177 |     if (listeners.once) this.removeListener(event, listeners.fn, undefined, true);
178 |
179 |     switch (len) {
180 |       case 1: return listeners.fn.call(listeners.context), true;
    |                                   ^
181 |       case 2: return listeners.fn.call(listeners.context, a1), true;
…
Object.<anonymous> throw.cjs:4:3  其他文件,不是这个 BUNDLE

原始源码 (2)
2 个带内容的文件。路径已清理:没有绝对路径,没有 "..",重名会自动改名。
index.js                   8.9 KB
index.js_commonjs-entry    328 B

能做什么

输入。 拖入或选择一个或多个文件:一个 .map 文件,一个 .js、.mjs、.cjs 或 .css bundle,或者两者一起。其他扩展名的文件会按内容判断(map 以 { 开头)。加入 bundle 时,页面会读取它最后一条 sourceMappingURL 注释:

  • 内联 map(//# sourceMappingURL=data:application/json;base64,...)会被解码直接使用,所以单独上传这个 bundle 就够了;
  • 外部引用(//# sourceMappingURL=app.js.map)会在报告里列出,并提示你把那个文件一起上传。页面不会替你去加载它。

如果上传了多个 map,优先使用 bundle 声明的那个(其次是 <bundle>.map,再其次是第一个),其余的会出现在警告里。上限:所有文件合计 256 MiB,300 万个映射段,200 万行生成代码,20 万个源文件,索引 map 最多 1 万个 section、嵌套 64 层,分析超时时间 2 分钟。

报告。

  • map 字段:格式(普通 map,或带 sections 的索引 map,会被展平)、file、sourceRoot、源文件数量以及其中带 sourcesContent 的数量、名称数量、映射段数和生成代码行数。
  • 忽略列表:有多少源文件在 ignoreList 或更早的 x_google_ignoreList 里(打包工具标记为第三方或生成的代码)。这些源文件在目录树和查询结果里会打上标记。
  • 打包工具线索:统计看起来像 webpack(webpack://)、Turbopack(turbopack://)、Vite(/@fs/、/@vite/)、Rollup(\0 虚拟模块、commonjsHelpers)、Rolldown、esbuild(<stdin>)或 Parcel(/__parcel_source_root/)的源文件名。这只是从文件名得到的线索,不能证明一定是哪个工具打的包。
  • node_modules 占比:有多少源文件位于 node_modules/ 目录下。
  • Bundle:大小、行数,以及它的 sourceMappingURL 写了什么。

原始源码。 每个带内容的源文件都会出现在一棵可折叠、可搜索的目录树里。路径会先规整:拼上 sourceRoot,去掉 webpack://、file:// 之类的 URL 协议,解析掉 . 和 ..,保证路径不会跑出目录树。你可以以文本方式预览、单独下载一个文件,或者点 全部下载为 .zip。map 里只有名字、没带内容的源文件会单独列出。

位置查询。 在查询框里粘贴一行或多行:

  • 位置,例如 1:2446(行号和列号都从 1 开始,与 DevTools 和堆栈里显示的一致);
  • Chrome 或 Node 的堆栈行(at explode (https://example.com/assets/app.js:1:2446)),或者 Firefox、Safari 的堆栈行(explode@https://example.com/assets/app.js:1:2446)。

每条结果给出原始的 路径:行:列、map 里记录的原始名称(如果有),以及目标位置附近几行原始代码,并标出目标行。如果上传了 bundle,还会显示该位置附近的生成代码。指向其他文件的堆栈帧会标为其他文件;如果没有任何一帧指向上传的 bundle(比如文件改过名),则所有帧都会被映射。

原理

source map 第 3 版由 ECMA-426 定义。mappings 字段是一长串 Base64 VLQ 数字:; 分隔生成代码的行,, 分隔映射段,每个段有 1、4 或 5 个数(生成列号,然后是源文件索引、原始行、原始列,以及可选的名称索引)。

在真正解码之前,页面先检查 map 的结构,再把 mappings 不解码地扫一遍。这一遍会统计映射段数和行数,拒绝 Base64 以外的字符、只有 2 或 3 个数的段,以及超过 32 位的数字,一旦超过上限就立刻停下。索引 map 逐个 section 做同样的检查:偏移必须严格递增;通过 url 引用其他 map 的 section 会被拒绝,因为页面不从网络加载任何东西。之后用 @jridgewell/trace-mapping 0.3.31 解码,并逐段检查解码结果是否落在它所属 section 的源文件和名称范围内。

查询时,取不晚于你给出位置的最大生成位置(可以跨行往前找),并返回这个位置上的所有映射段。这条规则、预检的内容和顺序都参照了 morluto 的开源项目 REA(MIT 许可)在 JavaScript 分析中追踪 source map 位置的做法。

.zip 在页面里直接生成:不压缩(stored),文件名用 UTF-8,每个文件带 CRC-32。写入之前会清理每条路径,保证解压时不会写到目标文件夹之外:没有绝对路径,没有 ..,没有空文件名,没有 Windows 不允许的字符;重名的文件(包括只差大小写的)会加上 ~2 这样的后缀。

文件如何保持私密

文件不会上传:

  • 工具运行在一个单独的框架(iframe)里,来源是 tools.rea-reverse.com。下面说的处理都在这个框架里、在你的设备上完成。
  • 这个页面在正文之后展示一个广告。广告脚本运行在 rea-reverse.com 上,而框架属于另一个源(origin),浏览器不允许广告脚本或本页的任何其他脚本读取你拖入的文件或结果。
  • 框架的内容安全策略拦截它的脚本能发起的所有请求(connect-src 'none',也不加载任何第三方资源)。框架不能自行打开窗口或让本页跳转;只有你点击报告里的链接时才会打开页面。它传给本页的只有自身高度和下面这些统计事件。
  • 解析、解码、查询和打包 .zip 都在你设备上的 Web Worker 里完成。文件里的 URL,包括 bundle 的 sourceMappingURL 和索引 map section 的 url,都不会被请求。
  • 源码、文件名、路径和代码片段都以纯文本显示,文件里的任何内容都不会被当作 HTML 解析或执行。
  • 统计事件只有粗粒度信息:成功或错误码、map 的来源(file、inline 或 none)、源文件数量区间(如 11-100)、大小区间和耗时区间。不会包含文件名、源文件路径、位置、堆栈或代码。框架把事件交给本页,由本页发送。完整列表见隐私页。
  • 不想要任何广告和统计?在单独页面打开工具。

如何自己验证

  1. 打开浏览器开发者工具的 Network(网络) 面板,加载本页。发往 tools.rea-reverse.com 的请求就是框架:它的 HTML、样式、字体和脚本,以及 Worker 脚本 /_astro/sourcemap-worker-*.js。发往 Google 域名的请求是本页的广告。
  2. 清空日志,然后分析一个 map,做一次位置查询,再下载 .zip。在过滤框里输入 domain:tools.rea-reverse.com:最多只会再出现一次 Worker 脚本 /_astro/sourcemap-worker-*.js(为下一次运行准备的新 Worker),没有任何请求带着你的数据。广告请求可能还会继续,但它们来自本页,不带框架里的任何内容。
  3. 点开来自 tools.rea-reverse.com 的 js-sourcemap/ 文档,查看 Headers:它的 Content-Security-Policy 里有 connect-src 'none' 和 frame-ancestors https://rea-reverse.com。在 Chrome 里,Application(应用)> Frames 也能看到框架的源和策略。Worker 运行在 default-src 'none'; script-src 'self'; connect-src 'none' 这条策略下,浏览器不允许它发出任何网络请求。
  4. 检查隔离:在 Console(控制台) 里,上下文选 top,运行 document.getElementById('tool-frame').contentDocument,结果是 null;frames[0].document 会抛出 SecurityError。广告脚本拿到的也是这个结果。
  5. 或者先打开页面再断网(关掉 Wi-Fi,或在 Network 面板勾选 Offline),然后分析文件,照样能用。

局限

  • 没有 map 就无法还原。 没有 source map 时,这个工具不会做反压缩、变量重命名或拆分模块。那需要反混淆工具:本站的 JavaScript 反混淆工具可以在浏览器里排版、重命名并拆分 webpack 和 browserify 打包,命令行下可以用 Wakaru(unminify / unpack);bundle 源码还原教程介绍了 REA 如何调用 Wakaru。
  • 只有 map 带了内容才有原始代码。 关闭了 sourcesContent 的 map(例如 webpack 的 nosources-source-map)只能给出文件名和位置,没有原始代码。
  • 原始代码是打包工具看到的版本。 对 TypeScript、Vue 或 JSX,通常就是原始文件,但有些 loader 存的是已经转换过的中间版本。
  • 不会加载外部 map。 如果 bundle 指向单独的 .map 文件,请自己把它加进来。section 使用 url 的索引 map 需要先合并成一个文件。
  • section 互相重叠的 map 会被拒绝,和 REA 的做法一致,即使有些工具能接受。
  • 列号。 查询使用 UTF-16 列号,与 JavaScript 引擎和 source map 格式一致。位于一行最后一个映射之后的位置,会归到这最后一个映射上。
  • 查询上限。 每次最多映射 200 个位置或堆栈行,其余的只计数、不映射。超过 1024 个字符的行会被忽略。
  • zip 格式。 不支持 ZIP64:一个压缩包最多 65,534 个文件、4 GiB。

合规

请只分析你自己的项目、你获得授权评估的项目或开源软件的 bundle 和 map。尊重被还原代码的许可证(包括其中限制逆向工程的条款)以及适用的法律。参见免责声明。

对应的命令行

用 jq 1.7.1 查看 map 的头部字段:2 个源文件、60 个名称,与报告一致:

$ jq -c "{version, file, sources, names: (.names | length)}" eventemitter3.umd.min.js.map
{"version":3,"file":"eventemitter3.umd.min.js","sources":["../index.js?commonjs-entry","../index.js"],"names":60}

throw.cjs 加载压缩后的文件,注册一个会抛错的监听函数并调用 emit。Node 22.22.3 打印的是压缩代码中的位置(这里和下面都把绝对路径缩短成了文件名):

$ node throw.cjs
…
Error: boom
    at c.<anonymous> (throw.cjs:3:28)
    at c.emit (eventemitter3.umd.min.js:1:1661)
    at Object.<anonymous> (throw.cjs:4:3)
…

加上 --enable-source-maps 后,Node 读取同一个 map,落到的原始行列和上面的查询结果相同:

$ node --enable-source-maps throw.cjs
…
Error: boom
    at c.<anonymous> (throw.cjs:3:28)
    at c.c.emit (index.js:180:35)
    at Object.<anonymous> (throw.cjs:4:3)
…

常见问题

怎样用 source map 解析压缩代码的报错堆栈?

打开与抛错的那次构建完全对应的 .map,把堆栈粘贴到查询框,点“映射到源码”。属于这个 bundle 的帧会映射到原始文件、行、列和名称;其他文件的帧会单独标出来。

怎样从 source map 拿回原始源码?

如果 map 带有 sourcesContent,原始文件就在里面:在目录树里浏览,预览或下载单个文件,或者点“全部下载为 .zip”。map 只列了名字、没带内容的文件,会列在“没有内容的源文件”里。

网站的 source map 在哪里找?

看脚本最后一行有没有 sourceMappingURL 注释,或者响应头里有没有 SourceMap。只下载你有权分析的网站的 map。

堆栈映射到了错误的位置。

确认 map 和抛错的 bundle 来自同一次构建,并且列号来自浏览器或运行时,而不是格式化过的文件副本。

为什么有些路径里带 ~2?

两个源文件在路径清理后落到了同一个位置,比如 webpack://app/./src/a.js 和 webpack:///src/a.js。第二个会被改名,保证在任何系统上解压时两个文件都能保留下来。

能把原始位置反查到 bundle 里吗?

这个工具不支持。它只把生成代码的位置映射到原始位置,这正是看报错堆栈和读压缩代码时需要的方向。

更新于

相关教程