MessageWeave/cloudflare-worker/src/index.js

188 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Cloudflare Worker 入口 — MessageWeave 多实例 LB/HA(ARCH-LB-WORKER / deployment.md §10)。
*
* 稳定 ID(docs/charter.md §3 安全边界;注册表 §8):
* - SAF-LB-PASSTHRU:透传 headers/body(含鉴权头),不做鉴权改写。
* - C-HTTPS-INBOUND:仅 https 后端 origin。
* - ARCH-LB-WORKER / C-LB-SINGLE-REG-URL:safelist 路由,未知路径 404、method 不符 405。
* - MOD-HEALTH-AGG:/healthz 由 Worker 聚合,不落后端。
* - C-NO-DB / C-REDIS-ONLY-STATE:不代理 Redis/JMAP;不检查数据库侧可用性。
* - C-NO-LONG-CONN:纯请求-响应,body 一次性读入回灌;无 WS/SSE/长轮询。
* - C-NO-SECRET-IN-IMAGE:日志仅「方法/路径/origin/失败类别」,不含 secret;origin 清单走 wrangler secret。
*
* 路由模型:
* - GET /、/assets/config.js、/assets/styles.css → 内嵌 SPA 静态资源透传
* - GET /api/status → 后端配置引导状态;只返回缺失变量名称
* - GET|PUT /api/config、GET|PUT /api/business-config、POST /api/business-config/preflight、
* POST /api/admin/session[/revoke]
* → 透传至后端;后端校验 bootstrap 凭据或短期 admin session
* - GET /healthz → LB 聚合健康(MOD-HEALTH-AGG)
* - POST /webhook/tg|/push/jmap|/api/push/register|/api/push/disable|/reconcile、GET /ready → 透传 + 有界故障转移
* - 其它 → 404 / 405
*
* 日志红线:仅打「方法 / 路径 / origin / 失败类别」,绝不打印 header/body/secret。
*/
import { parseBackendOrigins, LB_ROUTES } from "./backends.js";
import { forwardWithFailover } from "./lb.js";
import { aggregateHealth, healthResponse, DEFAULT_HEALTH_TTL_MS } from "./health.js";
const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_ATTEMPTS = 2; // 首次 + 1 次故障转移(§10.4 建议)
// /reconcile 是后端同步长任务:handler 持集群级锁 `lock:reconcile`(初租 300s、30s 心跳续期至 90s,
// notify.rs reconcile 入口)并在同一请求内同步跑 reconcile()。全局 10s 超时会把它误判失败并故障转移到
// 第二实例——第二实例必然立刻撞 409(锁已被占),重试只放大冲突。故仅对此路由覆盖:
// 长超时(默认 320s > 300s 初租 + 心跳余量)+ maxAttempts=1,绝不故障转移;其余快路径路由保持全局默认。
const DEFAULT_RECONCILE_TIMEOUT_MS = 320_000;
/**
* 健康探测缓存(模块作用域 = CF Worker isolate 内跨请求复用)。
* 仅存「origin → {at,up,status}」,不含任何 header/body/secret(SAF-LOG-PURITY 精神)。
* TTL 由 LB_HEALTH_TTL_MS 控制;isolate 冷启时为空,首个 /healthz 全量探测。
*/
const healthCache = new Map();
/** 各透传路由允许的 method(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL;其余 405,不透传至后端)。 */
const ROUTE_METHODS = Object.freeze({
"/": ["GET"],
"/assets/config.js": ["GET"],
"/assets/styles.css": ["GET"],
"/api/status": ["GET"],
"/api/config": ["GET", "PUT"],
"/api/business-config": ["GET", "PUT"],
"/api/business-config/preflight": ["POST"],
"/api/admin/session": ["POST"],
"/api/admin/session/revoke": ["POST"],
"/api/enabled": ["GET", "PUT"],
"/webhook/tg": ["POST"],
"/push/jmap": ["POST"],
"/api/push/register": ["POST"],
"/api/push/disable": ["POST"],
"/reconcile": ["POST"],
"/ready": ["GET"],
});
/**
* Worker 主处理器(纯函数,便于单测)。
* @param {Request} request
* @param {object} env { BACKEND_ORIGINS_JSON, LB_REQUEST_TIMEOUT_MS?, LB_MAX_ATTEMPTS? }
* @returns {Promise<Response>}
*/
export async function handleFetch(request, env) {
const path = new URL(request.url).pathname;
// LB 级健康聚合探针(不含敏感信息,SAF-PROBE-PUBLIC 精神)。
if (path === "/healthz") {
if (request.method !== "GET") {
return text("method not allowed for /healthz", 405, { allow: "GET" });
}
return handleHealth(env);
}
// safelist 路由校验:未知路径 404,不透传至后端(C-LB-SINGLE-REG-URL)。
if (!LB_ROUTES.includes(path)) {
return text(`route not forwarded: ${path}`, 404);
}
const allowed = ROUTE_METHODS[path];
if (!allowed || !allowed.includes(request.method)) {
return text(`method not allowed for ${path}`, 405, { allow: allowed?.join(", ") });
}
let origins;
try {
origins = parseBackendOrigins(env.BACKEND_ORIGINS_JSON).map((o) => o.url);
} catch {
return text("misconfigured backends", 503); // fail-closed,不泄漏 secret
}
if (origins.length === 0) {
return text("no backends configured", 503);
}
let timeoutMs = intFromEnv(env.LB_REQUEST_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
let maxAttempts = intFromEnv(env.LB_MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS);
// Per-route 覆盖:/reconcile 是后端同步长任务(handler 持集群级锁 lock:reconcile,
// 初租 300s、30s 心跳续期,notify.rs reconcile 入口),并在同一请求内同步跑 reconcile()。
// 全局 10s 超时会把它误判失败并按 maxAttempts=2 故障转移到第二实例——第二实例必然立刻撞
// 409(锁已被占),重试只放大冲突、对 curl/cron 调度器是硬伤。故仅对此路由覆盖:
// 长超时(LB_RECONCILE_TIMEOUT_MS,默认 320s > 300s 初租 + 心跳余量)+ maxAttempts=1,
// 绝不故障转移;其余快路径(/webhook/tg 等)保持全局默认 10s/2 不变。
if (path === "/reconcile") {
timeoutMs = intFromEnv(env.LB_RECONCILE_TIMEOUT_MS, DEFAULT_RECONCILE_TIMEOUT_MS);
maxAttempts = 1; // 故障转移无意义:切到另一 origin 必然撞 lock:reconcile 409
}
// 透传转发 + 有界故障转移(仅超时/5xx)。
return forwardWithFailover(origins, request, {
maxAttempts,
timeoutMs,
fetch: lazyFetch(),
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
rng: Math.random,
log: (msg) => {
try {
console.error(`[lb] ${request.method} ${path}: ${msg}`);
} catch {
/* 日志不可用不致命 */
}
},
});
}
/** 透传路由的统一错误/兜底日志不打印任何 header/body。 */
async function handleHealth(env) {
const version = lbVersion(env);
let origins;
try {
origins = parseBackendOrigins(env.BACKEND_ORIGINS_JSON).map((o) => o.url);
} catch {
return healthResponse({ available: 0, total: 0, backends: [] }, version);
}
if (origins.length === 0) {
return healthResponse({ available: 0, total: 0, backends: [] }, version);
}
const summary = await aggregateHealth(origins, {
fetch: lazyFetch(),
timeoutMs: intFromEnv(env.LB_REQUEST_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
ttlMs: intFromEnv(env.LB_HEALTH_TTL_MS, DEFAULT_HEALTH_TTL_MS),
cache: healthCache,
});
return healthResponse(summary, version);
}
/**
* `/healthz` 的版本标识:确认线上是哪次部署在回答(`env.LB_VERSION`,写在
* `wrangler.toml` 的 `[vars]`,随 git 变动;每次改 LB 逻辑的提交同时 bump)。
* 不进 `[vars]` 的理由:它必须与代码同版本、可 diff,dashboard 里的手写值反而容易漂。
* 未配置回退 `unknown`——探针永远不因缺这个字段而失败。
*/
function lbVersion(env) {
return env.LB_VERSION ?? "unknown";
}
/** 惰性绑定 globalThis.fetch,避免解构时丢失 this。 */
function lazyFetch() {
return (...args) => globalThis.fetch(...args);
}
function text(body, status, extraHeaders = {}) {
return new Response(body, {
status,
headers: { "content-type": "text/plain; charset=utf-8", ...extraHeaders },
});
}
function intFromEnv(raw, fallback) {
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return fallback;
return Math.floor(n);
}
/** Cloudflare Workers module 入口。 */
export default {
async fetch(request, env, _ctx) {
return handleFetch(request, env);
},
};