a3216's picture
chore: 同步到上游 1.12.0-panel + 凭证同步/独立启动器/保活
6d60378 verified
Raw History Blame Contribute Delete
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);
}
},
};