# WorkBuddy2API — Cloudflare Worker:反代 + 保活 把公网入口 `cli.3216gemini.dpdns.org` 反代到 Hugging Face Space `a3216/gcli2api`(直连域名 `https://a3216-gcli2api.hf.space`), 并用 Cron 定时打 `/healthz`,防止 Space 被 HF 暂停。 ## 这个 Worker 做什么 | 能力 | 说明 | |---|---| | 🔁 **全量反向代理** | 方法 / 路径 / query / 请求体 / 端到端请求头原样转发,源站的状态码、响应头、响应体原样回传 | | ⚡ **零缓冲流式** | 直接返回 `originResponse.body`,不调用 `.text()` / `.json()`。`POST /v1/chat/completions` 是 SSE 长连接,缓冲会直接毁掉流式体验 | | ↩️ **不吞后端 302** | `fetch(..., { redirect: 'manual' })`,所以 `GET /` → `/panel/` 的跳转是**浏览器**看到的,不会被 Worker 内部跟掉 | | 🌐 **CORS 全透传** | 后端已经返回宽松的 CORS 头,Worker 一律不自己造 `Access-Control-*`,避免出现重复头 | | 🧹 **逐跳头剥离** | 剥掉 `host` / `connection` / `keep-alive` / `transfer-encoding` / `upgrade` / `proxy-*` / `cf-*` / `x-forwarded-*`;`Host` 永远不会用公网域名转发过去 | | 🍪 **set-cookie 原样** | 不做 domain / path 改写,多条也完整保留 | | ⏰ **Cron 保活** | `scheduled()` 每 5 分钟 ping 一次源站 `/healthz`,25s 硬超时 + 失败重试 1 次 + 每次一行日志 | | 🛡️ **不抛异常** | Cron 处理器内部吞掉所有异常,不会污染 Cron 运行记录 | 涉及的后端接口(都不需要 Worker 特殊处理,纯透传): - `GET /healthz` → `{"healthy":2,"realm_servable":{...},"service":"workbuddy2api","total":3}`(无可服务账号时 503) - `GET /` → 302 → `/panel/` - `GET /panel/` → 单页管理面板 - `/panel/api/*` → 面板 JSON API,需 `Authorization: Bearer ` - `POST /v1/chat/completions` → OpenAI 兼容,**SSE 流式**,需同一个 Bearer 密钥 - `GET /v1/models`、`GET /status` ## 为什么需要这个 Cron Hugging Face 免费 `cpu-basic` Space 有一条硬规则:**连续 48 小时没有任何 HTTP 流量就被暂停**, 下一次访问要等 **30–60 秒**冷启动。 所以「反代」和「保活」其实是同一件事的两半:反代提供入口,Cron 保证入口后面那台机器不会睡着。 只要有任意 HTTP 请求打到 Space,HF 的闲置计时器就会归零——**响应内容是什么完全不影响判断**, 这就是为什么一次裸的 `GET /healthz` 就够了。 ## ⚠️ 接管前必读:现有入口的 `/healthz` 是 Cloudflare 自己应答的 同一时刻实测对比: | 目标 | `/healthz` 响应 | 判据 | | --- | --- | --- | | `https://a3216-gcli2api.hf.space/healthz` | `{"healthy":2,…,"service":"workbuddy2api"}` | 带 `x-service`、`x-proxied-replica`,以及 HF 的 `link: …/spaces/a3216/gcli2api` 头 | | `https://cli.3216gemini.dpdns.org/healthz` | **`ok`(2 字节)** | 只有 `cf-ray`;**没有** `x-service`、**没有** `link` | **结论:现有入口把 `/healthz` 短路了,请求根本没到 Space。** 很可能是故意的 —— 避免探活 / 监控 / 负载均衡把 30~60s 冷启动打起来。它只短路了这一个路径: `/status`、`/panel/*` 等仍是透传的(两边都带 HF 的 `link` 头,可自证)。 两个直接后果: 1. **千万别拿 `cli.3216gemini.dpdns.org/healthz` 做保活。** 它会稳定返回 200、 看起来一切健康,但 Space 完全没被唤醒 —— 48 小时一到照样被暂停。 保活必须打 Space 的直连域名(见仓库根的 `.github/workflows/space-keepalive.yml`)。 2. **本 Worker 接管路由后,`/healthz` 的行为会变**:它会把 `/healthz` 原样透传给 Space, 于是返回的是网关 JSON 而不是 `ok`。对"真探活"来说这是**更正确**的 (能反映账号可用性),但如果你有监控依赖那个 `ok`,请先调整它。 想让本 Worker 也短路 `/healthz`,在 `worker.js` 的 `fetch()` 里加一条早返回即可。 ## ⚠️ 免费版只有 5 个 Cron(最容易踩的坑) **Cloudflare 免费版的 cron 触发器限制是「每个账号 5 个」,不是每个 Worker 5 个。** 如果你账号里别的 Worker 已经占了 5 个,再部署本 Worker 就会看到这种**半成功**: ```text Uploaded workbuddy2api-hf-proxy (1.63 sec) Deployed workbuddy2api-hf-proxy triggers (1.62 sec) ✘ [ERROR] Trigger configuration for "workbuddy2api-hf-proxy" was only partially updated: Cron schedules: ... failed. - This account has reached the Workers Free limit of 5 cron triggers per account. [code: 10072] Successful trigger changes were not rolled back. ``` **怎么读这段**:Worker 的**代码确实上传成功了**,但 **cron 没加上**,而且 "Successful trigger changes were not rolled back" 只是说"已成功的部分不回滚"。 结果是这个 Worker 处于**空转**状态——没有 cron,它不会去唤醒任何人。 (`* * * * *` 这种多行 crons 数组里**每一行都算一个**触发器,别写成 5 行。) 两条出路: ### 出路 A:腾一个 CF cron 名额(保持 CF 原生方案) Dashboard → Workers & Pages → **逐个 Worker** 看 Settings → Triggers → Cron Triggers, 找出已经不用的那个删掉,然后重新 `npx wrangler deploy`。 想用 API 一次性列全账号的 cron(需要 `CLOUDFLARE_API_TOKEN`,权限含 *Workers Scripts:Read*): ```bash ACC=a75c5f86fdd1b75eb3e860f5436a7970 # 你的 account id for w in $(curl -s "https://api.cloudflare.com/client/v4/accounts/$ACC/workers/scripts" \ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -r '.result[].id'); do echo "== $w" curl -s "https://api.cloudflare.com/client/v4/accounts/$ACC/workers/scripts/$w/schedules" \ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -c '.result.schedules' done ``` ### 出路 B:改用 GitHub Actions(**推荐,无数量限制**) 本仓库自带 `.github/workflows/space-keepalive.yml`,每 6 小时 ping 一次 Space, 零成本、不占 CF 名额。**而且 6 小时一次完全够用**——HF 的判定阈值是 48 小时, 所以 GitHub 定时任务偶发延迟几十分钟毫无影响(反过来说,本 Worker 里 `*/5` 的 5 分钟粒度其实是**严重过量**的,纯粹因为免费额度够用才这么写)。 两条路不冲突:Worker 留着做反代,保活交给 Actions 也完全可以。 ## 部署 > **先看上一节**:如果你账号的 5 个 cron 名额已满,`wrangler deploy` 会报 `code: 10072`, > 这时按「出路 A/B」处理。 ### 1. 前置条件 ```bash node -v # 需要 Node 18+ npx wrangler -v # 首次会提示安装 wrangler npx wrangler login # 登录你的 Cloudflare 账号 ``` ### 2. 部署 ```bash cd D:\workbuddy2api-panel\deploy\cloudflare-worker npx wrangler deploy ``` 部署成功后 Worker 名称为 `workbuddy2api-hf-proxy`(见 `wrangler.toml` 的 `name`)。 ### 3. 接线路由 `wrangler.toml` 里的 `routes` **默认是注释掉的**,两种接法任选其一: - **A. 交给 wrangler**:取消 `routes` 注释后重新 `wrangler deploy`,路由自动创建; - **B. 在 Dashboard 手动加**:Workers → 你的 Worker → Settings → Domains & Routes → 添加 `cli.3216gemini.dpdns.org/*`(推荐,避免误删线上路由)。 > ⚠️ 启用路由前确认该主机名当前没有指向别处的「已代理」DNS 记录, > 否则 Worker 路由和 DNS 记录会互相打架。正确做法是该主机名由 Worker 路由接管 > (DNS 里留一条指向 `100::` 的 AAAA 占位记录)。 ### 4. 设置密钥(可选) 只有开启「可选唤醒/签到」时才需要。**密钥永远不要写进 `wrangler.toml`,也不要提交。** ```bash npx wrangler secret put WB2A_API_KEY # 粘贴网关的 api_key(与面板登录用的是同一个),回车即可 ``` 未设置时 `scheduled()` 只做 `/healthz` 裸 ping,不发送任何鉴权请求。 ## 验证 > **先接路由再验**:下面这些 URL 打的是 `cli.3216gemini.dpdns.org`,只有在该主机名已经 > 路由到本 Worker 时才是"在验 Worker"。没接路由的话,你验到的仍是原来的链路。 > > **`*.workers.dev` 在部分网络下不可用**(国内常见:DNS 被污染,解析到不相干的 IP、 > 443 不通)。所以 `wrangler deploy` 打印的 `https://..workers.dev` > 在你这里可能**根本连不上**——这**不代表部署失败**,用自定义域名的路由来验即可。 ```bash # 探活:能看到网关自己的 JSON,且带 total / service 字段 curl -i https://cli.3216gemini.dpdns.org/healthz # 后端自带的 302 应该原样到达客户端(不要出现 -L 跟随) curl -i https://cli.3216gemini.dpdns.org/ # 面板 curl -I https://cli.3216gemini.dpdns.org/panel/ # 流式:应逐块吐出,而不是憋到最后一次性返回 curl -N -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"model":"deepseek-v4-flash","stream":true,"messages":[{"role":"user","content":"hi"}]}' \ https://cli.3216gemini.dpdns.org/v1/chat/completions ``` 看到 `server: cloudflare` 与 `cf-ray` 属正常——那是 Cloudflare 边缘加的,不代表后端没响应; 后端的响应头(如 `service`、`x-*`)应当同时原样出现。 ## 看日志 ```bash cd D:\workbuddy2api-panel\deploy\cloudflare-worker npx wrangler tail # 实时请求日志 npx wrangler tail --format pretty # 人类可读格式 ``` > **`wrangler tail` 报 `Error: Unexpected server response: 400`?** > 它走的是 WebSocket,经过本地抓包代理(DevSidecar / Reqable 之类)时经常被挡掉。 > 换成这几条路即可: > > - Dashboard → Workers & Pages → 你的 Worker → **Logs** → Live / Cron Events(最省事); > - 关掉代理再 `npx wrangler tail`; > - 或者干脆不依赖 tail:Cron 有没有在跑,**看 Space 的 `last_checkin_day` 有没有变成当天** > 最直接(桶里的 `data/state.json`),或看 `npx wrangler deployments list` 的更新时间。 Cron 每次运行会打一行: ``` [keepalive] 2025-01-01T00:05:03.412Z /healthz -> HTTP 200 (183ms) ``` 失败时是 `warn` 级别,同时带上首次失败原因,便于区分「偶发抖动」和「真的没醒」: ``` [keepalive] 2025-01-01T00:05:03.412Z /healthz -> FAILED status=0 (25013ms) first=ping timeout detail=ping timeout ``` ### Cron 粒度说明 - `[triggers] crons = ["*/5 * * * *"]` = 每 5 分钟一次,**这是 Cloudflare Cron Triggers 的最小粒度**(不支持每分钟); - 频率换算:每 5 分钟 ≈ 288 次/天,对比 48 小时暂停阈值,余量极大; - Cron 触发时间有**秒级抖动**(官方说明可能延迟数秒到数十秒),对保活完全无影响; - 如果只是防休眠,**不需要再加密**;调成 `*/30` 之类也够用,但 5 分钟最稳。 ## 本地调试 **推荐:先用 `dev-server.mjs` 把代理逻辑验完,再考虑 deploy。** 它用 Node 起一个本地服务, **原样 `import` 这个 worker.js**(不做任何改写),转发到真实的 HF Space——不需要 wrangler、 不需要登录: ```bash cd deploy/cloudflare-worker node dev-server.mjs # 监听 127.0.0.1:8787 # PORT=9000 node dev-server.mjs # 换端口 # ORIGIN=https://other.hf.space node dev-server.mjs ``` 另开一个终端逐条验: ```bash curl -i http://127.0.0.1:8787/ # 期望 302 → /panel/(不要加 -L) curl -i http://127.0.0.1:8787/healthz # 期望网关自己的 JSON(带 total / service) curl -i http://127.0.0.1:8787/status # 期望 401 invalid_api_key(说明真的打到了网关) curl -s http://127.0.0.1:8787/__cron # 手动跑一次保活定时器,看控制台日志 ``` `/__ping` 与 `/__cron` 是**本地专用**路径,永远不会被代理到源站,部署后的 Worker 里没有它们。 也可以走 wrangler(需要它自己的本地环境): ```bash npx wrangler dev curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*" # 临时触发 Cron 处理器 ``` ### `.dev.vars` 本地调试需要的变量(尤其是 `WB2A_API_KEY`)放在同目录 `.dev.vars` 里,**不要提交**: ```bash cp .dev.vars.example .dev.vars # 然后按需填写 ``` 格式为 `KEY="value"`,一行一个。`wrangler dev` 会自动读取该文件中的变量与密钥。 > ✅ **`.gitignore` 已经覆盖到位**(根目录规则):`.dev.vars` / `.dev.vars.*` / `.wrangler/` > 全部被忽略,同时用 `!.dev.vars.example` 把**模板**放回版本库(模板里只有占位符)。 > 四个可提交文件:`worker.js`、`wrangler.toml`、`.dev.vars.example`、本 `README.md`。 > > **任何 secret 都不得提交**,线上一律走 `wrangler secret put`。 ## 环境变量一览 | 名称 | 类型 | 默认值 | 说明 | |---|---|---|---| | `ORIGIN` | vars | `https://a3216-gcli2api.hf.space` | 源站地址。换 Space 只改这里 | | `ROOT_REDIRECT` | vars(注释中) | `/panel/` | `GET /` 的跳转目标;设成空串则关闭跳转、直接透传后端的 404 | | `HEALTH_PATH` | vars(注释中) | `/healthz` | 保活探测路径 | | `CHECKIN_CRON` | vars(注释中) | 空 = 关闭 | 只在此 Cron 表达式触发时做签到 | | `CHECKIN_PATH` | vars(注释中) | 空 = 关闭 | 带 Bearer 调用的路径,如 `/panel/api/keepalive` | | `WB2A_API_KEY` | **secret** | 未设置 | 面板/网关 Bearer 密钥,仅签到功能需要 | > `ORIGIN` 若填成非法 URL,Worker 会**回落到默认源站**而不是整体 500——配置拼错时仍能提供服务。 ## 如果你已有自己的反代 Worker **不要替换掉你现有的反代逻辑**,只把「保活」那一小块搬过去即可,改动量极小: 1. **加 `scheduled()` 处理器**:从 `worker.js` 里复制 `scheduled(event, env, ctx)` 整个函数, 连同它依赖的 `DEFAULT_ORIGIN` / `HEALTH_PATH` / `PING_TIMEOUT_MS` / `PING_RETRY_DELAY_MS` 常量、`resolveOrigin()` / `normalizePath()` 两个工具函数一起拿走。 (可选签到那段如果不需要,连同 `CHECKIN_*` 一起删掉即可。) 2. **确认你的默认导出里有 `scheduled`**:必须是 `export default { fetch(request, env, ctx) {...}, scheduled(event, env, ctx) {...} }` 这种形状。 如果你原来是 `export default { async fetch(...) }`,直接把 `scheduled` 加进同一个对象即可, **不要**改成 `export { scheduled }` 之外的写法。 3. **在 `wrangler.toml` 加上 trigger 块**: ```toml [triggers] crons = ["*/5 * * * *"] ``` 4. **重新部署**:`npx wrangler deploy`。改 `[triggers]` 必须重新部署才会生效, 只改 Dashboard 里的 Cron 是临时的、下次部署会被覆盖。 反代部分如果你已经有实现,**保持原样**——本 Worker 的 `fetch()` 只是一个满足 「不缓冲 / 不跟随 302 / 剥离逐跳头 / 透传 CORS」四条硬约束的参考实现, 你的版本只要满足同样四条,效果完全等价。 ## 常见问题 ### 代理后 `/v1/chat/completions` 变成一次性返回,没有流式效果? 几乎一定是某处调用了 `await res.text()` / `res.json()` / 中间做了缓冲(包括自己包一层 `TransformStream` 却忘了 flush)。确认直接 `return new Response(originResponse.body, ...)`。 ### `GET /` 返回的是 `/panel/` 的内容,看不到 302? 说明 `fetch` 的 `redirect: 'manual'` 丢了。默认的 `redirect: 'follow'` 会在 Worker 内部 把 302 跟掉,客户端就永远看不到那次跳转。 ### `GET /` 到底该返回 302 还是后端原始的 404? **302 → `/panel/`。** 这是实测出来的既有行为,值得记一笔:网关只注册了 `/panel/`, 所以**直连 Space 的 `GET /` 是 404**;但公网入口 `https://cli.3216gemini.dpdns.org/` 返回的却是 302 → `/panel/`。同一路径两种结果,说明这层跳转是 **CF 侧加的**(Worker 或 Redirect Rule)。`worker.js` 因此也自带了这个跳转(`ROOT_REDIRECT`,默认 `/panel/`), 换用本 Worker 后直接打开域名首页的体验不变。 想对比两种行为: ```bash curl -i https://a3216-gcli2api.hf.space/ # 404(后端真实行为) curl -i https://cli.3216gemini.dpdns.org/ # 302 → /panel/(入口层加的) ``` 想把跳转关掉(严格透传后端):把 `ROOT_REDIRECT` 设成空串。 ### 浏览器报 CORS 重复头错误? Worker 自己造了 `Access-Control-Allow-Origin`,而后端也返回了同一个头。 删掉 Worker 里的 CORS 处理,让它纯透传。 ### Cron 日志里一直是 503,是不是保活失败了? **不是。** 503 是后端「当前没有可用账号可服务」的业务语义,但只要它回了 HTTP, Space 就是醒着的,保活目的已经达到。只有 `status=0`(超时/网络失败)才需要关注—— 那通常意味着正在冷启动,或者 Space 真的挂了。 ### 想确认 Cron 到底有没有在跑? ```bash npx wrangler tail --format pretty # 等 5 分钟就能看到 [keepalive] 行 ``` 或者 Dashboard → Workers → 你的 Worker → Logs → 筛选 Cron Events。