跳到主要内容

Docker Compose 服务重启后起不来?检查 restart 策略

· 阅读需 5 分钟

在 RAG 知识库项目中排查依赖 Milvus 的服务启动失败,以下是完整排查过程。

TL;DR​

宿主机重启(或容器崩溃)后,一组服务没有自动恢复,应用端口无监听、docker ps -a 里容器全是 Exited。根因是 docker-compose.yml 没配 restart 策略(默认 no),容器挂了就永远躺着。解法:给所有生产服务加 restart: always,让基础设施在崩溃或重启后自愈。

Docusaurus scripts 添加 inline 脚本构建失败?只支持 src 不接受 content

· 阅读需 5 分钟

在 docusaurus.config.ts 的 scripts 数组里用 { content: '...' } 注入 inline 脚本(例如百度统计的 IIFE),执行 npm run build 直接报错。

在开发 CCLEE Docusaurus Theme 时遇到此问题——基于 Docusaurus 3.x 的高级文档主题,紫色主题 + 深色模式 + Tailwind 排版增强,开箱即用的生产级文档站点模板。

TL;DR​

Docusaurus 的 scripts 配置只接受 src,不支持 inline content。把 inline 脚本挪到 static/js/ 下,用 { src: '/js/xxx.js', async: true } 引用即可;若脚本加载外部域名,还要同步更新 CSP。

问题现象​

按百度统计官方代码,本能地想直接塞进 scripts:

// docusaurus.config.ts
const config: Config = {
scripts: [
{
content: `var _hmt=_hmt||[];(function(){var hm=document.createElement("script");hm.src="https://hm.baidu.com/hm.js?XXXX";var s=document.getElementsByTagName("script")[0];s.parentNode.insertBefore(hm,s);})();`,
},
],
};

构建立刻失败:

[ERROR] Error: "scripts[1]" is invalid.
A script must be a plain string (the src), or an object with at least a "src" property.
at validateConfig (.../configValidation.js:397:15)

根因​

Docusaurus 的 scripts 配置在构建期被 validateScripts 逐条校验,每条只允许两种形态:

  1. 纯字符串:直接当作 src 处理
  2. 对象:必须包含 src 属性,可选 async、defer、data-* 等

设计上 scripts 只生成形如 <script src="..." /> 的标签,没有为 inline 脚本保留 content / innerHTML 字段。所以无论 inline 内容多短,校验都会在 src 缺失时直接抛错,本地构建、Vercel 远端构建同样失败。

想注入带构建期变量的 inline 脚本,应改用顶层的 headTags 配置(tagName: 'script' + innerHTML),而不是 scripts。

解决方案​

1. 把 inline 脚本放进 static/js/​

// static/js/baidu-tongji.js
var _hmt = _hmt || [];
(function () {
var hm = document.createElement('script');
hm.src = 'https://hm.baidu.com/hm.js?XXXX';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(hm, s);
})();

static/ 目录下的文件会被原样拷贝到站点根目录,最终 URL 即 /js/baidu-tongji.js。

2. 在 scripts 用 src 引用​

// docusaurus.config.ts
scripts: [
// 已有的 Umami(参见 /blog/docusaurus-umami-analytics)
{
src: 'https://tj.ccleeai.com/script.js',
async: true,
'data-website-id': 'xxxx',
},
// 百度统计:走静态文件
{
src: '/js/baidu-tongji.js',
async: true,
},
],

3. 同步更新 CSP​

如果站点启用了 Content-Security-Policy(推荐做法,参见我们之前总结的 Umami 集成与 CSP 配置),新加的外部域名必须放行,否则脚本会被浏览器拦截:

themeConfig: {
metadata: [
{
'http-equiv': 'Content-Security-Policy',
// script-src 加 https://hm.baidu.com
// connect-src、img-src 同步放行(hm.js 会发图片像素和 fetch 上报)
content: "default-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://tj.ccleeai.com https://hm.baidu.com; " +
"connect-src 'self' https://tj.ccleeai.com https://hm.baidu.com; " +
"img-src 'self' data: https://hm.baidu.com; " +
"style-src 'self' 'unsafe-inline'; " +
"object-src 'none'; base-uri 'self'",
},
],
},

百度统计的 IIFE 内部用 document.createElement('script') 动态注入 <script src="hm.baidu.com/...">,所以光放 'unsafe-inline' 不够,必须把 hm.baidu.com 加进 script-src。

