feat(worker): add Cache-Control: no-store for all dynamic routes
All non-static routes now return Cache-Control: no-store to prevent Cloudflare edge caching of dynamic API responses. Static assets (/, /assets/config.js, /assets/styles.css) are exempted to allow edge caching for performance. Cloudflare may cache responses based on origin headers or its own heuristics. Stale cached API/webhook responses would mask real backend state changes (e.g., stale health probes, cached config reads, cached webhook receipts). The no-store directive prevents this entirely. Implementation: - withNoStore(response) helper appends Cache-Control: no-store header - Applied to all dynamic routes: /api/*, /webhook/*, /push/*, /reconcile, /worker, /healthz*, /ready - Static asset routes (/, /assets/*) exempt - Error responses (404/405/503) also get no-store Tests: - New tests verify no-store presence on dynamic routes - New tests verify static routes remain exempt - All 43 tests pass Docs: - Both READMEs: updated pass-through semantics, test counts 38->43, per-file breakdown (index 15->19, lb 12->13) - retired.md: fixed index.js anchor 77-92->104-117 (drift from this commit's line additionsBump LB_VERSION to 2026.10.14 GATE-DOCS: 6/6 clean
This commit is contained in:
parent
66aeb3b9e7
commit
9d4996ca67
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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`。
|
||||
|
||||
两个坑:
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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("<!doctype html><html></html>", {
|
||||
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 超时会把
|
||||
// 一次正常的排空误判成失败并切到第二实例,而第二个实例只会重复排同一批消息。
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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`「已知边界」同句已删 |
|
||||
|
||||
---
|
||||
|
|
|
|||
Loading…
Reference in New Issue