File size: 14,100 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
/**
 * WorkBuddy2API — Cloudflare Worker:Hugging Face Space 反向代理 + 定时保活
 *
 * 作用有两个,缺一不可:
 *   1. 反代:把 https://cli.3216gemini.dpdns.org/* 的流量原样转发到 HF Space
 *      (默认 https://a3216-gcli2api.hf.space,可用 ORIGIN 变量覆盖),
 *      方法 / 路径 / query / 请求体 / 端到端请求头全部保留,
 *      响应状态码、响应头、响应体原样回传。
 *   2. 保活:HF 免费 cpu-basic Space 连续 48 小时没有 HTTP 流量就会被暂停,
 *      冷启动要 30–60s。scheduled() 定时打一次 /healthz 就能避免被暂停。
 *
 * 关键实现约束(改动前请先读懂):
 *   - 响应用 `return originResponse.body` 直接流式回传,绝不对代理响应调用
 *     .text() / .json() / .arrayBuffer()——一旦缓冲,/v1/chat/completions 的
 *     SSE 流就会退化成"憋完再吐",流式体验当场报废。
 *   - fetch 必须带 `redirect: 'manual'`。后端的 `GET /` 返回 302 到 /panel/,
 *     这是给浏览器的,不能在 Worker 内部被跟随掉。
 *   - Host 头不能被转发(Workers 里 Host 由 URL 决定);同时去掉逐跳头,
 *     否则 Cloudflare 会拒绝或产生"重复头"类的诡异问题。
 *   - CORS 全部透传后端的:后端(ASP.NET/Kestrel)已经返回宽松的 CORS 头,
 *     Worker 一律不要自己造 Access-Control-*,否则会出现重复头把浏览器搞挂。
 *   - set-cookie 原样透传(浏览器看的是公网域名,后端很少下发 cookie)。
 *
 * 部署:见同目录 README.md 与 wrangler.toml。
 * 作者备注:本文件刻意保持零依赖、单文件,方便直接贴进已有反代 Worker。
 */

/**
 * 默认源站:HF Space 的直连域名。
 * 与 Space 名 a3216/gcli2api 对应(下划线在 HF 域名里写作连字符)。
 */
const DEFAULT_ORIGIN = "https://a3216-gcli2api.hf.space";

/** 保活探测路径:/healthz 是网关自带的探活端点,无鉴权、开销最小。 */
const HEALTH_PATH = "/healthz";

/** 单次保活请求的硬超时(ms)。冷启动时 503/超时都属正常,必须能快速失败。 */
const PING_TIMEOUT_MS = 25_000;

/** 首次失败后的重试间隔(ms)。 */
const PING_RETRY_DELAY_MS = 3_000;

/**
 * 根路径跳转目标。
 *
 * 为什么需要这个:网关只注册了 `/panel/`,`GET /` 在它那里是 404。而现有入口
 * `https://cli.3216gemini.dpdns.org/` 实测返回 **302 → /panel/**(实测对比:
 * 直连 Space 的 `/` 是 404,公网域名却是 302 —— 说明这层跳转是 CF 侧加的)。
 * 换用本 Worker 后如果不复刻,直接打开域名首页就会从"进入面板"变成"404 页"。
 * 设为空字符串即可关掉。
 */
const DEFAULT_ROOT_REDIRECT = "/panel/";

/**
 * 逐跳头 + 宿主相关头,禁止转发到源站,也禁止从源站回传给客户端。
 * 取值范围:RFC 7230 的 hop-by-hop 头 + Workers 运行时自带的 cf-* 头 + host。
 * 统一小写比较。
 */
const STRIP_HEADERS = new Set([
  "host",
  "connection",
  "keep-alive",
  "transfer-encoding",
  "upgrade",
  "proxy-connection",
  "proxy-authenticate",
  "proxy-authorization",
  "te",
  "trailer",
  "http2-settings",
  "cf-connecting-ip",
  "cf-connecting-ipv6",
  "cf-ipcountry",
  "cf-ray",
  "cf-visitor",
  "cf-worker",
  "cf-ew-via",
  "cf-pseudo-ipv4",
  "cdn-loop",
  "x-forwarded-host",
  "x-forwarded-proto",
  "x-real-ip",
]);

/**
 * 判断一个头名是否要被剥掉。
 * 除白名单外,再兜掉所有 `proxy-` / `cf-` / `x-forwarded-` 前缀的头——逐跳语义
 * 的扩展头基本都长这样,逐个列举容易漏。
 */
function shouldStripHeader(name) {
  const lower = name.toLowerCase();
  if (STRIP_HEADERS.has(lower)) return true;
  if (lower.startsWith("proxy-")) return true;
  if (lower.startsWith("cf-")) return true;
  if (lower.startsWith("x-forwarded-")) return true;
  return false;
}

/** 过滤一组 Headers,返回新的 Headers。 */
function sanitizeHeaders(headers) {
  const out = new Headers();
  for (const [name, value] of headers) {
    if (shouldStripHeader(name)) continue;
    // append 而不是 set:set-cookie 可能有多条,set 会互相覆盖。
    out.append(name, value);
  }
  return out;
}

