MessageWeave/cloudflare-worker
anonsaber 1c1b1e644e worker: add the second origin messageweave-eu-2 to the LB origin list
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 aa78c13 and the same file says so
twice below it.

eu-2 needs its DNS record and a live backend before this deploy: with
LB_MAX_ATTEMPTS=2 both origins are covered, so a not-yet-deployed eu-2
costs a failed round-trip on about half the requests, and /reconcile
plus /worker run with maxAttempts=1 so those cron runs fail outright
on the half that land there first.
2026-10-01 17:18:35 +08:00
..
src feat(worker): redirect HTTP to HTTPS 2026-10-01 02:52:01 +08:00
test feat(worker): redirect HTTP to HTTPS 2026-10-01 02:52:01 +08:00
.gitignore feat: initial MessageWeave release 2026-09-25 09:14:04 +08:00
README.md docs: add the worker bilingual README and name both deployment forms 2026-10-01 13:40:39 +08:00
README.zh-CN.md docs: add the worker bilingual README and name both deployment forms 2026-10-01 13:40:39 +08:00
package-lock.json worker: wrangler 升到 4.144.0,消掉 CI 里的 out-of-date 警告 2026-09-30 18:08:48 +08:00
package.json worker: wrangler 升到 4.144.0,消掉 CI 里的 out-of-date 警告 2026-09-30 18:08:48 +08:00
tsconfig.json feat: initial MessageWeave release 2026-09-25 09:14:04 +08:00
wrangler.toml worker: add the second origin messageweave-eu-2 to the LB origin list 2026-10-01 17:18:35 +08:00

README.md

MessageWeave Cloudflare Worker gateway

中文版本 / Chinese version → README.zh-CN.md

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) 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

  • 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

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 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).

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, 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.

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 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

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 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

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-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