跳到主要内容

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+PReload 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 值为 hcdncloudflare 等 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@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 部署问题?联系我聊聊你的技术栈,看看能不能帮你少踩几个坑。

联系合作

Chrome 扩展消息被意外接收?postMessage targetOrigin 用星号的跨域泄露风险

· 阅读需 3 分钟

在为客户开发 Chrome 扩展数据采集工具时遇到此问题,记录根因与解法。

TL;DR

window.postMessage(data, '*') 会把消息广播给页面中所有 frame,包括第三方 iframe。如果你的 Chrome 扩展通过 postMessage 传递用户数据(如 memberId、业务报表),任何嵌入页面的 iframe 都能监听到。把 '*' 改为 window.location.origin 即可精确限定接收方。

问题现象

Chrome 扩展的采集脚本通过 postMessage 将电商数据传递给 content script:

// ❌ 不安全:消息广播到所有 frame
window.postMessage({
type: 'CCL_SHOP_REPORT_DAILY',
memberId: 'b2b-2214126315258ad300', // 用户 ID
rows: [{ uv: 403, payAmt: 19478.47 }] // 业务数据
});
// 等同于 window.postMessage(data, '*')

页面上如果嵌入了第三方 iframe(广告、统计、社交插件),这些 iframe 的 message 事件监听器同样能收到这条消息

根因

postMessage 的第二个参数 targetOrigin 决定消息的接收范围:

targetOrigin行为
'*' 或省略广播到所有 frame,不检查来源
'https://example.com'只发送到 origin 为 https://example.com 的 frame
window.location.origin只发送到当前页面同源的 frame

省略第二个参数时,浏览器默认使用 '*'。这在 Chrome 扩展场景下尤其危险——扩展注入的脚本运行在电商平台页面,页面上可能有多个第三方 iframe。

解决方案

封装安全的 postMessage 工具函数

// safePostMessage:强制使用 window.location.origin
function safePostMessage(data) {
window.postMessage(data, window.location.origin);
}

// 使用
safePostMessage({
type: 'CCL_SHOP_REPORT_DAILY',
subType: 'daily',
memberId: memberId,
rows: [row]
});

接收端也要验证 origin

// content script 中监听消息
window.addEventListener('message', (event) => {
// ✅ 验证来源 origin
if (event.origin !== window.location.origin) return;

// ✅ 验证消息结构
if (!event.data || typeof event.data.type !== 'string') return;

switch (event.data.type) {
case 'CCL_SHOP_REPORT_DAILY':
handleDailyReport(event.data);
break;
case 'CCL_ITEM_WEEKLY_REPORT':
handleWeeklyReport(event.data);
break;
}
});

什么时候可以用 '*'

只有一种场景安全:消息内容完全不含敏感信息,且接收方 origin 不可预知。例如纯 UI 状态通知("面板已打开")。即使如此,用 window.location.origin 也更安全。

注意事项

注意事项

  • Chrome 扩展的 MAIN world 脚本和 content script 运行在不同的 JavaScript 隔离环境,postMessage 是它们通信的标准方式——务必保护好这条通道(遇到热重载后消息重复处理时需要手动清理旧监听器)
  • 接收端 event.origin 验证和发送端 targetOrigin 限定缺一不可,单向防护不完整
  • 如果消息需要跨 origin 传递(如从页面发到扩展 background),应使用 chrome.runtime.sendMessage(参考 Service Worker Token 同步)而不是 postMessage

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 格式变化