docs: add the worker bilingual README and name both deployment forms

Root README gains §2 "Two ways to run it": a side-by-side table of
Form A (backend origin only) against Form B (behind the Cloudflare Worker
gateway) — what answers, failover, the callback URL to register, the
reachable route surface, which health endpoint to watch, and the docs to
read. Late in the switch: registration is idempotent and re-adding retires
the previous subscription, so the switch is one SPA field, not a migration;
nothing secret moves to the gateway; /api/bootstrap and /debug/* stay
origin-only either way. Later sections renumbered, and the monitoring
paragraph now points at the gateway aggregate GET /healthz-worker instead
of the origin-passthrough /healthz.

cloudflare-worker/README.md rewritten as the canonical English version and
cloudflare-worker/README.zh-CN.md added as its translation, covering the
route safelist (19 paths with per-path methods), the timeout/retry budget
with per-route overrides, bounded failover, the two health endpoints,
callback registration, config, local verification, deploy, and the design
invariants.

Both fixes along the way:

- README.zh-CN said the startup env block has 2 required + 2 optional
  variables; the table has 3 rows, 2 required + 1 optional.
- docs/charter.md §8 / registry list now names the zh-CN twin next to the
  gateway README.

scripts/docs_check gains the new file in validate_docs, check_tables,
audit_anchors and audit_anchor_semantics so it is checked like every other
document. check_sec_refs resolves §-refs against the document being scanned
when a basename is shared: README.md exists both at the repo root and under
cloudflare-worker/, and the basename-keyed heading map silently dropped one
of them. The gate was passing for the wrong reason before.

GATE-DOCS 6/6 clean: 72 anchors, 454 section refs, 727 path refs.
audit_anchor_semantics still reports the same 7 pre-existing candidates,
none in the new or edited files.
This commit is contained in:
anonsaber 2026-10-01 13:40:39 +08:00
parent baa6d8a6a1
commit 42128433ed
11 changed files with 557 additions and 113 deletions

View File

@ -27,7 +27,39 @@ State is an architectural boundary here, not a preference.
- **What it is not.** It is not a mail client, not a proxy, and not a relay. It forwards
metadata to one bot; it does not read arbitrary URLs, open tunnels, or broker traffic.
## 2. 5-minute setup
## 2. Two ways to run it
One image, one Redis, one SPA. The only difference is what sits in front and which URL you hand
to Telegram and Stalwart.
| | Form A: backend origin only | Form B: behind the Cloudflare Worker gateway |
|---|---|---|
| What answers requests | one backend host | one Worker URL, N backend origins |
| Failover | none — a down origin stays down | bounded: 2 attempts by default, only timeout and `5xx` retry |
| Callback URL you register | `https://a.example` | `https://lb.example` |
| Reachable routes | every route, including `/api/bootstrap` and `/debug/*` | only the 19 safelisted paths |
| Health to watch | `GET /ready` on the origin | `GET /ready` proxied, plus `GET /healthz-worker` for the gateway aggregate |
| Extra moving parts | none | one Worker deploy, one origin list |
| Docs | `docs/deployment.md §2` | `docs/deployment.md §10`, `cloudflare-worker/README.md` |
Pick Form A for a single origin with no HA requirement. Pick Form B for two or more origins, or
whenever you want one stable callback URL while you roll or rebuild an instance.
Switching is a re-registration, not a migration. Registration is idempotent and registering a
new address retires the previous subscription, so moving from Form A to Form B, or back, is one
field in the SPA. Do not register both origins at once: there is a single registration slot, and
the platform entrance must stay internal (`C-LB-SINGLE-REG-URL`).
No secret moves to the gateway in Form B. It holds no business credential and checks no auth
header. Instead every backend must carry the same `SAF-AUTH-*` secrets and the same encrypted
business config, because a callback can land on any instance and no instance can prove it was
the one asked for (`C-LB-SHARED-SECRETS`). Mis-matched secrets show up as a random 401, not as
a routing error.
`POST /api/bootstrap` and `/debug/*` stay origin-only in both forms, so neither is exposed
through the gateway.
## 3. 5-minute setup
Requires a reachable Redis instance and a JMAP session plus a Telegram bot token.
@ -50,9 +82,9 @@ For local builds without Docker: `cargo build --locked` produces the `message-we
If the service answers requests but does nothing useful, check the logs for a startup
warning: a missing required variable does **not** crash the process. It serves a
read-only setup router over in-memory state instead. See §3.
read-only setup router over in-memory state instead. See §4.
## 3. Configuration entry points
## 4. Configuration entry points
Three layers. They are not interchangeable.
@ -79,7 +111,8 @@ secrets from the environment in normal operation.
returns 503 until configuration, Redis, and both upstreams (a JMAP session `GET` and
Telegram `getMe`, 3s each) are reachable. Probe `/ready`, not `/healthz`, when deciding
whether to route traffic — but `/ready` is the heavier probe (~3s worst case), so a monitor
that must stay cheap should watch the gateway's aggregated `/healthz`.
that must stay cheap should watch the gateway aggregate `GET /healthz-worker` when the gateway
is enabled.
**Remote debug (opt-in, off by default).**
`/debug/*` is an optional remote-debug surface: live JMAP and Telegram probes, the current
@ -92,7 +125,7 @@ unset. If you do turn it on for a one-off diagnosis, configure the chat allowlis
an empty allowlist the test send is not restricted to any chat. See
`docs/deployment.md` §2.1.
## 4. Security boundary, in one sentence
## 5. Security boundary, in one sentence
> All state lives in Redis; the process reads only two environment variables and writes
> nothing to disk.
@ -103,7 +136,7 @@ startup. It is only compared in constant time, and is never echoed, logged, or s
Redis ACL password, if you have one, authenticates the Redis connection alone and is not
the credential for any HTTP endpoint.
## 5. Where to read more
## 6. Where to read more
| Document | What it answers |
|---|---|
@ -113,6 +146,7 @@ the credential for any HTTP endpoint.
| [`docs/opengaps.md`](docs/opengaps.md) | Gaps, blockers and the next phase |
| [`docs/retired.md`](docs/retired.md) | What was tried and dropped — abandoned routes, unreleased designs, and names that never existed |
| [`docs/charter.md`](docs/charter.md) | Project charter: goals, locked technology choices, security invariants, prohibitions, and the stable-ID registry |
| [`cloudflare-worker/README.md`](cloudflare-worker/README.md) | The Cloudflare Worker gateway: route safelist, timeout and retry budget, bounded failover, health aggregate, deploy |
| [`AGENTS.md`](AGENTS.md) | Language-agnostic engineering norms: code style, config and secrets, build environment, testing gates, document governance |
Facts that matter are traceable. If two documents disagree, `docs/reference.md` wins.

View File

@ -25,10 +25,34 @@ MessageWeave 位于 JMAP 邮件服务器(例如 Stalwart)与 Telegram bot
- **它不是什么。** 它不是邮件客户端,不是代理,也不是转发中继。它只把一个 bot 的元数据
送达 Telegram;不抓取任意 URL、不开隧道、不中转流量。
## 2. 五分钟跑起来
## 2. 两种部署形态
同一个镜像、同一个 Redis、同一套 SPA。唯一的区别是前面放什么,以及你把哪个 URL 交给 Telegram 和 Stalwart。
| | 形态 A:仅后端 origin | 形态 B:前置 Cloudflare Worker 网关 |
|---|---|---|
| 谁应答请求 | 一个后端主机 | 一个 Worker URL 对 N 个后端 origin |
| 故障转移 | 没有——origin 挂了就一直挂着 | 有界——默认 2 次尝试,只重试超时与 `5xx` |
| 你注册的回调 URL | `https://a.example` | `https://lb.example` |
| 可达路由 | 全部路由,含 `/api/bootstrap` 与 `/debug/*` | 只有 19 个白名单路径 |
| 要看哪个健康接口 | origin 上的 `GET /ready` | 透传的 `GET /ready`,外加网关聚合 `GET /healthz-worker` |
| 额外组件 | 无 | 一个 Worker 部署、一份 origin 列表 |
| 文档 | `docs/deployment.md §2` | `docs/deployment.md §10`、`cloudflare-worker/README.zh-CN.md` |
单 origin 且没有 HA 要求时选形态 A;两个及以上 origin、或想在滚动与重建某个实例时保持一个稳定的回调 URL,就选形态 B。
切换是一次重新注册,不是迁移。注册是幂等的,注册新地址会注销旧订阅,所以从形态 A 换到形态 B(或反过来)只是 SPA 里改一个字段。不要同时注册两个 origin:注册槽只有一个,平台入口必须保持内网(`C-LB-SINGLE-REG-URL`)。
形态 B 里没有任何 secret 搬到网关。它不持有任何业务凭据,也不校验任何认证头。真正要求一致的是每个后端都携带同一组 `SAF-AUTH-*` secret 与同一份加密业务配置——因为回调可能落在任何一个实例上,而任何实例都证明不了自己是被点名的那一个(`C-LB-SHARED-SECRETS`)。凭据不一致表现为随机 401,而不是路由错误。
`POST /api/bootstrap` 与 `/debug/*` 在两种形态下都只保留在 origin 直连,不经过网关暴露。
## 3. 五分钟跑起来
需要可用的 Redis 服务、一个 JMAP session 与一个 Telegram bot token。
这段是**本地容器冒烟测试**。生产部署可以用任何能跑起后端容器、注入加密 secret 并连到外部托管 Redis 的平台,见 `docs/deployment.md` §3 与 `docs/reference.md` 的平台相关部分。
```sh
cp .env.example .env # 填写 REDIS_URL 与 CONFIG_ENCRYPTION_KEY
docker build -t messageweave:latest .
@ -42,13 +66,13 @@ docker run --env-file .env -p 8080:8080 messageweave:latest
不用 Docker 的本地构建:`cargo build --locked` 产出 `message-weave` 二进制。
如果服务有响应但什么都不做,请查看启动日志的告警:**缺少必需变量不会让进程崩溃**,它会
退化为运行在内存态上的只读配置路由。见 §3。
退化为运行在内存态上的只读配置路由。见 §4。
## 3. 配置入口
## 4. 配置入口
共三层,彼此不可替代。
**启动期环境变量 —— 2 个必需,2 个可选。**
**启动期环境变量 —— 2 个必需,1 个可选。**
| 变量 | 必需 | 默认值 |
|---|---|---|
@ -67,7 +91,7 @@ docker run --env-file .env -p 8080:8080 messageweave:latest
`GET /healthz` 无条件返回 200——它是存活探测,不是就绪探测。`GET /ready` 在配置、Redis 与
两条上游(JMAP session `GET`、Telegram `getMe`,各 3s)可达之前返回 503。判断是否可路由
流量时,请探测 `/ready` 而不是 `/healthz`;但 `/ready` 是较重的探针(最坏约 3s),需要
保持轻量的监控请看网关聚合的 `/healthz`。
保持轻量的监控在启用网关时请看网关聚合 `GET /healthz-worker`。
**远程联调面(可选,默认关闭)。**
`/debug/*` 是一套可选的远程联调面:JMAP 与 Telegram 实时探针、当前业务配置,以及单条
@ -77,13 +101,13 @@ Telegram 发送。它由双因子开关控制——进程必须带 `--debug` **
设 `DEBUG_TOKEN`。若确实要为一次排障临时开启,请先配好 chat 白名单——白名单为空时,测试
发送不会受限到任何 chat。见 `docs/deployment.md` §2.1。
## 4. 安全边界,一句话
## 5. 安全边界,一句话
> 所有状态都在 Redis 里;进程只读两个环境变量,不写任何磁盘。
有一条推论与部署直接相关:SPA 管理凭据——也就是 bootstrap 的信任根——就是 `CONFIG_ENCRYPTION_KEY` 本身,即启动时提供的那个 32 字节 hex 值。它只做常数时间比较,绝不回显、记录或落盘。Redis ACL 密码(如有)只认证 Redis 连接本身,不是任何 HTTP 端点的认证凭据。
## 5. 去哪读更多
## 6. 去哪读更多
| 文档 | 回答什么 |
|---|---|
@ -93,6 +117,7 @@ Telegram 发送。它由双因子开关控制——进程必须带 `--debug` **
| [`docs/opengaps.zh-CN.md`](docs/opengaps.zh-CN.md) | 缺口、阻塞项与下一阶段目标 |
| [`docs/retired.zh-CN.md`](docs/retired.zh-CN.md) | 试过但没用的:废弃路线、未落地的设计与从未存在的名字 |
| [`docs/charter.zh-CN.md`](docs/charter.zh-CN.md) | 项目章程:项目目标、锁定的技术选型、安全不变量、禁止事项与稳定 ID 注册表 |
| [`cloudflare-worker/README.zh-CN.md`](cloudflare-worker/README.zh-CN.md) | Cloudflare Worker 网关:路由白名单、超时与重试预算、有界故障转移、健康聚合、部署 |
| [`AGENTS.md`](AGENTS.md) | 语言无关的工程规范:代码风格、配置与密钥、构建环境、测试门禁、文档治理 |
重要的事实都可追溯。两份文档不一致时,以 `docs/reference.md` 为准。

View File

@ -1,120 +1,295 @@
# MessageWeave Cloudflare Worker(ARCH-LB-WORKER)
# MessageWeave Cloudflare Worker gateway
统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。
**透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。
> [中文版本 / Chinese version → README.zh-CN.md](README.zh-CN.md)
> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 18 条白名单、
> 后端 origin 在部署期固定且仅允许 https、未知路径一律 404。它只对固定后端做请求转发与
> 故障转移,不接受任意目标主机,也不提供任何形式的流量中转或访问隐藏能力。
This is the canonical English version. The Chinese version is
[README.zh-CN.md](README.zh-CN.md).
> 设计依据:`docs/design.md §10 / NFR-HA-MULTI-INSTANCE`、`docs/deployment.md §10`。
> 安全基线与禁止事项:`docs/charter.md §3/§5`(稳定 ID 注册表 §8)。
One public HTTPS entry point in front of one or more MessageWeave backends, with bounded
multi-origin failover (HA/LB sub-project). **Pass-through model**: the Worker does not parse
business payloads and forwards the request as-is to the backend https origins; only *timeout /
5xx* triggers a bounded failover to another instance.
## 目录
> This component is a **safelist-restricted edge load balancer**: the route set is fixed (19
> whitelisted paths), the backend origins are fixed at deploy time and https-only, and every
> unknown path gets a `404`. It only forwards requests to those fixed backends and fails over
> between them. It does not accept an arbitrary target host and provides no general-purpose
> traffic-relay or access-hiding capability.
- `wrangler.toml` — Wrangler 示例配置(name/main/compat/vars;后端 secret 走 `wrangler secret`)
- `src/index.js` — Worker 入口(路由分发、/healthz-worker 聚合、safelist 校验)
- `src/backends.js` — origin 解析 + https/凭据/query 校验(C-HTTPS-INBOUND)
- `src/lb.js` — 透传转发 + 有界故障转移(SAF-LB-PASSTHRU)
- `src/health.js` — 健康聚合探针(MOD-HEALTH-AGG / C-NO-DB)
- `test/*.test.js` — 单元/集成测试(零依赖,`node --test`)
- `package.json` — npm 元数据(`dev`/`deploy` 调 wrangler;`test` 纯 node)
> Design basis: `docs/design.md §11.3` (`NFR-HA-MULTI-INSTANCE`) and `docs/deployment.md §10`.
> Security baseline and prohibitions: `docs/charter.md §3`, `docs/charter.md §5`; stable-ID
> registry `docs/charter.md §8`.
## 零依赖设计
## 1. What it is, and what it is not
运行时仅依赖 ES2022 标准 + 平台/Node 的 `fetch`/`Headers`/`Request`/`Response`/`URL`。
`npm run test` 直接 `node --test test/`,**无需安装**任何生产依赖;
`wrangler` 仅在 `check`/`deploy`/`dev` 中作 devDependency(CI 可选装)。
- **Forward only.** Method, headers (including auth headers) and body are forwarded as-is. The
Worker performs no authentication of its own and rewrites nothing.
- **Secretless.** No Telegram bot token, no JMAP password, no session secret. It forwards
whatever auth header it is given and checks none of them. All backends must instead share
the same set of `SAF-AUTH-*` secrets — `RECONCILE_TOKEN`, `TG_WEBHOOK_SECRET` and the
encrypted business config — because a callback can land on any instance and no instance can
prove which one was asked (`C-LB-SHARED-SECRETS`). Per-instance secrets mean a random 401.
- **No origin discovery.** Origins come from the deploy-time origin list only.
- **Zero runtime dependencies.** Pure ES2022 plus the platform `fetch`, `Headers`, `Request`,
`Response` and `URL`. `wrangler` is a devDependency, used only by `check`, `deploy` and
`dev`.
- **Zero state.** No Redis, no JMAP, no database (`C-NO-DB`, `C-REDIS-ONLY-STATE`). The only
mutable state is an in-isolate health probe cache.
- **No long-lived connections** (`C-NO-LONG-CONN`). Pure request-response. The request body is
read once as `arrayBuffer` and re-attached on every attempt, so streaming is never required.
- **Not a queue.** There is no retry beyond the bounded attempts below. When every origin
fails the gateway answers `503`, and Telegram / Stalwart redelivery is what makes up for it.
## 语义
## 2. Directory layout
### 透传(A,SAF-LB-PASSTHRU)
- 原样转发 method / headers(含鉴权头)/ body;不鉴权改写。
- 所有后端实例共享同一组 secret(`C-LB-SHARED-SECRETS`):同一 `TELEGRAM_WEBHOOK_SECRET`、
`JMAP_PUSH_VERIFICATION_CODE`、`API_TOKEN`(`API_TOKEN` 在 `docs/deployment.md` §10 仅用于多实例互斥,非鉴权)
+ 相同 App Password(各后端实例的 `JMAP` 可不同,`docs/deployment.md` §10.2)。
| file | role |
|---|---|
| `wrangler.toml` | `name`, `main`, compatibility flags, `[vars]` |
| `package.json` | `check` / `test` / `deploy` / `dev` scripts only, no runtime dependencies |
| `src/index.js` | entry point: HTTPS redirect, route dispatch, safelist, health aggregate |
| `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 |
### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL)
- HTTP requests receive a permanent `308` redirect to the same HTTPS URL before route validation or forwarding.
- 透传公开 `GET /api/status` 启动状态,以及管理 SPA API:`POST /api/admin/session[/revoke]`、`GET|PUT /api/enabled`(业务总开关,SPA 服务卡片读写)、`GET|PUT /api/config`、`GET|PUT /api/business-config`(读回 + 增量保存)、`POST /api/business-config/preflight`(保存前逐组件连通性预检)。启动状态只包含缺少的环境变量名称;管理 API 的鉴权仍由后端执行。
- 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/telegram/register-webhook`、`POST /api/push/disable`、`POST /reconcile`、`POST /worker`、`GET /ready`。
- 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。
- method 不符 → **405**。
## 3. Pass-through semantics
### 有界故障转移(`docs/deployment.md` §10.4)
- 每次请求只试 `min(LB_MAX_ATTEMPTS, origins.length)` 次;默认 2(首次 + 1 次换实例)。
- **仅** 超时(AbortError)或 5xx 触发换下一个实例;4xx / 2xx / 3xx 直接返回。
- 起点按 `rng` 随机化(默认 `Math.random`),实现双活分摊;单 origin 配置时退化为确定性。
- 全失败 → **503 All Backends Unavailable**(交由 Telegram / Stalwart 自动重投兜底,不丢消息)。
- 健康缓存(`aggregateHealth` / `LB_HEALTH_TTL_MS`)仅服务于 `/healthz-worker` 聚合视图;转发目前**不做健康路由过滤**(`docs/deployment.md` §10.4「只向健康实例转发」为后续增强,当前靠故障转移兜底)。
- **例外:`POST /reconcile` 与 `POST /worker` 走 per-route 覆盖。** 两者都是后端同步长任务:
全局 10s 超时会把它们误判为失败并故障转移到第二实例,而第二实例对 `/reconcile` 必然立刻
返回 409(集群级锁 `lock:reconcile`,初租 300s 由心跳续期),对 `/worker` 只会重复排同一批
消息。所以这两条路由改为:超时分别取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000`,留足锁初租 +
心跳余量)与 `LB_WORKER_TIMEOUT_MS`(默认 `300000`,等于后端单条事件下界),且 `maxAttempts=1`
绝不故障转移。其余快路径保持全局默认。
- Method, headers and body go out unchanged; the backend response status, body and headers are
returned as-is.
- The only header the gateway 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.
- `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 gateway itself (`redirect: "follow"`), so a
redirecting origin never hands the browser a second hop.
- Logs and error strings carry method, path, origin and failure class only. Never headers,
body, auth secrets or App Password (`SAF-LOG-PURITY`).
### 健康聚合(MOD-HEALTH-AGG;C-NO-DB / C-REDIS-ONLY-STATE)
- `GET /healthz-worker`(由 Worker 自身承载;入站 `/healthz` 保留给源站自己的健康检查并透传,两者互不遮蔽):
按 TTL 缓存(默认 30s)探测各后端 `/healthz`,返回
`{status: ok|down, version, available, total, backends:[{origin, up, status}]}`;
有 ≥1 后端 up → 200;全部 down → 503。
- **不代理 Redis/JMAP**:Worker 不做数据库侧检查;后端端到端就绪(配置完整性 + Redis 可达 + JMAP session + Telegram getMe,对应 `ARCH-READY-BASELINE`)由 `/ready`(透传)承担。
## 4. Route safelist
### 无长连接 / 无密钥日志(§3/§5 红线)
- 不使用 WebSocket/SSE/长轮询(`C-NO-LONG-CONN`):纯请求-响应转发,body 一次性 `arrayBuffer` 回灌。
- 日志只打「方法 / 路径 / origin / 失败类别」;**绝不**打印 header / body / 鉴权 secret / App Password(`SAF-LOG-PURITY`)。
Every inbound **HTTP** request gets a permanent **308** redirect to the same HTTPS URL before
route validation or forwarding (`C-HTTPS-INBOUND`). After that the request must hit one of 19
paths and carry a method that path allows; every other combination is refused at the edge.
## 环境变量
| path | methods |
|---|---|
| `/` | 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/telegram/register-webhook` | POST |
| `/api/push/disable` | POST |
| `/reconcile` | POST |
| `/worker` | POST |
| `/healthz` | GET |
| `/ready` | GET |
| 变量 | 必填 | 说明 |
|---|---|---|
| `BACKEND_ORIGINS_JSON` | 是 | JSON 数组,形如 `["https://a.platform1.example","https://b.platform2.example"]`;必须全部 https、无内嵌凭据/query/fragment/路径。已写在 `wrangler.toml` 的 `[vars]`。 |
| `LB_REQUEST_TIMEOUT_MS` | 否 | 单 origin 请求超时;默认 `10000`。建议 > 最坏 cold start + 最长 JMAP 拉取。 |
| `LB_MAX_ATTEMPTS` | 否 | 每请求最多 origin 尝试次数;默认 `2`(= 1 次故障转移)。`POST /reconcile` 与 `POST /worker` 固定为 `1`。 |
| `LB_RECONCILE_TIMEOUT_MS` | 否 | 仅 `POST /reconcile` 的单 origin 超时覆盖;默认 `320000`(须大于后端锁初租 300s + 心跳余量)。 |
| `LB_WORKER_TIMEOUT_MS` | 否 | 仅 `POST /worker` 的单 origin 超时覆盖;默认 `300000`(= 后端单条事件下界 `SINGLE_EVENT_CEILING_FLOOR_MS`)。一次排空 batch 条(默认 10,硬上限 10),batch 拉大时需相应上调。 |
| `LB_HEALTH_TTL_MS` | 否 | 健康探测缓存 TTL;默认 `30000`。 |
| `LB_VERSION` | 否 | `/healthz-worker` 响应体里的 `version` 字段,用于确认线上是哪次部署在回答;取自 `wrangler.toml` 的 `[vars]`(当前 `2026.10.4`),改 LB 逻辑的提交同时 bump |
- Unknown path: `404`, body `route not forwarded: <path>`. The gateway can therefore never be
turned into a jump host for arbitrary backend paths.
- Method mismatch on a known path: `405`, body `method not allowed for <path>`, plus an
`Allow` header.
- Bootstrap (`POST /api/bootstrap`) and the remote diagnostics face (`/debug/*`) are
deliberately absent, so both stay origin-only.
- `/healthz-worker` is not in the safelist at all: the gateway serves it itself, on `GET`
only.
> 默认写在 `wrangler.toml` 的 `[vars]`(**明文例外**,因为当前值只是公开可达的 https origin,
> 不是凭据)。一旦要放内网地址、带内嵌凭据的 URL、或不想公开的 staging 主机名,
> 立刻改用 `npx wrangler secret put BACKEND_ORIGINS_JSON` 并把该行从 `[vars]` 删掉。
## 5. Timeout and retry budget
## 本地 / CI 验证
| variable | required | default | applies to |
|---|---|---|---|
| `BACKEND_ORIGINS_JSON` | yes | — | all routes |
| `LB_REQUEST_TIMEOUT_MS` | no | `10000` | every route except the two below, and the §7.2 probe |
| `LB_MAX_ATTEMPTS` | no | `2` | attempts per request |
| `LB_RECONCILE_TIMEOUT_MS` | no | `320000` | `POST /reconcile` only |
| `LB_WORKER_TIMEOUT_MS` | no | `300000` | `POST /worker` only |
| `LB_HEALTH_TTL_MS` | no | `30000` | `/healthz-worker` probe cache only |
| `LB_VERSION` | no | `unknown` | `/healthz-worker` `version` field |
Integer env values are parsed with `Math.floor`. A non-finite or non-positive value is ignored
and the default applies, so a typo cannot switch the budget off.
`/reconcile` and `/worker` are **single-attempt routes** with per-route timeout overrides.
Both are long synchronous backend jobs: at the 10 s global timeout the gateway would declare
them failed and fail over to a second instance, but that instance answers `/reconcile` with
`409` immediately (the cluster-wide `lock:reconcile` starts with a 300 s lease, renewed by
heartbeat) and a second `/worker` would merely drain the same batch twice. They therefore get
`LB_RECONCILE_TIMEOUT_MS` (default 320 s, headroom over the 300 s lease plus heartbeats) and
`LB_WORKER_TIMEOUT_MS` (default 300 s, equal to the backend per-event floor
`SINGLE_EVENT_CEILING_FLOOR_MS`), both with `max_attempts = 1`.
`LB_WORKER_TIMEOUT_MS` must grow if the `/worker` batch grows: the backend batch default and
its hard cap are both 10.
## 6. Bounded failover
- Each request tries `max(1, min(LB_MAX_ATTEMPTS, origins.length))` origins. The default of
2 is one first attempt plus one failover.
- Only a timeout or a `5xx` response moves on to the next origin. `2xx`, `3xx` and `4xx` are
answers and are returned immediately.
- A timeout or transport failure counts as retryable when the error name is `AbortError`, or
when the message, or the message of a `cause` further down the chain, matches one of:
`fetch failed`, `network error`, `socket hang up`, `ECONNREFUSED`, `ECONNRESET`,
`EAI_AGAIN`, `ENOTFOUND`, `ETIMEDOUT`, `EHOSTUNREACH`, `ENETUNREACH`. Anything else fails
fast.
- With more than one origin and more than one attempt, the starting origin is chosen with
`rng` (defaulting to `Math.random`) so load spreads over the healthy instances. A single
origin degrades to a fixed order.
- Every origin fails: `503` with status text `All Backends Unavailable` and body
`all backends failed: <last reason>`. Telegram and Stalwart then redeliver, which is why
nothing is lost.
The health cache feeds only the `/healthz-worker` aggregate view. Forwarding does **not**
filter by health; today, failover is the safety net.
## 7. Health endpoints
The two endpoints deliberately do not overlap, so neither can hide the other.
### 7.1 GET /healthz — passthrough
Forwarded like any other safelisted route. The response is the backend's own liveness
envelope, which is exactly why it can read healthy while a different origin is down.
### 7.2 GET /healthz-worker — gateway aggregate
Answered by the gateway itself. It never touches the forwarded route set.
- Probes each origin's `/healthz` with the same request timeout as forwarding
(`LB_REQUEST_TIMEOUT_MS`, default 10 s). Redirects are followed, so an origin that answers
with a `3xx` still counts as up.
- `up` is true when the origin answered with a status below 500. A probe failure records
`up: false` with `status: null`.
- Results are cached per origin for `LB_HEALTH_TTL_MS` (default 30 s) in an in-isolate map,
to keep probe traffic bounded. A cache miss probes and stores; a fresh entry is served
without another probe.
- Response body: `{status, version, available, total, backends: [{origin, up, status}]}`.
- at least one origin up: HTTP `200`, `status: "ok"`
- every origin down: HTTP `503`, `status: "down"`
- no origins configured: HTTP `200`, `status: "no-backends"`
- `version` is `LB_VERSION`, defaulting to the literal string `unknown`
- Any method other than `GET`: `405` with `Allow: GET`.
- The gateway does not proxy Redis or JMAP readiness. End-to-end backend readiness
(`ARCH-READY-BASELINE`: config completeness, Redis reachable, JMAP session, Telegram
`getMe`) stays on the backend's `/ready`, which is passed through.
## 8. Callback registration
The SPA registers callback URLs against **one** origin (`C-LB-SINGLE-REG-URL`). Origin only,
no path:
- Gateway enabled: the Worker URL, for example `https://lb.example`.
- Gateway disabled: a backend origin, for example `https://a.example`.
Whichever origin you register is where `/webhook/tg` and `/push/jmap` land. All three
registration endpoints are safelisted and pass through the gateway, so registration works
through either front door. Registration is idempotent, and registering a new address retires
the previous subscription — that is how you switch fronts.
## 9. Configuration
- Defaults live in `wrangler.toml` `[vars]` as a **deliberate plaintext exception**: the
current value is only publicly reachable https origins, which is not a credential. The
moment you need an internal address, a URL with embedded credentials, or a hostname that
should not be public, move it to `npx wrangler secret put BACKEND_ORIGINS_JSON` and delete
the line from `[vars]`. Git history is irreversible, so deleting it does not un-publish it.
- `BACKEND_ORIGINS_JSON` is a JSON **array of plain strings**. Object shapes such as
`{"url": "..."}` are rejected: `parseBackendOrigins` throws, and because the config is
re-parsed on **every** request the failure shows up as `503` on every request rather than
at boot. The body is `misconfigured backends`. An empty list throws the same way, before
anything is forwarded.
- Each entry must be an https **origin** only: no http, no path, no query, no fragment, no
embedded credentials (`C-HTTPS-INBOUND`).
- `wrangler.toml` also pins `name = "messageweave-lb"`, `main = "src/index.js"`,
`compatibility_date = "2025-01-01"` and
`compatibility_flags = ["nodejs_compat_v2"]`. `nodejs_compat_v2` is what lets the test
files import `node:test` and `node:assert/strict`. The runtime code imports no
node module.
- Comments in `wrangler.toml` must stay single-line `#` comments. TOML has no block comment,
so a JSDoc `/** */` block makes wrangler fail to parse the file at all.
## 10. Local and CI verification
```bash
cd cloudflare-worker
# 单元/集成测试(零依赖):
npm run test
# 等价:node --test test/
# 语法检查 + wrangler dry-run(需 npx wrangler,会拉 devDependency):
npm run check
# 等价:node --check src/*.js && node --check test/*.test.js && wrangler deploy --dry-run
npm test # 38 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
step for the tests — the Worker has no runtime dependencies, and the test files import only
`node:test` and `node:assert/strict`.
Two traps:
- `node --test test/` (a directory) exits 0 after running **zero** tests on some Node
versions. Always pass the file glob.
- A default CI install step of `bun install` ignores the npm lockfile: it re-resolves and can
move wrangler across major versions. Add an explicit `npm ci` before the test step if you
need the pinned wrangler.
## 11. Deploy
```bash
cd cloudflare-worker
# 安装 dev dep(仅 wrangler):
npm install --no-audit --no-fund
# BACKEND_ORIGINS_JSON 已写在 wrangler.toml 的 [vars],直接部署:
npx wrangler deploy
# 可选调参(默认值已够用;要覆盖就用 dashboard 的 Add Variable):
# LB_REQUEST_TIMEOUT_MS / LB_MAX_ATTEMPTS / LB_HEALTH_TTL_MS
# LB_RECONCILE_TIMEOUT_MS / LB_WORKER_TIMEOUT_MS
# 仅当 [vars] 里要放非公开值时才改用 secret 注入:
npx wrangler secret put BACKEND_ORIGINS_JSON
# 部署后确认:
curl https://<your-worker>.workers.dev/healthz
npm run deploy
curl https://<your-worker>.workers.dev/healthz-worker
```
健康探针:
`npm run check` includes `wrangler deploy --dry-run --outdir=.build-check`, which bundles the
four sources and reports the artifact size before anything is published. The gateway is
deployed from push, so a `curl` of `/healthz-worker` and a compare of its `version` is the
acceptance check. Bump `LB_VERSION` in the same commit as any LB logic change, so you can tell
which build answered.
```bash
curl https://lb.messageweave.example/healthz
# => {"status":"ok","available":2,"total":2,"backends":[{"origin":"https://a.example","up":true,"status":200},...]}
```
Two production incidents worth keeping in mind:
- **2026-09-30**: a build wrote `x-lb-backend` onto proxied responses. Cross-origin responses
without `access-control-allow-*` headers have an immutable header guard, so `headers.set`
threw and the surrounding handler logged a healthy backend as unavailable and burned its
retries — the gateway returned `503` while `GET /` actually returned `200`.
`/healthz-worker` did not go through that code path, which is exactly what hid the outage.
Fixed by making the header write best-effort.
- **Object-shaped origin lists fail silently at parse time.** `parseBackendOrigins` throws and
you get `503 misconfigured backends` on every request. Keep `weight` and any other
per-origin tuning out of the list: nothing reads them.
## 12. Design invariants
- `SAF-LB-PASSTHRU` — no auth, header or body rewriting on the path
- `C-HTTPS-INBOUND` — https-only origins, embedded credentials and paths rejected, HTTP gets a
308
- `C-LB-SINGLE-REG-URL` — one callback origin, unknown paths 404
- `C-LB-SHARED-SECRETS` — every backend carries identical business credentials
- `MOD-HEALTH-AGG` — TTL-cached aggregate probe owned by the gateway
- `C-NO-DB`, `C-REDIS-ONLY-STATE` — no database or Redis access from the gateway
- `C-NO-LONG-CONN` — request-response only
- `ARCH-READY-BASELINE` — end-to-end readiness stays on the backend's `/ready`
- `SAF-LOG-PURITY` — method, path, origin and failure class only
- `ARCH-LB-WORKER` — the gateway is the only edge component
- `NFR-HA-MULTI-INSTANCE` — multi-origin failover is how HA is realised
## References
- `docs/deployment.md §10` — gateway deployment, configuration and rollout
- `docs/reference.md §4` — gateway versus backend route matrix
- `docs/reference.md §9.2` — backend configuration and secrets
- `docs/reference.md §9.4` — health and release checks
- `docs/design.md §11.3` — HA and multi-instance design
- `docs/charter.md §3`, `docs/charter.md §5`, `docs/charter.md §8` — security baseline,
prohibitions, stable-ID registry

