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:
parent
baa6d8a6a1
commit
42128433ed
46
README.md
46
README.md
|
|
@ -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
|
- **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.
|
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.
|
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
|
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
|
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.
|
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
|
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
|
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
|
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).**
|
**Remote debug (opt-in, off by default).**
|
||||||
`/debug/*` is an optional remote-debug surface: live JMAP and Telegram probes, the current
|
`/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
|
an empty allowlist the test send is not restricted to any chat. See
|
||||||
`docs/deployment.md` §2.1.
|
`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
|
> All state lives in Redis; the process reads only two environment variables and writes
|
||||||
> nothing to disk.
|
> 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
|
Redis ACL password, if you have one, authenticates the Redis connection alone and is not
|
||||||
the credential for any HTTP endpoint.
|
the credential for any HTTP endpoint.
|
||||||
|
|
||||||
## 5. Where to read more
|
## 6. Where to read more
|
||||||
|
|
||||||
| Document | What it answers |
|
| 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/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/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 |
|
| [`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 |
|
| [`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.
|
Facts that matter are traceable. If two documents disagree, `docs/reference.md` wins.
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,34 @@ MessageWeave 位于 JMAP 邮件服务器(例如 Stalwart)与 Telegram bot
|
||||||
- **它不是什么。** 它不是邮件客户端,不是代理,也不是转发中继。它只把一个 bot 的元数据
|
- **它不是什么。** 它不是邮件客户端,不是代理,也不是转发中继。它只把一个 bot 的元数据
|
||||||
送达 Telegram;不抓取任意 URL、不开隧道、不中转流量。
|
送达 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。
|
需要可用的 Redis 服务、一个 JMAP session 与一个 Telegram bot token。
|
||||||
|
|
||||||
|
这段是**本地容器冒烟测试**。生产部署可以用任何能跑起后端容器、注入加密 secret 并连到外部托管 Redis 的平台,见 `docs/deployment.md` §3 与 `docs/reference.md` 的平台相关部分。
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cp .env.example .env # 填写 REDIS_URL 与 CONFIG_ENCRYPTION_KEY
|
cp .env.example .env # 填写 REDIS_URL 与 CONFIG_ENCRYPTION_KEY
|
||||||
docker build -t messageweave:latest .
|
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` 二进制。
|
不用 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 与
|
`GET /healthz` 无条件返回 200——它是存活探测,不是就绪探测。`GET /ready` 在配置、Redis 与
|
||||||
两条上游(JMAP session `GET`、Telegram `getMe`,各 3s)可达之前返回 503。判断是否可路由
|
两条上游(JMAP session `GET`、Telegram `getMe`,各 3s)可达之前返回 503。判断是否可路由
|
||||||
流量时,请探测 `/ready` 而不是 `/healthz`;但 `/ready` 是较重的探针(最坏约 3s),需要
|
流量时,请探测 `/ready` 而不是 `/healthz`;但 `/ready` 是较重的探针(最坏约 3s),需要
|
||||||
保持轻量的监控请看网关聚合的 `/healthz`。
|
保持轻量的监控在启用网关时请看网关聚合 `GET /healthz-worker`。
|
||||||
|
|
||||||
**远程联调面(可选,默认关闭)。**
|
**远程联调面(可选,默认关闭)。**
|
||||||
`/debug/*` 是一套可选的远程联调面:JMAP 与 Telegram 实时探针、当前业务配置,以及单条
|
`/debug/*` 是一套可选的远程联调面:JMAP 与 Telegram 实时探针、当前业务配置,以及单条
|
||||||
|
|
@ -77,13 +101,13 @@ Telegram 发送。它由双因子开关控制——进程必须带 `--debug` **
|
||||||
设 `DEBUG_TOKEN`。若确实要为一次排障临时开启,请先配好 chat 白名单——白名单为空时,测试
|
设 `DEBUG_TOKEN`。若确实要为一次排障临时开启,请先配好 chat 白名单——白名单为空时,测试
|
||||||
发送不会受限到任何 chat。见 `docs/deployment.md` §2.1。
|
发送不会受限到任何 chat。见 `docs/deployment.md` §2.1。
|
||||||
|
|
||||||
## 4. 安全边界,一句话
|
## 5. 安全边界,一句话
|
||||||
|
|
||||||
> 所有状态都在 Redis 里;进程只读两个环境变量,不写任何磁盘。
|
> 所有状态都在 Redis 里;进程只读两个环境变量,不写任何磁盘。
|
||||||
|
|
||||||
有一条推论与部署直接相关:SPA 管理凭据——也就是 bootstrap 的信任根——就是 `CONFIG_ENCRYPTION_KEY` 本身,即启动时提供的那个 32 字节 hex 值。它只做常数时间比较,绝不回显、记录或落盘。Redis ACL 密码(如有)只认证 Redis 连接本身,不是任何 HTTP 端点的认证凭据。
|
有一条推论与部署直接相关: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/opengaps.zh-CN.md`](docs/opengaps.zh-CN.md) | 缺口、阻塞项与下一阶段目标 |
|
||||||
| [`docs/retired.zh-CN.md`](docs/retired.zh-CN.md) | 试过但没用的:废弃路线、未落地的设计与从未存在的名字 |
|
| [`docs/retired.zh-CN.md`](docs/retired.zh-CN.md) | 试过但没用的:废弃路线、未落地的设计与从未存在的名字 |
|
||||||
| [`docs/charter.zh-CN.md`](docs/charter.zh-CN.md) | 项目章程:项目目标、锁定的技术选型、安全不变量、禁止事项与稳定 ID 注册表 |
|
| [`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) | 语言无关的工程规范:代码风格、配置与密钥、构建环境、测试门禁、文档治理 |
|
| [`AGENTS.md`](AGENTS.md) | 语言无关的工程规范:代码风格、配置与密钥、构建环境、测试门禁、文档治理 |
|
||||||
|
|
||||||
重要的事实都可追溯。两份文档不一致时,以 `docs/reference.md` 为准。
|
重要的事实都可追溯。两份文档不一致时,以 `docs/reference.md` 为准。
|
||||||
|
|
|
||||||
|
|
@ -1,120 +1,295 @@
|
||||||
# MessageWeave Cloudflare Worker(ARCH-LB-WORKER)
|
# MessageWeave Cloudflare Worker gateway
|
||||||
|
|
||||||
统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。
|
> [中文版本 / Chinese version → README.zh-CN.md](README.zh-CN.md)
|
||||||
**透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。
|
|
||||||
|
|
||||||
> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 18 条白名单、
|
This is the canonical English version. The Chinese version is
|
||||||
> 后端 origin 在部署期固定且仅允许 https、未知路径一律 404。它只对固定后端做请求转发与
|
[README.zh-CN.md](README.zh-CN.md).
|
||||||
> 故障转移,不接受任意目标主机,也不提供任何形式的流量中转或访问隐藏能力。
|
|
||||||
|
|
||||||
> 设计依据:`docs/design.md §10 / NFR-HA-MULTI-INSTANCE`、`docs/deployment.md §10`。
|
One public HTTPS entry point in front of one or more MessageWeave backends, with bounded
|
||||||
> 安全基线与禁止事项:`docs/charter.md §3/§5`(稳定 ID 注册表 §8)。
|
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`)
|
> Design basis: `docs/design.md §11.3` (`NFR-HA-MULTI-INSTANCE`) and `docs/deployment.md §10`.
|
||||||
- `src/index.js` — Worker 入口(路由分发、/healthz-worker 聚合、safelist 校验)
|
> Security baseline and prohibitions: `docs/charter.md §3`, `docs/charter.md §5`; stable-ID
|
||||||
- `src/backends.js` — origin 解析 + https/凭据/query 校验(C-HTTPS-INBOUND)
|
> registry `docs/charter.md §8`.
|
||||||
- `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)
|
|
||||||
|
|
||||||
## 零依赖设计
|
## 1. What it is, and what it is not
|
||||||
|
|
||||||
运行时仅依赖 ES2022 标准 + 平台/Node 的 `fetch`/`Headers`/`Request`/`Response`/`URL`。
|
- **Forward only.** Method, headers (including auth headers) and body are forwarded as-is. The
|
||||||
`npm run test` 直接 `node --test test/`,**无需安装**任何生产依赖;
|
Worker performs no authentication of its own and rewrites nothing.
|
||||||
`wrangler` 仅在 `check`/`deploy`/`dev` 中作 devDependency(CI 可选装)。
|
- **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)
|
| file | role |
|
||||||
- 原样转发 method / headers(含鉴权头)/ body;不鉴权改写。
|
|---|---|
|
||||||
- 所有后端实例共享同一组 secret(`C-LB-SHARED-SECRETS`):同一 `TELEGRAM_WEBHOOK_SECRET`、
|
| `wrangler.toml` | `name`, `main`, compatibility flags, `[vars]` |
|
||||||
`JMAP_PUSH_VERIFICATION_CODE`、`API_TOKEN`(`API_TOKEN` 在 `docs/deployment.md` §10 仅用于多实例互斥,非鉴权)
|
| `package.json` | `check` / `test` / `deploy` / `dev` scripts only, no runtime dependencies |
|
||||||
+ 相同 App Password(各后端实例的 `JMAP` 可不同,`docs/deployment.md` §10.2)。
|
| `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)
|
## 3. Pass-through semantics
|
||||||
- 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**。
|
|
||||||
|
|
||||||
### 有界故障转移(`docs/deployment.md` §10.4)
|
- Method, headers and body go out unchanged; the backend response status, body and headers are
|
||||||
- 每次请求只试 `min(LB_MAX_ATTEMPTS, origins.length)` 次;默认 2(首次 + 1 次换实例)。
|
returned as-is.
|
||||||
- **仅** 超时(AbortError)或 5xx 触发换下一个实例;4xx / 2xx / 3xx 直接返回。
|
- The only header the gateway adds is best-effort observability: `x-lb-backend`, naming the
|
||||||
- 起点按 `rng` 随机化(默认 `Math.random`),实现双活分摊;单 origin 配置时退化为确定性。
|
origin that answered. The write sits behind a `try/catch`. A cross-origin response that
|
||||||
- 全失败 → **503 All Backends Unavailable**(交由 Telegram / Stalwart 自动重投兜底,不丢消息)。
|
carries no `access-control-allow-*` header comes back with an immutable header guard, and
|
||||||
- 健康缓存(`aggregateHealth` / `LB_HEALTH_TTL_MS`)仅服务于 `/healthz-worker` 聚合视图;转发目前**不做健康路由过滤**(`docs/deployment.md` §10.4「只向健康实例转发」为后续增强,当前靠故障转移兜底)。
|
`headers.set` throws on those. An observability write that throws must never mask a healthy
|
||||||
- **例外:`POST /reconcile` 与 `POST /worker` 走 per-route 覆盖。** 两者都是后端同步长任务:
|
`2xx` as a `503`, so the header is dropped silently when it cannot be written. That exact
|
||||||
全局 10s 超时会把它们误判为失败并故障转移到第二实例,而第二实例对 `/reconcile` 必然立刻
|
defect was the 2026-09-30 production incident.
|
||||||
返回 409(集群级锁 `lock:reconcile`,初租 300s 由心跳续期),对 `/worker` 只会重复排同一批
|
- `GET` and `HEAD` never forward a body: a bodyless upstream response is returned as it is,
|
||||||
消息。所以这两条路由改为:超时分别取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000`,留足锁初租 +
|
and no `Content-Length` is recomputed.
|
||||||
心跳余量)与 `LB_WORKER_TIMEOUT_MS`(默认 `300000`,等于后端单条事件下界),且 `maxAttempts=1`
|
- 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)
|
## 4. Route safelist
|
||||||
- `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`(透传)承担。
|
|
||||||
|
|
||||||
### 无长连接 / 无密钥日志(§3/§5 红线)
|
Every inbound **HTTP** request gets a permanent **308** redirect to the same HTTPS URL before
|
||||||
- 不使用 WebSocket/SSE/长轮询(`C-NO-LONG-CONN`):纯请求-响应转发,body 一次性 `arrayBuffer` 回灌。
|
route validation or forwarding (`C-HTTPS-INBOUND`). After that the request must hit one of 19
|
||||||
- 日志只打「方法 / 路径 / origin / 失败类别」;**绝不**打印 header / body / 鉴权 secret / App Password(`SAF-LOG-PURITY`)。
|
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 |
|
||||||
|
|
||||||
| 变量 | 必填 | 说明 |
|
- Unknown path: `404`, body `route not forwarded: <path>`. The gateway can therefore never be
|
||||||
|---|---|---|
|
turned into a jump host for arbitrary backend paths.
|
||||||
| `BACKEND_ORIGINS_JSON` | 是 | JSON 数组,形如 `["https://a.platform1.example","https://b.platform2.example"]`;必须全部 https、无内嵌凭据/query/fragment/路径。已写在 `wrangler.toml` 的 `[vars]`。 |
|
- Method mismatch on a known path: `405`, body `method not allowed for <path>`, plus an
|
||||||
| `LB_REQUEST_TIMEOUT_MS` | 否 | 单 origin 请求超时;默认 `10000`。建议 > 最坏 cold start + 最长 JMAP 拉取。 |
|
`Allow` header.
|
||||||
| `LB_MAX_ATTEMPTS` | 否 | 每请求最多 origin 尝试次数;默认 `2`(= 1 次故障转移)。`POST /reconcile` 与 `POST /worker` 固定为 `1`。 |
|
- Bootstrap (`POST /api/bootstrap`) and the remote diagnostics face (`/debug/*`) are
|
||||||
| `LB_RECONCILE_TIMEOUT_MS` | 否 | 仅 `POST /reconcile` 的单 origin 超时覆盖;默认 `320000`(须大于后端锁初租 300s + 心跳余量)。 |
|
deliberately absent, so both stay origin-only.
|
||||||
| `LB_WORKER_TIMEOUT_MS` | 否 | 仅 `POST /worker` 的单 origin 超时覆盖;默认 `300000`(= 后端单条事件下界 `SINGLE_EVENT_CEILING_FLOOR_MS`)。一次排空 batch 条(默认 10,硬上限 10),batch 拉大时需相应上调。 |
|
- `/healthz-worker` is not in the safelist at all: the gateway serves it itself, on `GET`
|
||||||
| `LB_HEALTH_TTL_MS` | 否 | 健康探测缓存 TTL;默认 `30000`。 |
|
only.
|
||||||
| `LB_VERSION` | 否 | `/healthz-worker` 响应体里的 `version` 字段,用于确认线上是哪次部署在回答;取自 `wrangler.toml` 的 `[vars]`(当前 `2026.10.4`),改 LB 逻辑的提交同时 bump |
|
|
||||||
|
|
||||||
> 默认写在 `wrangler.toml` 的 `[vars]`(**明文例外**,因为当前值只是公开可达的 https origin,
|
## 5. Timeout and retry budget
|
||||||
> 不是凭据)。一旦要放内网地址、带内嵌凭据的 URL、或不想公开的 staging 主机名,
|
|
||||||
> 立刻改用 `npx wrangler secret put BACKEND_ORIGINS_JSON` 并把该行从 `[vars]` 删掉。
|
|
||||||
|
|
||||||
## 本地 / 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
|
```bash
|
||||||
cd cloudflare-worker
|
cd cloudflare-worker
|
||||||
|
|
||||||
# 单元/集成测试(零依赖):
|
npm test # 38 tests, four files, fully offline
|
||||||
npm run test
|
npm run check # node --check on sources and tests, then wrangler deploy --dry-run
|
||||||
# 等价: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` 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
|
```bash
|
||||||
cd cloudflare-worker
|
cd cloudflare-worker
|
||||||
# 安装 dev dep(仅 wrangler):
|
|
||||||
npm install --no-audit --no-fund
|
npm install --no-audit --no-fund
|
||||||
# BACKEND_ORIGINS_JSON 已写在 wrangler.toml 的 [vars],直接部署:
|
npm run deploy
|
||||||
npx wrangler deploy
|
curl https://<your-worker>.workers.dev/healthz-worker
|
||||||
# 可选调参(默认值已够用;要覆盖就用 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 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
|
Two production incidents worth keeping in mind:
|
||||||
curl https://lb.messageweave.example/healthz
|
|
||||||
# => {"status":"ok","available":2,"total":2,"backends":[{"origin":"https://a.example","up":true,"status":200},...]}
|
- **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
|
||||||
|
|
|
||||||
|
|
@ -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 注册表
|
||||||
|
|
@ -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/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 |
|
| `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`) |
|
| `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**:
|
**Cross-document citation rules**:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查
|
||||||
| `docs/retired.md` | 已废弃或已改名方案的记录与替代指向 | 历史决策 |
|
| `docs/retired.md` | 已废弃或已改名方案的记录与替代指向 | 历史决策 |
|
||||||
| `docs/charter.md` | 本文件:项目约束、技术选型、实现阶段、安全不变量、稳定 ID 注册表 | 项目约束 |
|
| `docs/charter.md` | 本文件:项目约束、技术选型、实现阶段、安全不变量、稳定 ID 注册表 | 项目约束 |
|
||||||
| `web/`(无文档,4 个文件) | 管理 SPA 源:静态配置页与前端逻辑,由 `web/config.test.mjs` 覆盖 | 前端行为(权威事实记在 `docs/design.md` 与 `docs/reference.md`) |
|
| `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` | 网关自身的配置与语义 | 网关 |
|
||||||
|
|
||||||
**跨文档引用规则**:
|
**跨文档引用规则**:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md',
|
||||||
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.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/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_-]+)'
|
FILE = r'((?:[A-Za-z0-9_.-]+/)?[A-Za-z0-9_-]+)'
|
||||||
EXT = r'\.(rs|js):(\d+)'
|
EXT = r'\.(rs|js):(\d+)'
|
||||||
|
|
|
||||||
|
|
@ -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/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md',
|
||||||
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.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/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` or `Module::Ident` inside backticks, optional () after.
|
||||||
IDENT = r'[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)?'
|
IDENT = r'[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)?'
|
||||||
|
|
|
||||||
|
|
@ -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/design.zh-CN.md", "docs/deployment.md", "docs/deployment.zh-CN.md",
|
||||||
"docs/reference.md", "docs/reference.zh-CN.md", "docs/retired.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/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")
|
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
|
# §2.3, §5.7, §3 (bare), also `§2.3/§3.4` chains handled by finditer
|
||||||
|
|
@ -42,12 +43,23 @@ def headings(path):
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
names = {os.path.basename(p): p for p in DOCS}
|
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 = []
|
errors = []
|
||||||
checked = 0
|
checked = 0
|
||||||
|
|
||||||
for rel in DOCS:
|
for rel in DOCS:
|
||||||
base = os.path.basename(rel)
|
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)
|
full = os.path.join(ROOT, rel)
|
||||||
with open(full, encoding="utf-8") as fh:
|
with open(full, encoding="utf-8") as fh:
|
||||||
for lineno, line in enumerate(fh, 1):
|
for lineno, line in enumerate(fh, 1):
|
||||||
|
|
@ -69,7 +81,7 @@ def main():
|
||||||
target = m.group(1)
|
target = m.group(1)
|
||||||
override_pos = m.end()
|
override_pos = m.end()
|
||||||
checked += 1
|
checked += 1
|
||||||
if m.group(2) not in heads.get(target, set()):
|
if m.group(2) not in heading_set(target):
|
||||||
errors.append(
|
errors.append(
|
||||||
f"{rel}:{lineno}: §{m.group(2)} does not exist "
|
f"{rel}:{lineno}: §{m.group(2)} does not exist "
|
||||||
f"in {target}")
|
f"in {target}")
|
||||||
|
|
@ -93,7 +105,7 @@ def main():
|
||||||
re.search(r"([A-Za-z0-9_.-]+\.md)[`\s]*$",
|
re.search(r"([A-Za-z0-9_.-]+\.md)[`\s]*$",
|
||||||
prefix).group(1))
|
prefix).group(1))
|
||||||
checked += 1
|
checked += 1
|
||||||
if num not in heads.get(target, set()):
|
if num not in heading_set(target):
|
||||||
errors.append(
|
errors.append(
|
||||||
f"{rel}:{lineno}: §{num} does not exist in {target}")
|
f"{rel}:{lineno}: §{num} does not exist in {target}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md',
|
||||||
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.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/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]*`')
|
CODE = re.compile(r'`[^`\n]*`')
|
||||||
SEP = re.compile(r'^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$')
|
SEP = re.compile(r'^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$')
|
||||||
|
|
|
||||||
|
|
@ -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/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md',
|
||||||
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.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/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 = []
|
errors = []
|
||||||
|
|
||||||
def slug(t):
|
def slug(t):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue