Hono Operations

SkillCloud & infra

Hono on Cloudflare Workers - composition, middleware, typed bindings, validation, RPC, streaming, testing. Use for: hono, hono middleware, app.route, hono rpc, c.env bindings, onError, zValidator, vitest-pool-workers, spa fallback worker.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Hono Operations skill

What this skill tells your AI

The instructions your AI receives, as published by 0xdarkmatter/claude-mods in skills/hono-ops/SKILL.md and read by ahel’s review.

Hono on Cloudflare Workers: composing multi-app APIs in one Worker, middleware discipline, typed errors, validation at the HTTP boundary, SPA co-serving, RPC clients, and testing under vitest-pool-workers. Patterns here are distilled from a production multi-tenant Worker (one Hono app, 6+ mounted sub-apps, ~1350 tests).

Verified against Hono v4 (2026). Workers-first; the Node/Bun/Deno deltas and porting checklist live in references/runtime-adapters.md.

Staleness check: python scripts/check-hono-facts.py --offline asserts the version-bearing facts (Hono major, @hono/zod-validator, @cloudflare/vitest-pool-workers) are still named in the prose and the dated currency note above is present; --live confirms each package's npm major still matches. Catalog: assets/hono-facts.json.

Decision Tree

What are you doing with Hono?
│
├─ Structuring an app (generics, sub-apps, env typing)
│  └─ Below + references/app-composition.md
│
├─ Middleware (ordering, auth, headers, exclusion boundaries)
│  └─ Below + references/middleware.md
│
├─ Errors / 404s / request validation
│  └─ Below + references/errors-validation.md
│
├─ Path syntax, routers, c.req/c.res surface, cookies
│  └─ references/routing-and-request.md
│
├─ Serving a SPA / static assets from the same Worker
│  └─ references/workers-runtime.md
│
├─ Cron / queues alongside fetch; runtime gotchas
│  └─ references/workers-runtime.md
│
├─ Streaming / SSE / WebSockets / proxying / service bindings
│  └─ references/streaming-and-realtime.md
│
├─ Durable Objects (Hono in a DO, hibernated WS, alarms)
│  └─ references/durable-objects.md
│
├─ OpenAPI docs from routes (@hono/zod-openapi)
│  └─ references/openapi.md
│
├─ Server-rendered HTML / JSX / HTML emails
│  └─ references/jsx-ssr.md
│
├─ Running or porting to Node / Bun / Deno
│  └─ references/runtime-adapters.md
│
├─ Typed client (hc RPC vs hand-rolled)
│  └─ references/rpc-clients.md
│
├─ Testing (app.request, pool-workers, middleware isolation)
│  └─ references/testing.md + assets/vitest.config.template.ts
│
├─ Starting a new Worker from scratch
│  └─ assets/worker-template.ts (commented composition-root skeleton)
│
└─ Auditing an existing app's routes / middleware order
   └─ scripts/route-inventory.py (below)

App Composition (the 80%)

Type the app once with Bindings (wrangler-provided env) and Variables (per-request context you c.set):

import { Hono } from 'hono';

interface Env {
  DB: D1Database;
  ASSETS: Fetcher;          // static assets binding (SPA)
  API_KEYS?: string;        // optional secret: gate features on presence, 503 when unset
}
type Vars = { identity: Identity; repo: ScopedRepository };

export const app = new Hono<{ Bindings: Env; Variables: Vars }>();
  • c.env.DB — bindings, typed via Bindings.
  • c.set('identity', id) / c.get('identity') / c.var.identity — per-request state, typed via Variables. Middleware writes it; handlers read it.
  • Prefer the per-app Variables generic over global ContextVariableMap augmentation; the map is app-wide and leaks types across unrelated sub-apps (see references/app-composition.md).

Sub-app mounting — one Worker, many feature apps, each its own file:

// src/time/api.ts
export const timeApi = new Hono<{ Bindings: Env; Variables: Vars }>();
timeApi.get('/entries', (c) => { /* identity + repo already in context */ });

// src/index.ts — mounted under the auth middleware (see Middleware below)
app.route('/api/time', timeApi);       // timeApi sees paths relative to the mount
app.route('/api/time', billingApi);    // two sub-apps on one base is fine when
                                       // their paths are disjoint — Hono matches across both

The mounted sub-app inherits nothing implicitly except position: whatever middleware was registered on a matching path before the mount runs first. Position IS the security boundary — see Middleware.

Middleware: Order Is the Contract

