Server framework — Hono for backend perspectives

Decision date: 2026-05-16 Decision owner: Julian, after architecture discussion Status: Adopted; migration in progress

TL;DR

Pure-REST and pure-RPC perspectives (api, mcp; on the jobcache side these have since been folded into the jobcache-interface Hono process via host-based routing) run on Hono. Human-facing perspectives that render HTML + SSR + forms (ui, status, ops, qa, and the SvelteKit UI inside jobcache-interface) stay on SvelteKit. Wiki perspectives are moving to LeafWiki (separate decision).

This document records why Hono over SvelteKit, itty-router, worktop, or a raw fetch handler — and what "MCP as a deduction of the API" means.

Why not stay on SvelteKit for everything?

SvelteKit is excellent at what it does: SPA + SSR + hydration + forms + asset pipeline + file-based routing. Every one of those features is useful in ui/, status/, ops/, qa/, jobcache-ui. None of them is useful in api/ or the MCP workers, which are pure HTTP/JSON-RPC services.

The api worker on SK was carrying ~4MB of unused runtime per isolate. That's not a quota or pricing issue — but it's wrong shape, and shape compounds:

  • Cold start ~30–50ms instead of ~5–15ms
  • Memory footprint per isolate ~30–40MB instead of ~5–10MB
  • Tied to SvelteKit's adapter ecosystem; loses portability to other web-standards runtimes
  • File-based routing fights against operation-derived MCP tools

For a backbone API that will accrete users, observability, rate limiting, JWT auth, OpenAPI, distributed tracing over a 3–10 year horizon, the right shape is a framework built for the edge runtime.

Alternatives considered

