---
date: 2026-05-07
type: error
tags: [pm2, deploy, npm, esm, otplib, latent-bomb, production-safety, node-version, env-drift]
---

# pm2 reload 引爆 ESM 不兼容 + 三套环境 Node 版本漂移

> 2026-05-08 复盘修正：补充真实错误码、@noble/hashes 子路径门槛、Node 版本根因。
> 工单 #242 第二天调查发现初版定性有偏差，**修复方案应优先升 Node、不靠 override 兜底**。

## 现象

UAT `pm2 reload ffws-uat-backend --update-env`（仅为生效 `.env.uat` rename）后立即进入 crash loop：

```
Error: Package subpath './hmac.js' is not defined by "exports"
  in /srv/apps/ffworkspace-test/backend/node_modules/@noble/hashes/package.json
  code: 'ERR_PACKAGE_PATH_NOT_EXPORTED'
  at @otplib/plugin-crypto-noble/src/index.ts:1:22
```

`mfa.service.ts:15` (`import 'otplib'`) → `auth.module.ts:25` → `app.module.ts` 整个 NestJS 启动期 DI 链直接挂。↺ 短时间内涨到 1198+。

> 注：现场最早判断是 `ERR_REQUIRE_ESM`，初版 learning 也按此写。**实际错误码取决于 root `@noble/hashes` 装的是哪个版本**：
> - 若 root = `2.x` (ESM, `type: module`) + Node ≤ 21：`ERR_REQUIRE_ESM`
> - 若 root = `1.5.0`（无 `./hmac.js` 子路径）：`ERR_PACKAGE_PATH_NOT_EXPORTED`（UAT 实际命中）
> - 若 root = `1.8.0`（有 `./hmac.js` 子路径）+ Node 任意：能正常 require

## 真实根因（双层叠加）

### 第一层：Node 版本默认行为

| Node 版本 | `require()` 加载 ESM 包 |
|---|---|
| ≤ 20.x | `ERR_REQUIRE_ESM`（默认禁止） |
| 22.x+ | 默认开启 `--experimental-require-module`（容许，仍打 warning） |

### 第二层：otplib 依赖图变化

`otplib@13.4.0` 的 `dist/index.cjs` 在**模块顶层**就 `require("@otplib/plugin-base32-scure")` 和 `require("@otplib/plugin-crypto-noble")`——任何 `import 'otplib'` 立即触发完整 plugin 加载，没有 lazy 路径。

两个 plugin 都是 CJS，但分别 require：

- `@scure/base@2.x`（`type: "module"` 纯 ESM）— 触发第一层 ESM 边界
- `@noble/hashes@2.x` 的 `/hmac.js` `/sha2.js` `/legacy.js` `/utils.js` 子路径（**带 `.js` 后缀，1.6+ 才声明**）

**作者 paulmillr 在 `@scure/base@2.0` / `@noble/hashes@2.0` 同步做了 ESM 化重大升级**，CJS 老消费者全军覆没——所有用 `@otplib` 的 CJS Node 项目，只要 transitive dep 升到 2.x 就会撞这个面。

### 第三层：环境漂移让问题分布不均（**这是没沉淀过的项目特有教训**）

| 环境 | Node 实际版本 | docs 写 | 状态 |
|---|---|---|---|
| 本地（开发机） | v24.14.0 | — | 默认 require-module，跑得起 |
| CI quality-gates | 20（钉死在 `actions/setup-node@v4`） | — | 默认禁止，但 CI 没触发 mfa.service 加载，所以漏检 |
| UAT (`ubuntu@43.153.69.73`) | v20.20.2（2026-04-02 装机选 20）| 20.x | reload 立爆 |
| PROD (`srvadmin@43.130.6.44`) | v22.21.1（2025-12-09 装机直接装 22）| 20.x | 正常运行（吃了 Node 22 的兼容） |

**关键：四个环境四种 Node 行为**。本地开发跑得通 ≠ CI 跑得通 ≠ UAT/PROD 跑得通。bash_history 显示 PROD 装机时 `nvm install 22 # 或者 20`——运维当时就在两者之间犹豫，docs 没固化，UAT 4 个月后建机时选了 20，从此漂移。

