Skip to content

Request lifecycle

Every request follows the same path. Understanding this chain is the key to debugging and extending Kilat.

Workers runtime
▼ fetch(request, env)
worker.ts
├── initConfig(env) → config singleton
├── initDb(env.DB) → db singleton
├── initSessionCache() → sessionKv singleton (no-op without binding)
▼ app.fetch(request, env)
┌─────────────────────────────────────────────────┐
│ 1. requestLogger → correlation ID + JSON log │
│ 2. checkOrigin → CSRF (Origin header) │
│ 3. secureHeaders → nosniff, DENY, HSTS │
│ 4. CSP nonce (post-resp) → script-src 'nonce-...' │
│ 5. inertiaMiddleware → resolve session → c.var │
│ 6. globalLimiter → KV rate limit (200/60s) │
└─────────────────────────────────────────────────┘
guards + handler
├── requireAuth ── fail ──→ redirect /login
├── requireRole ── fail ──→ redirect /dashboard
├── guestOnly ── fail ──→ redirect /dashboard
▼ pass
route handler
├── c.var.inertia.render(component, props)
│ ├── browser visit → SSR HTML + data-page JSON
│ ├── X-Inertia XHR → JSON page payload
│ └── version mismatch → 409 + X-Inertia-Location
├── ValidationFailed ──→ onError → 422 Inertia
├── other error ──→ onError → 500
└── no route ──→ notFound → 404 Inertia

The Workers runtime calls fetch(request, env) per request. env carries the D1 + ASSETS bindings and environment variables:

src/worker.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
initConfig(env); // validate env → module-level config singleton
initDb(env.DB); // bind D1 → module-level db singleton
initSessionCache(env.SESSION_KV); // bind KV → module-level sessionKv singleton
return app.fetch(request, env);
},
} satisfies ExportedHandler<Env>;

initConfig and initDb run per-request — cheap pointer assignments to module-level singletons. Workers isolates are stateless; do not cache state across requests.

src/server/logger.ts assigns a random correlation ID to every request and logs the method + path as structured JSON. This is the first middleware — every subsequent log line can reference the ID.

src/server/security.ts checks the Origin header on state-changing requests (POST, PUT, DELETE, PATCH). If the origin doesn’t match the request host, the request is rejected. This is the CSRF defense — no token dance, just the Origin check.

Hono’s secureHeaders middleware sets X-Content-Type-Options: nosniff, X-Frame-Options: DENY, referrer policy, permissions policy, and HSTS. A per-request CSP nonce is generated in inertiaMiddleware and applied after the response — inline scripts (theme boot, page payload) and inline styles carry the nonce, so script-src 'unsafe-inline' is not needed.

src/server/inertia-middleware.ts reads the session cookie, resolves the user and flash from D1 via resolveSession (or KV cache when enabled), and stores the Inertia adapter + user on c.var (the AppEnv context). Every downstream handler reads c.var.inertia and c.var.user — no per-route session plumbing.

Guards are Hono middleware that short-circuit the chain by returning a Response, or call next() to continue:

  • requireAuth — redirect to /login if no session.
  • guestOnly — redirect to /dashboard if already logged in.
  • requireRole('admin') — redirect non-admins to /dashboard.
routes/pages.routes.ts
app.get("/dashboard", requireAuth, async (c) => {
return c.var.inertia.render("Dashboard", { user: c.var.user });
});

Guards MUST call next() to continue — returning undefined without next() errors with “Context is not finalized”.

The handler calls c.var.inertia.render(component, props). The Inertia adapter (src/server/inertia.ts) decides the response shape:

  • Browser visit (no X-Inertia header): full HTML with SSR markup + data-page JSON embedded. renderToString from react-dom/server runs inside the Worker bundle.
  • X-Inertia XHR: JSON page payload only — SPA navigation.
  • Asset-version mismatch: 409 + X-Inertia-Location — the client reloads.

app.onError handles two cases:

  • ValidationFailed (TypeBox) → 422 with field errors, mapped back to the Inertia page component that owns the URL.
  • Other errors → 500 Internal Server Error, logged with the correlation ID.

app.notFound renders the NotFound component as a 404 Inertia page.

  • All DB calls are async. D1 is async — this cascades to every handler, auth function, and middleware.
  • Response.redirect() returns immutable headers on Workers. Hono’s secureHeaders crashes trying to append to a frozen Response. All redirects use new Response(null, { status, headers: { location } }).
  • No custom compression. Wrangler/Miniflare auto-compresses with gzip/br. A custom compress middleware causes double-compression.
  • Middleware runs in registration order. Global app.use() middleware must precede the routes they cover.
  • c.header()-queued headers are dropped when a handler returns a custom Response — cookie helpers append to c.res.headers instead.