ArchitectureHono Routing Architecture

Hono Routing Architecture

The Cloudflare Worker request router was migrated from a 589-line imperative if/else chain (worker/handlers/router.ts) to a declarative Hono application...

Hono Routing Architecture

Overview

The Cloudflare Worker request router was migrated from a 589-line imperative if/else chain (worker/handlers/router.ts) to a declarative Hono application (worker/hono-app.ts) in Phase 1. Phase 2 extracted the repeated inline middleware into reusable factories defined in worker/middleware/hono-middleware.ts.

Phase 3 introduced built-in Hono middleware for compression, logging, and caching (see Hono Built-in Middleware).

All handler function signatures remain unchanged. Only the dispatch layer (the routing glue) was migrated to Hono.


Middleware Pipeline

Better Auth placement: The Better Auth handler (app.on(['POST','GET'], '/api/auth/*', ...)) is mounted before the unified auth middleware so that session creation and sign-in flows (/api/auth/sign-in/email, /api/auth/sign-up/email, etc.) are not intercepted by the auth verifier. The one exception is /api/auth/providers, which falls through to the normal pre-auth GET path and is served without a Better Auth session check.


Context Variables

These variables are set by middleware and available to all route handlers via c.get(key):

VariableTypeSet byDescription
requestIdstringRequest metadata middlewareUnique trace ID for the request
ipstringRequest metadata middlewareCF-Connecting-IP header or 'unknown'
analyticsAnalyticsServiceRequest metadata middlewareAnalytics/telemetry service instance
isSSRbooleanSSR origin middlewaretrue when request originates from the SSR Worker (CF-Worker-Source: ssr header)
authContextIAuthContextAuth middlewareAuthenticated user context (or anonymous)

/api Prefix Handling

The frontend uses API_BASE_URL = '/api', so all API requests from the frontend arrive as /api/compile, /api/rules, etc.

Prior to Phase 4, Hono's app.route() was used to mount the routes sub-app under both / and /api. The bare-path mount was removed in Phase 4 (domain route split) to eliminate the double-execution side-effect and simplify the routing surface:

// Phase 4: /api is the canonical base path — bare-path mount removed.
app.route('/api', routes);
// app.route('/', routes);  ← removed in Phase 4

/api is the only canonical base path. Bare-path requests (/compile, /health, etc.) are no longer served.


Phase 2: Middleware Extraction (complete)

Phase 2 eliminated repeated inline boilerplate by introducing four MiddlewareHandler factories in worker/middleware/hono-middleware.ts:

FactoryConcernError code
bodySizeMiddleware()Body size validation413
rateLimitMiddleware()Per-user/IP tiered rate limiting429
turnstileMiddleware()Cloudflare Turnstile CAPTCHA400 / 403
requireAuthMiddleware()Require authenticated caller401

Execution Order for write endpoints

The recommended order preserves correct body-stream semantics:

Why zValidator runs before Turnstile on /compile: turnstileMiddleware() on other routes calls Request.clone().json() to extract the token while leaving the body intact. On the /compile route, zValidator would parse the body a second time — doubling the I/O for every compile request. By running zValidator first and reading c.req.valid('json').turnstileToken in the handler, the body is parsed exactly once. All other routes still use turnstileMiddleware() (clone-based) before any schema validation step.

Before / After example

