当 Vercel 禁用了 require(esm):一次 Waline 升级的破案记录

前言

我的博客评论区用的是 Waline,服务端部署在 Vercel 上。

前几天在给博客做一轮插件优化:PWA、图床、字体、缓存,清单一项项划掉。最后一项是顺手升级评论系统的依赖,服务端还停在六月份部署的 1.40.3,官方最新已经到了 1.43.4。

我以为这是十分钟的活。改个版本号,重新部署,收工。

结果这个「十分钟的活」折腾了三个小时,线上两次翻车,中间还把整个依赖树扫了一遍,就为了找到一行 require。

破案之后回头复盘,这个坑很值得写下来:Waline 没有 bug,平台也没有出故障,纯粹是规则变了。Vercel 关掉了 require(esm),而一堆依赖树里还留着对它的隐形假设。

如果你也在托管平台上跑 Node 服务,这篇应该能帮你省下那两个小时。

一、例行升级变事故

Waline 的 Vercel 部署是当初照官方模板搭的,一年多来很省心:写完评论提交,服务端平时没有任何存在感。

这次是我主动去动它:把六月那次部署重新构建了一下,让依赖里的 latest 重新解析,服务端从 1.40.3 拉到了最新的 1.43.4。

构建很顺利,几十秒就绿了。

然后我刷了一下页面:评论区出不来。直接请求服务端任意路由:

Terminal window
$ curl -i "https://<评论服务>/api/article?path=/&type=time"
HTTP/2 500
x-vercel-error: FUNCTION_INVOCATION_FAILED

全都 500,连根路径也是。FUNCTION_INVOCATION_FAILED 的意思很明确:函数在业务代码跑起来之前就死了。

先别急着修,第一步是止血:Vercel 上可以把域名随时切回任意一个历史部署。我把评论服务的域名切回六月那份能工作的版本,几十秒后评论区恢复。

「先回滚再排查」这条老规矩,这一天救了我两次。

少年看着冒出烟圈的评论小终端(标注 500)

二、第一层根因:一行 require

回滚之后,终于可以安心看日志了。Vercel 的运行日志写得很直白:

Error [ERR_REQUIRE_ESM]: require() of ES Module
/var/task/node_modules/@mdit/plugin-emoji/dist/index.js
from /var/task/node_modules/@waline/vercel/src/service/markdown/index.js not supported.

翻译一下:Waline 的代码用 CommonJS 的 require() 去加载 @mdit/plugin-emoji,而这个包是纯 ESM 的。在当前运行时里,这种加载方式不被允许。

去 Waline 源码里翻,文件第一行就是「凶手」:

const { fullEmoji } = require('@mdit/plugin-emoji');

有意思的是,同一个文件往下看,其他 @mdit 插件全都已经改成了动态 import:

const { sub } = await import('@mdit/plugin-sub');
const { sup } = await import('@mdit/plugin-sup');
const { spoiler: spoilerPlugin } = await import('@mdit/plugin-spoiler');

对比之下,emoji 这一行像是某次重构时漏改的。

放大镜聚焦链条上唯一形状不同的环节(标注 require)