Framework Bundle Cold start Per-req cost Middleware ecosystem Verdict
SvelteKit ~4MB 30–50ms OK thin (built for full-stack apps, not APIs) Excellent for UI workers, wrong shape for pure REST
Hono ~14KB 5–10ms ~5μs (trie router) excellent (JWT, CORS, Zod, OpenAPI, observability all official) Picked
itty-router ~1.5KB <5ms ~30μs (linear regex) minimal (you write your own) Strong, defensible, lost on middleware-growth
worktop ~2KB <5ms ~10μs weak ecosystem Velocity has slowed; not picking
Raw fetch(req, env) smallest smallest unbeatable none (you write everything) Fine for tiny surfaces (mcp/'s original ~3 endpoints); becomes maintenance burden as API grows
tRPC small fast fast excellent for TS-to-TS Locks API to TS-only callers — kills MCP/curl/agent consumers
NestJS huge slow slow huge Not edge-native; assumes Node

Why bundle size is the wrong metric to optimize on alone

Bundle size affects cold start and memory footprint, not per-request CPU cost. Per-request cost is dominated by router algorithm, middleware overhead, and your own handler logic. Hono is ~10× the bundle of itty-router but ~1.8× the throughput per isolate, because its trie-based router is genuinely faster than itty-router's linear regex scan. Size and speed are loosely correlated, not identical.

For our load, both Hono and itty-router are wildly overpowered for the foreseeable future. The deciding factor isn't raw speed — it's how the framework's overhead grows as the API accretes features:

  • Hono per-request cost stays constant as routes/middleware grow (trie routing, O(log n) middleware composition)
  • itty-router per-request cost grows linearly with routes (regex scan per request)
  • Hono's ecosystem makes new middleware "install + 1 line"; itty-router is "write + maintain"

For a backbone API that's expected to grow, Hono wins on growth slope, not on starting position.

What Hono gets us, concretely

  1. Web-standards Request/Response. Same primitive shape as CF Workers, Deno, Bun, Vercel Edge, AWS Lambda@Edge, Node 20+. If we ever leave Cloudflare, the api worker ports with zero rewrite.

  2. Middleware as a first-class pattern. CORS, auth, rate limiting, logging, Zod validation, OpenAPI generation, tracing — all live as composable app.use() calls instead of glue code in each handler. We already had requireAdmin, requireAdminForPage, and checkLoginRateLimit hand-rolled in @cv/perspective-shared because SK doesn't have a real middleware story. Hono middleware replaces all of those with installable packages.

  3. Zod-first validation. We already use Zod in @cv/schemas. @hono/zod-validator reuses the same schemas to validate every endpoint's body/query/params/headers at the edge before handler logic runs. Malformed input gets caught with a typed error response, no per-handler validation code.

  4. OpenAPI auto-generation. @hono/zod-openapi walks the route table and emits an OpenAPI 3 spec. Free API documentation for wiki.careervector.corbet.ch. MCP tool definitions can be derived from the same source. AI agents discover endpoints without humans documenting them.

  5. Future-proof for observability. Axiom, Workers Analytics Engine, and a local /metrics pull endpoint all slot in as first-class Hono middleware. We don't have to write a logging shim or wonder where to inject metrics.

Why MCP becomes a "deduction of the API"

The careervector-mcp worker and the MCP surface inside jobcache-interface expose tools (list_jobs, get_workspace_summary, search_postings, etc.) that fundamentally call the same operations as the corresponding API endpoints. Under the prior architecture, the operations were duplicated:

API: /api/workspaces/[id]/jobs/+server.ts            calls workspaceOps.listJobs()
MCP: tools/list_jobs in src/tools.ts                  calls workspaceOps.listJobs()
                                                       (or worse: re-derives the query)

Under the Hono architecture, operations live in @cv/server (or a new @cv/operations) as pure typed functions. Both API and MCP are thin transport wrappers around the same operation registry:

Operation:  listJobs(env, workspaceId, filters): Promise<Job[]>

API:        app.get('/workspaces/:id/jobs', async (c) => {
              const jobs = await listJobs(c.env, c.req.param('id'), parseFilters(c));
              return c.json({ jobs });
            });

MCP:        tools.list_jobs = {
              handler: (args, ctx) => listJobs(ctx.env, args.workspaceId, args.filters),
              schema:  z.object({ workspaceId: z.string(), filters: FilterSchema })
            };

Adding a new operation:

  1. Define it in @cv/operations with a Zod input schema.
  2. Mount it on the API as a Hono route.
  3. The MCP tool registration picks it up automatically (or via a one-line tools.foo = mcpToolFor(opFoo)).

The wiki's API reference page reads the same registry to render docs.

What stays on SvelteKit

  • ui/ — full SPA, Svelte components, Yjs realtime, hydration
  • status/, ops/, qa/ (CV and JobCache) — HTML pages, login forms, session cookies, server-side renders. SK form actions are the right tool here.
  • jobcache-ui/ — SSR search page with form submission

These are pages, not APIs. SK is right for them and there's no benefit to migrating.

What moves to Hono

  • api/ — full migration in progress
  • mcp/ (careervector-mcp) — rewrite on Hono as a thin wrapper around shared operations
  • jobcache/mcp/ — same pattern

What's NOT changing

  • @cv/schemas — Zod schemas, framework-agnostic, used everywhere
  • @cv/mutations — op catalog, framework-agnostic
  • @cv/domain — business logic, framework-agnostic
  • @cv/health — health snapshot types, framework-agnostic
  • @cv/perspective-shared — admin auth + rate limit (used by SK perspective workers); stays as is since those workers stay on SK

The @cv/server lib is being refactored to take env directly instead of App.Platform. This is the unlock — once helpers take env, they work from Hono, SK, raw fetch handlers, any future runtime.

Migration plan

  1. Refactor @cv/server helpers: signatures take env: ServerEnv instead of platform: App.Platform | undefined. Update all callers in ui/ (SK, passes event.platform!.env) and api/ (Hono, passes c.env).
  2. Rewrite api routes as native Hono handlers grouped into sub-routers (api/src/routers/workspaces.ts, api/src/routers/llm.ts, etc.). Use @hono/zod-validator for input validation.
  3. Drop the sk-adapter shim; drop SvelteKit + adapter-cloudflare deps; drop svelte.config.js and vite.config.ts; wrangler compiles src/worker.ts directly.
  4. Lift operation logic out of api handlers into pure functions in @cv/server (or new @cv/operations).
  5. Rewrite mcp workers on Hono. Tool handlers call the operation functions directly.
  6. Deploy api and mcp workers under new versions; verify against the existing SK-based api worker before swapping the Custom Domain binding.
  7. Decommission the SK-era api artifacts.

What this decision does NOT cover

  • The wiki perspective is moving to LeafWiki, decided separately. See the wiki ADR (TODO).
  • Workers that need their own framework discussion (none currently planned).
  • The web client's apiUrl() helper continues to point at api.careervector.corbet.ch — no client-side changes needed during the migration. As long as the api worker preserves response shapes, the web client doesn't know or care which framework served the call.
Source: wiki/content/architecture/SERVER-FRAMEWORK-DECISION.md