rea-reverse

如何用 source map 还原压缩后的 JS 报错堆栈

发布者 rea-reverse更新于 约 6 分钟读完
本页目录

要还原 at e (app.BhjnrRq7.js:1:68) 这样的压缩代码堆栈,就拿同一次构建产出的 source map,按每一帧的行号和列号去查:map 会给出原始文件、行、列,有时还有名称。把堆栈和 .map 一起放进本地的 source map 工具,或者写脚本逐帧查询都行。下面用一个小示例应用的真实报错逐帧还原,再讲怎样让 map 不公开,以及结果对不上时该查什么。

为什么堆栈显示第 1 行第 68 列

压缩工具会把各个模块拼成少数几行很长的代码,并把局部函数改成单个字母。引擎报告的是它实际执行的那份代码里的位置,所以堆栈里给出的是 bundle 的坐标:文件、行、列,都从 1 开始数。source map 就是一张表,把这些生成位置对应到原始的 文件:行:列,每个位置还可以附带一个原始名称。还原堆栈,就是拿每一帧去这张表里查一次。

查得对不对,取决于两点:

  • 必须是同一次构建。map 要来自抛错的那一次构建。昨天部署的 map,会把同一个列号映射到别的地方去。
  • 列号的起点。堆栈里的列号从 1 开始,source map 格式里的列号从 0 开始。直接接收堆栈文本的工具会替你换算;接收原始位置的 API 一般要的是从 0 开始的列号。

第 1 步:构建 hidden source map

示例是一个只有两个文件的 TypeScript 应用。src/price.ts 负责校验一行商品:

export function lineTotal(item: LineItem): number {
  if (!Number.isInteger(item.quantity) || item.quantity < 1) {
    throw new RangeError(`Invalid quantity for ${item.sku}: ${item.quantity}`);
  }
  return item.unitCents * item.quantity;
}

src/main.ts 用 items.reduce((sum, item) => sum + lineTotal(item), 0) 计算购物车总价,再由 formatCart 格式化。用 Vite 构建,并打开 hidden map:

// vite.config.js
export default {
  build: { minify: true, sourcemap: 'hidden' },
};

为什么用 hidden:Vite 仍然会写出 assets/app.BhjnrRq7.js.map,但 bundle 里没有 sourceMappingURL 注释,浏览器和访客都不会被引向它。把 map 和构建的版本号或 commit 一起存起来,不放进部署目录,以后就能还原报错,又不会公开源码。webpack 里对应的设置是 devtool: 'hidden-source-map',esbuild 是 --sourcemap=external。为什么不能把 map 部署出去,见 发布前自查 source map 泄露。

真实的压缩输出只有一行:

function e(e){if(!Number.isInteger(e.quantity)||e.quantity<1)throw RangeError(`Invalid quantity for ${e.sku}: ${e.quantity}`);return e.unitCents*e.quantity}function t(t){return t.reduce((t,n)=>t+e(n),0)}function n(e){return`$${(t(e)/100).toFixed(2)}`}export{t as cartTotal,n as formatCart};

在 Node.js 22 里用数量 0 调用 formatCart,得到下面这段堆栈(目录缩写成了 /work):

