Spike: run the whole app on Cloudflare Workers #171

Open
opened 2026-09-01 07:01:55 +00:00 by jcoffey-dev · 1 comment
Owner

Draft, and not proposed as a deployment target. It answers "could this be forked for Cloudflare?" with a running Worker instead of an opinion.

The answer

The app runs on workerd today. createApp() is untouched in substance — every route, the JMAP proxy, the CSRF guard, the security headers, SSE — because it was already a Hono app speaking fetch/Request/Response. The Node server turns out to be an entry point, not an architecture.

Verified through the Worker against the mock Stalwart: sign-in, Mailbox/get over the JMAP proxy, /api/auth/sessions, revoke-others, and an SSE ping frame.

What shared code needed (~100 lines, mostly comment)

  • SessionBackend methods are Awaitable, and their 13 call sites in app.ts await. KV is over the network; a Map is not. The in-process store satisfies the wider type unchanged.
  • The backend is swappable — a Worker gets its bindings with the first request and cannot construct a store at import.
  • Three module-scope assumptions about being a process on a disk each killed startup before configuration was read: the .env loader, staticDir/immutability in config.ts, and version.mjs resolving the repo root at import. Guarded or lazy now.
  • The rate limiter's setInterval is an amortised sweep. Workers forbids timers in global scope; the sweep only reclaims keys nobody is asking about and never needed a clock.

What does not port

  • The image proxy returns 502 under workerd — confirmed, not assumed. It resolves the host and connects to the resolved address to close the DNS-rebinding gap, and Workers has no DNS API and no way to pin a connection. Cloudflare's fetch refuses private ranges itself, so a port would not be defenceless, but that is Cloudflare's guarantee rather than ours — a different claim.
  • Rate limiting is per-isolate, i.e. close to unenforced. Needs the rate-limiting binding or a Durable Object before anyone leans on it.
  • KV is eventually consistent between colos, so a revocation — including the one a password change triggers — has a window where an old cookie still resolves elsewhere. A Durable Object is the fix; KVSessionStore is the shape that swap would take.

Who the loopback objection applies to

This PR first argued that a Worker cannot reach a Stalwart on loopback, and treated that as the case against. That is true for the deployment the README describes — one container beside the mail server — and it is not true here: this deployment already runs the app and Stalwart in two datacentres, with STALWART_URL a public HTTPS name. There is no loopback to lose and no hop to add. Today the path is user → app datacentre → mail datacentre; on Workers it is user → nearest colo → mail datacentre — same second leg, shorter first one for anybody not sitting beside the app host.

So for a split deployment the architectural objection is not an objection, and what is left of the case against is the three items above.

If nothing else here lands

The ratelimit.ts sweep and the three startup guards stand on their own merits and could go in separately. server/cloudflare/ is a package of its own — its own wrangler and node_modules, nothing added to the server's manifest, nothing else in the repo depends on it, nothing builds it.

Rebuilt from: session transcript.

**Draft, and not proposed as a deployment target.** It answers "could this be forked for Cloudflare?" with a running Worker instead of an opinion. ## The answer The app runs on workerd today. `createApp()` is untouched in substance — every route, the JMAP proxy, the CSRF guard, the security headers, SSE — because it was already a Hono app speaking `fetch`/`Request`/`Response`. The Node server turns out to be an entry point, not an architecture. Verified through the Worker against the mock Stalwart: sign-in, `Mailbox/get` over the JMAP proxy, `/api/auth/sessions`, `revoke-others`, and an SSE `ping` frame. ## What shared code needed (~100 lines, mostly comment) - **`SessionBackend` methods are `Awaitable`**, and their 13 call sites in `app.ts` await. KV is over the network; a `Map` is not. The in-process store satisfies the wider type unchanged. - **The backend is swappable** — a Worker gets its bindings with the first request and cannot construct a store at import. - **Three module-scope assumptions about being a process on a disk** each killed startup before configuration was read: the `.env` loader, `staticDir`/immutability in `config.ts`, and `version.mjs` resolving the repo root at import. Guarded or lazy now. - **The rate limiter's `setInterval` is an amortised sweep.** Workers forbids timers in global scope; the sweep only reclaims keys nobody is asking about and never needed a clock. ## What does not port - **The image proxy** returns 502 under workerd — confirmed, not assumed. It resolves the host and connects to *the resolved address* to close the DNS-rebinding gap, and Workers has no DNS API and no way to pin a connection. Cloudflare's fetch refuses private ranges itself, so a port would not be defenceless, but that is Cloudflare's guarantee rather than ours — a different claim. - **Rate limiting is per-isolate**, i.e. close to unenforced. Needs the rate-limiting binding or a Durable Object before anyone leans on it. - **KV is eventually consistent between colos**, so a revocation — including the one a password change triggers — has a window where an old cookie still resolves elsewhere. A Durable Object is the fix; `KVSessionStore` is the shape that swap would take. ## Who the loopback objection applies to This PR first argued that a Worker cannot reach a Stalwart on loopback, and treated that as the case against. That is true for the deployment the README describes — one container beside the mail server — and it is not true here: this deployment already runs the app and Stalwart in **two datacentres**, with `STALWART_URL` a public HTTPS name. There is no loopback to lose and no hop to add. Today the path is user → app datacentre → mail datacentre; on Workers it is user → nearest colo → mail datacentre — same second leg, shorter first one for anybody not sitting beside the app host. So for a split deployment the architectural objection is not an objection, and what is left of the case against is the three items above. ## If nothing else here lands The `ratelimit.ts` sweep and the three startup guards stand on their own merits and could go in separately. `server/cloudflare/` is a package of its own — its own wrangler and `node_modules`, nothing added to the server's manifest, nothing else in the repo depends on it, nothing builds it. <sub>Rebuilt from: session transcript.</sub>
Author
Owner

Moved to a private exploration fork, Coffey-Labs/ihasmail-cfp, and this branch is deleted. The spike's notes travelled with it in server/cloudflare/README.md.

The two pieces that stand on their own merits — the ratelimit.ts sweep and the three module-scope startup guards in config.ts / version.mjs — did not land here and are worth their own PR against main whenever they are wanted.

Moved to a private exploration fork, `Coffey-Labs/ihasmail-cfp`, and this branch is deleted. The spike's notes travelled with it in `server/cloudflare/README.md`. The two pieces that stand on their own merits — the `ratelimit.ts` sweep and the three module-scope startup guards in `config.ts` / `version.mjs` — did not land here and are worth their own PR against main whenever they are wanted.
This repo is archived. You cannot comment on issues.