mirror of
https://github.com/SMNETSTUDIO/WeChat-AI.git
synced 2026-08-12 22:23:42 +08:00
441 lines
13 KiB
JavaScript
441 lines
13 KiB
JavaScript
const HOP_BY_HOP = new Set([
|
|
"connection",
|
|
"keep-alive",
|
|
"proxy-authenticate",
|
|
"proxy-authorization",
|
|
"te",
|
|
"trailers",
|
|
"transfer-encoding",
|
|
"upgrade",
|
|
"host",
|
|
"cf-connecting-ip",
|
|
"cf-ray",
|
|
"cf-visitor",
|
|
"cf-ipcountry",
|
|
"cdn-loop",
|
|
"x-forwarded-for",
|
|
"x-forwarded-proto",
|
|
"x-real-ip",
|
|
]);
|
|
|
|
/** @type {Map<string, { url: string, healthy: boolean, lastCheck: number, failCount: number }>} */
|
|
const states = new Map();
|
|
let rr = 0;
|
|
let bgProbeRunning = false;
|
|
|
|
function parseOrigins(raw) {
|
|
if (!raw || !String(raw).trim()) return [];
|
|
const out = [];
|
|
for (const part of String(raw).split(/[,\n]/)) {
|
|
let s = part.trim();
|
|
if (!s) continue;
|
|
s = s.replace(/\/$/, "");
|
|
if (!/^https?:\/\//i.test(s)) s = "http://" + s;
|
|
try {
|
|
out.push(new URL(s).origin);
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
return [...new Set(out)];
|
|
}
|
|
|
|
function getOrCreateStates(origins) {
|
|
for (const url of origins) {
|
|
if (!states.has(url)) {
|
|
// Start optimistic healthy — never block first request on probe
|
|
states.set(url, { url, healthy: true, lastCheck: 0, failCount: 0 });
|
|
}
|
|
}
|
|
for (const key of [...states.keys()]) {
|
|
if (!origins.includes(key)) states.delete(key);
|
|
}
|
|
return origins.map((u) => states.get(u));
|
|
}
|
|
|
|
async function probeOrigin(state, healthPath, timeoutMs) {
|
|
const path = healthPath.startsWith("/") ? healthPath : `/${healthPath}`;
|
|
const ctrl = new AbortController();
|
|
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
try {
|
|
const res = await fetch(`${state.url}${path}`, {
|
|
method: "GET",
|
|
redirect: "manual",
|
|
signal: ctrl.signal,
|
|
headers: { Accept: "application/json, */*", "Cache-Control": "no-store" },
|
|
// Avoid CF caching the health fetch oddly
|
|
cf: { cacheTtl: 0, cacheEverything: false },
|
|
});
|
|
const ok = res.status >= 200 && res.status < 400;
|
|
// drain body so connection can reuse
|
|
try {
|
|
await res.arrayBuffer();
|
|
} catch {
|
|
/* */
|
|
}
|
|
state.healthy = ok;
|
|
state.failCount = ok ? 0 : state.failCount + 1;
|
|
state.lastCheck = Date.now();
|
|
return ok;
|
|
} catch {
|
|
state.healthy = false;
|
|
state.failCount += 1;
|
|
state.lastCheck = Date.now();
|
|
return false;
|
|
} finally {
|
|
clearTimeout(t);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Background-only probes. Never call this with await on the request path
|
|
* unless HEALTH_ON_REQUEST=true (debug).
|
|
*/
|
|
async function runBackgroundProbes(origins, env) {
|
|
if (bgProbeRunning || !origins.length) return;
|
|
bgProbeRunning = true;
|
|
try {
|
|
const interval = Math.max(
|
|
3000,
|
|
Number(env.HEALTH_INTERVAL_MS || "15000") || 15000,
|
|
);
|
|
const timeout = Math.max(
|
|
300,
|
|
Number(env.HEALTH_TIMEOUT_MS || "1500") || 1500,
|
|
);
|
|
// Lightweight process check by default (NOT /health/ready + Redis)
|
|
const healthPath = (env.HEALTH_PATH || "/health").trim() || "/health";
|
|
const now = Date.now();
|
|
const due = origins.filter((s) => now - s.lastCheck >= interval);
|
|
if (!due.length) return;
|
|
// Cap concurrency: probe at most 4 at a time to avoid stampede
|
|
for (let i = 0; i < due.length; i += 4) {
|
|
const chunk = due.slice(i, i + 4);
|
|
await Promise.all(chunk.map((s) => probeOrigin(s, healthPath, timeout)));
|
|
}
|
|
} finally {
|
|
bgProbeRunning = false;
|
|
}
|
|
}
|
|
|
|
/** Prefer known-healthy; if none known yet, use all (optimistic). */
|
|
function pickCandidates(origins) {
|
|
const healthy = origins.filter((s) => s.healthy);
|
|
if (healthy.length) return healthy;
|
|
// All marked bad — still try them (avoid total outage from stale probes)
|
|
return origins;
|
|
}
|
|
|
|
function buildUpstreamHeaders(req, originUrl, env, clientHost) {
|
|
const out = new Headers();
|
|
req.headers.forEach((value, key) => {
|
|
const lk = key.toLowerCase();
|
|
if (HOP_BY_HOP.has(lk)) return;
|
|
if (lk.startsWith("cf-")) return;
|
|
out.set(key, value);
|
|
});
|
|
|
|
const mode = String(env.ORIGIN_HOST_MODE || "preserve").toLowerCase();
|
|
if (mode === "origin") {
|
|
try {
|
|
out.set("Host", new URL(originUrl).host);
|
|
} catch {
|
|
/* */
|
|
}
|
|
} else if (clientHost) {
|
|
out.set("Host", clientHost);
|
|
}
|
|
|
|
const clientIp =
|
|
req.headers.get("CF-Connecting-IP") ||
|
|
req.headers.get("X-Forwarded-For")?.split(",")[0]?.trim() ||
|
|
"";
|
|
if (clientIp) {
|
|
out.set("CF-Connecting-IP", clientIp);
|
|
out.set("X-Real-IP", clientIp);
|
|
const prior = req.headers.get("X-Forwarded-For");
|
|
out.set("X-Forwarded-For", prior ? `${clientIp}, ${prior}` : clientIp);
|
|
}
|
|
|
|
const proto =
|
|
req.headers.get("X-Forwarded-Proto") ||
|
|
(new URL(req.url).protocol === "https:" ? "https" : "http");
|
|
out.set("X-Forwarded-Proto", proto);
|
|
if (clientHost) out.set("X-Forwarded-Host", clientHost);
|
|
|
|
if (env.ORIGIN_PROXY_SECRET) {
|
|
out.set("X-WeChat-AI-Proxy-Secret", env.ORIGIN_PROXY_SECRET);
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
async function proxyRequest(req, originBase, env) {
|
|
const incoming = new URL(req.url);
|
|
const target = new URL(
|
|
incoming.pathname + incoming.search,
|
|
originBase.endsWith("/") ? originBase : originBase + "/",
|
|
);
|
|
|
|
const headers = buildUpstreamHeaders(req, originBase, env, incoming.host);
|
|
/** @type {RequestInit} */
|
|
const init = {
|
|
method: req.method,
|
|
headers,
|
|
redirect: "manual",
|
|
};
|
|
|
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
init.body = req.body;
|
|
// @ts-ignore
|
|
init.duplex = "half";
|
|
}
|
|
|
|
const upstream = await fetch(target.toString(), init);
|
|
const resHeaders = new Headers(upstream.headers);
|
|
resHeaders.set("X-WeChat-AI-LB", "1");
|
|
// Strip origin infra / ops headers (do not leak Railway host, edge, etc.)
|
|
for (const h of [
|
|
"x-wechat-ai-origin",
|
|
"x-railway-edge",
|
|
"x-railway-request-id",
|
|
"x-railway-cdn-edge",
|
|
"x-hikari-trace",
|
|
"server-timing",
|
|
]) {
|
|
resHeaders.delete(h);
|
|
}
|
|
|
|
return new Response(upstream.body, {
|
|
status: upstream.status,
|
|
statusText: upstream.statusText,
|
|
headers: resHeaders,
|
|
});
|
|
}
|
|
|
|
// ── Google AdSense (edge inject + ads.txt) ──────────────────────────
|
|
|
|
function normalizeAdsenseClient(raw) {
|
|
const s = (raw || "").trim();
|
|
if (!s) return null;
|
|
const ca = s.match(/^ca-pub-(\d+)$/i);
|
|
if (ca) return `ca-pub-${ca[1]}`;
|
|
const pub = s.match(/^pub-(\d+)$/i);
|
|
if (pub) return `ca-pub-${pub[1]}`;
|
|
if (/^\d+$/.test(s)) return `ca-pub-${s}`;
|
|
return null;
|
|
}
|
|
|
|
function adsTxtFromClient(client) {
|
|
const pub = client.replace(/^ca-/i, "");
|
|
return `google.com, ${pub}, DIRECT, f08c47fec0942fa0\n`;
|
|
}
|
|
|
|
function parseSkipPaths(env) {
|
|
const raw =
|
|
(env.ADSENSE_SKIP_PATHS && String(env.ADSENSE_SKIP_PATHS).trim()) ||
|
|
"/admin,/api/,/__lb/,/cdn/,/health";
|
|
return raw
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function pathSkipped(pathname, skips) {
|
|
const path = (pathname || "/").toLowerCase();
|
|
for (const skip of skips) {
|
|
const sk = skip.toLowerCase();
|
|
if (sk.endsWith("/")) {
|
|
if (path === sk.slice(0, -1) || path.startsWith(sk)) return true;
|
|
} else if (path === sk || path.startsWith(sk + "/")) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function adsenseEnabled(env) {
|
|
if (String(env.ADSENSE_ENABLED || "true").toLowerCase() === "false") {
|
|
return false;
|
|
}
|
|
return Boolean(normalizeAdsenseClient(env.ADSENSE_CLIENT));
|
|
}
|
|
|
|
function shouldInjectAdsense(pathname, env) {
|
|
if (!adsenseEnabled(env)) return false;
|
|
return !pathSkipped(pathname, parseSkipPaths(env));
|
|
}
|
|
|
|
function injectAdsenseScript(response, client) {
|
|
const ct = (response.headers.get("content-type") || "").toLowerCase();
|
|
if (!ct.includes("text/html")) return response;
|
|
if (response.status < 200 || response.status >= 300) return response;
|
|
|
|
const safe = client.replace(/[^a-zA-Z0-9-]/g, "");
|
|
if (!/^ca-pub-\d+$/i.test(safe)) return response;
|
|
|
|
const snippet =
|
|
`\n<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${safe}" crossorigin="anonymous"></script>\n`;
|
|
|
|
const headers = new Headers(response.headers);
|
|
headers.delete("content-length");
|
|
headers.delete("content-encoding");
|
|
headers.set("X-WeChat-AI-Adsense", "1");
|
|
|
|
return new HTMLRewriter()
|
|
.on("head", {
|
|
element(el) {
|
|
el.append(snippet, { html: true });
|
|
},
|
|
})
|
|
.transform(
|
|
new Response(response.body, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers,
|
|
}),
|
|
);
|
|
}
|
|
|
|
function maybeInjectAdsense(request, response, env) {
|
|
if (request.method !== "GET") return response;
|
|
const pathname = new URL(request.url).pathname;
|
|
if (!shouldInjectAdsense(pathname, env)) return response;
|
|
const client = normalizeAdsenseClient(env.ADSENSE_CLIENT);
|
|
if (!client) return response;
|
|
return injectAdsenseScript(response, client);
|
|
}
|
|
|
|
function tryServeAdsTxt(request, env) {
|
|
const url = new URL(request.url);
|
|
if (url.pathname !== "/ads.txt") return null;
|
|
if (request.method !== "GET" && request.method !== "HEAD") return null;
|
|
|
|
const custom = env.ADSENSE_ADS_TXT && String(env.ADSENSE_ADS_TXT).trim();
|
|
let body = null;
|
|
if (custom) {
|
|
body = custom.endsWith("\n") ? custom : custom + "\n";
|
|
} else {
|
|
const client = normalizeAdsenseClient(env.ADSENSE_CLIENT);
|
|
if (client) body = adsTxtFromClient(client);
|
|
}
|
|
if (!body) return null;
|
|
|
|
const headers = {
|
|
"Content-Type": "text/plain; charset=utf-8",
|
|
"Cache-Control": "public, max-age=3600",
|
|
"X-WeChat-AI-Adsense": "ads.txt",
|
|
};
|
|
if (request.method === "HEAD") {
|
|
return new Response(null, { status: 200, headers });
|
|
}
|
|
return new Response(body, { status: 200, headers });
|
|
}
|
|
|
|
export default {
|
|
async fetch(request, env, ctx) {
|
|
const url = new URL(request.url);
|
|
|
|
if (url.pathname === "/__lb/health") {
|
|
const origins = parseOrigins(env.ORIGINS);
|
|
const list = getOrCreateStates(origins);
|
|
const client = normalizeAdsenseClient(env.ADSENSE_CLIENT);
|
|
return Response.json({
|
|
ok: true,
|
|
service: "wechat-ai-lb",
|
|
mode: "non_blocking_probe",
|
|
adsense: {
|
|
enabled: adsenseEnabled(env),
|
|
client: client || null,
|
|
},
|
|
origins: list.map((s) => ({
|
|
url: s.url,
|
|
healthy: s.healthy,
|
|
lastCheck: s.lastCheck,
|
|
failCount: s.failCount,
|
|
})),
|
|
});
|
|
}
|
|
|
|
const adsTxt = tryServeAdsTxt(request, env);
|
|
if (adsTxt) return adsTxt;
|
|
|
|
if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") {
|
|
return new Response("WebSocket not supported", { status: 426 });
|
|
}
|
|
|
|
const origins = parseOrigins(env.ORIGINS);
|
|
if (!origins.length) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: "ORIGINS not configured",
|
|
hint: "Settings → Variables → ORIGINS = http://ip1:8787,http://ip2:8787",
|
|
}),
|
|
{ status: 503, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
const list = getOrCreateStates(origins);
|
|
|
|
// Optional debug: old blocking path (DO NOT use in production)
|
|
if (String(env.HEALTH_ON_REQUEST || "").toLowerCase() === "true") {
|
|
const timeout = Math.max(
|
|
300,
|
|
Number(env.HEALTH_TIMEOUT_MS || "1500") || 1500,
|
|
);
|
|
const healthPath = (env.HEALTH_PATH || "/health").trim() || "/health";
|
|
await Promise.all(
|
|
list.map((s) => probeOrigin(s, healthPath, timeout)),
|
|
);
|
|
} else if (ctx && typeof ctx.waitUntil === "function") {
|
|
// Fire-and-forget: never await on request path
|
|
ctx.waitUntil(runBackgroundProbes(list, env));
|
|
}
|
|
|
|
const candidates = pickCandidates(list);
|
|
// Try primary + failover without long waits (only real proxy errors)
|
|
const order = [...candidates];
|
|
// rotate start
|
|
if (order.length > 1) {
|
|
const start = rr % order.length;
|
|
rr = (rr + 1) % order.length;
|
|
const rotated = order.slice(start).concat(order.slice(0, start));
|
|
let lastErr;
|
|
for (const origin of rotated) {
|
|
try {
|
|
const res = await proxyRequest(request, origin.url, env);
|
|
// Soft signal: 5xx from origin → try next if multi
|
|
if (res.status >= 502 && res.status <= 504 && rotated.length > 1) {
|
|
lastErr = new Error(`upstream ${res.status}`);
|
|
continue;
|
|
}
|
|
// mark success
|
|
origin.healthy = true;
|
|
origin.failCount = 0;
|
|
return maybeInjectAdsense(request, res, env);
|
|
} catch (err) {
|
|
origin.failCount += 1;
|
|
if (origin.failCount >= 2) origin.healthy = false;
|
|
lastErr = err;
|
|
}
|
|
}
|
|
const msg = lastErr instanceof Error ? lastErr.message : String(lastErr);
|
|
return new Response(
|
|
JSON.stringify({ error: "all origins failed", detail: msg }),
|
|
{ status: 502, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
try {
|
|
const res = await proxyRequest(request, order[0].url, env);
|
|
return maybeInjectAdsense(request, res, env);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return new Response(
|
|
JSON.stringify({ error: "origin failed", detail: msg }),
|
|
{ status: 502, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
},
|
|
};
|