RangeError: Invalid quantity for TEE-07: 0
    at e (file:///work/price-app/dist/assets/app.BhjnrRq7.js:1:68)
    at file:///work/price-app/dist/assets/app.BhjnrRq7.js:1:196
    at Array.reduce (<anonymous>)
    at t (file:///work/price-app/dist/assets/app.BhjnrRq7.js:1:180)
    at n (file:///work/price-app/dist/assets/app.BhjnrRq7.js:1:229)
    at file:///work/price-app/run.mjs:6:3

浏览器里的堆栈格式一样,只是地址换成了 https://。

第 2 步:在浏览器里还原堆栈

打开 JS bundle 与 source map 还原工具,拖入 .map 文件(手上有 bundle 的话也一起拖进去),再把整段堆栈粘贴到查询框。工具能识别 Chrome 和 Node 的写法(at fn (url:line:col))、Firefox 和 Safari 的写法(fn@url:line:col),也能识别单独的 行:列。解析、解码和查询都在你电脑上的 Web Worker 里完成,map 不会被上传。这一点很重要,因为 map 里装着你的源码。

上面这段堆栈的还原结果如下:

堆栈里的帧 原始位置 该位置记录的名称
at e (…app.BhjnrRq7.js:1:68) src/price.ts:9:15 无
at …app.BhjnrRq7.js:1:196 src/main.ts:4:44 lineTotal
at t (…app.BhjnrRq7.js:1:180) src/main.ts:4:16 无
at n (…app.BhjnrRq7.js:1:229) src/main.ts:8:17 cartTotal
at …run.mjs:6:3 标为其他文件

每条结果都附带几行原始代码,并标出目标行。第一帧对应的是 lineTotal 里的 throw new RangeError(...)。如果上传了 bundle,还会显示第 68 列附近的生成代码。Array.reduce (<anonymous>) 这一行没有位置信息,直接跳过;run.mjs 那一帧指向的不是这个 bundle,所以不会拿这份 map 去映射。

名称这一列怎么读:它是 map 在这个确切位置上记录的标识符,而不是这一帧所在函数的名字。第 2 帧指向的是调用 e(n) 的地方,所以记录的名称是被调用的函数 lineTotal;第 4 帧指向调用 t(e) 的地方,记录为 cartTotal。要知道帧位于哪个函数里,看代码片段:第 1 帧在 lineTotal 里,第 2、3 帧在 cartTotal 里,第 4 帧在 formatCart 里。

查询规则:对每个位置,工具取不晚于它的最大映射位置(可以跨行往前找),并返回该位置上的全部映射段。REA 用的也是这条规则,下一节会用到。如果这个位置之前根本没有映射,这一帧标为未映射;如果那里的映射段没有原始位置,就标为仅有生成位置。两种情况都不会去猜。

第 3 步:map 对不上怎么办

结果不对,大多是因为文件配错了。按这个顺序检查:

  1. 文件哈希。app.BhjnrRq7.js 这种带内容哈希的文件名每次构建都会变。堆栈里的文件名应该和 map 的 file 字段(工具报告里能看到)一致。
  2. 行数。工具报告会列出 bundle 的行数和 map 的生成代码行数。如果堆栈提到一个单行 bundle 的第 3 行,说明这两个文件不是一套。
  3. 格式化过的副本。从 DevTools 里美化后的 bundle 抄来的位置,和原始文件对不上。要用报错里原始的位置。
  4. 改过名的 bundle。如果没有任何一帧指向上传的 bundle(比如 CDN 改写了文件名),工具会把所有帧都拿这份 map 去映射。这时要看代码片段确认结果说得通。
  5. 没有内容。没带 sourcesContent 的 map 仍能给出文件、行和列,只是没有代码片段。到同一个 commit 的代码里打开那个文件、那一行即可。

第 4 步:用 REA 自动化

还原一段粘贴来的堆栈,用浏览器工具最快。要在脚本里或让 agent 来做,可以用 REA(第三方开源逆向命令行工具,安装见 安装与接入)的 trace-web-source-location:它把一个已抓取脚本里的生成位置,通过你指定的本地 map 映射回去。它没有直接接收整段堆栈的命令,所以要每一帧查一次。

这个命令针对的是 REA 从页面抓取下来的脚本,这样堆栈和 map 都能对应到确切的字节。对你自己的站点或本地预览:

rea inspect-web-page http://127.0.0.1:9222 TARGET_ID \
  --include-script-sources --json > capture.json
rea export-web-scripts /absolute/path/capture.json /absolute/path/exported --json

rea trace-web-source-location \
  /absolute/path/exported/manifest.json 0 \
  /absolute/path/maps/app.BhjnrRq7.js.map \
  'https://your-site.example/assets/app.BhjnrRq7.js.map' \
  1 67 --json

六个参数依次是:manifest、该 manifest 里从 0 开始的脚本序号、本地 map 路径、一个只用来解析 map 里 sources 的 URL(不会去请求它)、从 1 开始的行号、从 0 开始的 UTF-16 列号。所以堆栈里的 1:68 要写成 1 67。浏览器怎么启动、目标 ID 怎么拿,见 REA 的 browser observation 文档。

按 REA 的输出 schema,结果里有这些内容:

  • lookup.matched_generated_position,以及 matches 里同一位置上的全部匹配。每个匹配要么是 generated-only,要么是 mapped,带 reported_source、resolved_url、original_position 和 name。
  • content.state:map 带 sourcesContent 时为 embedded,并附上原文;不带时为 unavailable。original_position.content_position 说明这个位置有没有在原文里核对过。
  • source_map.association: "caller-selected"、source_authenticity: "unknown"、execution: "unknown"。REA 会校验你给它的字节,但不会声称这份 map 就是和这个脚本一起部署的,也不会声称这一行真的执行过。

限制:map 最大 4 MiB,解码时限 20 秒,只支持普通 map 和内联 section 的索引 map;section 指向其他 URL 的索引 map 不支持。通过 MCP 调用时,操作名是 trace_web_source_location,agent 可以逐帧循环调用,并为每条结论给出依据。

接入错误上报流程

无论用哪个工具,保住 map 私密性的做法都一样:

  1. 用 hidden map 构建,并给这次构建打上版本号或 commit 哈希。
  2. 把 .map 文件按版本存到私有存储,或者上传到支持 map 上传的错误监控服务。
  3. 只部署 .js 文件。
  4. 收到报错时,按版本取出对应的 map,用本地工具或 REA 还原堆栈。

对你自己运行的 Node.js 服务,node --enable-source-maps 能在运行时还原堆栈,前提是 bundle 的 sourceMappingURL 指向一个可读的 map。hidden map 没有注释可循,所以要按上面的方法离线还原。Electron 应用的 renderer 也一样,先解包 app.asar 拿到 bundle 和 map。