Skip to content

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.ts
ts
// 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

  1. Cache static data with hono/cache middleware
  2. Use D1 prepared statements — never inline user input
  3. Stream long responses with streamSSE or stream
  4. Batch LLM calls — don't call per-NPC, batch by prompt similarity
  5. 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.

Last updated:

Released under the MIT License.