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。