注意事项

  • scripts 数组里的纯字符串和 src 是等价的:'https://x/a.js' 与 { src: 'https://x/a.js' } 等效
  • 路径以 / 开头时是相对站点根目录(static/ 拷贝产物),不是文件系统根
  • 若脚本依赖运行期变量、必须 inline,请用顶层 headTags 而非 scripts;headTags 支持 innerHTML
  • 多套统计(Umami + 百度 + GA)可以共存,但每个外部域名都要单独加进 CSP,否则该域名上报被静默拦截

常见问题​

如何在 Docusaurus 配置自定义 scripts?​

在 docusaurus.config.ts 顶层的 scripts 数组中添加条目,每条要么是字符串(视作 src),要么是带 src 属性的对象。inline 内容不能用 content 字段——把脚本放进 static/js/ 目录后用 src: '/js/xxx.js' 引用即可。如果还需要 async、defer 或自定义 data-* 属性,把它们和 src 放在同一个对象里。

Docusaurus scripts 为什么不支持 inline content?​

Docusaurus 的 scripts 配置在构建期生成的是 <script src="..."> 标签,本身没有为 inline 脚本设计字段。校验器(configValidation.ts 中的 validateScripts)逐条检查,只要对象缺少 src 就抛出 A script must be a plain string, or an object with at least a "src" property,无论你的 content 写得多完整。这是刻意的 API 边界,把 inline 注入的能力交给了 headTags。

Docusaurus 如何注入 inline JavaScript(如百度统计)?​

最稳的方式是「静态文件 + 内部动态注入」:把官方那段 IIFE 脚本完整保存到 static/js/baidu-tongji.js,scripts 用 { src: '/js/baidu-tongji.js', async: true } 引用;脚本内部再 document.createElement('script') 加载 hm.baidu.com/hm.js。最后别忘了在 CSP 的 script-src、connect-src、img-src 都加上 https://hm.baidu.com,否则上报会被浏览器拦截。

CCLEE

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

合作咨询

Python 任务全标 failed 却不报错?try/except 吞掉了异常

· 阅读需 5 分钟

在 RAG 知识库项目中排查文档同步任务全部标记 failed 的静默故障,以下是完整排查过程。

TL;DR​

重构一个公共方法改了参数签名,但漏改了一个调用方。调用方按旧契约传参抛 TypeError,而这个调用被包在 try/except 里,异常被悄悄吞进 failed 计数——服务不崩溃、日志没有 ERROR,只有计数字段悄悄上涨。这类「静默故障」是最难查的 bug。两个解法:重构签名后 grep 所有调用方同步;except 块必须记日志或重抛,绝不静默吞掉。

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 作用域可能已经退出。

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

凡是「注册时一个上下文、触发时另一个上下文」的回调都有这个坑:res.on('finish')、once、某些 setTimeout/setInterval、chrome.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 都要警惕;而 await、fetch().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 调到多少都不影响,行为和单线程一致。

如果处理器是抽出来的函数(比如 processWorkflowJob、processAtomicJob),同样在 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能力落地于真实商业场景。

合作咨询

Chrome 扩展 chrome.alarms 定时不准?MV3 生产环境最小周期约 1 分钟

· 阅读需 5 分钟

MV3 扩展用 chrome.alarms 设了 10 秒周期定时 flush 日志,上线后发现生产环境实际每分钟才触发一次,定时完全不准。

在为客户开发 电商数据采集工具 时遇到此问题——扩展的 background service worker 需要定期把累积的客户端日志批量上报服务端,本来想用 10 秒一次保证实时性,结果生产环境里最坏要等整整一分钟。

TL;DR​

MV3 的 service worker 会休眠,定时任务只能用 chrome.alarms(setInterval 不可靠);而 Chrome 对 chrome.alarms 在生产环境强制了约 1 分钟的最小周期,periodInMinutes < 1 会被悄悄提升到 1。解法是把 1 分钟当兜底,再靠「buffer 攒满即时触发」补上高频时段的延迟。

问题现象​

日志上报 relay 这样写,期望每 10 秒 flush 一次:

// background.js (MV3 service worker)
chrome.alarms.create('log-flush', { periodInMinutes: 0.16 }); // 想要 ~10s

chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'log-flush') {
flushLogs();
}
});

本地开发(unpacked)跑起来好像没问题,打包发布到商店后实测:监听器每分钟才触发一次,periodInMinutes: 0.16 被 Chrome 无视了。没有任何报错,就是定时被拉长。

根因​

两层原因叠在一起。

第一层:MV3 下 setInterval 不可用。 Manifest V3 的 background 是 service worker,Chrome 会在它空闲约 30 秒后挂起以省电。挂起后 setInterval 直接停止,醒来也不会补跑错过的那几轮。所以任何「即使页面/扩展空闲也要执行」的定时任务,必须用 chrome.alarms——它是 Chrome 原生的、能唤醒 service worker 的调度机制。

