diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index 04b81d7..1959fd7 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -64,18 +64,25 @@ business payloads and forwards the request as-is to the backend https origins; o | `src/backends.js` | origin parsing and validation, route safelist | | `src/lb.js` | pass-through forwarding and bounded failover | | `src/health.js` | TTL-cached health aggregate probe | -| `test/*.test.js` | 38 tests in four files, all offline | +| `test/*.test.js` | 43 tests in four files, all offline | ## 3. Pass-through semantics - Method, headers and body go out unchanged; the backend response status, body and headers are returned as-is. -- The only header the load balancer adds is best-effort observability: `x-lb-backend`, naming the - origin that answered. The write sits behind a `try/catch`. A cross-origin response that - carries no `access-control-allow-*` header comes back with an immutable header guard, and - `headers.set` throws on those. An observability write that throws must never mask a healthy - `2xx` as a `503`, so the header is dropped silently when it cannot be written. That exact - defect was the 2026-09-30 production incident. +- The load balancer adds two headers: + - **`x-lb-backend`** (best-effort observability): names the origin that answered. The write + sits behind a `try/catch`. A cross-origin response that carries no + `access-control-allow-*` header comes back with an immutable header guard, and + `headers.set` throws on those. An observability write that throws must never mask a + healthy `2xx` as a `503`, so the header is dropped silently when it cannot be written. + That exact defect was the 2026-09-30 production incident. + - **`Cache-Control: no-store`** on **all non-static routes** (`/api/*`, `/webhook/*`, + `/push/*`, `/reconcile`, `/worker`, `/healthz*`, `/ready`). Static assets (`/`, + `/assets/config.js`, `/assets/styles.css`) are exempted to allow Cloudflare edge caching + for performance. Dynamic API responses must never be cached at the edge — stale health + probes, stale config reads, or cached webhook delivery receipts would mask real backend + state changes. - `GET` and `HEAD` never forward a body: a bodyless upstream response is returned as it is, and no `Content-Length` is recomputed. - Backend `3xx` redirects are followed by the load balancer itself (`redirect: "follow"`), so a @@ -239,12 +246,12 @@ the previous subscription — that is how you switch fronts. ```bash cd cloudflare-worker -npm test # 38 tests, four files, fully offline +npm test # 43 tests, four files, fully offline npm run check # node --check on sources and tests, then wrangler deploy --dry-run ``` -`npm test` is `node --test`, which picks up `test/*.test.js`: `test/index.test.js` 15, -`test/lb.test.js` 12, `test/backends.test.js` 6, `test/health.test.js` 5. There is no install +`npm test` is `node --test`, which picks up `test/*.test.js`: `test/index.test.js` 19, +`test/lb.test.js` 13, `test/backends.test.js` 6, `test/health.test.js` 5. There is no install step for the tests — the Worker has no runtime dependencies, and the test files import only `node:test` and `node:assert/strict`. diff --git a/cloudflare-worker/README.zh-CN.md b/cloudflare-worker/README.zh-CN.md index 424eb9a..9dcc5b7 100644 --- a/cloudflare-worker/README.zh-CN.md +++ b/cloudflare-worker/README.zh-CN.md @@ -34,12 +34,14 @@ | `src/backends.js` | origin 解析与校验、路由白名单 | | `src/lb.js` | 透传转发与有界故障转移 | | `src/health.js` | 带 TTL 的健康聚合探测 | -| `test/*.test.js` | 四个文件共 38 条测试,全部离线 | +| `test/*.test.js` | 四个文件共 43 条测试,全部离线 | ## 3. 透传语义 - method、headers、body 原样发出;后端响应的 status、body、headers 原样返回。 -- 负载均衡器唯一新增的 header 是尽力而为的观测头:`x-lb-backend`,标记应答的 origin。写入放在 `try/catch` 里。跨域且不带 `access-control-allow-*` 的响应,其 header guard 是 immutable,`headers.set` 会抛异常。一次会抛异常的观测写入绝不能把健康的 `2xx` 掩盖成 `503`,所以写不进去时静默放弃该 header。这个缺陷就是 2026-09-30 的生产事故根因。 +- 负载均衡器新增两个 header: + - **`x-lb-backend`**(尽力而为的观测头):标记应答的 origin。写入放在 `try/catch` 里。跨域且不带 `access-control-allow-*` 的响应,其 header guard 是 immutable,`headers.set` 会抛异常。一次会抛异常的观测写入绝不能把健康的 `2xx` 掩盖成 `503`,所以写不进去时静默放弃该 header。这个缺陷就是 2026-09-30 的生产事故根因。 + - **`Cache-Control: no-store`**(非静态资源):`/api/*`、`/webhook/*`、`/push/*`、`/reconcile`、`/worker`、`/healthz*`、`/ready` 等所有动态路由一律加 `no-store`,阻止 Cloudflare 边缘缓存。静态资源(`/`、`/assets/config.js`、`/assets/styles.css`)豁免,保留缓存以提升性能。动态 API 响应绝不能被边缘缓存——过期的健康探针、配置读取或缓存的 webhook 投递回执会遮蔽真实的后端状态变化。 - `GET` 和 `HEAD` 不带 body 转发:上游没有 body 的响应直接原样返回,不重算 `Content-Length`。 - 后端的 `3xx` 由负载均衡器自己跟随(`redirect: "follow"`),跳转型的 origin 不会把第二跳甩给浏览器。 - 日志与错误串只包含 method、path、origin、失败类别,绝不含 headers、body、认证 secret 或 App Password(`SAF-LOG-PURITY`)。 @@ -148,11 +150,11 @@ SPA 的回调 URL 只注册**一个** origin(`C-LB-SINGLE-REG-URL`)。只填 ```bash cd cloudflare-worker -npm test # 四个文件共 38 条测试,全部离线 +npm test # 四个文件共 43 条测试,全部离线 npm run check # 对源码与测试跑 node --check,再 wrangler deploy --dry-run ``` -`npm test` 即 `node --test`,会自动收集 `test/*.test.js`:`test/index.test.js` 15、`test/lb.test.js` 12、`test/backends.test.js` 6、`test/health.test.js` 5。跑测试不需要安装依赖——Worker 没有运行时依赖,测试文件只引入 `node:test` 与 `node:assert/strict`。 +`npm test` 即 `node --test`,会自动收集 `test/*.test.js`:`test/index.test.js` 19、`test/lb.test.js` 13、`test/backends.test.js` 6、`test/health.test.js` 5。跑测试不需要安装依赖——Worker 没有运行时依赖,测试文件只引入 `node:test` 与 `node:assert/strict`。 两个坑: diff --git a/cloudflare-worker/src/index.js b/cloudflare-worker/src/index.js index efab58d..c38227f 100644 --- a/cloudflare-worker/src/index.js +++ b/cloudflare-worker/src/index.js @@ -29,6 +29,9 @@ import { parseBackendOrigins, LB_ROUTES } from "./backends.js"; import { forwardWithFailover } from "./lb.js"; import { aggregateHealth, healthResponse, DEFAULT_HEALTH_TTL_MS } from "./health.js"; +/** 静态资源路由:允许 Cloudflare 边缘缓存(SPA HTML + 内联 JS/CSS)。其余一律 no-store。 */ +const STATIC_ASSET_ROUTES = new Set(["/", "/assets/config.js", "/assets/styles.css"]); + const DEFAULT_TIMEOUT_MS = 10_000; const DEFAULT_MAX_ATTEMPTS = 2; // 首次 + 1 次故障转移(§10.4 建议) // /reconcile 是后端同步长任务:handler 持集群级锁 `lock:reconcile`(初租 60s、10s 心跳续期至 30s, @@ -94,7 +97,7 @@ export async function handleFetch(request, env) { if (request.method !== "GET") { return text("method not allowed for /healthz-worker", 405, { allow: "GET" }); } - return handleHealth(env); + return withNoStore(await handleHealth(env)); } // safelist 路由校验:未知路径 404,不透传至后端(C-LB-SINGLE-REG-URL)。 @@ -133,8 +136,8 @@ export async function handleFetch(request, env) { maxAttempts = 1; // 故障转移无意义:切到另一 origin 只会重复排同一批消息 } - // 透传转发 + 有界故障转移(仅超时/5xx)。 - return forwardWithFailover(origins, request, { + // 透传转发 + 有界故障转移(仅超时/5xx)。非静态资源加 no-store 阻止边缘缓存。 + const resp = await forwardWithFailover(origins, request, { maxAttempts, timeoutMs, fetch: lazyFetch(), @@ -149,6 +152,7 @@ export async function handleFetch(request, env) { } }, }); + return STATIC_ASSET_ROUTES.has(path) ? resp : withNoStore(resp); } /** 透传路由的统一错误/兜底日志不打印任何 header/body。 */ @@ -187,10 +191,26 @@ function lazyFetch() { return (...args) => globalThis.fetch(...args); } +/** + * 给非静态资源响应设 `Cache-Control: no-store`,阻止 Cloudflare 边缘缓存。 + * `fetch()` 返回的响应头可能是 immutable guard(跨域无 CORS),直接 `set` 会抛 + * TypeError(见 `lb.js` `mark()` 的 2026-09-30 事故根因);这里构造新 Response + + * 可变 Headers 副本,安全写入。body 流透传不缓冲。 + */ +function withNoStore(response) { + const headers = new Headers(response.headers); + headers.set("cache-control", "no-store"); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + function text(body, status, extraHeaders = {}) { return new Response(body, { status, - headers: { "content-type": "text/plain; charset=utf-8", ...extraHeaders }, + headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", ...extraHeaders }, }); } diff --git a/cloudflare-worker/test/index.test.js b/cloudflare-worker/test/index.test.js index 4fde326..5107679 100644 --- a/cloudflare-worker/test/index.test.js +++ b/cloudflare-worker/test/index.test.js @@ -301,6 +301,79 @@ test("Worker: /reconcile 走 per-route 长超时且绝不故障转移;其余 } }); +test("Worker: non-static API responses carry Cache-Control: no-store", async () => { + const restore = stubFetch(async (url) => { + // 后端响应不带 cache-control(或带任意值);LB 必须覆盖成 no-store。 + return new Response(JSON.stringify({ ready: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + try { + for (const path of ["/api/status", "/api/config", "/webhook/tg", "/ready", "/healthz"]) { + const method = path === "/webhook/tg" ? "POST" : "GET"; + const body = method === "POST" ? "{}" : undefined; + const res = await handleFetch(new Request(`https://lb.example${path}`, { method, body }), makeEnv()); + assert.equal( + res.headers.get("cache-control"), + "no-store", + `${path} must have no-store to block edge cache`, + ); + } + } finally { + restore(); + } +}); + +test("Worker: static assets (/, /assets/*) do NOT get no-store — allow edge cache for performance", async () => { + const restore = stubFetch(async (url) => { + // 后端返回静态资源,不带 cache-control;LB 不应加 no-store(保留原样或允许 CF 默认缓存)。 + return new Response("", { + status: 200, + headers: { "content-type": "text/html" }, + }); + }); + try { + for (const path of ["/", "/assets/config.js", "/assets/styles.css"]) { + const res = await handleFetch(new Request(`https://lb.example${path}`), makeEnv()); + assert.notEqual( + res.headers.get("cache-control"), + "no-store", + `${path} must NOT have no-store — static assets should be cacheable`, + ); + } + } finally { + restore(); + } +}); + +test("Worker: /healthz-worker (LB-local probe) also gets no-store", async () => { + const restore = stubFetch(async () => new Response("ok", { status: 200 })); + try { + const res = await handleFetch(new Request("https://lb.example/healthz-worker"), makeEnv()); + assert.equal(res.headers.get("cache-control"), "no-store", "LB probe must not be cached"); + } finally { + restore(); + } +}); + +test("Worker: error responses (404/405/503) also get no-store", async () => { + const res404 = await handleFetch(new Request("https://lb.example/unknown"), makeEnv()); + assert.equal(res404.status, 404); + assert.equal(res404.headers.get("cache-control"), "no-store"); + + const res405 = await handleFetch(new Request("https://lb.example/webhook/tg", { method: "GET" }), makeEnv()); + assert.equal(res405.status, 405); + assert.equal(res405.headers.get("cache-control"), "no-store"); + + const res503 = await handleFetch( + new Request("https://lb.example/webhook/tg", { method: "POST", body: "{}" }), + makeEnv({ BACKEND_ORIGINS_JSON: "not-json" }), + ); + assert.equal(res503.status, 503); + assert.equal(res503.headers.get("cache-control"), "no-store"); +}); + test("Worker: POST /worker 在白名单内,走 per-route 长超时且绝不故障转移;GET /worker => 405", async () => { // /worker 和 /reconcile 一样只由外部 cron 触发(scripts/cron-drain.sh)。全局 10s 超时会把 // 一次正常的排空误判成失败并切到第二实例,而第二个实例只会重复排同一批消息。 diff --git a/cloudflare-worker/wrangler.toml b/cloudflare-worker/wrangler.toml index e49187d..4660eb1 100644 --- a/cloudflare-worker/wrangler.toml +++ b/cloudflare-worker/wrangler.toml @@ -51,4 +51,4 @@ compatibility_flags = ["nodejs_compat_v2"] # LB_VERSION 是例外——它是版本标识,必须跟代码同版本、可 diff,写进 dashboard 反而容易漂。 [vars] BACKEND_ORIGINS_JSON = '["https://messageweave-eu-1.motofans.club","https://messageweave-eu-2.motofans.club"]' -LB_VERSION = "2026.10.4" +LB_VERSION = "2026.10.14" diff --git a/docs/retired.md b/docs/retired.md index 2e87b99..55b6eec 100644 --- a/docs/retired.md +++ b/docs/retired.md @@ -79,7 +79,7 @@ These names never exist in the code. Some of them appear in the directory tree o | `delivery:pending:{stream}` | Fictional key | Confused with Redis Streams' pending-entries list (PEL) - PEL is maintained internally by Redis and is not a writable key | Real key: `delivery:inflight:{stream}:{id}` (EX 60) and `delivery:committed:{stream}:{id}` (EX 604_800), see Delivery pipeline keys section of `docs/reference.md` | | `check_config_reload` | Fictional function | Uncommitted name in draft, never made it into code | Hot updated to `refresh_business_config` (`src/notify.rs:1173`) | | `push:registration:{sha256(callback_url)}` was once written as "360s registration solo lock" | Fact mislabeling | The key is 7d callback → subscription ID mapping | The real 360s solo lock is `lock:push-register:{sha256(callback_url)}` (`src/notify.rs:1565-1566`) | -| `MESSAGWEAVE_DOMAIN` was once written as "Worker whitelist is invalid when production is not configured" | Fictional assertion (unsubmitted draft/appeared in discussion, not documented) | This environment variable has zero hits in all positions; Worker whitelist is **unconditional fail-closed**: unknown path 404, method does not match 405, backend is missing or parsing failure 503 (`cloudflare-worker/src/index.js:77-92`) | No need to configure switches, the whitelist will always take effect | +| `MESSAGWEAVE_DOMAIN` was once written as "Worker whitelist is invalid when production is not configured" | Fictional assertion (unsubmitted draft/appeared in discussion, not documented) | This environment variable has zero hits in all positions; Worker whitelist is **unconditional fail-closed**: unknown path 404, method does not match 405, backend is missing or parsing failure 503 (`cloudflare-worker/src/index.js:104-117`) | No need to configure switches, the whitelist will always take effect | | `read_batch` / `retry_or_dlq` "Redis may still return `Ok(())` when an error occurs, so the consumption loop will not exit due to a single failure" | Fictional assertion | The consumption entry is HTTP handler `worker` (`notify.rs:433`), **not a background loop**: full position `src/` zero hit `select!`, no signal processing, no resident worker process. `read_batch` (`state.rs:515`) throws the Redis error as it is (`state.rs:555`) via `?`, and the only discarded result is the `BUSYGROUP` idempotent protection of XGROUP `CREATE` (`state.rs:523-531`); `retry_or_dlq` is also thrown up via `?`. The caller returns `503 service_unavailable` + `retryable=true` for every `Err`, so the semantic premise of "the loop does not exit due to a single failure" itself is not true | None - the gap does not exist; `docs/opengaps.md` (original name `docs/roadmap.md`) "Code Gap" 4 → 3, `docs/design.md` "Known Boundary" same sentence has been deleted | --- diff --git a/docs/retired.zh-CN.md b/docs/retired.zh-CN.md index 879e3a4..776d766 100644 --- a/docs/retired.zh-CN.md +++ b/docs/retired.zh-CN.md @@ -79,7 +79,7 @@ | `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 | | `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:1173`) | | `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:1565-1566`) | -| `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:77-92`) | 无需配置开关,白名单恒生效 | +| `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:104-117`) | 无需配置开关,白名单恒生效 | | `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:433`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:515`)经 `?` 把 Redis 错误原样上抛(`state.rs:555`),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(`state.rs:523-531`);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 | ---