跳到主要内容

14 篇博文 含有标签「Node.js」

查看所有标签

dotenv 值被 # 静默截断?.env 变量加双引号包裹

· 阅读需 4 分钟

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。

TL;DR

dotenv 把未加引号的值中 # 当作行内注释。KEY=value#hash 实际加载的值是 value#hash 被丢弃且无任何报错。解法:.env 中含 #、空格、特殊字符的值,一律用双引号包裹 —— KEY="value#hash"

问题现象

后端调用上游服务,一直返回 401 Invalid credentials

POST /api/v1/dag/trigger → 500
日志堆栈:Airflow JWT auth failed (401): {"detail":"Invalid credentials"}
at getJwtToken (airflow-client.ts)

排查发现 .env 文件中写的密码是 24 位、含 #&

AIRFLOW_PASSWORD=ooGR0^kThVI&ag#RyCpUmbIr

但 Node.js 进程实际加载到的 process.env.AIRFLOW_PASSWORD 长度只有 10,#RyCpUmbIr 整段消失。用 CLAUDE.md 记录的完整密码直接 curl 上游鉴权接口 → 返回 201;用 .env 解析出来的残缺值 → 返回 401。密码账号没问题,是 .env 加载出来的值被截断了

根因

dotenv 沿用 shell 习惯,把未加引号的值中 # 之后的内容视为行内注释:

# .env
AIRFLOW_PASSWORD=ooGR0^kThVI&ag#RyCpUmbIr
# dotenv 实际解析为:
# AIRFLOW_PASSWORD = "ooGR0^kThVI&ag"
# #RyCpUmbIr ← 被丢弃

这个行为符合 dotenv 文档,但没有任何警告或日志,运行时拿到的就是个静默截断的字符串。叠加 &、空格、$ 等字符的 shell 转义语义,问题更隐蔽:

字符未加引号时的行为
#之后内容当行内注释,截断
(空格)之后内容被丢弃
$VAR触发变量展开(可能为空字符串)
&shell 后台符号,在 dotenv 中通常保留但在 shell 拼接命令时再次踩坑

JWT_SECRETAPI_KEYDATABASE_URL 这类强随机串经常包含 #,是高发雷区。

解决方案

.env 中含特殊字符的值用双引号包裹:

# .env
AIRFLOW_PASSWORD="ooGR0^kThVI&ag#RyCpUmbIr"
JWT_SECRET="abc#def$ghi jkl"
DATABASE_URL="postgres://user:p@ss#word@host:5432/db"

重启服务使新值生效:

# pm2
pm2 restart analytics-api --update-env

# docker compose
docker compose restart api

# systemd
sudo systemctl restart api

为什么有效:dotenv 看到双引号包裹后,会按字面值读取到右引号为止,#、空格、$ 都不会被特殊处理(除非显式开启 expand 选项)。改完后立即验证加载结果:

// 启动时验证关键变量长度,提前拦截截断
const required = ['AIRFLOW_PASSWORD', 'JWT_SECRET', 'DATABASE_URL'] as const;
for (const key of required) {
const v = process.env[key];
if (!v || v.length < 16) {
throw new Error(`${key} 未正确加载(长度 ${v?.length ?? 0}),请检查 .env 引号`);
}
}

这样把 dotenv 的静默失败转成启动失败,下次踩坑时第一时间暴露。

注意事项

  • 单引号也能用,但 dotenv 在单引号内不展开 $VAR,双引号会展开。涉及密码通常希望字面值,建议双引号 + 不写 ${...}
  • dotenv 版本:v15+ 默认行为如上;早期版本(v8 之前)对 # 的处理略有差异,升级前先看 CHANGELOG。
  • Docker / Kubernetes Secret:通过 environment: 注入的变量不走 dotenv,不受此坑影响;只有 .env 文件、dotenv.config() 路径才受影响。
  • CI 环境:GitHub Actions、GitLab CI 把 secret 注入到 env 上下文,也不走 dotenv。

常见问题

为什么 .env 中含 # 的密码会变短?

dotenv 默认把 # 之后的内容当作行内注释丢弃。未加引号的值 KEY=value#hash 实际加载为 value,没有报错也没有日志。把值用双引号包裹 KEY="value#hash" 即可保留完整内容。

dotenv 不生效怎么排查?

三步:先确认 dotenv.config() 在所有 import 之前执行(ES Module 的 import 是静态提升的,详见 JWT 签名静默失败排查);再确认 .env 值不含未转义的 # 或空格;最后在进程启动后打印 process.env.XXX 的长度与字符,与 .env 源文件逐一比对。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Node.js AsyncLocalStorage 在回调里读不到值?EventEmitter 越界丢失上下文

· 阅读需 5 分钟

请求日志中间件在 res.on('finish') 回调里读 AsyncLocalStorage 的 traceId,getStore() 返回 undefined,每条响应日志的 traceId 都是空的。

在为客户开发 电商数据采集工具 时遇到此问题——服务端用 ALS 把每条请求的 traceId 贯穿整条处理链路,但响应日志死活关联不上,排查发现是「晚回调」丢了上下文。

TL;DR

res.on('finish') 这类 EventEmitter 回调,触发时已经脱离了注册它时的 async context,als.getStore() 自然拿不到请求的 store。最稳的解法是在同步段把值取到闭包变量,回调里直接用闭包值;需要完整 store 时则在回调内 als.run(store, fn) 重建上下文。

问题现象

一个看起来毫无问题的请求日志中间件:

// middleware/requestLog.js
import { als } from '../utils/als.js';

app.use((req, res, next) => {
res.on('finish', () => {
const store = als.getStore();
logger.info({
traceId: store?.traceId, // 响应日志里这里永远是 undefined
statusCode: res.statusCode,
}, 'request');
});
next();
});

中间件顺序没问题,traceId 在请求处理链路里(路由、业务函数)都读得到,唯独 res.on('finish') 里读不到。更迷惑的是:把 als.getStore() 挪到 next() 之前的同步段,它就有值。

根因

AsyncLocalStorage 靠 Node 的 async_hooks 把 store 绑定到当前激活的 async context 上,顺着异步调用链往下传。als.run(store, fn) 的语义是:在 fn 执行期间(及其派生的异步任务里),getStore() 都能拿到这个 store。

问题出在 EventEmitter。res.on('finish', cb) 做的事是cb 注册成监听器,等响应发送完毕后由 EventEmitter 的事件循环触发。触发 cb 的那个 async context,是 EventEmitter 派发事件时所在的上下文——不是注册它时的请求上下文。而且响应发送通常发生在请求处理链路之后,请求对应的 als.run 作用域可能已经退出。

