File size: 17,330 Bytes
6d60378 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 | # 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 <api_key>`
- `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://<worker>.<sub>.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 <api_key>" \
-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。
|