第二层:chrome.alarms 有最小周期下限。 出于性能和续航考虑,Chrome 长期对 alarms 强制约 1 分钟的最小周期:periodInMinutes < 1 会被钳制到 1。开发环境(unpacked / Dev channel)放得更宽,能跑出更短的周期,于是本地测试通过;但打包成正式版发布后,Chrome 会把它对齐回 1 分钟。这就是「本地正常、线上拉长」的根源。

两个约束合起来:你不得不用 chrome.alarms,又不能指望它短于 1 分钟。

解决方案​

既然 1 分钟是硬下限,就把它当「最坏情况兜底」,再用事件驱动的即时触发补足实时性——双保险:

// 1. 兜底定时:1 分钟一次,保证 service worker 挂起也能被唤醒 flush
const FLUSH_THRESHOLD = 50;
chrome.alarms.create('log-flush', { periodInMinutes: 1 }); // 不再挣扎于 < 1

chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'log-flush') {
flushLogs().catch(() => {});
}
});

// 2. 即时触发:日志进 buffer 时检查,攒满阈值就立刻 flush,不等闹钟
messageBus.on('log', (entry) => {
pushBuffer([entry]);
if (memBuffer.length >= FLUSH_THRESHOLD) {
flushLogs().catch(() => {}); // 高频时段几秒内就能凑满触发
}
});

这个组合把两个约束都吃下了:

  • 1 分钟兜底解决「service worker 挂起后定时还在不在」——chrome.alarms 会按时唤醒 worker 执行,最坏延迟被锁在 1 分钟内,日志不会因为扩展空闲而无限积压;
  • buffer 满即时触发解决「高频时段要不要等满一分钟」——只要短时间内累积达到阈值,就绕过闹钟立刻 flush,低频靠闹钟、高频靠事件,两端都不卡。

迁移代价极小:把原本指望「10 秒一次」的地方,改成「buffer 满 50 条 或 1 分钟,谁先到谁触发」。日志这类本就批量友好的场景几乎零成本;对延迟敏感的单条任务,则该重新设计成事件驱动而非轮询。

注意事项

  • 别用 setInterval 给 MV3 service worker 做关键定时——它随 worker 挂起而停止,醒来不补跑,是最隐蔽的「线上偶发丢任务」来源。chrome.alarms 是 MV3 唯一可靠的持久调度。
  • periodInMinutes 在生产环境按 1 分钟算账。开发环境能更短会骗过你,务必用打包后的产物在真实环境复测周期,不要只信 dev 模式。
  • 如果业务确实需要「恰好 N 秒」的精度(比如精确倒计时),alarms 给不了——它是「不早于 1 分钟」的粗粒度调度,可能被 Chrome 进一步延迟。这种情况应改为在活跃页面里用 setInterval,worker 只做兜底。
  • service worker 另一个高频坑是登录态读不到,见 Chrome 扩展 Service Worker 读不到登录态?跨上下文 Token 同步方案。

常见问题​

为什么 chrome.alarms 设置的周期不生效,被拉长到 1 分钟?​

Chrome 出于性能和续航考虑,对 alarms 强制约 1 分钟的最小周期,periodInMinutes 小于 1 会被钳制到 1。开发环境(unpacked)通常放得更宽能跑更短,但发布到商店的正式版会被对齐回 1 分钟,所以本地测正常、线上被拉长。

MV3 service worker 里能用 setInterval 做定时任务吗?​

不可靠。MV3 的 service worker 空闲约 30 秒就会被 Chrome 挂起,setInterval 随之停止,醒来也不会补跑错过的轮次。需要持久定时必须用 chrome.alarms(它能唤醒 worker),或把状态持久化到 chrome.storage、worker 唤醒时按时间差补做。

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能力落地于真实商业场景。

合作咨询

VSCode WSL 扩展安装 Failed to fetch?vsix 下载后变 gzip 的排查与解决

· 阅读需 5 分钟

在 VSCode 大版本升级后,Claude Code 扩展卡住长时间无响应,只能卸载重装,重装时遇到 Failed to fetch、vsix 文件格式错误等一系列安装问题。

TL;DR​

VSCode 升级后扩展可能卡住无响应,卸载重装时报 Failed to fetch,手动下载 vsix 又遇到格式错误。根因是 marketplace 服务器返回 gzip 压缩内容(标准 HTTP 内容协商),curl -L 跟随重定向时未自动解压,导致保存的文件是 gzip 格式而非 zip 格式。解法:手动 curl 下载 vsix → gunzip 解压 → code --install-extension 安装。