Before (Phase 1 — inline):
routes.post('/compile', async (c) => {
    const sz = await validateRequestSize(c.req.raw, c.env);
    if (!sz.valid) return c.json({ success: false, error: sz.error || 'Request body too large' }, 413);
    const rl = await checkRateLimitTiered(c.env, c.get('ip'), c.get('authContext'));
    if (!rl.allowed) return rateLimitResponse(c, rl.limit, rl.resetAt);
    const tsErr = await checkTurnstile(c);
    if (tsErr) return tsErr;
    return handleCompileJson(c.req.raw, c.env, c.get('analytics'), c.get('requestId'));
});
After (Phase 2 — factory stack with single-parse optimisation):
routes.post(
    '/compile',
    bodySizeMiddleware(),
    rateLimitMiddleware(),
    // zValidator runs before Turnstile to avoid double body parsing
    zValidator('json', CompileRequestSchema as any, (result, c) => {
        if (!result.success) return c.json({ success: false, error: 'Invalid request body', details: result.error }, 422);
    }),
    async (c) => {
        // Turnstile reads from already-validated body — no second clone/parse
        if (c.env.TURNSTILE_SECRET_KEY) {
            const token = (c.req.valid('json') as any).turnstileToken ?? '';
            const tsResult = await verifyTurnstileToken(c.env, token, c.get('ip'));
            if (!tsResult.success) {
                c.get('analytics').trackSecurityEvent({ eventType: 'turnstile_rejection', ... });
                return c.json({ success: false, error: tsResult.error ?? 'Turnstile verification failed' }, 403);
            }
        }
        const validatedBody = c.req.valid('json');
        const syntheticReq = new Request(c.req.url, { method: 'POST', headers: c.req.raw.headers, body: JSON.stringify(validatedBody) });
        return handleCompileJson(syntheticReq, c.env, c.get('analytics'), c.get('requestId'));
    },
);

Zod Validation Integration

POST /compile uses @hono/zod-validator to validate the request body against CompileRequestSchema before the handler runs.

Module-identity note

This project uses jsr:@zod/zod (Zod v4 from JSR), while @hono/zod-validator imports npm:zod. Both modules resolve to Zod v4 with an identical runtime API, but TypeScript treats them as distinct module identities. The as any cast on the schema avoids a compile-time type mismatch that has no runtime effect:

zValidator('json', CompileRequestSchema as any, (result, c) => { ... })

Body stream consumption and Turnstile ordering

zValidator consumes the original c.req.raw body stream. On the /compile route, zValidator runs before Turnstile verification so the body is only parsed once. The Turnstile token is then read from the already-cached validated data:

async (c) => {
    // Turnstile from validated body — no second clone/parse
    if (c.env.TURNSTILE_SECRET_KEY) {
        const token = (c.req.valid('json') as any).turnstileToken ?? '';
        const tsResult = await verifyTurnstileToken(c.env, token, c.get('ip'));
        if (!tsResult.success) { ... return 403; }
    }
    // Reconstruct Request for legacy handler signature
    const validatedBody = c.req.valid('json');
    const syntheticReq = new Request(c.req.url, {
        method: 'POST',
        headers: c.req.raw.headers,
        body: JSON.stringify(validatedBody),
    });
    return handleCompileJson(syntheticReq, c.env, c.get('analytics'), c.get('requestId'));
},

tRPC endpoint