所以 cbals.getStore() 拿到的是「当前激活上下文」的 store,而那个上下文根本不属于这次请求,结果就是 undefined(或更糟,串到别的上下文)。

凡是「注册时一个上下文、触发时另一个上下文」的回调都有这个坑:res.on('finish')once、某些 setTimeout/setIntervalchrome.alarms 监听器等等。

解决方案

按场景给两个模式,按需选。

模式 A(推荐):同步段闭包捕获

如果你的回调只需要 store 里的某几个值(最常见就是 traceId),最简单也最可靠——在同步段(store 一定存活的时刻)把值取出来存进闭包,回调里直接用闭包变量,彻底不依赖 ALS:

app.use((req, res, next) => {
// 同步段:此时一定在 als.run 作用域内,getStore() 必有值
const traceId = als.getStore()?.traceId;
const start = Date.now();

res.on('finish', () => {
// 回调里用闭包里的 traceId,不再碰 ALS
logger.info({
traceId, // 稳定拿到
statusCode: res.statusCode,
durationMs: Date.now() - start,
}, 'request');
});

next();
});

这一步把「异步上下文是否还活着」这个不确定性,换成了一个确定的闭包引用。回调何时触发都不影响——值已经在闭包里了。

模式 B:als.run 重建上下文

当回调里要调用一坨内部都依赖 getStore() 的代码(比如 logger 的 mixin、Sentry 的 scope 注入),逐个改成闭包不现实,就在回调入口重建上下文:

res.on('finish', () => {
const traceId = capturedTraceId; // 同步段捕获的值
if (traceId) {
// 在回调内重新建立 ALS 上下文,后续 record() 内部 getStore() 能正常拿到
als.run({ traceId }, () => record(res, start));
} else {
record(res, start);
}
});

als.run(store, fn) 会为 fn 建立一个新的、独立的 async context 并把 store 绑上去,fn 内部及它派生的异步调用都能读到。这比 als.enterWith 更安全——后者改写的是「当前共享上下文」,在并发场景下会串值,那是另一个坑,见 AsyncLocalStorage 并发读到错误的值?enterWith 改用 run 隔离上下文

注意事项

  • 判断某回调是否会丢上下文,看它是不是「注册和触发分离」。res.on('finish')once、跨 tick 的 setTimeout 都要警惕;而 awaitfetch().then() 这类顺着 async chain 走的则天然继承,不用处理。
  • 模式 A 优先。它把问题降维成一个普通闭包,可读性最好,也不会引入「重建上下文」的隐式行为;只有回调内部有大量依赖 getStore() 的既有代码时,才上模式 B。
  • 别用 als.enterWith 在回调里补救——它在并发下会改写共享父上下文导致串扰,是比「丢上下文」更难查的 bug。

常见问题

为什么 res.on('finish') 回调里读不到 AsyncLocalStorage 的值?

res.on('finish', cb)cb 注册为 EventEmitter 监听器,响应发送完毕后才触发。触发时的 async context 是事件派发所在的上下文,不是注册它的请求上下文,请求的 als.run 作用域可能已退出,因此 getStore() 返回 undefined。

怎么让 EventEmitter 回调重新拿到 AsyncLocalStorage 上下文?

最简单的是在同步段把需要的值取到闭包变量,回调里直接用闭包值;如果回调内部有大量依赖 getStore() 的代码,则在回调入口用 als.run(store, fn) 重建上下文。前者优先,后者用于改造既有逻辑。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Node.js AsyncLocalStorage 并发读到错误的值?enterWith 改用 run 隔离上下文

· 阅读需 6 分钟

BullMQ worker 设了 concurrency: 3,上线后发现并发的几个 job 日志和 Sentry 上报全串了——A 任务的错误堆栈挂在了 B 任务的 traceId 下,排查时对着错误的链路看了半天。

在构建 AI运营 数据分析平台时遇到此问题——为电商运营智能分析市场趋势、用户行为与销售数据,后台用 BullMQ 并发跑分析任务,每个任务都靠 AsyncLocalStorage 打 traceId 做日志关联,并发一上来 traceId 就开始错乱。

TL;DR

als.enterWith(store) 改写的是当前激活的共享父上下文,并发任务在 await 交错时会互相覆盖,最后写的那个值「赢」,所有交错的任务都读到同一个错误的值。解法是改用 als.run(store, fn) 把整个处理器包起来——它为每次调用建立独立的新上下文,退出后自动恢复,并发再高也互不干扰。

问题现象

每个 job 进来时往 ALS 里塞自己的 traceId,处理器内部(含 await)读这个 traceId 打日志、上报 Sentry:

// worker.js —— 串扰写法
new Worker('analytics', (job) => {
als.enterWith({ traceId: job.data.executionId }); // 进来就写
return processJob(job); // 内部多处 await + logger.info({ traceId: als.getStore().traceId })
}, { concurrency: 3 });

单跑没问题,concurrency: 3 一开就出诡异现象:

# job A (executionId: aaa) 与 job B (executionId: bbb) 几乎同时进入
[worker] job A start traceId=aaa
[worker] job B start traceId=bbb
# A 内部 await 让出,B 调了 enterWith({bbb}),A 恢复后:
[worker] job A step2 traceId=bbb ← 串到 B 了
[worker] job A error traceId=bbb ← Sentry 上报到 B 的链路下

不是偶发,是只要并发就稳定复现,而且 traceId 永远等于「最近一次 enterWith 写入的值」。

根因

关键在于 enterWith 改写的不是「这次调用专属」的上下文,而是当前激活的那个共享父上下文

AsyncLocalStorage 的上下文是树状的:一个 async context 可以被多个子任务共享。als.enterWith(store) 的语义是「把 store 写到我当前所处的这个 context 上」。当 worker 用 concurrency: 3 时,三个 job 的处理器共享同一个父上下文(worker 循环的上下文),于是:

  • job A 调 enterWith({aaa}) → 共享上下文被写成 aaa
  • job A await 让出执行权;
  • job B 调 enterWith({bbb}) → 同一个共享上下文被覆盖成 bbb
  • job A 恢复,读 getStore() → 拿到的是 bbb

这就是经典的「last-write-wins」串扰。await 点越多、并发越高,覆盖越频繁,错乱越严重。concurrency: 1 时看似正常,是因为根本没有交错——这也是它最坑的地方:开发时单线程调试永远发现不了。

Node 官方文档对此有明确告警:enterWith() 会产生预期外的副作用,推荐用 run() 替代

解决方案

enterWith 换成 run,并且用 run 包裹整个处理器(而不是某一段):

// worker.js —— 隔离写法
new Worker('analytics', (job) => {
return als.run(
{ traceId: job.data.executionId },
() => processJob(job), // 整个处理器都在独立上下文里跑
);
}, { concurrency: 3 });