问题现象​

升级 VSCode 后出现以下异常:

  1. Claude Code 对话长时间无响应,提示 Manifesting... 卡住
  2. 点击扩展图标半天无对话框窗口
  3. 只能卸载后重装,重装时各种方式报错

卸载后尝试重装,所有安装方式都失败:

# VSCode UI 安装 → Failed to fetch
# 命令行安装 → 同样失败
code --install-extension anthropic.claude-code
# 安装扩展时出错: Failed to fetch

根因​

三个因素叠加导致本次异常:

VSCode 升级触发 WSL 扩展宿主重新初始化。 Claude Code 这类扩展必须在 WSL 侧安装才能运行(被定义为远程扩展主机运行),大版本升级后扩展可能卡住无响应,需要卸载重装。

VSCode 扩展安装走内部网络栈,不读 WSL 的 http_proxy。 即使 WSL 中配置了代理(如 http://172.30.0.1:7897),VSCode 的扩展下载流程使用自己的网络通道,不受系统代理变量影响。

Marketplace 服务器返回 gzip 压缩内容,curl -L 未自动解压。 marketplace API 端点在 HTTP 内容协商中返回 gzip 压缩响应,这是标准行为。curl -L 跟随重定向时,在某些情况下不会自动解压 Content-Encoding: gzip,导致保存的文件是 gzip 格式而非预期的 zip 格式。用 file 命令检查会看到 gzip compressed data 而非 Zip archive data。

解决方案​

1. 手动下载 vsix​

在 WSL 终端中用 curl 下载:

curl -L "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/anthropic/vsextensions/claude-code/latest/vspackage" \
-o ~/claude-code.vsix

2. 检查文件格式​

file ~/claude-code.vsix

输出 gzip compressed data 说明文件是 gzip 格式,需要解压。输出 Zip archive data 则是原始格式,跳过下一步。

3. 解压还原 zip 格式​

gunzip -c ~/claude-code.vsix > ~/claude-code-real.vsix

确认格式正确:

file ~/claude-code-real.vsix
# 输出:Zip archive data, at least v2.0 to extract

4. 确认版本(可选)​

unzip -p ~/claude-code-real.vsix extension/package.json | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['version'])"

5. 安装​

code --install-extension ~/claude-code-real.vsix

安装完成后重新加载 VSCode 窗口(Ctrl+Shift+P → Reload Window),扩展即可恢复正常。

WSL2 环境下这类环境问题不少见——如果你也遇到过 Docker Desktop host 网络模式下容器端口从宿主机访问不到 的问题,同样跟 WSL2 的特殊架构有关。

注意事项

  • 此解法适用于所有通过 marketplace 下载 vsix 文件变成 gzip 格式的情况,不限于 Claude Code 扩展
  • 手动安装的扩展不会自动更新,后续 VSCode 升级如果正常走更新通道则不需要再手动操作
  • 下载时可尝试加 --compressed 参数让 curl 自动处理 gzip 解压,若无效再走手动 gunzip 流程

常见问题​

VSCode 扩展安装报 Failed to fetch 是代理的问题吗?​

不一定是代理。marketplace 服务器本身会返回 gzip 压缩内容(标准 HTTP 内容协商),curl -L 在跟随重定向时可能不会自动解压,导致保存的文件是 gzip 格式。手动下载并 gunzip 解压后安装即可。

VSCode 升级后 WSL 扩展卡住无响应怎么办?​

VSCode 大版本升级会重新初始化 WSL 远程扩展宿主,扩展可能卡住无响应。卸载后手动下载 vsix 文件,用 code --install-extension 安装即可恢复。

vsix 安装报 not a zip file 怎么办?​

文件是 gzip 格式而非 zip 格式。marketplace 服务器返回 gzip 压缩内容,curl -L 未自动解压。用 gunzip -c file.vsix.gz > file.vsix 解压后再安装,用 file 命令可以确认格式。

CCLEE

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

合作咨询

WordPress REST API 上传图片返回 405?检查你的 Hostinger CDN

· 阅读需 4 分钟

在为客户构建 WooCommerce 产品导入工具时,调用 /wp-json/wp/v2/media 上传图片,前几张成功后突然全部返回 405 Not Allowed。

TL;DR​

