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.
Scanned 9/5/2026
Install to Claude Code
npx -y skills add 0xDarkMatter/claude-mods --skill hono-ops --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Hono Ops?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/0xdarkmatter-hono-ops)More formats (shields.io, HTML) on the badges page.
---
name: hono-ops
description: "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."
license: MIT
allowed-tools: "Read Write Bash Grep Glob"
metadata:
author: claude-mods
related-skills: "cloudflare-ops, typescript-ops, sqlite-ops, rest-ops, testing-ops, auth-ops"
---
# Hono Operations
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`):
```typescript
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](references/app-composition.md)).
**Sub-app mounting** — one Worker, many feature apps, each its own file:
```typescript
// 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.
```typescript
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](references/middleware.md).
## Errors: One Typed Boundary
Throw typed errors anywhere below the handler; map them to HTTP in exactly one
place:
```typescript
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](references/errors-validation.md).
## Testing Quickstart
`app.request()` / `app.fetch()` run the real app — middleware, routing, errors —
with no server:
```typescript
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](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)
```bash
# 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)
| Gotcha | Why | Fix |
|---|---|---|
| "Illegal invocation" on fetch | Calling `this.fetchImpl(...)` binds `this` to your object; global fetch requires no receiver | Detach first: `const doFetch = this.fetchImpl; await doFetch(url, ...)` |
| Mutating `ASSETS.fetch` response headers throws | Any `fetch()`-derived Response has immutable headers in workerd | Rebuild: `new Response(res.body, { status, headers: new Headers(res.headers) })` |
| `caches` API "cache" misses constantly | It's per-colo, not global — every PoP has its own | Treat as a short-TTL local collapse (poll-storm absorber), never as KV |
| `waitUntil` work vanishes | Post-response work must be registered before the handler returns; unregistered promises are cancelled | `c.executionCtx.waitUntil(promise)` inside the handler |
| Middleware doesn't run for a route | Registered after the handler — order is matching order | Register middleware first; verify with `route-inventory.py --check` |
| `wrangler dev` host surprises | Dev rewrites the request host to the `[[routes]]` pattern | Pin `[dev] host` in wrangler config when auth branches on hostname |
| Optional secret unset | Route depends on an env secret that isn't configured | Gate 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](references/workers-runtime.md).
## Reference Files
| Reference | When to Load |
|-----------|-------------|
| [references/app-composition.md](references/app-composition.md) | Generics (`Bindings`/`Variables`), `ContextVariableMap` trade-offs, sub-app mounting semantics, `basePath`, env-shape design |
| [references/middleware.md](references/middleware.md) | Onion model, ordering proofs, auth middleware that builds context, security headers, bearer-auth sub-apps outside the session boundary |
| [references/errors-validation.md](references/errors-validation.md) | `onError` mapping, typed error classes, 404 strategy, zValidator vs hand-rolled validation trade-offs |
| [references/routing-and-request.md](references/routing-and-request.md) | Router internals, path syntax (params/regex/optional/wildcards), matching precedence, `c.req`/response helpers, cookies (incl. signed), JSX/html |
| [references/testing.md](references/testing.md) | `app.request()` patterns, vitest-pool-workers config (D1 migrations, bindings, isolation), JWT test harness, middleware-in-isolation |
| [references/rpc-clients.md](references/rpc-clients.md) | `hc<AppType>` RPC client, chained-route inference requirement, when a hand-rolled typed client is the better call |
| [references/workers-runtime.md](references/workers-runtime.md) | SPA/static assets from one Worker, `scheduled()` + queue handlers beside `fetch`, `waitUntil`, `caches`, detached fetch |
| [references/streaming-and-realtime.md](references/streaming-and-realtime.md) | `stream`/`streamText`/`streamSSE`, WebSockets (plain Worker vs Durable Object hibernation), proxying, service bindings |
| [references/durable-objects.md](references/durable-objects.md) | Routing into DOs, a Hono app per object, hibernated WebSockets, alarms, Hono-in-DO vs RPC methods |
| [references/openapi.md](references/openapi.md) | `@hono/zod-openapi` schema-first routes, swagger/Scalar UI, `hono-openapi` annotations, when to skip OpenAPI entirely |
| [references/jsx-ssr.md](references/jsx-ssr.md) | `hono/jsx` server rendering, `jsxRenderer` layouts, async components + Suspense streaming, `raw()` escaping rules, the SPA-scope guard (HonoX ladder) |
| [references/runtime-adapters.md](references/runtime-adapters.md) | Node (`@hono/node-server`) / Bun / Deno deltas — env, static files, WebSockets, cron — plus the Workers→Node porting checklist |
**Starter assets:**
- [assets/worker-template.ts](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](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
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!