Hono
The HTTP router for the entire backend. Tiny, fast, TypeScript-first.
Why Hono?
We chose Hono for the backend because:
- ⚡ Tiny — <14KB runtime, fastest cold start in the Workers ecosystem
- 🟦 TypeScript-first — full type inference from route definitions
- 🌐 Multi-runtime — works on Workers, Bun, Node, Deno
- 🧩 Middleware ecosystem — auth, logging, CORS all available
- 📐 Familiar — Express-like API, easy onboarding
Other options considered: Itty Router (too minimal), tRPC (too RPC-heavy for this use case).
Basic Setup
ts
import { Hono } from 'hono';
const app = new Hono<{ Bindings: Env }>();
app.get('/', (c) => c.text('AI World Sim API'));
export default app;Route Organization
We split routes into modules:
src/
├── router.ts # mounts all sub-routers
└── routes/
├── world.ts
├── npc.ts
├── diary.ts
├── suggestions.ts
├── sse.ts
└── admin.tsts
// router.ts
import { Hono } from 'hono';
import { worldRoutes } from './routes/world';
import { npcRoutes } from './routes/npc';
const app = new Hono<{ Bindings: Env }>();
app.route('/world', worldRoutes);
app.route('/npc', npcRoutes);
app.get('/', (c) => c.json({ name: 'AI World Sim API', version: '0.1.0' }));
export default app;Middleware Stack
ts
import { logger } from 'hono/logger';
import { cors } from 'hono/cors';
import { secureHeaders } from 'hono/secure-headers';
app.use('*', logger());
app.use('*', secureHeaders());
app.use('*', cors({
origin: ['https://aiworldsim.pages.dev', 'http://localhost:3000'],
credentials: true,
}));SSE Support
Hono has native SSE support via streamSSE:
ts
import { streamSSE } from 'hono/streaming';
app.get('/api/sse/connect', async (c) => {
return streamSSE(c, async (stream) => {
const subscriber = (event: TickEvent) => {
stream.writeSSE({
event: event.type,
data: JSON.stringify(event),
});
};
tickBroadcaster.on('tick', subscriber);
stream.onAbort(() => {
tickBroadcaster.off('tick', subscriber);
});
while (!stream.aborted) {
await stream.sleep(1000);
}
});
});Type-safe Bindings
Cloudflare bindings are typed via Env:
ts
type Env = {
DB: D1Database;
AI: Ai;
VECTORIZE: VectorizeIndex;
TICK_DO: DurableObjectNamespace;
ENVIRONMENT: 'development' | 'production';
};
const app = new Hono<{ Bindings: Env }>();
app.get('/api/npc/:id', async (c) => {
const id = c.req.param('id');
const npc = await c.env.DB.prepare(
'SELECT * FROM npc WHERE id = ?'
).bind(id).first();
return c.json(npc);
});Error Handling
Consistent error envelope:
ts
app.onError((err, c) => {
console.error(err);
return c.json({
error: {
code: err.name || 'INTERNAL_ERROR',
message: err.message || 'Something went wrong',
},
}, 500);
});
app.notFound((c) => {
return c.json({
error: { code: 'NOT_FOUND', message: `No route for ${c.req.path}` },
}, 404);
});Validation with Zod
Every request body is validated:
ts
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const suggestionSchema = z.object({
npcId: z.string().min(1),
text: z.string().min(3).max(500),
});
app.post('/api/suggestions',
zValidator('json', suggestionSchema),
async (c) => {
const body = c.req.valid('json');
// ...
}
);Authentication (v1: minimal)
v1 has no player login. Suggestions are anonymous and rate-limited per IP.
ts
import { rateLimiter } from '@/middleware/rateLimiter';
app.use('/api/suggestions/*', rateLimiter({
windowMs: 60_000,
max: 5,
}));Performance Tips
- Cache static data with
hono/cachemiddleware - Use D1 prepared statements — never inline user input
- Stream long responses with
streamSSEorstream - Batch LLM calls — don't call per-NPC, batch by prompt similarity
- Minimize middleware — every middleware adds CPU
Testing
ts
import { app } from './router';
describe('GET /world/state', () => {
it('returns world state', async () => {
const res = await app.request('/world/state', {
method: 'GET',
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveProperty('worldTime');
});
});We use @cloudflare/vitest-pool-workers to run tests in a real Workers environment.