Hostinger CDN(hcdn)默认拦截了 POST /wp-json/wp/v2/media 请求。响应头 server: hcdn + x-hcdn-request-id 是关键证据。关闭 CDN 或联系 Hostinger 客服放行 /wp-json/* POST 请求即可解决。

问题现象​

通过 WP REST API 批量上传图片到 WordPress Media Library:

curl -X POST 'https://example.com/wp-json/wp/v2/media' \
-u 'user:app_password' \
-H 'Content-Disposition: attachment; filename="product-01.jpg"' \
-H 'Content-Type: image/jpeg' \
--data-binary @image.jpg

前 2-4 张图片返回 201 Created,之后的请求全部返回:

<html>
<head><title>405 Not Allowed</title></head>
<body>
<center><h1>405 Not Allowed</h1></center>
<hr><center>nginx</center>
</body>
</html>

"部分成功"这个现象容易误导判断——看起来像是频率限制(Rate Limiting),但实际原因完全不同。

根因​

用 curl -v 查看完整的 response header:

< HTTP/2 405
< server: hcdn
< x-hcdn-request-id: cfc5ad1198938cd9f1e02ce71ed0ae61-kul-edge1

关键信息:

  • server: hcdn — 这是 Hostinger 自研 CDN(hcdn),不是源站 nginx
  • x-hcdn-request-id — CDN 边缘节点 ID(kul-edge1 = 吉隆坡),说明请求在 CDN 层就被拦截了,根本没有到达 WordPress

Hostinger CDN 默认安全规则拦截了 /wp-json/wp/v2/media 的 POST 方法。前几张成功可能是因为 CDN 规则存在短暂的冷启动窗口或缓存未命中。

解决方案​

方案 1:关闭 CDN(快速验证)​

在 Hostinger hPanel → Website → CDN → 关闭 CDN。

关闭后立即生效,但会失去 CDN 加速能力。适合 staging 环境或紧急修复。

方案 2:联系 Hostinger 客服放行 API 路径(推荐)​

提交工单要求放行 /wp-json/* 的 POST 请求。Hostinger Manage 页面目前不提供自定义 CDN 规则选项,必须通过客服操作。

方案 3:代码层增加重试与延迟(防御性措施)​

即使 CDN 配置正确,加入重试逻辑也能应对偶发的 CDN 限流:

import time
import random

def upload_image(url, image_bytes, filename, auth, max_retries=3):
for attempt in range(max_retries):
resp = httpx.post(
url,
content=image_bytes,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Type": "image/jpeg",
},
auth=auth,
timeout=30,
)
if resp.status_code != 405:
return resp
delay = 3 * (attempt + 1) + random.uniform(0, 2)
time.sleep(delay)
resp.raise_for_status()

排查过程回顾​

这个问题绕了不少弯路,记录排查路径供参考:

排查方向操作结果
WP 插件拦截停用 Speed Optimizer / Auto Upload Images仍 405,排除
请求频率限制图片间加 2-5s 延迟 + 重试仍 405,排除
REST API 禁用GET /wp-json/wp/v2/settings正常返回,排除
凭证错误WC Test Connection成功,排除
CDN 拦截curl -v 查看 response headerserver: hcdn 确认 CDN 拦截

关键转折点是用 curl -v 看到了 server: hcdn,才知道请求根本没到达 WordPress 层。

注意事项

  • 关闭 CDN 后 DNS 缓存可能需要几分钟刷新,不要立刻重试
  • 如果你的站点在 Hostinger 且使用 REST API 做批量操作,上线前务必测试 CDN 是否会拦截
  • WooCommerce 的 WC API (/wc/v3/products) 走的是不同的认证机制(Consumer Key),通常不受此影响;受影响的主要是 WP REST API (/wp-json/wp/v2/*) 的写操作

常见问题​

WordPress REST API 上传图片返回 405 Not Allowed 怎么办?​

先检查 response header 中的 server 字段。如果值为 hcdn(Hostinger CDN)或其他 CDN 标识,说明请求被 CDN 拦截,未到达 WordPress。关闭 CDN 或联系服务商放行即可。

如何判断 405 是 CDN 拦截还是 WordPress 返回的?​

用 curl -v 查看 response header:server 值为 hcdn、cloudflare 等 CDN 标识说明是 CDN 层拦截;server 值为 nginx/apache 且包含 X-WP-* 或 X-RateLimit-* 头说明请求已到达 WordPress。


在为 LightCT 构建 WooCommerce 产品导入工具时遇到此问题。如果你也在用 Hostinger 做 WordPress 开发,遇到类似的 REST API 问题,欢迎联系交流。

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@3 和 https-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 上的 undici@8.x 的 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 版本无关,不存在兼容性问题。生产环境无代理时 agent 为 undefined,自动直连。

注意事项

  • 不要尝试用 setGlobalDispatcher 覆盖全局 fetch——在 tsx watch 热重载环境下修改不会传播到工作模块
  • npm undici@8.x 的 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 网络问题?

联系合作