View File

@ -0,0 +1,194 @@
# MessageWeave Cloudflare Worker 网关
> [English version / 英文版 → README.md](README.md)
面向一个或多个 MessageWeave 后端的统一 HTTPS 入口,带**有界多 origin 故障转移**(HA/LB 子项目)。**透传模型**:Worker 不解析业务载荷,把请求原样转发给后端 https origin;只有*超时 / 5xx* 才触发有界的故障转移到另一个实例。
> 本组件是**基于白名单限制的边缘负载均衡器**:路由集合固定(19 个白名单路径)、后端 origin 在部署时固定且仅允许 https、任何未知路径一律 `404`。它只把这些固定后端之间的请求做转发与故障转移,不接受任意目标主机,也不提供任何通用的流量中转或隐身能力。
> 设计依据:`docs/design.md §11.3`(`NFR-HA-MULTI-INSTANCE`)与 `docs/deployment.md §10`。
> 安全基线与禁令:`docs/charter.md §3`、`docs/charter.md §5`;稳定 ID 注册表 `docs/charter.md §8`。
## 1. 它是什么,不是什么
- **只转发。** method、headers(含认证头)、body 一律原样转发。Worker 自身不做任何认证,也不改写任何内容。
- **无 secret。** 没有 Telegram bot token,没有 JMAP 密码,没有 session secret。它收到什么认证头就转发什么,自己一个都不校验。真正共享的是后端之间必须一致的同一组 `SAF-AUTH-*` secret——`RECONCILE_TOKEN`、`TG_WEBHOOK_SECRET` 与加密后的业务配置——因为回调可能落在任何一个实例上,而任何实例都证明不了自己是被点名的那一个(`C-LB-SHARED-SECRETS`)。凭据不一致的表现是随机 401,而不是路由错误。
- **不做 origin 发现。** origin 只来自部署时的 origin 列表。
- **零运行时依赖。** 纯 ES2022 加平台提供的 `fetch`、`Headers`、`Request`、`Response`、`URL`。`wrangler` 是 devDependency,只用于 `check`、`deploy`、`dev`。
- **零状态。** 不连 Redis、不连 JMAP、不连数据库(`C-NO-DB`、`C-REDIS-ONLY-STATE`)。唯一的可变状态是 isolate 内的健康探测缓存。
- **无长连接**(`C-NO-LONG-CONN`)。纯请求-响应。请求体只读一次为 `arrayBuffer`,每次尝试重新挂上去,因此不需要流式。
- **不是队列。** 除了下面那几轮有界尝试,不做任何重试。所有 origin 都失败时网关返回 `503`,靠 Telegram / Stalwart 的自身重投兜底。
## 2. 目录结构
| 文件 | 职责 |
|---|---|
| `wrangler.toml` | `name`、`main`、compatibility flags、`[vars]` |
| `package.json` | 只有 `check` / `test` / `deploy` / `dev` 脚本,无运行时依赖 |
| `src/index.js` | 入口:HTTPS 重定向、路由分发、白名单、健康聚合 |
| `src/backends.js` | origin 解析与校验、路由白名单 |
| `src/lb.js` | 透传转发与有界故障转移 |
| `src/health.js` | 带 TTL 的健康聚合探测 |
| `test/*.test.js` | 四个文件共 38 条测试,全部离线 |
## 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 的生产事故根因。
- `GET` 和 `HEAD` 不带 body 转发:上游没有 body 的响应直接原样返回,不重算 `Content-Length`。
- 后端的 `3xx` 由网关自己跟随(`redirect: "follow"`),跳转型的 origin 不会把第二跳甩给浏览器。
- 日志与错误串只包含 method、path、origin、失败类别,绝不含 headers、body、认证 secret 或 App Password(`SAF-LOG-PURITY`)。
## 4. 路由白名单
所有入站 **HTTP** 请求先被永久 **308** 重定向到同一个 HTTPS URL,之后才做路由校验与转发(`C-HTTPS-INBOUND`)。此后请求必须命中 19 个路径之一且 method 匹配,其余组合一律在边缘拒绝。
| 路径 | 允许的 method |
|---|---|
| `/` | 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/telegram/register-webhook` | POST |
| `/api/push/disable` | POST |
| `/reconcile` | POST |
| `/worker` | POST |
| `/healthz` | GET |
| `/ready` | GET |
- 未知路径:`404`,body 为 `route not forwarded: <path>`。因此网关永远不可能被当作跳板去访问后端的任意路径。
- 路径已知但 method 不匹配:`405`,body 为 `method not allowed for <path>`,并附带 `Allow` header。
- 引导接口(`POST /api/bootstrap`)与远程诊断面(`/debug/*`)刻意不在白名单内,两者都只保留在 origin 直连。
- `/healthz-worker` 完全不在白名单里:它由网关自己应答,且只接受 `GET`。
## 5. 超时与重试预算
| 变量 | 必填 | 默认值 | 适用范围 |
|---|---|---|---|
| `BACKEND_ORIGINS_JSON` | 是 | — | 所有路由 |
| `LB_REQUEST_TIMEOUT_MS` | 否 | `10000` | 除下面两条外的所有路由,以及 §7.2 的探测 |
| `LB_MAX_ATTEMPTS` | 否 | `2` | 每请求的尝试次数 |
| `LB_RECONCILE_TIMEOUT_MS` | 否 | `320000` | 仅 `POST /reconcile` |
| `LB_WORKER_TIMEOUT_MS` | 否 | `300000` | 仅 `POST /worker` |
| `LB_HEALTH_TTL_MS` | 否 | `30000` | 仅 `/healthz-worker` 探测缓存 |
| `LB_VERSION` | 否 | `unknown` | `/healthz-worker` 的 `version` 字段 |
整数型 env 值用 `Math.floor` 解析。非有限数或非正值会被忽略并回落到默认值,所以手滑不会把预算关掉。
`/reconcile` 与 `/worker` 是**单次尝试路由**,各自有独立的超时覆盖。两者都是后端耗时的同步长任务:按全局 10 s 超时,网关会判定它们失败并故障转移到第二个实例,但第二个实例对 `/reconcile` 会立刻返回 `409`(集群级 `lock:reconcile` 初始租约 300 s,靠心跳续租),而第二个 `/worker` 只会把同一批消息再排一遍。所以这两条路由拿到 `LB_RECONCILE_TIMEOUT_MS`(默认 320 s,比 300 s 租约加心跳多留余量)与 `LB_WORKER_TIMEOUT_MS`(默认 300 s,等于后端单条事件地板 `SINGLE_EVENT_CEILING_FLOOR_MS`),并且都是 `max_attempts = 1`。
如果 `/worker` 的批大小上调,`LB_WORKER_TIMEOUT_MS` 必须跟着加:后端批量的默认值与硬上限都是 10。
## 6. 有界故障转移
- 每个请求尝试 `max(1, min(LB_MAX_ATTEMPTS, origins.length))` 个 origin。默认 2 即首次加一次故障转移。
- 只有超时或 `5xx` 才会换下一个 origin。`2xx`、`3xx`、`4xx` 都是应答,立即原样返回。
- 超时与网络层失败被判为可重试的条件:错误 name 是 `AbortError`,或消息、或 `cause` 链上更深的错误消息命中 `fetch failed`、`network error`、`socket hang up`、`ECONNREFUSED`、`ECONNRESET`、`EAI_AGAIN`、`ENOTFOUND`、`ETIMEDOUT`、`EHOSTUNREACH`、`ENETUNREACH`。其余一律 fail-fast。
- 当 origin 大于 1 且尝试次数大于 1 时,起点 origin 由 `rng` 决定(默认 `Math.random`),把负载摊到健康实例上;单个 origin 退化为固定顺序。
- 所有 origin 都失败:`503`,status text 为 `All Backends Unavailable`,body 为 `all backends failed: <last reason>`。随后由 Telegram 与 Stalwart 重投,消息因此不丢。
健康缓存只服务于 `/healthz-worker` 的聚合视图。转发路径**不**按健康过滤;现阶段故障转移就是安全网。
## 7. 健康接口
两个接口刻意不重叠,谁都不能掩盖谁。
### 7.1 GET /healthz — 透传
与其他白名单路由一样被转发。响应是后端自己的 liveness envelope,这也正是它在另一个 origin 已挂时依然显示健康的原因。
### 7.2 GET /healthz-worker — 网关聚合
由网关自己应答,完全不接触被转发的路由集合。
- 用与转发同源的请求超时(`LB_REQUEST_TIMEOUT_MS`,默认 10 s)探测每个 origin 的 `/healthz`。重定向会被跟随,所以返回 `3xx` 的 origin 同样计为 up。
- origin 应答状态码低于 500 即为 `up`。探测失败记 `up: false` 且 `status: null`。
- 结果按 origin 缓存 `LB_HEALTH_TTL_MS`(默认 30 s)在 isolate 内的 map 中,用来控制探测流量。未命中就探测并写入;未过期的缓存直接返回,不再探测。
- 响应体:`{status, version, available, total, backends: [{origin, up, status}]}`。
- 至少一个 origin 在线:HTTP `200`,`status: "ok"`
- 全部 origin 离线:HTTP `503`,`status: "down"`
- 未配置 origin:HTTP `200`,`status: "no-backends"`
- `version` 即 `LB_VERSION`,未配置时为字面量 `unknown`
- 任何非 `GET` 的 method:`405`,并带 `Allow: GET`。
- 网关不代理 Redis 或 JMAP 的 readiness。端到端后端就绪判定(`ARCH-READY-BASELINE`:配置完整、Redis 可达、JMAP session、Telegram `getMe`)留在后端的 `/ready` 上,`/ready` 本身被透传。
## 8. 回调注册
SPA 的回调 URL 只注册**一个** origin(`C-LB-SINGLE-REG-URL`)。只填 origin,不带路径:
- 启用网关:填 Worker URL,例如 `https://lb.example`。
- 不启用网关:填后端 origin,例如 `https://a.example`。
你注册的是哪个 origin,`/webhook/tg` 与 `/push/jmap` 就落在哪。三个注册接口都在白名单内、都会经过网关透传,所以两种前门都能注册。注册是幂等的,且注册新地址会注销旧订阅——换前门就靠这一步。
## 9. 配置
- 默认值写在 `wrangler.toml` 的 `[vars]` 里,这是一个**刻意的明文例外**:当前取值只是公开可达的 https origin,不是凭据。一旦出现内网地址、带内嵌凭据的 URL、或不想公开的主机名,立刻改用 `npx wrangler secret put BACKEND_ORIGINS_JSON` 并删掉 `[vars]` 里那一行。git 历史不可逆,删掉并不等于没进过库。
- `BACKEND_ORIGINS_JSON` 是**纯字符串数组**的 JSON。`{"url": "..."}` 这类对象形式会被拒绝:`parseBackendOrigins` 抛异常,而配置是**每个请求**重新解析的,所以表现是**每个请求都 503**,而不是启动即失败。body 为 `misconfigured backends`;空列表同样走这个异常路径,在转发任何请求之前就被拦下。
- 每一项必须是 https **origin**:不允许 http、不允许 path、不允许 query、不允许 fragment、不允许内嵌凭据(`C-HTTPS-INBOUND`)。
- `wrangler.toml` 同时固定 `name = "messageweave-lb"`、`main = "src/index.js"`、`compatibility_date = "2025-01-01"` 与 `compatibility_flags = ["nodejs_compat_v2"]`。`nodejs_compat_v2` 的作用是让测试文件能 import `node:test` 与 `node:assert/strict`;运行时代码不引入任何 node 模块。
- `wrangler.toml` 的注释必须保持单行 `#` 注释。TOML 没有块注释,用 JSDoc 的 `/** */` 块会让 wrangler 直接解析不了整个配置文件。
## 10. 本地与 CI 验证
```bash
cd cloudflare-worker
npm test # 四个文件共 38 条测试,全部离线
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`。
两个坑:
- `node --test test/`(传目录)在某些 Node 版本上会跑完**零条**测试然后退出 0。必须传文件 glob。
- 如果 CI 默认安装步骤是 `bun install`,它会忽略 npm lockfile:自行重新解析,可能把 wrangler 跨大版本移动。需要锁住 wrangler 时,在测试步骤前显式加一步 `npm ci`。
## 11. 部署
```bash
cd cloudflare-worker
npm install --no-audit --no-fund
npm run deploy
curl https://<your-worker>.workers.dev/healthz-worker
```
`npm run check` 内含 `wrangler deploy --dry-run --outdir=.build-check`,会在发布前把四个源文件打包并报告产物体积。网关由 push 触发部署,所以对 `/healthz-worker` 发一次 `curl` 并对比其 `version` 就是验收手段。改 LB 逻辑的提交要同时 bump `LB_VERSION`,这样才能判断线上是哪一次构建在应答。
两起值得记住的生产事故:
- **2026-09-30**:某次构建把 `x-lb-backend` 写进了代理响应。跨域且不带 `access-control-allow-*` 的响应 header guard 是 immutable,`headers.set` 抛异常,外层 catch 于是把一个健康的后端记成不可用并耗尽重试——网关返回 `503`,而 `GET /` 实际返回的是 `200`。`/healthz-worker` 不走那段代码,这正是事故被掩盖的原因。已改为把 header 写入做成尽力而为。
- **对象形式的 origin 列表在解析期静默失败。** `parseBackendOrigins` 抛异常后每个请求都是 `503 misconfigured backends`。`weight` 之类的 per-origin 调参不要写进列表,没有任何地方读它们。
## 12. 设计不变量
- `SAF-LB-PASSTHRU` — 透传路径不改写认证、header 或 body
- `C-HTTPS-INBOUND` — origin 仅允许 https,拒绝内嵌凭据与路径,HTTP 走 308
- `C-LB-SINGLE-REG-URL` — 只注册一个回调 origin,未知路径 404
- `C-LB-SHARED-SECRETS` — 所有后端携带同一组业务凭据
- `MOD-HEALTH-AGG` — 由网关持有的带 TTL 健康聚合探测
- `C-NO-DB`、`C-REDIS-ONLY-STATE` — 网关不访问数据库与 Redis
- `C-NO-LONG-CONN` — 仅请求-响应
- `ARCH-READY-BASELINE` — 端到端就绪判定留在后端的 `/ready`
- `SAF-LOG-PURITY` — 仅 method、path、origin、失败类别
- `ARCH-LB-WORKER` — 网关是唯一边缘组件
- `NFR-HA-MULTI-INSTANCE` — 多 origin 故障转移即 HA 的实现方式
## References
- `docs/deployment.md §10` — 网关部署、配置与上线
- `docs/reference.md §4` — 网关与后端的路由矩阵
- `docs/reference.md §9.2` — 后端配置与 secret
- `docs/reference.md §9.4` — 健康与发布检查
- `docs/design.md §11.3` — HA 与多实例设计
- `docs/charter.md §3`、`docs/charter.md §5`、`docs/charter.md §8` — 安全基线、禁令、稳定 ID 注册表

View File

@ -178,7 +178,7 @@ Answer only one question per document; **Tables, lists, or values ​​may not
| `docs/retired.md` | Records and alternative links to abandoned or renamed plans | Historical decisions |
| `docs/charter.md` | This document: project constraints, technology selection, implementation stage, security invariants, stable ID registry | Project constraints |
| `web/` (no documentation, 4 files) | Manage SPA sources: static configuration pages and front-end logic, covered by `web/config.test.mjs` | Front-end behavior (authoritative facts documented in `docs/design.md` and `docs/reference.md`) |
| `cloudflare-worker/README.md` | Configuration and semantics of the gateway itself | Gateway |
| `cloudflare-worker/README.md` / `cloudflare-worker/README.zh-CN.md` | Configuration and semantics of the gateway itself | Gateway |
**Cross-document citation rules**:

View File

@ -178,7 +178,7 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查
| `docs/retired.md` | 已废弃或已改名方案的记录与替代指向 | 历史决策 |
| `docs/charter.md` | 本文件:项目约束、技术选型、实现阶段、安全不变量、稳定 ID 注册表 | 项目约束 |
| `web/`(无文档,4 个文件) | 管理 SPA 源:静态配置页与前端逻辑,由 `web/config.test.mjs` 覆盖 | 前端行为(权威事实记在 `docs/design.md` 与 `docs/reference.md`) |
| `cloudflare-worker/README.md` | 网关自身的配置与语义 | 网关 |
| `cloudflare-worker/README.md` / `cloudflare-worker/README.zh-CN.md` | 网关自身的配置与语义 | 网关 |
**跨文档引用规则**:

View File

@ -49,7 +49,8 @@ DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
'docs/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md',
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md',
'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md',
'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md']
'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md',
'cloudflare-worker/README.zh-CN.md']
FILE = r'((?:[A-Za-z0-9_.-]+/)?[A-Za-z0-9_-]+)'
EXT = r'\.(rs|js):(\d+)'

View File

@ -45,7 +45,8 @@ DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
'docs/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md',
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md',
'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md',
'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md']
'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md',
'cloudflare-worker/README.zh-CN.md']
# `Ident` or `Module::Ident` inside backticks, optional () after.
IDENT = r'[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)?'

View File

@ -17,7 +17,8 @@ DOCS = ["AGENTS.md", "README.md", "README.zh-CN.md", "docs/design.md",
"docs/design.zh-CN.md", "docs/deployment.md", "docs/deployment.zh-CN.md",
"docs/reference.md", "docs/reference.zh-CN.md", "docs/retired.md",
"docs/retired.zh-CN.md", "docs/opengaps.md", "docs/opengaps.zh-CN.md",
"docs/charter.md", "docs/charter.zh-CN.md", "cloudflare-worker/README.md"]
"docs/charter.md", "docs/charter.zh-CN.md", "cloudflare-worker/README.md",
"cloudflare-worker/README.zh-CN.md"]
HEADING = re.compile(r"^(#{2,4})\s+(\d+(?:\.\d+)?\.?)\s")
# §2.3, §5.7, §3 (bare), also `§2.3/§3.4` chains handled by finditer
@ -42,12 +43,23 @@ def headings(path):
def main():
names = {os.path.basename(p): p for p in DOCS}
heads = {os.path.basename(p): headings(os.path.join(ROOT, p)) for p in DOCS}
# `README.md` exists both at the repo root and under `cloudflare-worker/`, so a
# basename-keyed map silently drops one of them. Keep every file's own heading
# set and let the first occurrence win on a basename collision.
own_heads = {p: headings(os.path.join(ROOT, p)) for p in DOCS}
heads = {}
for p in DOCS:
heads.setdefault(os.path.basename(p), own_heads[p])
errors = []
checked = 0
for rel in DOCS:
base = os.path.basename(rel)
# A basename held by more than one file (`README.md` lives both at the
# repo root and under `cloudflare-worker/`) must resolve against the
# document being scanned when it names that document itself.
heading_set = (lambda t: own_heads[rel] if t == base
else heads.get(t, set()))
full = os.path.join(ROOT, rel)
with open(full, encoding="utf-8") as fh:
for lineno, line in enumerate(fh, 1):
@ -69,7 +81,7 @@ def main():
target = m.group(1)
override_pos = m.end()
checked += 1
if m.group(2) not in heads.get(target, set()):
if m.group(2) not in heading_set(target):
errors.append(
f"{rel}:{lineno}: §{m.group(2)} does not exist "
f"in {target}")
@ -93,7 +105,7 @@ def main():
re.search(r"([A-Za-z0-9_.-]+\.md)[`\s]*$",
prefix).group(1))
checked += 1
if num not in heads.get(target, set()):
if num not in heading_set(target):
errors.append(
f"{rel}:{lineno}: §{num} does not exist in {target}")

View File

@ -6,7 +6,8 @@ DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
'docs/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md',
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md',
'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md',
'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md']
'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md',
'cloudflare-worker/README.zh-CN.md']
CODE = re.compile(r'`[^`\n]*`')
SEP = re.compile(r'^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$')

View File

@ -5,7 +5,8 @@ DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
'docs/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md',
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md',
'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md',
'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md']
'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md',
'cloudflare-worker/README.zh-CN.md']
errors = []
def slug(t):