Skip to content

Architecture overview

src/
├── worker.ts # Cloudflare Workers entry: initConfig, initDb, initSessionCache, app.fetch
├── server/
│ ├── app.ts # composition: middleware order, onError, notFound, routes
│ ├── config.ts # validated env config via initConfig(env) per-request
│ ├── db.ts # D1 async query helpers (prepare/bind/first/all/run)
│ ├── auth.ts # PBKDF2 (Web Crypto), sessions, flash, cookies, guards, optional KV session cache
│ ├── inertia.ts # Inertia v3 server adapter (SSR shell, XHR, 409)
│ ├── inertia-middleware.ts # per-request session resolve → c.var (AppEnv)
│ ├── validation.ts # TypeBox JSON validation → ValidationFailed (422)
│ ├── mailer.ts # mail drivers: log / resend / mailtrap
│ ├── rate-limit.ts # KV-backed fixed-window rate limiter (fails open without binding)
│ ├── logger.ts # structured JSON logging for Workers Observability
│ ├── security.ts # CSRF origin check (headers via hono/secure-headers)
│ ├── url.ts # defensive request-URL parsing
│ ├── assets.ts # re-exports InertiaAssets type
│ └── routes/
│ ├── auth.routes.ts # /login /register /logout /forgot/reset (GET+POST)
│ ├── google-oauth.routes.ts # /auth/google, /auth/google/callback
│ ├── pages.routes.ts # app-shell pages: /, /dashboard, /admin
│ ├── profile.routes.ts # /profile page + /profile/avatar
│ └── avatars.routes.ts # /avatars/* — R2 avatar serving
├── client/
│ ├── app.tsx # Inertia client bootstrap (hydrate or render)
│ ├── ssr.tsx # in-process SSR renderer (react-dom/server)
│ ├── pages.ts # explicit page registry (shared by SSR + bundle)
│ ├── pages/ # Login, Register, Dashboard, ForgotPassword,
│ │ # ResetPassword, Admin, NotFound, Profile
│ ├── components/ # Layout, AuthLayout, Brand, Field
│ └── styles.css # plain CSS, light/dark
├── shared/
│ ├── types.ts # User, Role, FlashData, SharedPageProps, Paginated
│ └── inertia.d.ts # InertiaConfig augmentation → typed props.auth
├── migrations/ # versioned SQL schema files (0001, 0002, …)
└── tests/ # bun:test E2E suite
scripts/
├── build.ts # esbuild: bundle client → dist/assets/app-[hash].js + CSS
└── seed.ts # wrangler d1 execute kilat --local + hashPassword
wrangler.toml # Workers config: D1 binding, ASSETS binding, nodejs_compat, env vars
dist/ # build output (gitignored), served by Workers Static Assets

src/server/ is flat except routes/ — no feature subfolders. Shared or transport-independent logic becomes a single flat module (auth.ts, security.ts, mailer.ts). Extract a module only when logic is reused across routes or independent of Hono’s context.

The Workers runtime calls fetch per request. env carries the D1, ASSETS, KV, and R2 bindings plus environment variables:

src/worker.ts
import { createApp } from "./server/app";
import { initConfig, type EnvVars } from "./server/config";
import { initDb } from "./server/db";
import { initSessionCache } from "./server/auth";
import manifest from "../dist/manifest.json";
import type { InertiaAssets } from "./server/inertia";
export interface Env extends EnvVars {
DB: D1Database;
ASSETS: Fetcher;
RATE_LIMIT_KV: KVNamespace;
AVATARS: R2Bucket;
SESSION_KV?: KVNamespace;
}
const assets = manifest as InertiaAssets;
const app = createApp(assets);
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 → session cache (no-op if absent)
return app.fetch(request, env);
},
} satisfies ExportedHandler<Env>;

initConfig, initDb, and initSessionCache run per-request — they mutate module-level singletons via cheap pointer assignments. Workers isolates are stateless; do not cache state across requests. initSessionCache is a no-op when SESSION_KV is absent (tests, local dev without the binding). The app is built once at module load (the createApp(assets) call); only config, DB, and session KV are re-bound each request.

Kilat uses esbuild (not Bun.build) because the app runs on Workers, not Bun. The build produces content-hashed assets and a manifest the Inertia adapter reads:

scripts/build.ts
↓ esbuild (entry: src/client/app.tsx)
dist/assets/app-[hash].js ← client bundle (React + Inertia)
dist/assets/app-[hash].css ← bundled stylesheet
dist/manifest.json ← { version, js, css } for the Inertia adapter

The asset version is the JS content hash — Inertia uses it for 409 reload negotiation. When assets change, stale clients get a 409 and reload.

Terminal window
bun run build # run before `wrangler dev` or `wrangler deploy`

Wrangler’s esbuild can’t compile .svelte or .vue at deploy time, so those templates pre-build the SSR bundle to dist/ssr.js. The Worker imports the pre-built bundle instead of compiling SSR in-process. React 19 renderToString runs inside the Worker bundle directly — no pre-build step needed.

Static assets are served via the Workers Static Assets binding (env.ASSETS), not a custom handler. run_worker_first = ["/*", "!/assets/*"] in wrangler.toml means all requests hit the Worker first except /assets/*, which bypass to the static asset binding directly.