als.run(store, fn) 的语义是:新建一个独立的 async context,把 store 绑定到它上面,在 fn 执行期间(及其派生的所有异步任务里)getStore() 都能拿到这个 store,fn 返回后上下文自动恢复到调用前的状态。

因为每次调用 run 建立的都是全新的、这次调用专属的上下文,并发任务之间天然隔离——job A 的 context 里永远是 aaa,job B 的里永远是 bbb,无论怎么在 await 处交错都不会互相覆盖。

这个改动的收益是直接的:

  • 每次调用独立快照:traceId 在进入 job 时绑定,整个处理链路(含所有 await、子函数、Sentry scope)读到的都是这个 job 自己的值;
  • 退出自动恢复:job 结束后上下文归位,不会泄漏到下一个 job 或 worker 主循环;
  • 并发安全concurrency 调到多少都不影响,行为和单线程一致。

如果处理器是抽出来的函数(比如 processWorkflowJobprocessAtomicJob),同样在 Worker 构造处包一层即可,不需要改处理器内部:

new Worker(queue, (job) => als.run({ traceId: job.data.id }, () => processWorkflowJob(job)), { concurrency });

注意事项

  • 只要存在并发(worker concurrency > 1、HTTP 并发请求、Promise.all 批处理),就别用 enterWith。它是为「单线程顺序设置一次」设计的,并发下必然串扰。
  • run 要包住整个处理器,不是只包入口的同步段——否则处理器内部的 await 之后又回到共享上下文,等于没改。
  • concurrency: 1 会掩盖这个 bug。开发时务必用目标并发数压测,否则上线才暴露。
  • 另一个 ALS 高频坑是回调里读不到值(上下文丢失),见 Node.js AsyncLocalStorage 在回调里读不到值?EventEmitter 越界丢失上下文

常见问题

als.enterWith 和 als.run 有什么区别?

enterWith 把 store 写到当前激活的共享父上下文上,后续并发的异步任务会互相覆盖;run 则为回调新建一个独立的新上下文并绑定 store,回调结束后自动恢复到之前的状态,每次调用互不干扰。Node 官方推荐用 run 替代 enterWith

为什么并发任务会读到错误的 traceId,串到别的请求?

并发任务在 await 处交错时,enterWith 写入的值会被最后一次调用覆盖,所有交错的任务读到的都是同一个错误的 traceId。改用 als.run 给每次调用建立独立上下文即可隔离,并发再高也不会串。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Node.js require nanoid 报 ERR_REQUIRE_ESM?v5 改纯 ESM 的替代方案

· 阅读需 5 分钟

在 CommonJS 项目里 require('nanoid') 生成唯一 ID,进程一启动就抛 ERR_REQUIRE_ESM 直接退出。

在为客户开发 电商数据采集工具 时遇到此问题——浏览器端实时抓取商品图片、SKU、价格与评价数据,清洗后导出结构化文件,服务端需要为每条请求生成稳定的 traceId 做跨服务日志关联。

TL;DR

nanoid 从 v5 起改为纯 ESM 包,CommonJS 的 require() 无法加载它,必然抛 ERR_REQUIRE_ESM。如果你的项目还是 CJS,最省事的替代是 Node 内置的 crypto.randomUUID()——零依赖、CJS/ESM 通吃、生成的就是标准 UUID。

问题现象

CJS 项目里一行最普通的引入:

// server.js(CommonJS)
const { nanoid } = require('nanoid');

const traceId = nanoid();

启动即崩,堆栈指向 nanoid 的入口文件:

node server.js

internal/modules/cjs/loader.js:905
Error [ERR_REQUIRE_ESM]: require() of ES Module
/node_modules/nanoid/index.js from server.js not supported.

Instead change the require of index.js in server.js to a CommonJS module,
or use a dynamic import() call.

注意它不是「偶尔报错」或「某些环境下报错」,而是确定性崩溃——只要进了 v5,CJS 这条路就走不通。

根因

nanoid 在 v5 完成了 ESM-only 迁移:包的 package.json 不再带 CommonJS 入口,只导出 ESM。Node 的 CommonJS 加载器 require() 是同步的,无法同步加载一个 ESM 模块,于是直接抛 ERR_REQUIRE_ESM

这不是 nanoid 的 bug,而是整个生态的模块格式演进:越来越多的包选择只发 ESM(got v12+、node-fetch v3、uuid v7+ 等都一样)。只要你的宿主项目是 CommonJS,遇到这类包就会撞同一堵墙。

如果你还撞过动态 import() 找不到模块,本质也是 ESM 解析规则的问题,可以看 Node.js ESM 动态 import 报模块找不到?检查文件扩展名

解决方案

按「改造代价从低到高」给三个方案,按需选。

方案 1(推荐):用 crypto.randomUUID()

生成唯一 ID 的场景下,nanoid 的核心价值就是「短且唯一」。但只要这个 ID 不需要拼进 URL、不需要极致缩短,标准 UUID 完全够用,而且 Node 14.17+ 内置、零依赖:

// CommonJS 与 ESM 都能直接用
const { randomUUID } = require('node:crypto');

const traceId = randomUUID();
// => '1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed'

这一步同时解决了三个问题:

  • 依赖归零:不再引入第三方包,也就不再被它的模块格式绑架;
  • 格式对齐:UUID 是跨语言、跨服务的通用格式,做日志关联、数据库主键都顺手;
  • CJS/ESM 通吃node:crypto 是 Node 内置模块,两种模块系统下行为一致。

唯一要权衡的是长度——UUID 36 字符,比 nanoid() 默认的 21 字符长。对 traceId、主键这类场景,长度几乎不构成成本;如果是要拼进短链,才需要继续往下看。

方案 2:锁定 nanoid v3

nanoid 的 v3.x 是最后一个兼容 CommonJS 的大版本,require 直接可用:

// package.json —— 显式钉死 v3
{
"dependencies": {
"nanoid": "^3.3.7"
}
}
const { nanoid } = require('nanoid');
const id = nanoid(); // 21 字符短 ID

适合「就是想要短 ID、又暂时无法把项目迁到 ESM」的情况。代价是停留在旧版,拿不到 v5 的后续更新。

方案 3:异步动态 import

如果你必须用 v5,只能走 ESM 的异步加载:

// CommonJS 里用动态 import() 加载 ESM 包
async function makeId() {
const { nanoid } = await import('nanoid');
return nanoid();
}

// 调用处本身得是 async
const id = await makeId();

能用,但 nanoid 是同步生成 ID 的工具,被迫包一层 async/await 会把调用链一路传染成异步,通常不值得。