The tRPC v11 handler is mounted directly on the top-level app at /api/trpc/*:

// Mounted BEFORE app.route('/api', routes) so the routes sub-app
// (with compress + logger middleware) never wraps tRPC responses.
app.all('/api/trpc/*', (c) => handleTrpcRequest(c));

This placement ensures:

  • The global middleware chain (timing, metadata, Better Auth handler, unified auth, CORS, secure headers) does run before tRPC requests — authContext is already populated.
  • The compress() and logger() middleware scoped to the routes sub-app do not wrap tRPC responses.

See docs/architecture/trpc.md for the full tRPC procedure catalogue, client usage, and ZTA notes.


Phase 4 — Domain Route Modules (complete)

Phase 4 split the worker/hono-app.ts monolith into domain-scoped route files under worker/routes/. Each file exports a single OpenAPIHono sub-app instance that is mounted on the routes sub-app in hono-app.ts.

New file layout

worker/
  hono-app.ts                      ← app setup + middleware only; imports route modules
  routes/
    compile.routes.ts              ← /compile/*, /validate, /ast/parse, /ws/compile, /validate-rule
    rules.routes.ts                ← /rules/*
    queue.routes.ts                ← /queue/*
    configuration.routes.ts        ← /configuration/*
    admin.routes.ts                ← /admin/* (users, neon, agents, auth-config, usage, storage)
    monitoring.routes.ts           ← /health/*, /metrics/*, /container/status
    api-keys.routes.ts             ← /keys/*
    webhook.routes.ts              ← /notify
    workflow.routes.ts             ← /workflow/*
    workflow-diagram.routes.ts     ← /workflow/diagram, /workflow/diagram/:name
    browser.routes.ts              ← /browser/* (stub — routes added in a future PR)
    index.ts                       ← barrel: exports all sub-apps
    shared.ts                      ← shared types (AppContext) and helpers used by route files

Mount strategy

Each domain sub-app is mounted on the routes sub-app at the root path:

routes.route('/', compileRoutes);
routes.route('/', rulesRoutes);
routes.route('/', queueRoutes);
// ... etc

The routes sub-app itself is mounted only at /api:

app.route('/api', routes);
// app.route('/', routes);  ← bare-path double-mount removed in Phase 4

Middleware inheritance

All middleware registered on routes (logger, compress with NO_COMPRESS_PATHS exclusion, ZTA permission check) still wraps every sub-app route, because the sub-apps are mounted on routes — not directly on app. No middleware changes were needed.

CI route-order guard

scripts/lint-route-order.ts validates four invariants on every CI run:

  1. timing() is the first app.use() call
  2. The Better Auth /api/auth/* handler is registered before agentRouter
  3. app.route('/', routes) is absent (no bare-path double-mount)
  4. The compress middleware uses the NO_COMPRESS_PATHS exclusion pattern

Run manually with: deno task lint:routes


Phase 5 — Prisma Hono Context (c.get('prisma'))

Background

Prior to Phase 5 every route handler that needed a database connection called _internals.createPrismaClient(env.HYPERDRIVE.connectionString) directly. This created a new PrismaClient instance per call-site, making it impossible to share a single request-scoped client across multiple helpers within the same request.

Phase 5 introduces a global prismaMiddleware in the routes sub-app that creates one PrismaClient per request and stores it in the Hono context:

// worker/hono-app.ts — routes sub-app, before domain route modules
routes.use('*', async (c, next) => {
    if (c.env.HYPERDRIVE) {
        const prisma = createPrismaClient(c.env.HYPERDRIVE.connectionString);
        c.set('prisma', prisma);
    }
    await next();
});

Context type

prisma is included in Variables (in worker/routes/shared.ts) and the local AppVars mirror (in worker/middleware/hono-middleware.ts):

export interface Variables {
    authContext: IAuthContext;
    analytics: AnalyticsService;
    requestId: string;
    ip: string;
    isSSR: boolean;
    /** Request-scoped PrismaClient — set by prismaMiddleware() when HYPERDRIVE is bound. */
    prisma: InstanceType<typeof PrismaClient>;
}

Handler usage pattern

Route handlers that need Prisma should prefer c.get('prisma') over creating a new client:

// ✅ Preferred — uses the shared request-scoped client set by global middleware
adminRoutes.get('/admin/users', async (c) => {
    const prisma = c.get('prisma');
    if (!prisma) return c.json({ error: 'Database not configured' }, 503);
    const users = await prisma.user.findMany();
    return c.json({ success: true, users });
});

// ⚠️ Legacy — still works but creates a second PrismaClient for this request
const prisma = _internals.createPrismaClient(env.HYPERDRIVE.connectionString);

Guards

When HYPERDRIVE is not configured (local dev without the binding, unit tests, or static-asset requests), prismaMiddleware is skipped and c.get('prisma') returns undefined. Always guard:

const prisma = c.get('prisma');
if (!prisma) return c.json({ error: 'Database service unavailable' }, 503);

Test isolation

In Deno unit tests the global middleware does not run. Handlers that use the _internals pattern can still be stubbed as before:

using _ = stub(_internals, 'createPrismaClient', () => mockPrisma as never);

For integration tests that go through the full Hono app, ensure makeEnv() includes a fake HYPERDRIVE binding so prismaMiddleware runs.