Hono middleware is an onion — code before await next() runs inbound, code after runs outbound — and registration order is matching order. A middleware registered after a matching handler never runs for it.

app.use('*', securityHeaders());        // 1. outermost: response hardening
app.get('/api/health', (c) => c.json({ ok: true }));  // 2. before auth = unauthenticated

app.use('/api/*', async (c, next) => {  // 3. auth: verify, then stash identity
  if (c.req.path === '/api/health') return next();   // skip-list for exceptions
  const user = await verifyAndResolve(c.req.raw, c.env);   // throws/403s on failure
  if (!user) return c.json({ error: 'forbidden' }, 403);
  c.set('identity', user);
  c.set('repo', scopedRepo(c.env.DB, user));  // handlers never touch raw bindings
  await next();
});

app.route('/api/time', timeApi);        // 4. inside the auth boundary
app.route('/vesper', vesper);           // 5. OUTSIDE /api/* — bearer-key auth, on purpose
app.route('/ingest', ingest);           // machine-to-machine, own auth in the sub-app

app.all('/api/*', (c) => c.json({ error: 'not_found' }, 404));  // JSON 404 for API
app.all('*', (c) => c.env.ASSETS.fetch(c.req.raw));             // SPA fallback, LAST

Two load-bearing rules:

  1. Auth middleware verifies, then builds the request's whole world (identity, scoped repo/session) into context. Handlers read c.get(...) and can't reach unscoped resources by construction.
  2. Routes with a different auth model mount OUTSIDE the middleware's path pattern (/vesper, /ingest/* above), each carrying its own auth middleware. Don't punch exemptions through session auth with flags — move the mount.

Depth (skip-lists vs path shape, security headers + the immutable-headers trap, timing-safe bearer compare): references/middleware.md.

Errors: One Typed Boundary

Throw typed errors anywhere below the handler; map them to HTTP in exactly one place:

export class AppError extends Error {
  constructor(public readonly status: number, public readonly code: string, message: string) {
    super(message); this.name = 'AppError';
  }
}
export const NotFound  = (m = 'not found')  => new AppError(404, 'not_found', m);
export const Forbidden = (m = 'forbidden')  => new AppError(403, 'forbidden', m);
export const Conflict  = (m = 'version conflict, reload and retry') => new AppError(409, 'conflict', m);