注意事项

  • 同一个坑不只 nanoid 一个:uuid v7+、node-fetch v3、got v12+ 都是 ESM-only,CJS 项目里 require 它们会报一模一样的 ERR_REQUIRE_ESM。判断方法是看目标包的 package.json 有没有 "type": "module" 或是否只导出 "import" 入口。
  • crypto.randomUUID() 需要 Node 14.17+;如果你的运行时更老,可以用 crypto.randomBytes(16).toString('hex') 自行拼装。
  • 别用 require('nanoid') 的同时又在 ESM 项目里 import nanoid——混用会让依赖树里同时存在新旧两份,行为更难预测。

常见问题

为什么 Node.js 中 require('nanoid') 报 ERR_REQUIRE_ESM?

因为 nanoid 从 v5 起只发布 ESM 产物,而 Node 的 CommonJS require() 是同步加载,无法加载 ESM 模块,加载到 nanoid 入口时直接抛 ERR_REQUIRE_ESM。这是 CJS/ESM 模块系统的硬性边界,不是配置问题。

nanoid v5 还能在 CommonJS 项目里使用吗?

可以,但要么用 await import('nanoid') 异步加载(注意整条调用链会变 async),要么把版本锁定在仍兼容 CJS 的 v3.x。如果只是要一个唯一 ID,直接用 Node 内置的 crypto.randomUUID() 最省事,零依赖且两种模块系统都支持。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Node.js fetch 代理不生效?undici 不读 http_proxy 环境变量

· 阅读需 4 分钟

在 WSL2 环境下设置了 https_proxy 环境变量,Node.js 的 fetch() 仍然直连外网超时。

在为客户构建 AI 电商工具时遇到此问题,记录根因与解法。

TL;DR

Node.js 22+ 内置的 fetch() 基于 undici 实现,设计上不读取 http_proxy/https_proxy 环境变量。解决方案:安装 node-fetch@3 + https-proxy-agent,创建带代理配置的 fetch 实例,生产环境无代理时自动直连。

问题现象

WSL2 环境下,https_proxy 已正确设置,curl 能正常访问外网:

echo $https_proxy
# http://172.30.224.1:7897

curl -I https://httpbin.org/ip
# HTTP/1.1 200 OK

但 Node.js 的 fetch() 直接超时:

await fetch('https://httpbin.org/ip');
// FetchError: fetch failed
// cause: TimeoutError: Headers Timeout Error

如果你同时遇到 WSL2 代理完全不通(连 curl 也不行),先排查防火墙问题。

根因

Node.js v22+ 的全局 fetch() 由内置 undici 7.x 提供。undici 从设计上就不读取 http_proxy/https_proxy 环境变量——这是有意为之,不是 bug。

对比不同 HTTP 客户端的代理行为:

客户端读取环境变量走代理
curl自动读取 https_proxy
Node.js http/https 模块不读取
axios / node-fetch@3读取 https_proxy
Node.js 内置 fetch()(undici)不读取

这导致在必须通过代理才能访问外网的环境(WSL2、企业内网)下,fetch() 直连超时。

解决方案

安装 node-fetch@3https-proxy-agent

npm install node-fetch@3 https-proxy-agent

创建一个自动感知代理的 fetch 实例:

import fetch from 'node-fetch';
import { HttpsProxyAgent } from 'https-proxy-agent';

const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;

export async function fetchWithProxy(url, options = {}) {
return fetch(url, { ...options, agent });
}

使用方式和原生 fetch() 几乎一致:

// 替换前
const res = await fetch('https://httpbin.org/ip');

// 替换后
const res = await fetchWithProxy('https://httpbin.org/ip');

为什么用 node-fetch 而不是 undici 的 ProxyAgent?

Node.js v24 内置 undici 7.x,但 npm 上的 [email protected]ProxyAgent 与内置版本不兼容:

import { ProxyAgent, setGlobalDispatcher } from 'undici';

// Node v24 下报错:UND_ERR_INVALID_ARG
// npm undici@8 的 ProxyAgent 与内置 undici@7 的 setGlobalDispatcher 不兼容
setGlobalDispatcher(new ProxyAgent(proxyUrl));

node-fetch@3 + https-proxy-agent 与 Node 版本无关,不存在兼容性问题。生产环境无代理时 agentundefined,自动直连。

注意事项

  • 不要尝试用 setGlobalDispatcher 覆盖全局 fetch——在 tsx watch 热重载环境下修改不会传播到工作模块
  • npm [email protected]FormData 类型与全局 FormData 不兼容,混用会导致 TypeScript 编译报错
  • node-fetch@3 是 ESM-only 包,import 导入即可,不支持 require()

常见问题

为什么 Node.js fetch 不读 http_proxy 环境变量?

Node.js 22+ 内置的 fetch 基于 undici 实现,undici 设计上不读取 http_proxy/https_proxy 环境变量。需要用 node-fetch 或 undici 的 ProxyAgent 手动配置代理。

Node.js fetch 如何通过代理发送请求?

安装 node-fetch@3 和 https-proxy-agent,创建带代理的 fetch 实例。生产环境无代理时自动直连,不依赖 Node 版本。

WSL2 环境下还有其他网络陷阱——Docker Desktop 的 host 模式也会让容器端口在 WSL2 里访问不到,排查思路类似:先确认 curl 能否到达,再查应用层配置。

同样的 Node 版本升级还可能踩到其他坑——比如 Node 24 下 JWT 密钥格式变更,升级时建议一并检查。

遇到 Node.js 网络问题?

联系合作

Puppeteer 被反爬检测拦截?从 Chrome CDP 到 Electron 的替代方案

· 阅读需 7 分钟

在为客户构建数据采集工具时遇到此问题,记录从 Puppeteer 到 Electron 的完整排查过程。

TL;DR

Puppeteer stealth plugin 无法绕过高级验证码反爬系统。切换到 Chrome CDP 远程调试方案后,又遇到 WSL2 网络隔离和 Chrome 单实例限制两个坑。最终用 Electron BrowserWindow 加载目标网站,用户手动登录后通过 session.cookies.get() 自动提取 Cookie,彻底解决了反爬检测和跨平台问题。

场景一:Puppeteer 被 Anti-Bot 拦截

问题现象

使用 puppeteer-extra + puppeteer-extra-plugin-stealth 自动登录目标网站,浏览器启动后立即触发验证码拦截。即使通过了验证码,后续页面也会再次检测到自动化环境并强制退出。

import puppeteer from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';

puppeteer.use(StealthPlugin());

const browser = await puppeteer.launch({
headless: false,
args: [
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
'--disable-infobars',
],
});

const page = await browser.newPage();
await page.goto('https://target-site.com/login');
// 验证码系统检测到自动化环境,页面被拦截

根因

