Skip to content

Conventions

Kilat’s structure is standardized on purpose. When everyone writes the same way, anyone can find anything. These rules are codified in AGENTS.md — read it before writing, moving, or restructuring code.

File = URL namespace. Every URL lives in exactly one file, with its GET render and POST actions together:

  • /login, /register, /logout, /forgot-password, /reset-passwordroutes/auth.routes.ts
  • /auth/google, /auth/google/callbackroutes/google-oauth.routes.ts
  • /, /dashboard, /adminroutes/pages.routes.ts (app shell only)
  • /profile, /profile/avatarroutes/profile.routes.ts

Given a URL, the file name follows from its first segment — that’s the discoverability contract. Paste a broken URL and you land in exactly one place.

src/server/routes/posts.routes.ts
import { Hono } from "hono";
import type { AppEnv } from "../inertia-middleware";
export const postsRoutes = () => new Hono<AppEnv>()
.get("/posts", async (c) => { /* render list */ })
.post("/posts", async (c) => { /* create */ });

Mounted in app.ts:

app.route("/", postsRoutes());

Route factories take no arguments — the Inertia adapter is a global middleware on the app, not per-route state. Handler logic stays inline in the route file; never create a routes.ts inside a feature folder.

/health stays in app.ts, not a route file. Browser/DevTools well-known probes (/.well-known/*) return a plain 404 from app.ts.

All SQL lives in src/server/db.ts as async query functions via the D1 binding. D1 is async — await env.DB.prepare(sql).bind(...).first() — which cascades to all route handlers, auth functions, and middleware. Never use sync DB patterns.

src/server/db.ts
export async function getUserByEmail(email: string): Promise<User | null> {
return await db.prepare("SELECT * FROM users WHERE email = ?")
.bind(email)
.first<User>();
}

Keep db.ts a single file — one coherent module reads better than a tree of small domain files. Reconsider splitting by domain only past ~600–800 lines.

Schema changes are plain SQL files in migrations/, applied via Wrangler:

Terminal window
npx wrangler d1 migrations apply kilat --local # dev
npx wrangler d1 migrations apply kilat --remote # prod

Rules:

  • Never edit an applied migration — add a new numbered file instead.
  • SQLite ALTER TABLE ADD COLUMN with NOT NULL requires a DEFAULT.
  • To rebuild local dev DB from scratch, delete .wrangler/state/v3/d1/ and re-apply migrations.

Environment variables are read per-request via initConfig(env) in config.ts, called from the Worker fetch handler. Never read process.env in other modules — Workers does not expose it.

Adding a config key means updating three places:

  1. config.ts — read and validate the key.
  2. wrangler.toml [vars] — set the value.
  3. The README env table — document it.

Invalid or incomplete config fails fast with a clear message.

Validation uses TypeBox schemas at the route level. app.onError maps ValidationFailed to 422 Inertia page payloads with field-level errors:

// at the route level
import { Type } from "@sinclair/typebox";
import { validate } from "../validation";
const RegisterSchema = Type.Object({
email: Type.String({ format: "email" }),
password: Type.String({ minLength: 8 }),
});
const body = validate(RegisterSchema, await c.req.json());

The email format is registered explicitly in validation.ts — plain @sinclair/typebox does not pre-register string formats. Add other formats there.

styles.css holds only global base: design tokens (:root, [data-theme]), reset, :focus-visible, and shared UI primitives used across multiple pages (.btn, .badge, .panel, .table, .avatar).

Everything else lives in a sibling .css file imported by the component or page that uses it (Brand.css, Layout.css, Dashboard.css, …). Never add page-specific or component-specific rules to styles.css. esbuild bundles all imported .css files into one stylesheet via the import graph.

strict + noUncheckedIndexedAccess + verbatimModuleSyntax are on:

  • No loose any — queries are parameterized and typed.
  • Type-only imports MUST use import type.
  • Array access returns T | undefined — no forgotten null checks.
Terminal window
bun run typecheck # tsc --noEmit