Download deploy/cloudflare-worker/worker.js from a3216/gcli2api: direct link, hf CLI and curl.
- Browser
- Download file 14.1 kB
-
https://huggingface.co/spaces/a3216/gcli2api/resolve/main/deploy/cloudflare-worker/worker.js
- Command line
-
hf download hf://spaces/a3216/gcli2api/deploy/cloudflare-worker/worker.js
-
curl -L -o worker.js https://huggingface.co/spaces/a3216/gcli2api/resolve/main/deploy/cloudflare-worker/worker.js
14.1 kB
| /** | |
| * 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); | |
| } | |
| }, | |
| }; | |