/**
 * 把入站请求的 URL 映射到源站 URL。
 * 只保留 path + search,绝不把公网 Host 带过去——源站只认自己的域名。
 */
function buildOriginUrl(origin, requestUrl) {
  const incoming = new URL(requestUrl);
  const base = origin.endsWith("/") ? origin.slice(0, -1) : origin;
  return base + incoming.pathname + incoming.search;
}

/** 解析 ORIGIN 变量;非法值回落到默认源站,避免整个 Worker 因配置拼错而 500。 */
function resolveOrigin(env) {
  const raw = env && typeof env.ORIGIN === "string" ? env.ORIGIN.trim() : "";
  if (!raw) return DEFAULT_ORIGIN;
  try {
    const parsed = new URL(raw);
    if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
      return DEFAULT_ORIGIN;
    }
    return parsed.origin;
  } catch {
    return DEFAULT_ORIGIN;
  }
}

/** 归一化可选的唤醒/签到路径:允许 "healthz"、"//healthz"、"/healthz"。 */
function normalizePath(rawPath) {
  const trimmed = String(rawPath || "").trim();
  if (!trimmed) return "";
  return trimmed.startsWith("/") ? trimmed : "/" + trimmed;
}

export default {
  /**
   * 全量反向代理。
   *
   * 注意 `fetch(request, { redirect: 'manual' })`:这里直接把 Request 对象交给
   * Cloudflare 的 fetch,但 URL 已经被改写成源站域名。因为 Request 的 URL 是
   * 不可变的,需要先 new Request 出来再传。
   */
  async fetch(request, env, ctx) {
    const origin = resolveOrigin(env);

    // 根路径跳转:复刻现有入口的行为(见 DEFAULT_ROOT_REDIRECT 的说明)。
    // env.ROOT_REDIRECT 可以改目标;显式设成空串则关闭,直接透传。
    const rootRedirect =
      env && typeof env.ROOT_REDIRECT === "string"
        ? env.ROOT_REDIRECT.trim()
        : DEFAULT_ROOT_REDIRECT;
    if (rootRedirect) {
      const incoming = new URL(request.url);
      if (incoming.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
        const target = rootRedirect + incoming.search;
        return new Response(null, {
          status: 302,
          headers: { location: target, "cache-control": "no-store" },
        });
      }
    }

    const targetUrl = buildOriginUrl(origin, request.url);

    // 构造转发请求:保留方法、请求体(stream 形式透传,不读进内存)、
    // 过滤后的端到端请求头。Authorization / Content-Type / Accept 等全部保留。
    const forwardHeaders = sanitizeHeaders(request.headers);

    // 把访客真实 IP 补回去。上面按逐跳规则剥掉了全部 cf-*,其中就包括
    // CF-Connecting-IP——而网关的「运行日志 / 请求归档」是按
    // X-Forwarded-For 首段 → X-Real-IP → TCP 对端 的顺序取来源 IP 的,
    // 三段全被剥掉后,面板里所有调用来源都会显示成 HF 边缘节点,排障时没法看。
    // 这里在过滤之后显式补成网关认的那两个头(不要补 CF-Connecting-IP 本身,
    // 它会在经过 HF 边缘时被覆盖掉)。
    const visitorIP = request.headers.get("cf-connecting-ip");
    if (visitorIP) {
      forwardHeaders.set("x-forwarded-for", visitorIP);
      forwardHeaders.set("x-real-ip", visitorIP);
    }

    const outbound = new Request(targetUrl, {
      method: request.method,
      headers: forwardHeaders,
      // GET / HEAD 不允许带 body,其余一律流式透传。
      body:
        request.method === "GET" || request.method === "HEAD"
          ? undefined
          : request.body,
      redirect: "manual",
    });

    let originResponse;
    try {
      originResponse = await fetch(outbound);
    } catch (err) {
      // 源站不可达 / 握手失败。给一个明确的 502,附带便于排查的提示,
      // 不要把它伪装成后端的错误响应。
      const detail = err && err.message ? err.message : String(err);
      return new Response(
        JSON.stringify({
          error: "bad_gateway",
          message: "上游 Hugging Face Space 不可达(可能正在冷启动,30–60s 后重试)",
          origin,
          detail,
        }) + "\n",
        {
          status: 502,
          headers: { "content-type": "application/json; charset=utf-8" },
        },
      );
    }

    // 响应头同样过滤逐跳头;set-cookie 原样保留(可能多条,append 保证不丢)。
    const responseHeaders = sanitizeHeaders(originResponse.headers);

    // 关键:body 直接流式回传,不做任何缓冲。SSE 就必须这样。
    return new Response(originResponse.body, {
      status: originResponse.status,
      statusText: originResponse.statusText,
      headers: responseHeaders,
    });
  },

  /**
   * 定时保活:击败 HF 免费 Space 的 48 小时无流量暂停。
   *
   * 行为:
   *   - 打 ORIGIN + /healthz(可用 env.HEALTH_PATH 覆盖);
   *   - 25s 硬超时(AbortController)——冷启动期间 503 或超时都算预期;
   *   - 失败后等 3s 重试一次;
   *   - 每次运行只打一行日志:时间 / 状态 / 耗时;
   *   - 任何异常都在内部吞掉:绝不从 scheduled 抛出(抛了也只会污染 Cron 日志)。
   *
   * 可选(默认关闭):当 WB2A_API_KEY 已设置、且本次 Cron 触发带了签到标记时,
   * 额外对 env.CHECKIN_PATH 发一次带 Bearer 的 POST。
   * 一般来说根本不必要——裸 ping /healthz 就足以阻止 Space 休眠,因为 HF 只看
   * "有没有 HTTP 请求",不关心响应内容。只有当 Space 自身存在"需要主动调用才
   * 会真正醒来"的业务逻辑时,才需要打开它。
   */
  async scheduled(event, env, ctx) {
    const origin = resolveOrigin(env);
    const healthPath = normalizePath(env && env.HEALTH_PATH) || HEALTH_PATH;

    const attempt = async () => {
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort("ping timeout"), PING_TIMEOUT_MS);
      const startedAt = Date.now();
      try {
        const res = await fetch(origin + healthPath, {
          method: "GET",
          // 不让 Cloudflare 边缘缓存探活结果,否则 Space 可能返回 200 但其实没人打到它。
          cache: "no-store",
          redirect: "manual",
          signal: controller.signal,
          headers: {
            "user-agent": "WorkBuddy2API-KeepAlive/1.0 (+cloudflare-worker-cron)",
            accept: "*/*",
          },
        });
        return { ok: true, status: res.status, ms: Date.now() - startedAt };
      } catch (err) {
        const detail = err && err.message ? err.message : String(err);
        return { ok: false, status: 0, ms: Date.now() - startedAt, detail };
      } finally {
        clearTimeout(timer);
      }
    };

    let result = await attempt();
    if (!result.ok || result.status >= 500) {
      // 503 是网关"没有可用账号可服务"的语义,对保活而言无所谓——
      // 只要它回了 HTTP,Space 就是醒着的。但 5xx / 网络失败仍重试一次,
      // 用来区分"偶发抖动"和"确实没醒"。
      await new Promise((resolve) => setTimeout(resolve, PING_RETRY_DELAY_MS));
      const retry = await attempt();
      result = {
        ...retry,
        // 首次失败原因在日志里一并带上,方便判断是不是冷启动。
        firstAttempt: result.detail || `HTTP ${result.status}`,
      };
    }

    const stamp = new Date().toISOString();
    if (result.ok && result.status < 500) {
      console.log(
        `[keepalive] ${stamp} ${healthPath} -> HTTP ${result.status} (${result.ms}ms)`,
      );
    } else {
      console.warn(
        `[keepalive] ${stamp} ${healthPath} -> FAILED status=${result.status} ` +
          `(${result.ms}ms) first=${result.firstAttempt || "-"} detail=${result.detail || "-"}`,
      );
    }

    // —— 可选的带鉴权签到(默认关闭)——
    // 开启条件三选三:设了 WB2A_API_KEY、设了 CHECKIN_PATH、且本次触发带标记。
    // 标记来源是 wrangler.toml 里给 Cron 表达式加的注释式约定(见该文件),
    // 这里用 event.cron 与 env.CHECKIN_CRON 匹配;不配就永远不触发。
    const apiKey = env && env.WB2A_API_KEY;
    const checkinPath = normalizePath(env && env.CHECKIN_PATH);
    const cronExpr = event && typeof event.cron === "string" ? event.cron : "";
    const checkinCron = env && env.CHECKIN_CRON ? String(env.CHECKIN_CRON) : "";

    if (!apiKey || !checkinPath || !checkinCron || cronExpr !== checkinCron) {
      return;
    }

    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort("checkin timeout"), PING_TIMEOUT_MS);
    const startedAt = Date.now();
    try {
      const res = await fetch(origin + checkinPath, {
        method: "POST",
        cache: "no-store",
        redirect: "manual",
        signal: controller.signal,
        headers: {
          authorization: `Bearer ${apiKey}`,
          "content-type": "application/json",
          "user-agent": "WorkBuddy2API-KeepAlive/1.0 (+cloudflare-worker-cron)",
          accept: "application/json",
        },
        body: "{}",
      });
      console.log(
        `[checkin] ${new Date().toISOString()} ${checkinPath} -> HTTP ${res.status} ` +
          `(${Date.now() - startedAt}ms)`,
      );
    } catch (err) {
      // 同样不抛出:签到失败不该让 Cron 报错,保活本身已经完成了。
      const detail = err && err.message ? err.message : String(err);
      console.warn(
        `[checkin] ${new Date().toISOString()} ${checkinPath} -> FAILED ` +
          `(${Date.now() - startedAt}ms) detail=${detail}`,
      );
    } finally {
      clearTimeout(timer);
    }
  },
};