去 Waline 的 issue 区搜了下,确实有先例:两个 issue(#3736 、#3801 )描述的报错和我遇到的完全一样。

官方给的修复方案是改 vercel.json,打开一个环境变量:

{
"env": {
"NODE_OPTIONS": "--experimental-require-module"
}
}

原理:Node 早期版本的 require(esm) 能力藏在实验开关后面,用这个 flag 手动打开,CJS 代码就能正常加载 ESM 包。issue 下面还有用户回复说「改完部署就好了」。

我照着改了,重新部署。

还是 500。

三、官方方案翻车:NODE_OPTIONS 为什么没用了

报错一模一样,说明 NODE_OPTIONS 根本没生效。把新版运行时的日志再翻一遍,两件事进入视野。

第一,调用栈里出现了这样的帧:

at /opt/rust/nodejs.js:2:14482
at Function.Eo (/opt/rust/nodejs.js:2:14860)

/opt/rust/ 这个前缀说明 Vercel 换了运行时引擎:新版用 Rust 重写了 Node 的模块加载器。这不是个普通 Node 环境。

第二,在 Vercel 社区里翻到有人把同类问题挖到了底(原帖 ):新版运行时会在进程启动时,强制往参数里塞一个 --no-experimental-require-module。具体是哪次平台更新换的规则,我没翻到公告,但复现出来的行为和这条帖子完全对得上。

这个细节是整个案子的转折点。Node 的参数优先级里,命令行参数(execArgv)压过环境变量(NODE_OPTIONS)。也就是说:

  • 我通过 NODE_OPTIONS 说:打开 require(esm);
  • 运行时通过 execArgv 说:关掉;
  • 后者赢。

官方 issue 里那个方案写在运行时更新之前。在被验证「有效」的时间点和被我遇到的时间点之间,Vercel 换了运行时,旧的 workaround 就此过期。

结论:在当前运行时下,这条路是死的。想升级,就得让依赖树自己兼容「禁用 require (esm)」的环境。

四、修一层崩一层

既然平台不肯打开开关,那就把依赖树里所有「CJS 加载 ESM」的地方找出来处理掉。它们分两类:一类在 Waline 自己的源码里(比如那行 emoji),一类藏在依赖深处:某个库的某个子依赖在 require 别的 ESM 包。

先修了 emoji 那行(改成懒加载的动态 import,后面细说)。部署。

还是 500,但报错变了:

Error [ERR_REQUIRE_ESM]: require() of ES Module
/var/task/node_modules/@exodus/bytes/encoding-lite.js
from /var/task/node_modules/html-encoding-sniffer/lib/html-encoding-sniffer.js not supported.

修掉一层,暴露下一层。这次是 html-encoding-sniffer(jsdom 的子依赖)在加载 @exodus/bytes。如果这样一层层修,不知道要打几只地鼠。

于是我停下来,换了个思路:写个小脚本,把整个 node_modules 扫一遍:解析每个包里的 require('xxx') 调用,判断目标包是不是 ESM-only,是的话就标记出来。核心逻辑就十几行:

// 伪代码:遍历所有 .js 文件里的 require() 字面量
for (const spec of file.matchAll(/require\(['"]([^'"]+)['"]\)/g)) {
const target = resolvePackage(spec); // 找到目标包
if (target.type !== 'module') continue; // 目标是 CJS,安全
if (hasRequireEntry(target, spec)) continue; // exports 里有 require 条件,安全
flag(file, spec, target); // 剩下的就是断边
}

跑出来 22 条断边。分布很有规律:

来源数量情况
Waline 源码1emoji 那行,已修
jsdom@30 依赖链一大片@exodus/bytes、@asamuzakjp/css-color、@asamuzakjp/dom-selector 等
其它几条复核后是误报(包内有 cjs 子目录 / cli 文件运行时不会加载)

探照灯扫过依赖树,照出 22 个问题节点

大部分断边指向同一个源头:jsdom。而 Waline 全站只有一个地方用它:xss.js 里做评论内容的 XSS 清洗,用的还是最基础的 new JSDOM(...)。

于是决策变得简单:给 jsdom 加一个版本覆盖,钉在最后一个「全 CJS 依赖树」的大版本上。选 24.1.1 的依据:

  1. jsdom 从 27 之后才开始引入 @exodus/bytes 这类新式 ESM 依赖,24.x 还没沾上;
  2. Waline 的 xss.js 在 1.40.3 和 1.43.4 之间一字未改,而 1.40.3 配的正是 jsdom 19,API 完全兼容;
  3. 我们只用它一个 API(new JSDOM()),降级风险接近零。
{
"overrides": {
"jsdom": "24.1.1"
}
}

改完之后再扫一遍,断边清零,剩下的只有人工复核过的误报。

五、搭一个「本地 Vercel」

改是改完了,但我不想再拿线上试错,两次翻车已经够本。接下来要在本地证明一件事:「修复后的依赖树」不会再撞 require (esm)。

这里的关键其实一句话:Vercel 运行时的行为 = Node 加一个 --no-experimental-require-module。把同样的组合在本地拼出来,就得到一个「本地 Vercel」。

选 Node 22.11 是因为它默认已开启 require(esm),加上禁用的 flag 强行关掉,行为与 Vercel 运行时一致。一条命令就能拉起这个环境:

Terminal window
npx -y [email protected] --no-experimental-require-module <你的脚本>

然后搭了个三级验证环,从窄到宽:

第一级,单点复现。 直接加载 Waline 入口:先在本地复现出和线上一模一样的报错(说明环境可信),修复后再跑,通过。

第二级,全树扫描。 就是上面那个脚本:修复前 22 条断边,修复后清零。

第三级,全量功能模拟。 写了个几十行的脚本,真启动整个 Waline 应用,用 mock 的 HTTP 请求打真实路由,还顺手跑一遍完整渲染管线(emoji、公式的 MathJax 渲染、XSS 清洗)。数据库用占位配置(连接失败是预期),只要不出现模块加载崩溃就算过。

第三关全绿的那一刻比较爽:

[sim] boot OK
[sim] RENDER DEFAULT OK: "<p>hi 😄 and <svg ..." ← MathJax 渲染出了 SVG
[sim] SANITIZE OK
[sim] GET / -> 200

这套环后来证明值回票价:它把「上线」从赌博变成了过闸机。

在窗边照着窗外景物搭桌面微缩布景(标注 本地)

六、修复三件套上线

最终上线的是三个文件。看着不多,但每一条都是前面几层排查换来的:

文件内容解决什么
patch-waline.cjs + postinstall把 emoji 那行静态 require 改成懒加载的 await importWaline 源码里唯一的 CJS→ESM 断边
package.json overridesjsdom 钉在 24.1.1jsdom@27+ 的整条 ESM 子依赖链
vercel.json官方新版模板(includeFiles 打包 MathJax 字体等运行时数据)动态 import 的资源文件在构建时被漏打包

postinstall 补丁有个小心思:它不改别人的仓库,只在部署流程里(npm install 之后)对依赖文件做一次定点替换,找不到目标就跳过(no-op)。将来 Waline 官方把源码修掉,这个补丁自然退役,不需要谁来记得删。

还有一个反直觉的决定:把依赖从 latest 改成了写死的 1.43.4。以前我也喜欢 latest,自动吃新版本多省心。这次它教育了我:latest 给你的是「未知的组合」,而排障最需要的是「确定的组合」。升级可以手动来,一次一个版本。

第三次上线,验证清单全过:

  • ✅ 服务端响应头里的版本号:1.43.4
  • ✅ 评论计数、浏览量读写正常(真实数据库)
  • ✅ 评论 RSS 正常渲染(完整跑过 Markdown 渲染管线)
  • ✅ 运行日志零错误

评论区回来了,而且比之前更快,因为顺带把评论包的懒加载也做了(那是另一场优化,改天单独写)。

评论终端恢复运转,少年拿着核对清单

七、复盘

三个小时换来的经验,逐条记录:

1. 平台运行时是个隐形依赖。 你的服务实际跑在「Node + 平台私货」上。平台升级运行时不会通知你的依赖树,但会改变它成立的条件。require(esm) 只是其中一种「方言差异」。

2. 依赖连锁问题,扫全树,别打地鼠。 修一层崩一层的时候,最诱人的做法是继续修下一层。正确姿势是停下来,先把问题边界摸清楚(全树扫描),再找最小的干预点,这次最后只动了一个版本覆盖。

3. 把线上复刻回本地,再动手。 「本地 Vercel」听起来麻烦,其实就是一条命令加一个 flag。有了它,验证从赌运气变成确定性动作。两次翻车的教训是一致的:没在本地复现过的修复,都是在拿线上做实验。

4. 官方 workaround 有保质期。 七月有效的方案,十月失效,因为中间的运行时换了。搜到旧帖时,先看时间,再想它假设的前提今天还成不成立。

5. 回滚能力要在事故前练熟。 这天里最亮的一个动作发生在第一次翻车后 30 秒:把域名切回旧部署。故障最终能造成多大伤害,很多时候就看你的回滚速度。

最后一点碎碎念:有人说「不要重复造轮子」,这次偏是靠「重复造轮子」破的案。那个扫描器和模拟脚本市面上没有现成货,加起来花了半小时,省下好几轮线上试错。

平台的便利是租的;真出了事,能救场的只有自己搭出来的排查能力。

十分钟的活,三小时的课。值。