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);
}
},
};
|