One Worker, one app, one environment
apps/web/src/worker.ts is the entry point and exports three handlers —
fetch, queue and scheduled — alongside the Durable Object
and Workflow classes. HTTP requests, queue batches and cron ticks are three doors into the same
code, the same configuration and the same database client factory.
The Hono application itself lives in apps/web/src/api/index.ts, which exports the app
and nothing else. That separation is not cosmetic: it means a test can build a request, hand it
an environment and an execution context, and drive the real application through every middleware
under plain Node — no platform emulator, no mocked router, no second wiring of the app that can
drift from the one that ships.
Why one Worker instead of several. Splitting the API, the realtime hub and the background consumers into separate Workers buys independent deploys and costs you a shared type graph, three deploy pipelines and a class of "which one is stale" bug. One Worker with three exported handlers deploys atomically, shares the config schema, and is one thing to roll back.
Configuration is validated once, and routes never read the environment
loadConfig(env) parses the Cloudflare environment through a zod schema at the top of
all three handlers, memoised per isolate, so a malformed variable fails loudly at the boundary
rather than as an undefined three layers down. Under wrangler dev the
memoisation keys on the environment object itself, so editing local variables re-validates
instead of serving a stale parse.
Routes then read c.get('config') and never c.env directly.
process.env is forbidden throughout the Worker's source — the compatibility flag
would populate it, but that hides which binding a module actually depends on and is simply empty
in tests. The environment discriminator is APP_ENV, with the values
development, staging and production;
NODE_ENV is a Node concept and stays in the test scripts where it belongs.
The middleware order is an argument, not an accident
Each layer sits where it does because of what has to be true before it runs, and what must still be catchable after it.
| Layer | Why here |
|---|---|
| error handler | outermost, so even a configuration failure comes back as the JSON envelope |
| request logger | next, so everything below has a request id to log against |
| config | everything below reads the parsed config rather than raw bindings |
| security headers | applied after the handler, and returns a 101 upgrade untouched |
| body limit | before anything reads a body; upload routes mount their own larger cap |
| CORS | before CSRF, so preflight requests get answered rather than rejected |
| CSRF | cookie-only and cheap — reject before spending a database connection |
| database | the first real cost: one client per request, closed in waitUntil |
| tracing | after the database, flushed after the handler, never on the response path |
| route mounts | authentication is applied per mount, not globally |
Authentication is deliberately not a global middleware. The public surface — health, OAuth callbacks, invite acceptance — is small and can be listed, so mounting auth per group makes the unauthenticated routes an explicit, reviewable set rather than a scattering of exemptions.
The security-header layer returns a WebSocket upgrade untouched on purpose. A 101 response has immutable headers, and re-wrapping it drops the socket — a failure that looks like a flaky network rather than a bug in a header.
Routes are thin
A route reads its authenticated context through one helper, authorises with one ability check, runs a tenant-filtered query, and returns. Bodies, query strings and parameters are validated against a schema imported from the shared contracts package, so the shape the handler receives is the shape the client was promised.
Errors are thrown as typed errors — not found, forbidden, validation, conflict — and turned into responses in one place. No route hand-writes a status code and an error body, which is how the envelope stays identical across a hundred endpoints.
One error envelope, one pagination shape
Every failure, including a validation failure, comes back in the same form, and successful bodies
are bare — no data wrapper to unwrap:
{ error, statusCode, code?, details? }
Paginated endpoints answer with the items alongside a pagination block of
page, pageSize, total and totalPages. Both
shapes live in the shared package, so the UI's error toast and the CLI's exit-code mapping read
the same fields the server wrote.
Long work never happens in a request
A route enqueues a job or creates a workflow instance and answers immediately. Side effects that
may outlive the response — sending mail, flushing traces, nudging a socket, closing the database
client — go through waitUntil. Streaming endpoints do everything that can fail as
JSON before the first frame is written, because after that a failure can only be an error event
in the stream.
The catch-all that never serves HTML to an API client
The single-page application is served from the static-asset binding by a catch-all route, which is what makes client-side routing work. That catch-all explicitly returns a JSON 404 for anything under the API, auth, analytics or websocket prefixes.
Without that guard, a typo in an endpoint path returns index.html with a 200 — and
the client fails somewhere far away, parsing HTML as JSON. A missing route should look like a
missing route.