高级反爬验证码系统不只检查 navigator.webdriver 等基础指纹。它通过多个维度判断自动化环境:Chromium 编译特征、Canvas/WebGL 渲染差异、鼠标轨迹模式、甚至 DevTools Protocol 调用栈。stealth plugin 能修复已知的指纹泄露点,但无法消除 Puppeteer Chromium 与正常 Chrome 的底层差异。

经过 8 轮排查(移除超时、监听断开事件、排查环境差异、stealth plugin 补全等),确认无论怎么配置都无法绕过。

解法

放弃 Puppeteer 自动登录,改用 Chrome DevTools Protocol (CDP) 连接用户真实浏览器提取 Cookie。

场景二:WSL2 连不上 Windows Chrome CDP

问题现象

在 WSL2 中运行 Node.js 脚本,通过 chrome-remote-interface 连接 Windows Chrome 的调试端口,连接超时:

import CDP from 'chrome-remote-interface';

const client = await CDP({
host: 'localhost',
port: 9222,
});
// Error: connect ECONNREFUSED 127.0.0.1:9222

Windows 端启动 Chrome 的命令:

chrome.exe --remote-debugging-port=9222

在 Windows PowerShell 中 curl localhost:9222/json 正常返回,但 WSL2 内无法连接。

根因

Chrome --remote-debugging-port=9222 默认绑定 127.0.0.1,即 Windows 的本地回环地址。WSL2 和 Windows 有各自独立的网络栈——WSL2 内的 localhost 指向 Linux 的回环地址,不是 Windows 的。所以从 WSL2 访问 localhost:9222 实际访问的是 Linux 的 9222 端口,而非 Windows Chrome。

解法

启动 Chrome 时加 --remote-debugging-address=0.0.0.0,让 CDP 监听所有网卡:

chrome.exe --remote-debugging-port=9222 --remote-debugging-address=0.0.0.0

或者用 Windows netsh 配置端口转发:

netsh interface portproxy add v4tov4 listenport=9222 listenaddress=0.0.0.0 connectport=9222 connectaddress=127.0.0.1

注意事项

--remote-debugging-address=0.0.0.0 会将 CDP 端口暴露给局域网,存在安全风险。仅在内网开发环境使用,生产环境务必配合防火墙规则限制访问来源。

场景三:Chrome 忽略 --remote-debugging-port

问题现象

Chrome 已经在运行,带 --remote-debugging-port=9222 参数重新启动,参数被静默忽略。Chrome 只是在已有窗口中打开新标签页,CDP 端口没有开启:

# Chrome 已在运行
chrome.exe --remote-debugging-port=9222
# 没有报错,但 9222 端口并未监听

根因

Chrome 设计为单实例应用。检测到已有 Chrome 进程时,新启动的 Chrome 会将启动参数中的 URL 转发给已有进程,然后自行退出。--remote-debugging-port 等参数只在进程首次创建时生效,已有进程不会动态加载。

解法

先关闭所有 Chrome 进程,再带参数重启:

# Windows
taskkill /F /IM chrome.exe
chrome.exe --remote-debugging-port=9222

# macOS
pkill -f "Google Chrome"
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222

也可以用 --user-data-dir 指定独立配置目录,避免与日常使用的 Chrome 冲突:

chrome.exe --remote-debugging-port=9222 --user-data-dir="C:\chrome-debug-profile"

三个场景的踩坑说明了一个事实:依赖外部 Chrome 进程做 Cookie 提取,在 WSL2 + 反爬检测 + 进程管理的组合下太脆弱。最终方案是用 Electron 的 BrowserWindow 替代外部 Chrome

为什么 Electron 有效

  1. 不触发反爬检测:Electron 内置的 Chromium 与 Chrome 共享渲染引擎,反爬系统不将其标记为自动化环境
  2. 无外部 Chrome 依赖:不需要管理 Chrome 进程、CDP 端口、profile 目录
  3. 无 WSL2 网络问题:Electron 直接运行在目标 OS 上,不存在跨系统网络隔离
  4. 无单实例冲突:Electron 创建独立的 BrowserWindow,不与用户日常 Chrome 冲突

完整实现

打开登录窗口并轮询 Cookie:

import { BrowserWindow } from 'electron';

let cookieWindow = null;

function openLoginWindow() {
cookieWindow = new BrowserWindow({
width: 1000,
height: 700,
title: '登录目标平台',
webPreferences: {
// 关键:使用独立 session,不影响主窗口
partition: 'cookie-login',
contextIsolation: true,
nodeIntegration: false,
},
});

cookieWindow.loadURL('https://target-site.com/login');

// 轮询检测目标 Cookie
const interval = setInterval(async () => {
const cookies = await cookieWindow.webContents.session.cookies.get({
domain: '.target-site.com',
});

const sessionCookie = cookies.find((c) => c.name === 'session_token');
if (sessionCookie) {
clearInterval(interval);

// 拼接完整 Cookie 字符串
const cookieStr = cookies
.map((c) => c.name + '=' + c.value)
.join('; ');

// 写入环境变量
process.env.SESSION_COOKIE = cookieStr;
updateEnvFile('SESSION_COOKIE', cookieStr);

cookieWindow.close();
}
}, 2000);

cookieWindow.on('closed', () => {
clearInterval(interval);
cookieWindow = null;
});
}

preload 脚本暴露 IPC 接口:

import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('electronAPI', {
extractCookie: () => ipcRenderer.invoke('extract-cookie'),
onCookieExtracted: (callback) =>
ipcRenderer.on('cookie-extracted', (_, data) => callback(data)),
});

渲染进程调用:

// 检测是否在 Electron 环境
if (window.electronAPI) {
document.getElementById('extractBtn').addEventListener('click', () => {
window.electronAPI.extractCookie();
});

window.electronAPI.onCookieExtracted((data) => {
console.log('Cookie 已提取:', data);
});
}

注意事项

  • partition: 'cookie-login' 创建隔离 session,登录窗口的 Cookie 不会污染主窗口。如果需要共享登录状态,去掉 partition 参数或使用相同 partition 名
  • 轮询间隔 2 秒是平衡体验和性能的经验值,不要用 while + await 替代 setInterval,那会阻塞渲染进程
  • session.cookies.get() 只能获取当前 session 的 Cookie,无法跨 partition 读取

常见问题

Puppeteer stealth plugin 仍然被反爬检测到怎么办?

stealth plugin 只能绕过基础指纹检测(如 navigator.webdriver),高级反爬系统通过浏览器行为和底层 API 特征识别自动化环境。检测维度包括 Chromium 编译特征、Canvas 渲染差异、鼠标轨迹模式等,stealth plugin 无法完全覆盖。改用 Electron BrowserWindow 加载目标网站,用户手动登录后通过 session.cookies.get() 提取 Cookie 即可。

