- Move operational content (env var reference, multi-instance prerequisites, troubleshooting, cron-drain, CF Dashboard Git integration, BACKEND_ORIGINS_JSON format, security checklist) into README and README.zh-CN - Delete docs/deployment.md and docs/deployment.zh-CN.md - Update 5 validator DOCS lists to remove deployment entries - Update charter §7 doc boundary table, AGENTS §2.5 line-anchor exception - Fix cross-doc references in design/reference/opengaps/retired/worker READMEs - Fix cron-drain.sh header comment reference (deployment.md §6.3 → README §3.10) - GATE-DOCS 6/6 PASS (55 anchors / 441 sec-refs / 699 path-refs, 0 errors) |
||
|---|---|---|
| .. | ||
| src | ||
| test | ||
| .gitignore | ||
| README.md | ||
| README.zh-CN.md | ||
| package-lock.json | ||
| package.json | ||
| tsconfig.json | ||
| wrangler.toml | ||
README.md
MessageWeave Cloudflare Worker load balancer
This is the canonical English version. The Chinese version is README.zh-CN.md.
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 load balancer for the fixed set of routes MessageWeave needs behind one URL: the route set is fixed (19 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 capability.Why it exists. Cloudflare's Load Balancer product is not available on the free plan, so this is the way a free-plan account gets multi-origin load sharing and failover in front of its MessageWeave backends. That is the whole reason for it, and the load balancer is optional: the backend runs identically without it.
Why it is not a security feature. There is no intent here to hide the backends and no intent to harden them. The origins stay directly reachable with or without the load balancer and no access control is added at the edge, so the route safelist is not a firewall and nothing in this document is meant to be read as protection.
Design basis:
docs/design.md §11.3(NFR-HA-MULTI-INSTANCE) anddocs/charter.md§8. Security baseline and prohibitions:docs/charter.md §3,docs/charter.md §5; stable-ID registrydocs/charter.md §8.
1. What it is, and what it is not
- 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_SECRETand 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. - Not a security layer. It is a load balancer, not an access-control boundary. The backend origins are directly reachable with or without it, and nothing here hides or hardens them. The safelist's fail-closed answer on unknown paths is what makes the balancer well-defined for a fixed set of paths, not a protection to be relied on.
- No origin discovery. Origins come from the deploy-time origin list only.
- Zero runtime dependencies. Pure ES2022 plus the platform
fetch,Headers,Request,ResponseandURL.wrangleris a devDependency, used only bycheck,deployanddev. - 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 asarrayBufferand 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 load balancer answers
503, and Telegram / Stalwart redelivery is what makes up for it.
2. Directory layout
| 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 |
3. Pass-through semantics
- Method, headers and body go out unchanged; the backend response status, body and headers are returned as-is.
- The only header the load balancer adds is best-effort observability:
x-lb-backend, naming the origin that answered. The write sits behind atry/catch. A cross-origin response that carries noaccess-control-allow-*header comes back with an immutable header guard, andheaders.setthrows on those. An observability write that throws must never mask a healthy2xxas a503, so the header is dropped silently when it cannot be written. That exact defect was the 2026-09-30 production incident. GETandHEADnever forward a body: a bodyless upstream response is returned as it is, and noContent-Lengthis recomputed.- Backend
3xxredirects are followed by the load balancer 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).
4. Route safelist
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 |
- Unknown path:
404, bodyroute not forwarded: <path>. The load balancer can therefore never be turned into a jump host for arbitrary backend paths. - Method mismatch on a known path:
405, bodymethod not allowed for <path>, plus anAllowheader. - Bootstrap (
POST /api/bootstrap) and the remote diagnostics face (/debug/*) are deliberately absent, so both stay origin-only. /healthz-workeris not in the safelist at all: the load balancer serves it itself, onGETonly.
5. Timeout and retry budget
| 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 load balancer 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
5xxresponse moves on to the next origin.2xx,3xxand4xxare 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 acausefurther 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 toMath.random) so load spreads over the healthy instances. A single origin degrades to a fixed order. - Every origin fails:
503with status textAll Backends Unavailableand bodyall 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 — load balancer aggregate
Answered by the load balancer itself. It never touches the forwarded route set.
- Probes each origin's
/healthzwith the same request timeout as forwarding (LB_REQUEST_TIMEOUT_MS, default 10 s). Redirects are followed, so an origin that answers with a3xxstill counts as up. upis true when the origin answered with a status below 500. A probe failure recordsup: falsewithstatus: 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" versionisLB_VERSION, defaulting to the literal stringunknown
- at least one origin up: HTTP
- Any method other than
GET:405withAllow: GET. - The load balancer does not proxy Redis or JMAP readiness. End-to-end backend readiness
(
ARCH-READY-BASELINE: config completeness, Redis reachable, JMAP session, TelegramgetMe) 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:
- Load balancer enabled: the Worker URL, for example
https://lb.example. - Load balancer 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 load balancer, 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 tonpx wrangler secret put BACKEND_ORIGINS_JSONand delete the line from[vars]. Git history is irreversible, so deleting it does not un-publish it. BACKEND_ORIGINS_JSONis a JSON array of plain strings. Object shapes such as{"url": "..."}are rejected:parseBackendOriginsthrows, and because the config is re-parsed on every request the failure shows up as503on every request rather than at boot. The body ismisconfigured 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.tomlalso pinsname = "messageweave-lb",main = "src/index.js",compatibility_date = "2025-01-01"andcompatibility_flags = ["nodejs_compat_v2"].nodejs_compat_v2is what lets the test files importnode:testandnode:assert/strict. The runtime code imports no node module.- Comments in
wrangler.tomlmust 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
cd cloudflare-worker
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 installignores the npm lockfile: it re-resolves and can move wrangler across major versions. Add an explicitnpm cibefore the test step if you need the pinned wrangler.
11. Deploy
cd cloudflare-worker
npm install --no-audit --no-fund
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 load balancer 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.
Two production incidents worth keeping in mind:
- 2026-09-30: a build wrote
x-lb-backendonto proxied responses. Cross-origin responses withoutaccess-control-allow-*headers have an immutable header guard, soheaders.setthrew and the surrounding handler logged a healthy backend as unavailable and burned its retries — the load balancer returned503whileGET /actually returned200./healthz-workerdid 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.
parseBackendOriginsthrows and you get503 misconfigured backendson every request. Keepweightand 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 pathC-HTTPS-INBOUND— https-only origins, embedded credentials and paths rejected, HTTP gets a 308C-LB-SINGLE-REG-URL— one callback origin, unknown paths 404C-LB-SHARED-SECRETS— every backend carries identical business credentialsMOD-HEALTH-AGG— TTL-cached aggregate probe owned by the load balancerC-NO-DB,C-REDIS-ONLY-STATE— no database or Redis access from the load balancerC-NO-LONG-CONN— request-response onlyARCH-READY-BASELINE— end-to-end readiness stays on the backend's/readySAF-LOG-PURITY— method, path, origin and failure class onlyARCH-LB-WORKER— the load balancer is the only edge componentNFR-HA-MULTI-INSTANCE— multi-origin failover is how HA is realised
References
docs/reference.md §9.5— load balancer deployment, configuration and rolloutdocs/reference.md §4— load balancer versus backend route matrixdocs/reference.md §9.2— backend configuration and secretsdocs/reference.md §9.4— health and release checksdocs/design.md §11.3— HA and multi-instance designdocs/charter.md §3,docs/charter.md §5,docs/charter.md §8— security baseline, prohibitions, stable-ID registry