BACKEND_ORIGINS_JSON now carries eu-1 and eu-2. Both are public https
origins, so the existing [vars] plaintext exception still applies and
nothing moves to secrets.
LB_VERSION stays at 2026.10.4: it is a code-version marker and the LB
logic is unchanged. The origin list is observable anyway through the
backends array in GET /healthz-worker, which now reports total 2.
Also drop a stale clause that said the origin list travels through
secret; it has lived in [vars] since
|
||
|---|---|---|
| .. | ||
| src | ||
| test | ||
| .gitignore | ||
| README.md | ||
| README.zh-CN.md | ||
| package-lock.json | ||
| package.json | ||
| tsconfig.json | ||
| wrangler.toml | ||
README.md
MessageWeave Cloudflare Worker gateway
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 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.
Design basis:
docs/design.md §11.3(NFR-HA-MULTI-INSTANCE) anddocs/deployment.md §10. 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. - 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 gateway 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 gateway 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 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).
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 gateway 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 gateway 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 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
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 — gateway aggregate
Answered by the gateway 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 gateway 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:
- 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 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 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.
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 gateway 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 gatewayC-NO-DB,C-REDIS-ONLY-STATE— no database or Redis access from the gatewayC-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 gateway is the only edge componentNFR-HA-MULTI-INSTANCE— multi-origin failover is how HA is realised
References
docs/deployment.md §10— gateway deployment, configuration and rolloutdocs/reference.md §4— gateway 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