Chrome --remote-debugging-port 为什么不生效?

Chrome 采用单进程架构,已有 Chrome 进程运行时 --remote-debugging-port 参数会被静默忽略。新启动的 Chrome 只会在现有实例中打开新标签页,调试端口不会开启。需要先用 taskkill /F /IM chrome.exe(Windows)或 pkill -f "Google Chrome"(macOS)关闭所有 Chrome 进程,再带参数重新启动。也可以用 --user-data-dir 指定独立配置目录避免冲突。

WSL2 怎么连接 Windows 上 Chrome 的调试端口?

Chrome 的 --remote-debugging-port 默认绑定 Windows 的 127.0.0.1,而 WSL2 有独立的网络栈,localhost 指向 Linux 回环地址而非 Windows。两种解法:一是启动 Chrome 时加 --remote-debugging-address=0.0.0.0 让 CDP 监听所有网卡;二是在 Windows 上用 netsh interface portproxy 配置端口转发,将 WSL2 的请求转发到 Windows 的 CDP 端口。


需要数据采集或自动化工具开发?

联系我们

Vercel Serverless Function 多级路由 404?用 rewrite 绕过 catch-all 陷阱

· 阅读需 5 分钟

在将 Hono 后端部署到 Vercel Serverless Function 时,遇到三个层层递进的问题:esbuild 打包格式报错、原生依赖无法打包、以及最隐蔽的 catch-all 路由无法匹配多级路径。记录完整排查过程和最终方案。

TL;DR

  1. esbuild 打包 Hono:用 --format=cjs 输出,原生依赖 --external 排除
  2. 多级路由 404api/[[...path]].ts 不可靠,改用 api/index.ts + vercel.json rewrite
  3. Better Auth 客户端baseURL 必须完整 URL,用 window.location.origin 拼接

问题一:Vercel 内置 TS 编译失败

api/[[...path]].ts 直接 import Hono 的 app.ts,Vercel 用内置 TypeScript 5.9.3(nodenext 模式)编译,报出一串错误:

Relative import paths need explicit file extensions
Cannot find name 'process'
Module '"@libsql/client"' declares 'Client' locally, but it is not exported

根因是 Vercel 的内置 TS 编译器对 nodenext 模块解析要求严格,而 Hono + Turso 客户端库的导出方式不兼容。

解决方案:用 esbuild 预打包,让 Vercel 直接执行编译产物。

esbuild server/src/app.ts \
--bundle --platform=node --format=cjs \
--outfile=dist/_server.cjs \
--external:@libsql/client

api/[[...path]].ts 只需一行引用打包产物:

import app from '../../dist/_server.cjs';
export default app;

为什么用 CJS 而不是 ESM?

--format=esm 全量打包时,dotenv 内部 require("path")Dynamic require is not supported。这是 ESM 规范限制,CJS 没有这个问题。

如果你在 Node.js ESM 动态 import 中也遇到过模块解析问题,根因是一样的——ESM 对模块格式要求严格,CJS 更宽容。

原生依赖处理

@libsql/client 包含原生二进制(@libsql/linux-x64-gnu),esbuild 打包后运行时找不到模块。解决方案:

  1. --external:@libsql/client 排除打包
  2. package.json 声明 @libsql/client 为 dependency,让 Vercel 安装到 node_modules

问题二:多级路由被 Vercel 拦截

esbuild 打包解决后,一级路由 /api/health/api/tasks 全部正常。但所有多级路径(如 /api/auth/sign-in/email)返回 Vercel 层 404:

HTTP/2 404
x-vercel-error: NOT_FOUND
content-type: text/plain; charset=utf-8

排查过程

通过对比 response headers 定位断裂点:

路径到达 Hono?关键特征
/api/healthx-vercel-cache: MISS、有 CORS header
/api/gate有 CORS header
/api/gate/sessionx-vercel-error: NOT_FOUND、无 CORS

一级路径到达函数,二级路径被拦截。 与路径名无关(不含 auth 的路径也 404),与 Vercel 内部路由规则无关。根因是 api/[[...path]].ts 的 catch-all pattern 只能匹配单级路径

解决方案

放弃 catch-all,改用 api/index.ts + vercel.json rewrite:

{
"rewrites": [
{ "source": "/api/(.*)", "destination": "/api/index" },
{ "source": "/((?!api/).*)", "destination": "/index.html" }
]
}