app.onError((err, c) => {
  if (err instanceof AppError)    return c.json({ error: err.code, message: err.message }, err.status as 400);
  if (err instanceof SyntaxError) return c.json({ error: 'bad_request', message: 'invalid JSON body' }, 400);
  console.error('unhandled error', err);          // log the real thing…
  return c.json({ error: 'internal' }, 500);      // …never leak it to the wire
});
  • Cross-scope access returns 404, not 403 — a 403 confirms the row exists in someone else's scope.
  • Unmatched /api/* gets a JSON 404; everything else falls through to the SPA shell. Never let an API typo return index.html.
  • app.notFound() exists but only fires when nothing matched — with a catch-all SPA route it never runs; use the explicit two-route split above.

Validation at the boundary (zValidator vs hand-rolled assertions, and when each wins): references/errors-validation.md.

Testing Quickstart

app.request() / app.fetch() run the real app — middleware, routing, errors — with no server:

import { env } from 'cloudflare:test';   // vitest-pool-workers: real bindings
import { app } from '../src/index';

const res = await app.request('/api/health', {}, env);   // env = 3rd arg (Bindings)
expect(res.status).toBe(200);

Under @cloudflare/vitest-pool-workers the test runs inside workerd with real D1/KV/R2 bindings from defineWorkersConfig. Full setup — migrations into the test DB, isolated storage, an Access-JWT signing harness, testing one middleware in isolation, and the workerd-version-lag trap: references/testing.md.

Route Inventory Script

scripts/route-inventory.py statically scans a Hono TypeScript source tree and lists every route, middleware registration, and app.route() mount with file:line — plus --check, three registration-order lints (every finding is a consequence of Hono matching in registration order):

  • bypass — a route registered before a middleware whose pattern covers it (it silently skips that middleware: the #1 Hono ordering bug)
  • duplicate — the same (method, path) registered twice (the second is dead)
  • shadowed — a route after an earlier broader same-method route (never matches)
# Inventory a Worker's HTTP surface (TSV: kind, method, path, file:line)
python skills/hono-ops/scripts/route-inventory.py src/

# JSON envelope for downstream tooling
python skills/hono-ops/scripts/route-inventory.py --json src/ | jq '.data[] | select(.kind=="mount")'

# Lint registration order: exit 10 = findings (each carries an `issue` field in --json)
python skills/hono-ops/scripts/route-inventory.py --check src/

Exit codes: 0 clean, 2 usage, 3 path not found, 10 findings (--check). Regex-based on purpose — it needs no TypeScript compiler API and works on any checkout.

Gotchas (Workers-Specific)

GotchaWhyFix
"Illegal invocation" on fetchCalling this.fetchImpl(...) binds this to your object; global fetch requires no receiverDetach first: const doFetch = this.fetchImpl; await doFetch(url, ...)
Mutating ASSETS.fetch response headers throwsAny fetch()-derived Response has immutable headers in workerdRebuild: new Response(res.body, { status, headers: new Headers(res.headers) })
caches API "cache" misses constantlyIt's per-colo, not global — every PoP has its ownTreat as a short-TTL local collapse (poll-storm absorber), never as KV
waitUntil work vanishesPost-response work must be registered before the handler returns; unregistered promises are cancelledc.executionCtx.waitUntil(promise) inside the handler
Middleware doesn't run for a routeRegistered after the handler — order is matching orderRegister middleware first; verify with route-inventory.py --check
wrangler dev host surprisesDev rewrites the request host to the [[routes]] patternPin [dev] host in wrangler config when auth branches on hostname
Optional secret unsetRoute depends on an env secret that isn't configuredGate on presence: if (!c.env.KEY) return c.json({ error: 'unavailable' }, 503)

More depth (SPA assets config, run_worker_first, scheduled/queue handlers, per-cron branching): references/workers-runtime.md.

Reference Files

ReferenceWhen to Load
references/app-composition.mdGenerics (Bindings/Variables), ContextVariableMap trade-offs, sub-app mounting semantics, basePath, env-shape design
references/middleware.mdOnion model, ordering proofs, auth middleware that builds context, security headers, bearer-auth sub-apps outside the session boundary
references/errors-validation.mdonError mapping, typed error classes, 404 strategy, zValidator vs hand-rolled validation trade-offs
references/routing-and-request.mdRouter internals, path syntax (params/regex/optional/wildcards), matching precedence, c.req/response helpers, cookies (incl. signed), JSX/html
references/testing.mdapp.request() patterns, vitest-pool-workers config (D1 migrations, bindings, isolation), JWT test harness, middleware-in-isolation
references/rpc-clients.mdhc<AppType> RPC client, chained-route inference requirement, when a hand-rolled typed client is the better call
references/workers-runtime.mdSPA/static assets from one Worker, scheduled() + queue handlers beside fetch, waitUntil, caches, detached fetch
references/streaming-and-realtime.mdstream/streamText/streamSSE, WebSockets (plain Worker vs Durable Object hibernation), proxying, service bindings
references/durable-objects.mdRouting into DOs, a Hono app per object, hibernated WebSockets, alarms, Hono-in-DO vs RPC methods
references/openapi.md@hono/zod-openapi schema-first routes, swagger/Scalar UI, hono-openapi annotations, when to skip OpenAPI entirely
references/jsx-ssr.mdhono/jsx server rendering, jsxRenderer layouts, async components + Suspense streaming, raw() escaping rules, the SPA-scope guard (HonoX ladder)
references/runtime-adapters.mdNode (@hono/node-server) / Bun / Deno deltas — env, static files, WebSockets, cron — plus the Workers→Node porting checklist

Starter assets:

  • assets/worker-template.ts — commented composition-root skeleton (typed env, security headers, auth middleware, bearer sub-app, 404 split, onError, cron) with adapt-points marked. Copy it as the seed of a new Worker.
  • assets/vitest.config.template.ts — vitest-pool-workers config (D1 migrations into the test DB, isolation, worktree excludes, the compatibility-date pin) ready to adapt.

See Also

  • cloudflare-ops — wrangler config, bindings provisioning, deploy/CI
  • sqlite-ops — D1 specifics (sessions/bookmarks, batch semantics, query plans)
  • typescript-ops — generics, Zod 4, type-narrowing the payloads you validate
  • rest-ops / api-design-ops — endpoint and contract design above the framework
  • auth-ops — JWT/session/token theory behind the auth middleware patterns

Signals

GitHub stars
36
Forks
5
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
hono-ops
Source
github.com/0xdarkmatter/claude-mods