All concepts
04 — Concept

The API shell

One Worker runs one Hono app with one validated config, and every middleware sits where it does for a stated reason.

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.

The middleware order: a request runs down the stack and its response comes back up request response onError every failure, config included, gets the JSON envelope request logger a request id for everything below config loadConfig(env), validated once per isolate security headers applied after the handler; a 101 passes through untouched body limit 1 MB JSON; upload routes mount their own larger cap CORS before CSRF, so preflights are answered CSRF cookie-only, no database — cheap to reject database one client per request, closed in waitUntil tracer Langfuse batcher or a no-op, flushed after the handler route mounts auth per mount, never global
A request enters at the top and runs down; the response comes back up through the same layers. Authentication is not a layer at all — it is mounted per route group at the bottom.
LayerWhy here
error handleroutermost, so even a configuration failure comes back as the JSON envelope
request loggernext, so everything below has a request id to log against
configeverything below reads the parsed config rather than raw bindings
security headersapplied after the handler, and returns a 101 upgrade untouched
body limitbefore anything reads a body; upload routes mount their own larger cap
CORSbefore CSRF, so preflight requests get answered rather than rejected
CSRFcookie-only and cheap — reject before spending a database connection
databasethe first real cost: one client per request, closed in waitUntil
tracingafter the database, flushed after the handler, never on the response path
route mountsauthentication 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.