api/[[...path]].ts 重命名为 api/index.ts(内容不变)。所有 /api/* 请求由 rewrite 统一转发到 /api/index 函数,Hono 内部自行路由。

部署后验证:

curl -sI https://example.com/api/gate/session
# HTTP/2 404
# access-control-allow-credentials: true ← 到达 Hono
# x-vercel-cache: MISS ← 不再是 Vercel 层 404

三条路径 /api/gate/api/gate//api/gate/session 全部到达 Hono。POST 请求也正常:

curl -X POST https://example.com/api/gate/sign-in/email \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"123456"}'
# {"message":"Invalid email or password","code":"INVALID_EMAIL_OR_PASSWORD"}

Auth 路由完全打通。

注意事项

  • rewrite 规则顺序很重要,/api/(.*) 必须在 SPA fallback 之前
  • 部署后浏览器可能缓存旧的 JS 文件,如果前端仍请求旧路径,用 Ctrl+Shift+R 强制刷新
  • 如果你也在排查部署后线上未更新的问题,可以参考 排查前端部署后线上未更新的问题

问题三:Better Auth 客户端 baseURL 错误

路由打通后,前端页面报错:

BetterAuthError: Invalid base URL: /api/gate
Caused by: TypeError: Failed to construct 'URL': Invalid URL

Better Auth 客户端内部用 new URL() 解析 baseURL,相对路径无法直接构造 URL。

解决方案:用 window.location.origin 拼接完整 URL:

export const authClient = createAuthClient({
baseURL: `${window.location.origin}/api/gate`,
})

本地开发展开为 http://localhost:5173/api/gate(Vite proxy 转发),线上展开为 https://your-domain.com/api/gate,无需环境变量。

完整配置参考

最终生效的三个关键文件:

vercel.json

{
"buildCommand": "pnpm run check && pnpm exec esbuild server/src/app.ts --bundle --platform=node --format=cjs --outfile=dist/_server.cjs --external:@libsql/client && pnpm -F client run build",
"outputDirectory": "client/dist",
"rewrites": [
{ "source": "/api/(.*)", "destination": "/api/index" },
{ "source": "/((?!api/).*)", "destination": "/index.html" }
]
}

api/index.ts

import app from '../dist/_server.cjs';
export default app;

client/src/lib/auth-client.ts

import { createAuthClient } from 'better-auth/react';

export const authClient = createAuthClient({
baseURL: `${window.location.origin}/api/gate`,
});

常见问题

为什么 Vercel api/[[...path]].ts 匹配不到多级路径?

Vercel 的 catch-all pattern 只能匹配单级路径(/api/foo),无法匹配多级(/api/foo/bar)。需要改用 api/index.ts + vercel.json rewrite 规则显式转发所有 /api/* 请求。

Vercel Serverless Function 如何打包 Hono + esbuild?

用 esbuild 打包为 CJS 格式(--format=cjs),原生依赖如 @libsql/client--external 排除,并在根 package.json 声明为 dependency 让 Vercel 安装。

Better Auth 客户端报 Invalid base URL 怎么办?

createAuthClientbaseURL 不支持相对路径,必须传完整 URL。用 window.location.origin 拼接可同时兼容本地开发和线上环境。


遇到类似的 Vercel 部署问题?联系我聊聊你的技术栈,看看能不能帮你少踩几个坑。

联系合作

UPSERT 写入全零?Drizzle sql 模板混用参数化值与 SQL 表达式的坑

· 阅读需 4 分钟

在为客户构建电商数据分析平台时遇到此问题,记录根因与解法。

TL;DR

Drizzle ORM 的 sql 模板标签中,sql.join(values.map(v => sql(v))) 会把所有值参数化传递。如果 values 数组里混入了 SQL 表达式(如 date_trunc('week', '2026-05-17'::date)::date),PostgreSQL 会把它当成普通字符串解析,报 invalid input syntax for type date 错误。SQL 表达式必须用 sql.raw() 或单独写在模板外部

问题现象

电商数据采集流程:Chrome 扩展采集 → CCLHub 转发 → Analytics 写库。现象:

  1. CCLHub 日志显示采集数据正常(uv: 403, payAmt: 19478.47
  2. Analytics 返回 200 成功
  3. 但数据库查询结果全是 0uv: 0, pay_amt: 0.00
-- 数据库实际数据
report_date | uv | pay_amt | reveal_cnt
-------------+-----+----------+------------
2026-05-12 | 392 | 7333.67 | 11879 -- 旧数据正常
2026-05-13 | 0 | 0.00 | 0 -- 新数据全零!

同时 Analytics 错误日志有:

PostgresError: invalid input syntax for type date:
"date_trunc('week', '2026-05-17'::date)::date"

根因

原始代码混用了参数化值和 SQL 表达式:

// ❌ 问题代码
const insertVals: (string | number | null)[] = [
String(shop_id),
String(platform_id),
reportDate,
tenant_id,
`date_trunc('week', '${reportDate}'::date)::date`, // ← SQL 表达式
];

// sql.join 会把所有值参数化,包括 date_trunc 表达式
await db.execute(sql`
INSERT INTO table (..., week_start_date)
VALUES (${sql.join(insertVals.map(v => sql`${v}`), sql`,`)})
...
`);

生成的 SQL:

-- PostgreSQL 收到的 $5 参数值是字面字符串
INSERT INTO table (..., week_start_date)
VALUES ($1, $2, $3, $4, $5, ...)
-- $5 = "date_trunc('week', '2026-05-17'::date)::date" ← 被当字符串!

PostgreSQL 尝试把 "date_trunc('week', '2026-05-17'::date)::date" 解析为 date 类型 → 报错。

为什么数据是 0 而不是报错? 因为同一张表有独立的询盘写入(PARTIAL UPSERT),询盘 INSERT 成功创建了行(看板列默认值 0),日报 UPSERT 失败但没有回滚已存在的行。

解决方案

把 SQL 表达式从参数化数组中分离出来,用 sql.raw() 或直接写在模板中:

// ✅ 修复:参数化值和 SQL 表达式分开
const insertCols = ['shop_id', 'platform_id', 'report_date', 'tenant_id'];
const insertVals: (string | number | null)[] = [
String(shop_id), String(platform_id), reportDate, tenant_id,
];

// 19 个数据列正常参数化
for (const [apiKey, dbCol] of Object.entries(DAILY_COLUMNS)) {
insertCols.push(dbCol);
insertVals.push(row[apiKey] != null ? String(row[apiKey]) : '0');
}

// week_start_date 用 SQL 表达式,不进参数化数组
await db.execute(sql`
INSERT INTO table (${sql.raw(insertCols.join(', '))}, week_start_date)
VALUES (
${sql.join(insertVals.map(v => sql`${v}`), sql`,`)},
date_trunc('week', ${reportDate}::date)::date -- ← 直接写在模板里
)
...
`);

关键区别:

写法Drizzle 处理方式PostgreSQL 收到
sql 模板插值参数化($N字符串字面量
sql.raw(expression)原样拼入 SQLSQL 表达式
直接写在 sql 模板中作为模板的一部分SQL 表达式

注意事项

注意事项

  • sql.raw() 存在 SQL 注入风险,不要用于用户输入。本例中 reportDate 来自内部 API,格式可控
  • Drizzle 的 sql 模板标签会自动参数化所有插值——这是安全特性,但 SQL 函数调用不该被参数化
  • 如果整条 SQL 都是动态构建的,考虑用 Drizzle 的 query builder API 代替 raw SQL
  • 数据库连接配置也容易踩坑——如果你遇到连接到了错误的 PostgreSQL 实例,可能是端口被 Docker 静默占用
  • 环境变量加载时序也是常见坑源,JWT 签名静默失败就是 dotenv 在 import 链之后才执行的典型例子

JWT 签名静默失败?检查 Node.js 环境变量的加载顺序

· 阅读需 3 分钟

在为客户构建 SaaS 认证系统时遇到此问题,记录根因与解法。

TL;DR

Node.js ES Module 中,import 语句在 dotenv.config() 之前执行。如果模块级代码读取 process.env.JWT_SECRET,拿到的是 undefined,导致 JWT 签名用 "undefined" 字符串作为密钥——不报错,但所有 token 校验都失败。解决方案:延迟初始化(lazy init)。

问题现象

JWT 登录接口返回 200,但后续请求全部 401。排查发现:

  1. 登录生成的 token 无法被 jwtVerify() 验证
  2. 每次重启服务,之前签发的 token 全部失效
  3. 打印 process.env.JWT_SECRET,结果是 undefined
// jwt.ts — 模块级代码
import crypto from 'crypto';

// ❌ 这行在 dotenv.config() 之前执行,JWT_SECRET 是 undefined
const SECRET = crypto.createSecretKey(
new TextEncoder().encode(process.env.JWT_SECRET)
);

最坑的是:不报错new TextEncoder().encode(undefined) 会把字符串 "undefined" 编码成字节,生成一个合法但错误的密钥。

根因

ES Module 的 import静态提升的:

// server.ts(入口文件)
import { router } from './routes/auth'; // ← 先执行
import { authenticateToken } from './middleware/auth'; // ← 先执行

dotenv.config(); // ← 后执行,但 import 链已经跑完了

执行顺序:

  1. Node.js 扫描所有 import,构建依赖图
  2. 深度优先执行所有被导入模块的顶层代码(jwt.tsconst SECRET = ... 在这里执行)
  3. 回到 server.ts,执行 dotenv.config()
  4. 此时 .env 才加载到 process.env

所以 jwt.ts 模块级代码读到的 process.env.JWT_SECRETundefined

解决方案

方案一:延迟初始化(推荐)

把密钥初始化从模块级移到函数内部,首次调用时才读取环境变量:

import crypto from 'crypto';

let _secret: crypto.KeyObject | null = null;

function getSecret(): crypto.KeyObject {
if (!_secret) {
const secretValue = process.env.JWT_SECRET;
if (!secretValue) {
throw new Error('JWT_SECRET 环境变量未设置');
}
_secret = crypto.createSecretKey(
new TextEncoder().encode(secretValue)
);
}
return _secret;
}

// 所有需要密钥的地方改用 getSecret()
export async function generateToken(payload: any): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.sign(getSecret()); // ← 延迟到运行时读取
}

优势:不依赖入口文件的 import 顺序,任何调用时机都安全。

方案二:入口文件顶部调用 dotenv

// server.ts — 确保这两行在所有 import 之前
import 'dotenv/config'; // 或 require('dotenv').config()
import express from 'express';
// ...其他 import

局限:如果有其他入口文件(如 cron job、worker)忘记加这行,问题复现。

注意事项

注意事项

  • 这个坑不只影响 JWT,所有模块级代码读取环境变量都有同样风险(数据库连接、API Key 等);ESM 模块解析还有另一个常见坑——动态 import 缺少 .js 扩展名会导致生产环境模块找不到
  • require('dotenv').config() 只在 CommonJS 中能保证顺序;ES Module 中 import 始终先于运行时代码
  • 延迟初始化模式适用于:密钥、数据库连接池、外部 API 客户端等一次性资源;如果你的 JWT 使用 jose 库且升级了 Node 24,也要注意 jose KeyObject 格式变化

Node.js ESM 动态 import 报模块找不到?检查文件扩展名

· 阅读需 4 分钟

在为客户构建 SaaS 数据分析平台时遇到此问题,记录根因与解法。

TL;DR

Node.js ESM 模式下,import('./path/to/module') 不会自动解析 ./path/to/module.js。TypeScript 编译的 dist 产物如果遗漏 .js 后缀,模块加载时抛出 ERR_MODULE_NOT_FOUND。如果这个 import 在延迟逻辑中(如定时器、条件分支),应用启动正常但运行一段时间后崩溃,PM2 表现为重启计数飙升。

解法:确保所有 ESM 动态 import 路径包含 .js 扩展名,并在 build 流程中自动修复。

问题现象

PM2 状态显示应用不断重启:

│ name             │ ↺    │ status │ uptime │
│ analytics-api │ 9 │ online │ 28m │

错误日志每几分钟重复:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/dist/domains/video/cleanup'
imported from /app/dist/server.js

但文件实际存在:

$ ls dist/domains/video/
cleanup.js executor.js queue.js

根因

ESM 不自动解析文件扩展名

Node.js 的 CommonJS (require()) 会自动尝试 .js.json 等后缀。ESM (import) 不会

// ❌ ESM 模式下找不到模块
import('./domains/video/cleanup')
// Node.js 查找: ./domains/video/cleanup (精确路径,无扩展名)
// 实际文件: ./domains/video/cleanup.js

// ✅ 必须带 .js 后缀
import('./domains/video/cleanup.js')

延迟 import 隐藏了问题

这个 import 在定时器中延迟执行:

// server.ts — 启动时不立即执行
import('./domains/video/cleanup.js').then(({ startCleanupScheduler }) => {
startCleanupScheduler(); // 几秒后才触发
});

应用启动成功(DB 连接、端口监听都正常),定时器触发时 import 报错 → 进程崩溃 → PM2 重启 → 再次启动成功 → 定时器再次触发 → 再次崩溃。形成崩溃循环

为什么之前没问题?

旧版本的 dist 产物是通过手动 build 生成的,build 脚本包含 .js 后缀修复步骤。某次部署时跳过了 build 后的修复步骤,直接用 tsc 输出的产物部署,tsc 不修改 import 路径。

解决方案

1. TypeScript 源码中写 .js 后缀

TypeScript 官方推荐:即使在 .ts 文件中,也写 .js 后缀:

// ✅ TypeScript 源码中也写 .js
import('./domains/video/cleanup.js').then(({ startCleanupScheduler }) => {
startCleanupScheduler();
});

2. Build 后自动修复(推荐)

在 build 流程中添加后缀修复脚本,自动为没有扩展名的 import 补上 .js

{
"scripts": {
"build": "tsc && node fix-imports.js"
}
}

fix-imports.js 核心逻辑:

import { readFileSync, writeFileSync, readdirSync } from 'fs';
import { join } from 'path';

function fixImports(dir) {
for (const file of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, file.name);
if (file.isDirectory()) {
fixImports(fullPath);
} else if (file.name.endsWith('.js')) {
let content = readFileSync(fullPath, 'utf8');
// 修复动态 import: from 'xxx' → from 'xxx.js'
const fixed = content.replace(
/import\(['"](\.[^'"]+)['"]\)/g,
(match, path) => path.endsWith('.js') ? match : match.replace(path, path + '.js')
);
// 修复静态 import: from 'xxx' → from 'xxx.js'
const fixed2 = fixed.replace(
/from\s+['"](\.[^'"]+)['"]/g,
(match, path) => path.endsWith('.js') ? match : match.replace(path, path + '.js')
);
if (fixed2 !== content) {
writeFileSync(fullPath, fixed2);
}
}
}
}

build 流程自动为所有相对路径 import 补全 .js 后缀,TypeScript 源码保持无后缀写法,ESM 部署不再出现模块找不到的错误。

注意事项

  • 此问题只在 Node.js ESM 模式("type": "module".mjs 文件)下出现,CommonJS 不受影响
  • 静态 import(import ... from './foo')同样受此限制,不仅限于动态 import();ESM 的 import 提升还会导致另一个常见问题——dotenv 在 import 链之后才执行,环境变量读不到
  • 使用 tsxts-node 开发时不报错(它们会自动解析后缀),但 node dist/server.js 生产运行时出错
  • PM2 崩溃循环的典型特征:重启计数(↺)持续增长,每次 uptime 不超过几分钟