## 现场处置（已做但**未真正解决**）

1. UAT root `npm install @scure/base@1.2.6 @noble/hashes@1.5.0 --no-save --no-package-lock`
2. 删 `node_modules/@otplib/plugin-base32-scure/node_modules/@scure/base` + `plugin-crypto-noble/node_modules/@noble/hashes` 两个嵌套副本
3. 未 reload 收尾

**第二天验证**：root `@noble/hashes@1.5.0` 的 `exports` 字段**只声明 `./hmac`、没有 `./hmac.js`**——pm2 仍在 crash loop（错误从 `ERR_REQUIRE_ESM` 换成 `ERR_PACKAGE_PATH_NOT_EXPORTED`）。所以"装回 1.5.0 + 删嵌套"这个方案根本没跑通过 reload，只是没人验证。

## 永久修复（采用：直接升 Node 22 对齐生产）

放弃 `package.json` overrides 方案——只解决 otplib 这一例，未来任何 transitive dep ESM 化都会复发；同时让 lock 跟主流社区版本脱节（升级阻力越来越大）。

**正确方案**：把 UAT / CI / docs / engines 字段统一钉到 Node 22。PROD 已经在 22 上跑 27h（业务证据），等价复制即可。

落地清单（PR `fix/uat-node-upgrade-to-22`）：

1. `.gitea/workflows/quality-gates.yml`：`node-version: 20` → `22`（两处）
2. `backend/package.json` + `frontend/package.json`：加 `"engines": { "node": ">=22" }`
3. `docs/ops/01-server-infrastructure.md`：Node 版本写实并固化为 22 基线
4. PR 合并后 UAT 服务器侧操作（不进 git）：
   ```bash
   nvm install 22.21.1 && nvm alias default 22.21.1
   cd /srv/apps/ffworkspace-test/backend && rm -rf node_modules && npm ci
   cd /srv/apps/ffworkspace-test/frontend && rm -rf node_modules && npm ci
   pm2 restart ffws-uat-backend ffws-uat-frontend ffws-uat-backend-temporal-worker --update-env
   ```

## 适用范围 / 教训

1. **任何长 uptime 的 PM2 进程是炸弹**——uptime > 24h + 期间发生过 deploy。下次 reload 不一定再起得来。
2. **deploy 脚本必须强制 reload**——不允许"`npm ci` 完没重启 pm2"的尾巴。
3. **CJS 项目用 `@otplib` 系列**，要么钉 `@scure/base@^1` + `@noble/hashes@^1.8`（注意 1.5.0 子路径不全），要么直接 Node 22+ 跑。后者更彻底。
4. **`npm install <包> --no-save` 不安全**——npm 仍按 lock 解析图重建嵌套副本。要彻底跳过 lock 必须加 `--no-package-lock`。
5. **配置类小改动也可能触发完整重启路径**——`pm2 reload --update-env` 是 fork 新进程，等价 cold start。
6. **本地 / CI / UAT / PROD 四套 Node 版本必须在 docs 固化并定期对账**——本次问题暴露：本地 24、CI 20、UAT 20、PROD 22 同时存在，"本地跑通 ≠ 部署跑通"。装机脚本应直接 `nvm install 22 && nvm alias default 22`，不留运维手感空间。
7. **错误现象不要锁定在第一眼看到的 stack**——`ERR_REQUIRE_ESM` 和 `ERR_PACKAGE_PATH_NOT_EXPORTED` 在本案是同一根因的两种表现，差别只在 root 装的小版本。修复前先复算依赖图，不然"看似修了"。
8. **现场处置必须 reload 验证再交班**——本次现场删嵌套后没 reload 就交给"明天处理"，导致第二天才发现处置无效。

## 关联

- 工单 #242（晚间打卡 unauthorized 根因 + UAT 事故记录）
- `.learnings/2026-05-08-node-version-drift-across-envs.md`（Node 版本漂移治理）
- 涉及代码：`backend/src/modules/organization/auth/services/mfa.service.ts:15`
