hono-ops
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.
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/hono-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
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):
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 viaBindings.c.set('identity', id)/c.get('identity')/c.var.identity— per-request state, typed viaVariables. Middleware writes it; handlers read it.- Prefer the per-app
Variablesgeneric over globalContextVariableMapaugmentation; 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:
- 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. - 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 returnindex.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)
| 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.
Reference Files
| Reference | When to Load |
|---|---|
| references/app-composition.md | Generics (Bindings/Variables), ContextVariableMap trade-offs, sub-app mounting semantics, basePath, env-shape design |
| 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 | onError mapping, typed error classes, 404 strategy, zValidator vs hand-rolled validation trade-offs |
| 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 | app.request() patterns, vitest-pool-workers config (D1 migrations, bindings, isolation), JWT test harness, middleware-in-isolation |
| 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 | SPA/static assets from one Worker, scheduled() + queue handlers beside fetch, waitUntil, caches, detached fetch |
| references/streaming-and-realtime.md | stream/streamText/streamSSE, WebSockets (plain Worker vs Durable Object hibernation), proxying, service bindings |
| references/durable-objects.md | Routing 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.md | hono/jsx server rendering, jsxRenderer layouts, async components + Suspense streaming, raw() escaping rules, the SPA-scope guard (HonoX ladder) |
| 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 — 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/CIsqlite-ops— D1 specifics (sessions/bookmarks, batch semantics, query plans)typescript-ops— generics, Zod 4, type-narrowing the payloads you validaterest-ops/api-design-ops— endpoint and contract design above the frameworkauth-ops— JWT/session/token theory behind the auth middleware patterns
Files (claude-mods)
-
assets
-
hono-facts.json 883 B
{ "schema": "claude-mods.hono-ops.facts/v1", "as_of": "2026-08-08", "comment": "Version-bearing external facts this skill states as current. check-hono-facts.py --offline asserts each prose_token is still named in the skill prose; --live asserts the npm package still resolves and (where documented_major is set) its latest major still matches.", "hono": { "prose_token": "Hono v4", "package": "hono", "documented_major": "4" }, "zod_validator": { "prose_token": "@hono/zod-validator", "package": "@hono/zod-validator" }, "pool_workers": { "prose_token": "@cloudflare/vitest-pool-workers", "package": "@cloudflare/vitest-pool-workers" }, "zod_openapi": { "prose_token": "@hono/zod-openapi", "package": "@hono/zod-openapi" }, "node_server": { "prose_token": "@hono/node-server", "package": "@hono/node-server" } } -
vitest.config.template.ts 2.9 KB
// hono-ops starter: vitest-pool-workers config for testing a Hono Worker // inside workerd with real bindings. ADAPT-POINTS marked <<<. // Companion: the skill's references/testing.md explains every choice here. // // Install: npm i -D vitest @cloudflare/vitest-pool-workers // Type the `env` import in tests via a test/env.d.ts: // declare module 'cloudflare:test' { // interface ProvidedEnv extends Env {} // your Worker's Env // interface ProvidedEnv { TEST_MIGRATIONS: D1Migration[] } // } import { defineWorkersConfig, readD1Migrations } from '@cloudflare/vitest-pool-workers/config'; export default defineWorkersConfig(async () => { // Apply the REAL migrations into each test run's D1 so tests hit the same // schema (and CHECK constraints) as production. Delete if you have no D1. const migrations = await readD1Migrations('./migrations'); // <<< your migrations dir return { test: { include: ['test/**/*.{test,spec}.ts'], // Exclude sibling git worktrees and the SPA's own suite: worktrees under // .claude/worktrees/* carry their own (often stale) copy of test/ + // migrations/ — sweeping them in double-counts and lets an unrelated // worktree's broken migration fail this run. exclude: ['**/node_modules/**', '**/.claude/**', 'web/**'], // <<< adjust web/ to your SPA dir setupFiles: ['./test/apply-migrations.ts'], // test/apply-migrations.ts is two lines: // import { applyD1Migrations, env } from 'cloudflare:test'; // await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); poolOptions: { workers: { // One workerd instance for the whole suite: faster, and module-level // state (JWKS caches etc.) behaves like a live isolate. singleWorker: true, // Per-TEST-FILE storage isolation: D1/KV/R2 writes in one file never // leak into another. Within a file, re-seed in beforeEach. isolatedStorage: true, miniflare: { // KEEP EQUAL to your wrangler config's compatibility_date — a skew // here is the classic "passes local, fails deployed" source. compatibilityDate: '2024-12-01', // <<< compatibilityFlags: ['nodejs_compat'], d1Databases: { DB: 'my-app-test' }, // <<< binding name -> test db id // r2Buckets: ['FILES'], // <<< R2 bindings, if any // durableObjects: { ROOM: 'Room' }, // <<< DO bindings, if any bindings: { TEST_MIGRATIONS: migrations, // Plain-var bindings the suite needs; per-test overrides are just // a spread at the call site: app.request(path, {}, { ...env, KEY: 'x' }) SOME_CONFIG_VAR: 'test-value', // <<< }, }, }, }, }, }; }); -
worker-template.ts 5.8 KB
// hono-ops starter: a Hono v4 composition root for one Cloudflare Worker serving // an authed JSON API + a SPA, with a bearer-auth machine surface and cron beside it. // ADAPT-POINTS are marked <<< — everything else is the load-bearing skeleton. // Registration ORDER in this file is the security topology (see the skill's // middleware.md): middleware before the routes it must cover, catch-alls last. // // Pairs with wrangler config: // assets = { directory: "./web/dist", binding: "ASSETS", // not_found_handling: "single-page-application", // run_worker_first: ["/api/*", "/ingest/*"] } // triggers = { crons: ["*/5 * * * *"] } import { Hono } from 'hono'; import type { MiddlewareHandler } from 'hono'; // --- env: the Worker's configuration contract -------------------------------- interface Env { DB: D1Database; // <<< your bindings ASSETS: Fetcher; // static assets binding (the built SPA) /** Comma-separated bearer keys for /ingest (two during rotation). Optional: * while unset the surface 503s — absence disables, never crashes. */ INGEST_KEYS?: string; } // Per-request context set by the auth middleware; handlers read ONLY this. type Identity = { userId: string; email: string; role: 'admin' | 'member' }; type Vars = { identity: Identity }; export const app = new Hono<{ Bindings: Env; Variables: Vars }>(); // --- 1. outermost: response hardening (fills missing headers on EVERY response) function securityHeaders(): MiddlewareHandler { return async (c, next) => { await next(); // ASSETS.fetch responses have IMMUTABLE headers — copy, then rebuild. const headers = new Headers(c.res.headers); const setIfMissing = (n: string, v: string) => { if (!headers.has(n)) headers.set(n, v); }; setIfMissing('x-content-type-options', 'nosniff'); setIfMissing('x-frame-options', 'DENY'); setIfMissing('referrer-policy', 'no-referrer'); c.res = new Response(c.res.body, { status: c.res.status, statusText: c.res.statusText, headers }); }; } app.use('*', securityHeaders()); // --- 2. unauthenticated exceptions, registered BEFORE auth (deliberate bypass) app.get('/api/health', (c) => c.json({ ok: true })); // --- 3. auth: verify the credential, then build the request's world ---------- app.use('/api/*', async (c, next) => { if (c.req.path === '/api/health') return next(); // skip-list documents the exception const identity = await verifyCaller(c.req.raw, c.env); // <<< your JWT/session verify if (!identity) return c.json({ error: 'forbidden' }, 403); c.set('identity', identity); await next(); }); // --- 4. routes + feature sub-app mounts (inside the auth boundary) ----------- app.get('/api/me', (c) => c.json({ identity: c.get('identity') })); // app.route('/api/widgets', widgetsApi); // <<< identity already set for sub-apps // --- 5. machine surface OUTSIDE /api/*: its own bearer auth, not session auth - const ingest = new Hono<{ Bindings: Env }>(); ingest.use('*', async (c, next) => { if (!c.env.INGEST_KEYS) return c.json({ error: 'unavailable' }, 503); const token = (c.req.header('authorization') ?? '').replace(/^Bearer /, ''); const keys = c.env.INGEST_KEYS.split(',').map((k) => k.trim()).filter(Boolean); if (!token || !keys.some((k) => timingSafeEqual(token, k))) { return c.json({ error: 'unauthorized' }, 401); } await next(); }); ingest.post('/events', async (c) => { const body = await c.req.json<{ events?: unknown[] }>().catch(() => ({}) as { events?: unknown[] }); if (!Array.isArray(body.events)) return c.json({ error: 'bad_request', message: 'events[] required' }, 400); return c.json({ accepted: body.events.length }, 202); }); app.route('/ingest', ingest); // --- 6. the 404 split: JSON for API typos, SPA shell for everything else ----- app.all('/api/*', (c) => c.json({ error: 'not_found' }, 404)); app.all('*', (c) => c.env.ASSETS.fetch(c.req.raw)); // --- 7. one error boundary --------------------------------------------------- class AppError extends Error { constructor(public readonly status: number, public readonly code: string, message: string) { super(message); this.name = 'AppError'; } } 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); // detail to logs, return c.json({ error: 'internal' }, 500); // generic to the wire }); // --- export: fetch + cron in one Worker -------------------------------------- export default { fetch: app.fetch, scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) { // Branch per cron expression; each job independently waitUntil'd so one // failure never suppresses a sibling. Jobs must be idempotent — crons re-run. if (controller.cron === '*/5 * * * *') { ctx.waitUntil(drainOutbox(env)); // <<< your jobs } }, } satisfies ExportedHandler<Env>; // --- helpers (stubs — replace) ----------------------------------------------- async function verifyCaller(_req: Request, _env: Env): Promise<Identity | null> { throw new AppError(500, 'not_implemented', 'wire your JWT/session verification here'); // <<< } async function drainOutbox(_env: Env): Promise<void> {} // <<< function timingSafeEqual(a: string, b: string): boolean { // Constant-time compare over a fixed length — an early-return compare leaks // prefix length via timing. const enc = new TextEncoder(); const ab = enc.encode(a), bb = enc.encode(b); const len = Math.max(ab.length, bb.length, 1); let diff = ab.length ^ bb.length; for (let i = 0; i < len; i++) diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0); return diff === 0; }
-
-
references
-
app-composition.md 5.8 KB
# App Composition — Typing, Sub-Apps, Mount Semantics How to structure a Hono app that stays navigable at 6+ feature areas in one Worker. Companion to SKILL.md's "App Composition" section; this file owns the depth. ## The generics: `Bindings` and `Variables` ```typescript import { Hono } from 'hono'; interface Env { DB: D1Database; ASSETS: Fetcher; /** Comma-separated bearer keys (two during rotation). Optional: absence * disables the dependent surface (503), it never crashes boot. */ VESPER_KEYS?: string; } type Vars = { identity: Identity; // set by auth middleware repo: ScopedRepository; // set by auth middleware; handlers use ONLY this }; export const app = new Hono<{ Bindings: Env; Variables: Vars }>(); ``` - `Bindings` types `c.env`. Keep the `Env` interface in one place and make every optional integration key genuinely optional (`?:`) — the route gates on presence and 503s, the cron no-ops. A required key that isn't bound crashes every request, not just the feature. - `Variables` types `c.set` / `c.get` / `c.var`. `c.var.identity` is the property-style accessor for `c.get('identity')`. - Document each env key at its declaration (what it is, whether it's a secret, what happens when unset). The `Env` interface is the Worker's configuration contract — treat it like one. ### `ContextVariableMap` vs the `Variables` generic ```typescript // Global augmentation — every Hono instance in the process sees this: declare module 'hono' { interface ContextVariableMap { requestId: string; } } ``` | | `Variables` generic | `ContextVariableMap` | |---|---|---| | Scope | One app (and sub-apps you type the same) | Every Hono app in the build | | Fit | App-specific state (identity, repo) | Truly cross-cutting values set by a shared middleware package (request id, logger) | | Risk | Repeating the type in each sub-app file | Type leakage: unrelated apps "have" variables nothing set | Default to the generic. Reach for `ContextVariableMap` only when you publish a middleware whose consumers shouldn't have to thread a generic through. ### Typing middleware helpers A standalone middleware factory uses `MiddlewareHandler` (optionally with the same env shape): ```typescript import type { MiddlewareHandler } from 'hono'; export function securityHeaders(): MiddlewareHandler { return async (c, next) => { await next(); /* … */ }; } ``` Use `createMiddleware<{ Bindings: Env; Variables: Vars }>()` (from `hono/factory`) when the middleware body needs the typed `c.env`/`c.var`. ## Sub-app mounting with `app.route()` ```typescript // Feature file exports a Hono instance typed with the SAME env shape: export const timeApi = new Hono<{ Bindings: Env; Variables: Vars }>(); timeApi.get('/entries', (c) => c.json({ entries: [] })); // path is mount-relative // Root file mounts it: app.route('/api/time', timeApi); // serves GET /api/time/entries ``` Semantics that matter in practice: - **Paths are mount-relative.** The sub-app never knows its prefix; you can remount it elsewhere (or in a test) without edits. - **Two sub-apps on one base path is legal** — Hono matches across both. Keep their route sets disjoint; when they are, registration order between them is irrelevant (say so in a comment where you mount them, or the next reader will assume order is load-bearing). - **Mount position decides which middleware applies.** `app.route()` inside a `app.use('/api/*', auth)` pattern's coverage runs behind auth; a mount at `/vesper` outside it does not. There is no "inherit auth" flag — position is the mechanism (see middleware.md). - **Context typing is by convention.** If the parent's middleware `c.set`s `identity`, the sub-app's handlers read it because both declare the same `Variables` type. TypeScript won't stop you mounting a sub-app that assumes variables no middleware sets — a mount-site comment ("identity + repo already set by the /api/* middleware") is the cheap guard, and an integration test through the real parent app is the real one (testing.md). ## `basePath` ```typescript const api = new Hono().basePath('/api'); api.get('/health', …); // matches /api/health ``` `basePath` bakes the prefix into the app itself; `app.route(prefix, sub)` keeps the sub-app relocatable. Prefer `route()` for feature composition; use `basePath` when an entire deployment is served under a prefix (e.g. behind a gateway that doesn't strip it). ## One composition-root file Keep every `app.use` / `app.route` / fallback / `onError` registration in one root file (`src/index.ts`), ordered top-to-bottom as the request flows: 1. Global outbound middleware (security headers) 2. Unauthenticated exceptions (health) 3. Auth middleware for the protected pattern 4. Protected routes + sub-app mounts 5. Alternate-auth mounts (bearer sub-apps) outside the pattern 6. JSON 404 for the API pattern 7. SPA/asset catch-all — always last 8. `app.onError` A reader (or `route-inventory.py`) can then audit the entire security topology by reading one file in order. Scattering `app.use` calls across feature files destroys that property — sub-apps may register their *own* interior middleware, but boundary middleware belongs to the root. ## Growing to "many apps in one Worker" The scale pattern (from a production 7-app Worker): - Each feature = one exported sub-app in its own file/directory (`src/time/api.ts`, `src/pulse-api.ts`), typed with the shared `Env`/`Vars`. - Feature-specific gates (e.g. an app-enabled check, an extra role gate) are registered as `app.use('/api/pulse/*', gate)` in the root, directly above that mount — visible in the composition root, not hidden in the feature file. - Sub-apps double-enforce their own authorization (a role check inside the sub-app AND the data layer scoping) — mounts move, defence-in-depth survives. -
durable-objects.md 4.9 KB
# Durable Objects + Hono — Per-Object Apps, WebSockets, Alarms A Durable Object gives one addressable, single-threaded instance with durable storage — the coordination primitive Workers lack. Hono composes with DOs in two directions: the main Worker's Hono app *routes into* DOs, and a DO can run *its own* Hono app for its HTTP surface. ## Routing into a DO from the main app ```typescript interface Env { ROOM: DurableObjectNamespace } // Resolve the object id from a stable name (tenant, room, document id) that // the AUTH MIDDLEWARE verified — never from raw client input, or one caller // can address another's object. app.all('/api/rooms/:room/*', (c) => { const identity = c.get('identity'); const id = c.env.ROOM.idFromName(`${identity.tenantId}:${c.req.param('room')}`); return c.env.ROOM.get(id).fetch(c.req.raw); // forward the original request }); ``` - The parent app's middleware (auth, headers) has already run — the DO receives a request the boundary vetted. Pass verified identity explicitly (a header you set, or rewrite the URL) rather than re-verifying inside every object. - `idFromName` is deterministic — same name, same object, globally. That's the whole coordination model: pick the name so that everything that must agree routes to one object (a room, a tenant's rate limiter, a document). ## A Hono app inside the DO ```typescript import { DurableObject } from 'cloudflare:workers'; import { Hono } from 'hono'; export class Room extends DurableObject<Env> { private app = new Hono(); constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); // Routes close over `this` — each object instance gets its own app bound // to its own storage. Paths are the FULL path as forwarded by the parent. this.app.get('/api/rooms/:room/state', async (c) => c.json({ members: await this.ctx.storage.get<string[]>('members') ?? [] })); this.app.get('/api/rooms/:room/ws', (c) => { const pair = new WebSocketPair(); // HIBERNATION API — not ws.accept(). The runtime can evict the isolate // while sockets stay connected; you stop paying wall-clock for idle rooms. this.ctx.acceptWebSocket(pair[1]); return new Response(null, { status: 101, webSocket: pair[0] }); }); } fetch(request: Request) { return this.app.fetch(request); } // Hibernation handlers live on the CLASS, not in Hono — a hibernated socket's // message may arrive with no Hono request in flight at all. webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) { for (const peer of this.ctx.getWebSockets()) peer.send(msg); } webSocketClose(ws: WebSocket) { /* presence bookkeeping */ } // Alarms: the DO-native scheduler (per-object, exact-time — unlike cron). async alarm() { await this.flushBuffer(); // Re-arm if the loop should continue; a fired alarm does not repeat itself. await this.ctx.storage.setAlarm(Date.now() + 60_000); } } ``` What matters in this shape: - **Hono handles the HTTP surface; the class handles the lifecycle.** Upgrade requests, storage reads, and route parsing go through Hono middleware/routes as usual. WebSocket *events* and `alarm()` bypass HTTP entirely and must be class methods. - **Bind identity to the socket at upgrade time** (e.g. `this.ctx.acceptWebSocket(ws, [identity.userId])` tags — retrievable via `ws.deserializeAttachment()`/tags) — after 101 there is no per-message auth. - **A DO is single-threaded per object.** No two requests interleave mid-await surprise-free by default (input gates); use `ctx.blockConcurrencyWhile()` in the constructor for must-finish-first init. Don't add mutexes — the platform is the mutex. - **Storage:** `ctx.storage` KV API, or SQLite-backed DOs (`ctx.storage.sql`) for relational per-object state — see `sqlite-ops` for the SQL side. ## Hono-in-DO vs plain RPC methods Modern DOs support direct RPC: public methods on the class, called as `stub.increment()` from the Worker — no Request/Response at all. | Signal | Hono app in the DO | RPC methods | |---|---|---| | Surface shape | HTTP-shaped (paths, methods, middleware, WS upgrades) | A typed internal API | | Callers | Forwarded browser requests, several route shapes | Your own Worker code only | | Middleware reuse | Yes — same middleware idioms as the parent | n/a | | Ceremony | Request forwarding, path coupling with the parent | Lowest — plain typed calls | Rule of thumb: forwarding *client* traffic (especially WebSocket upgrades) → Hono in the DO. Worker-internal coordination (counters, locks, buffers) → RPC methods, no Hono inside. ## Testing DOs vitest-pool-workers provisions DO bindings from the config (`miniflare.durableObjects`). Route into them through the composed app exactly like production (`app.fetch(new Request('/api/rooms/x/state'), env)`). For alarm logic, `runDurableObjectAlarm(stub)` from `cloudflare:test` fires a due alarm deterministically. `isolatedStorage` resets object storage per test file (testing.md). -
errors-validation.md 6.9 KB
# Errors and Validation — onError, Typed Errors, the 404 Split, Boundary Validation One error boundary, typed error classes, a deliberate 404 strategy, and the zValidator-vs-hand-rolled decision for request validation. ## Typed error classes → one `onError` mapping Domain code (data layer, integrations) throws typed errors; the HTTP layer maps them in exactly one place. Handlers stay thin and no layer needs to know HTTP. ```typescript // errors.ts — the app's error vocabulary 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 BadRequest = (m = 'bad request') => new AppError(400, 'bad_request', m); export const Conflict = (m = 'version conflict, reload and retry') => new AppError(409, 'conflict', m); ``` Design notes: - **`code` is the machine field, `message` the human one.** Clients branch on `code`; never make them parse prose. - **Mint a distinct code when the client's next action differs.** A retryable 409 ("reload and retry") and a non-retryable 409 ("this needs manual reconciliation") deserve different codes even at the same status — the code tells the caller *what to do*, the status tells proxies what happened. - **Factory functions with default messages** (`NotFound()`) keep call sites one-word cheap, which is what makes people actually throw typed errors. - **Cross-scope reads throw NotFound, not Forbidden.** A 403 on someone else's row confirms it exists; 404 doesn't leak existence. ```typescript app.onError((err, c) => { if (err instanceof AppError) return c.json({ error: err.code, message: err.message }, err.status as 400); if (err instanceof AuthError) return c.json({ error: 'forbidden' }, 403); if (err instanceof SyntaxError) return c.json({ error: 'bad_request', message: 'invalid JSON body' }, 400); if (err instanceof UpstreamError && err.code === 'not_configured') { return c.json({ error: 'not_configured', message: err.message }, 503); } console.error('unhandled error', err); // full detail to logs return c.json({ error: 'internal' }, 500); // generic to the wire — never leak stack/message }); ``` - The `err.status as 400` cast satisfies Hono's `StatusCode`-literal typing when status is a runtime number; the class constructor is the real guard. - `SyntaxError` is what an unhandled `await c.req.json()` throws on a malformed body — mapping it here turns garbage bodies into a clean 400 for every route that didn't bother to `.catch`. - Map upstream/integration error types by *their* codes to statuses that tell the truth: `not_configured` → 503, upstream validation refusal → 422, upstream rate-limit → 429, upstream broke → 502. - Hono also has `HTTPException` (`hono/http-exception`); its `onError` case is `err instanceof HTTPException ? err.getResponse() : …`. Prefer your own `AppError` vocabulary for domain errors — `HTTPException` couples domain code to HTTP and carries no machine `code` field. ## The 404 split: JSON for the API, shell for the SPA With a SPA served from the same Worker, "not found" means two different things: ```typescript // After all real routes/mounts: app.all('/api/*', (c) => c.json({ error: 'not_found' }, 404)); // API typo → JSON 404 app.all('*', (c) => c.env.ASSETS.fetch(c.req.raw)); // anything else → SPA ``` - Without the explicit `/api/*` 404, a fat-fingered API path falls through to the SPA catch-all and returns `index.html` with a 200 — the client then fails on `res.json()` three layers away from the actual bug. - `app.notFound(handler)` only fires when nothing matched; a `*` catch-all means nothing is ever unmatched, so it's dead code in this topology. Use the explicit route pair. - Order: the `/api/*` 404 goes after every API mount, and the `*` catch-all is the last route in the file. ## Validation at the HTTP boundary Two viable approaches; pick per-app, not per-route (consistency is a feature). ### Schema middleware: `@hono/zod-validator` ```typescript import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; const CreateUser = z.object({ email: z.email(), role: z.enum(['admin', 'user']) }); app.post('/api/users', zValidator('json', CreateUser), async (c) => { const body = c.req.valid('json'); // fully typed, already validated … }); ``` - Targets: `json`, `query`, `param`, `header`, `form`, `cookie`. - Invalid input → automatic 400 with Zod's error structure; customise the response shape with the third `(result, c) => …` hook argument — do this once in a wrapped helper so your error envelope (`{ error, message }`) stays consistent with `onError`'s. - `c.req.valid('json')` is the *only* typed accessor; `await c.req.json()` in the same handler bypasses validation entirely. - Valibot/ArkType/effect equivalents exist (`@hono/valibot-validator`, …) — same shape; valibot's tree-shaken bundle is materially smaller, which matters at Workers' bundle-size limits. ### Hand-rolled: tolerant parse + explicit assertions ```typescript // Parse failure degrades to {} — the explicit checks below produce the 400s. const body = await c.req.json<{ email?: string }>().catch(() => ({}) as { email?: string }); if (!body.email) return c.json({ error: 'bad_request', message: 'email is required' }, 400); // Shared assertion helpers for recurring shapes: const date = assertDateString(body.date, 'date'); // throws BadRequest('date must be YYYY-MM-DD') ``` The `.catch(() => ({}))` idiom means a malformed body and a missing field take the same, deliberate 400 path (with your envelope), rather than a `SyntaxError` surfacing through `onError`. ### Trade-offs | | zValidator (schema middleware) | Hand-rolled assertions | |---|---|---| | Types | Inferred from schema — payload type and validation can't drift | `c.req.json<T>()` is a **cast, not a check** — T drifts from reality silently | | Error shape | Zod's, unless you customise the hook everywhere | Yours by construction, consistent with `onError` | | Deps / bundle | zod (or valibot) in the Worker bundle | Zero | | Cross-field / DB-dependent rules | Awkward — lands in the handler anyway | Same place as everything else | | RPC | Required — `hc` derives input types from validators (rpc-clients.md) | No input typing on the client | | Best for | Broad CRUD surfaces, RPC apps, teams | Small/hot Workers, apps whose real invariants are enforced in the data layer | The honest middle: schema-validate the *shape* at the boundary, keep *business* invariants (version checks, scoping, state-machine rules) in the domain layer throwing typed errors. Never let a schema pass for authorization — identity comes from verified credentials (middleware.md), not from a validated body. -
jsx-ssr.md 5.3 KB
# JSX / SSR — Server-Rendered HTML from the Same Worker `hono/jsx` renders JSX to HTML on the server with zero client runtime — the right tool for server-rendered pages, admin one-pagers, HTML emails, and error pages living beside an API. This file covers setup, the renderer middleware, async/streaming components, the escaping rules, and — load-bearing — where the approach stops scaling. ## Scope guard (read first) **Don't grow an app-scale SPA in JSX inside an API Worker.** hono/jsx has no client-side state model, no router, no hydration story worth building on by hand. The ladder: | Need | Right tool | |---|---| | A few server-rendered pages, emails, error pages | `hono/jsx` (this file) | | Interactive islands on mostly-static pages | **HonoX** (Hono's file-based meta-framework with islands) or Astro | | A real SPA | Build it separately, serve via the assets binding (workers-runtime.md) | If a `hono/jsx` page has accumulated three `hono/jsx/dom` islands and a hand-rolled data-fetch layer, you're past the ladder's first rung — move it. ## Setup ```jsonc // tsconfig.json { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "hono/jsx" } } ``` ```tsx import type { FC, PropsWithChildren } from 'hono/jsx'; const Layout: FC<PropsWithChildren<{ title: string }>> = (props) => ( <html> <head><title>{props.title}</title></head> <body>{props.children}</body> </html> ); app.get('/status', (c) => c.html(<Layout title="Status"><h1>All good</h1></Layout>)); ``` Components are plain functions returning JSX; `Fragment`, `memo`, and a server-side `createContext`/`useContext` (for threading e.g. the request's identity through a layout tree without prop-drilling) all exist. ## The renderer middleware `hono/jsx-renderer` gives every route in a subtree a shared layout: ```tsx import { jsxRenderer } from 'hono/jsx-renderer'; app.use('/admin/*', jsxRenderer(({ children }) => ( <Layout title="Admin">{children}</Layout> ))); app.get('/admin/users', async (c) => c.render(<UserTable users={await load(c)} />)); ``` - `c.render(...)` wraps the page in the nearest registered layout; nested `jsxRenderer` calls compose (inner receives `Layout` as a prop to extend). - Declare the extra `c.render` argument types via the `ContextRenderer` interface if you pass per-page props (title, meta) through `c.render`. - Because it's middleware, the ordering rules from middleware.md apply — the renderer must be registered before the routes that call `c.render`. ## Async components and streaming Components may be `async` and awaited data renders inline — no loader ceremony. For slow sections, stream the shell first: ```tsx import { Suspense } from 'hono/jsx'; const SlowReport = async () => <pre>{JSON.stringify(await expensiveQuery())}</pre>; app.get('/report', (c) => c.html( <Layout title="Report"> <h1>Report</h1> <Suspense fallback={<p>crunching…</p>}> <SlowReport /> </Suspense> </Layout>, ), ); ``` With `Suspense` in the tree, `c.html` streams: the shell (with the fallback) flushes immediately and the resolved content follows in the same response. Same caveats as any streamed body (streaming-and-realtime.md): the status line is committed at first flush, so errors inside a suspended component can't become a 500 — they surface in the streamed content. Keep failure-prone work *before* `c.html`, and Suspense for genuinely slow-but-safe sections. ## Escaping — the one security rule Interpolated values are HTML-escaped automatically; the two escape hatches are the XSS surface: ```tsx import { raw } from 'hono/html'; <div>{userInput}</div> {/* safe — escaped */} <div>{raw(trustedPrerenderedHtml)}</div> {/* raw() = you are the sanitizer */} <div dangerouslySetInnerHTML={{ __html: x }} /> {/* same contract as raw() */} ``` `raw()` on anything user-influenced is stored XSS. If you must render user-authored rich text, sanitize server-side first and mark the sanitizer call site with a comment — the next reader can't tell trusted from untrusted by looking at the JSX. The `html` tagged-template (`hono/html`) follows the same rule: interpolations escaped, `raw()` opts out. ## Client-side sprinkle: `hono/jsx/dom` `hono/jsx/dom` is a small (~3KB) React-compatible runtime (`render`, `useState`, `useEffect`) for mounting an interactive widget into a server-rendered page. It shares component syntax with the server side, which makes it tempting — apply the scope guard: one or two self-contained widgets (a copy button, a live counter) is the intended dose. Bundling per-page client entries from the same Worker means a build step anyway, at which point HonoX (which automates exactly this islands pattern, file-routed) is less machinery than what you'd hand-roll. ## Where it pays off in an API Worker - **Error/maintenance pages** for the non-API fallthrough — a branded 503 from the Worker when the SPA assets are unavailable. - **HTML emails** — render the same `FC` components to strings for the mail provider; JSX beats string concatenation for nested tables, and escaping is handled. - **Admin/status one-pagers** (`/design`, `/status`) that want zero build step and live beside the data they render. - **OG/social preview *markup*** — but rendering OG *images* is satori/resvg territory, not hono/jsx. -
middleware.md 9.8 KB
# Middleware — Ordering, Auth Boundaries, Response Hardening The middleware layer is where a Hono app's security topology lives. This file covers the execution model, the auth-middleware pattern that makes handlers safe by construction, and the two boundary patterns (skip-lists and outside-the-pattern mounts). ## The execution model (onion + registration order) ```typescript app.use('*', async (c, next) => { // inbound: runs before any matching handler await next(); // outbound: runs after the handler (and after inner middleware) — c.res is set }); ``` - **Registration order is matching order.** For a request, Hono runs every middleware whose path pattern matches, in the order registered, then the handler. A middleware registered *after* a matching handler does not run for it. This is the single most common Hono bug; `route-inventory.py --check` (this skill) flags it statically. - **Returning without `await next()` short-circuits** — that's how auth rejects (`return c.json({ error: 'forbidden' }, 403)`). - **Outbound code sees `c.res`** and may replace it (`c.res = new Response(...)`). - `try { await next(); } finally { … }` guarantees outbound bookkeeping runs even when a handler throws (e.g. persisting a session cookie regardless of outcome). Note `onError` produces the response *after* your `finally` runs. ## The auth middleware pattern: verify, then build the request's world Verify credentials once, then stash everything downstream code needs — identity AND pre-scoped resources — so handlers physically can't do unscoped work: ```typescript app.use('/api/*', async (c, next) => { if (c.req.path === '/api/health') return next(); // skip-list (see below) // 1. Resolve the tenant/context from the request (host, header…) const tenant = await findTenantByHost(c.env.DB, new URL(c.req.url).host.toLowerCase()); if (!tenant?.active) return c.json({ error: 'unknown tenant' }, 404); // 2. Verify the credential. Verification failures are typed and mapped to 403 — // never a 500, never a passthrough. let user: UserRow | null; try { user = await resolveCaller(c.req.raw, c.env, tenant); // JWT verify + user lookup } catch (err) { if (err instanceof AuthError) return c.json({ error: 'forbidden' }, 403); throw err; } if (!user) return c.json({ error: 'no access for this user' }, 403); // 3. Build the verified world into context. Handlers read c.get(...) only. c.set('identity', { userId: user.id, tenantId: tenant.id, role: user.role }); c.set('repo', createScopedRepository(c.env.DB, c.get('identity'))); await next(); }); ``` Why this shape wins: - **Identity comes from the verified credential, never the request body.** No handler ever reads a `tenantId` out of JSON. - **Handlers get a scoped data layer, not raw bindings.** A handler that only has `c.get('repo')` cannot query another tenant even by bug — the scoping argument was bound before the handler existed. - **One place to extend.** Impersonation, read-replica session selection, and audit stamping all layer into this middleware without touching handlers. ### JWT verification specifics Verify signature + issuer + audience, never just decode. With `jose`, `createRemoteJWKSet` caches the JWKS per isolate and refetches on unknown `kid`, so key rotation doesn't cause spurious 403s. Cache the JWKS instance in a module-level `Map` keyed by issuer — module scope survives across requests in a Workers isolate. ## Boundary pattern 1: skip-lists (exceptions inside the pattern) One or two public routes inside an otherwise-protected pattern: register the route before the middleware AND skip it inside (belt + braces — order protects it today, the skip-list documents intent and survives reordering): ```typescript app.get('/api/health', (c) => c.json({ ok: true })); // before auth app.use('/api/*', async (c, next) => { if (c.req.path === '/api/health') return next(); // explicit exception … }); ``` Use a skip-list for a *handful* of exact paths. The moment you're pattern-matching exceptions (`startsWith`, regex), you want pattern 2 instead. ## Boundary pattern 2: mount OUTSIDE the pattern (different auth model) Machine-to-machine endpoints (a bearer-key read API, an ingest webhook) must not inherit interactive session auth. Don't exempt them from the session middleware — mount them on a path the middleware pattern doesn't cover, with their own auth: ```typescript // Sub-app with its own bearer auth (own file): export const vesper = new Hono<{ Bindings: VesperEnv; Variables: VesperVars }>(); vesper.use('*', async (c, next) => { const auth = c.req.header('authorization') ?? ''; const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''; const keys = (c.env.VESPER_KEYS ?? '').split(',').map((k) => k.trim()).filter(Boolean); if (!(token.length > 0 && keys.some((k) => timingSafeEqual(token, k)))) { return c.json({ error: 'unauthorized' }, 401); } // build this surface's own (narrow) context, then: await next(); }); // Root: /vesper and /ingest are NOT under /api/*, so session auth never sees them. app.route('/vesper', vesper); app.route('/ingest', ingest); ``` Operational notes for this pattern: - **Accept two comma-separated keys** so rotation is zero-downtime: add the new key, roll clients, remove the old. - **Compare bearer keys in constant time** over a fixed length (XOR-accumulate across `max(len(a), len(b))`, fold in the length difference) — an early-return string compare leaks prefix length via timing. - If the Worker sits behind an edge access product (e.g. Cloudflare Access), these paths need an explicit bypass/service-auth policy at the edge too — the bearer key is the real gate, but the edge must let the request through. - Give the bearer sub-app its own narrow `Env`/`Vars` types: it needs the DB and its keys, not the whole interactive surface. ## Response-hardening middleware (and the immutable-headers trap) Global outbound middleware that fills in missing security headers: ```typescript export function securityHeaders(): MiddlewareHandler { return async (c, next) => { await next(); // Responses derived from fetch()/ASSETS.fetch have IMMUTABLE headers in the // Workers runtime — mutating them in place throws and 500s every page load. // Copy into a fresh Headers and hand back a new Response. const headers = new Headers(c.res.headers); const setIfMissing = (n: string, v: string) => { if (!headers.has(n)) headers.set(n, v); }; setIfMissing('x-content-type-options', 'nosniff'); setIfMissing('x-frame-options', 'DENY'); setIfMissing('referrer-policy', 'no-referrer'); setIfMissing('strict-transport-security', 'max-age=31536000; includeSubDomains'); c.res = new Response(c.res.body, { status: c.res.status, statusText: c.res.statusText, headers }); }; } app.use('*', securityHeaders()); // registered FIRST = outermost = sees every response ``` - `setIfMissing` (not `set`) preserves route-owned headers — the layer fills gaps, it doesn't override decisions. - Register it first so the SPA fallback's responses pass through it too. - Roll out CSP as `content-security-policy-report-only` first; enforce after the report stream is quiet. ## CORS — usually needed less than you think A SPA served from the **same Worker** (workers-runtime.md) is same-origin — it needs **no CORS at all**. Configure CORS only when a *different* origin genuinely calls the API (another product's frontend, a partner site, localhost dev against a deployed API): ```typescript import { cors } from 'hono/cors'; app.use('/api/*', cors({ origin: ['https://app.example.com', 'https://staging.example.com'], // exact allowlist credentials: true, // cookies/bearer across origins allowHeaders: ['content-type', 'authorization'], maxAge: 86400, // cache preflights a day })); ``` - Register it **before** the auth middleware so preflight `OPTIONS` (which carries no credentials) is answered by the CORS layer, not 403'd by auth. - `credentials: true` forbids `origin: '*'` — the browser rejects the combo. Use the exact allowlist, or a function `(origin) => allowed(origin) ? origin : null`. - An origin function must return the origin string to allow, `null`/`''` to deny — returning `'*'` from it re-introduces the wildcard bug. - CORS is browser policy, not security: a curl caller ignores it entirely. Auth still does the gating. ## The production middleware stack The layers a production Worker actually wants, outermost first: | Layer | Package / shape | Note | |---|---|---| | Security headers | own 20-liner (above) or `hono/secure-headers` | Own it once product decides CSP | | Request id | `hono/request-id` → `c.get('requestId')` | Include it in every error log line | | CORS | `hono/cors` on the cross-origin pattern only | See above; skip for same-origin SPA | | Body limit | `hono/body-limit` `({ maxSize: 1024 * 1024 })` | Before any route that parses bodies; upload routes set their own cap | | Timeout | `hono/timeout` on slow upstream-calling patterns | Converts a hung upstream into a 504 | | Auth | own (this file) | The boundary | | Rate limiting | Workers rate-limit binding (`c.env.LIMITER.limit({ key })`) or a DO counter per key | Key on the *verified* identity, not IP alone | | Logging | `hono/logger` in dev; structured `console.log` JSON in prod | Workers Logs indexes JSON fields | Also in the box: `hono/etag`, `hono/jwt`, `hono/bearer-auth`, `hono/cache` (wraps the per-colo `caches` API — same per-colo caveats, workers-runtime.md). `hono/compress` is a no-op cost on Workers — the platform already compresses. Use built-ins for the generic 80%; write your own when the behaviour is a product decision (which headers, which auth-failure shape) — a 20-line middleware you fully own beats configuring around a generic one. -
openapi.md 3.7 KB
# OpenAPI — Documented APIs from Hono Routes Generating an OpenAPI spec (and interactive docs) from the routes themselves, so the documentation cannot drift from the implementation. Two libraries, one decision, and the gotchas. ## `@hono/zod-openapi` — schema-first routes `OpenAPIHono` replaces `Hono`; each route is declared as a `createRoute` object carrying its request/response schemas, then bound to a handler: ```typescript import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; const UserSchema = z.object({ id: z.string().openapi({ example: 'u_123' }), name: z.string(), }).openapi('User'); // named component in the spec const getUser = createRoute({ method: 'get', path: '/users/{id}', // OpenAPI syntax: {id}, NOT :id request: { params: z.object({ id: z.string() }) }, responses: { 200: { content: { 'application/json': { schema: UserSchema } }, description: 'The user' }, 404: { description: 'Not found' }, }, }); const app = new OpenAPIHono(); app.openapi(getUser, (c) => { const { id } = c.req.valid('param'); // typed + validated, as with zValidator return c.json({ id, name: 'Ada' }, 200); // response is checked against the schema TYPE }); app.doc('/doc', { openapi: '3.1.0', info: { title: 'My API', version: '1' } }); ``` Serve interactive docs beside it: ```typescript import { swaggerUI } from '@hono/swagger-ui'; app.get('/ui', swaggerUI({ url: '/doc' })); // or Scalar: @scalar/hono-api-reference ``` The gotchas that cost time: - **Path syntax flips**: `createRoute` paths use `{id}`; everything else in the app still uses `:id`. Mixing them silently 404s. - **Status codes are part of the contract**: `c.json(body, 200)` must name a status declared in `responses`, and the body must match that status's schema type — this is the drift-proofing, so don't cast around it. - **Validation-failure shape** defaults to Zod's; set `defaultHook` on the `OpenAPIHono` constructor once to emit your `{ error, message }` envelope (keep it consistent with `onError` — errors-validation.md). - **Auth in docs**: register security schemes via `app.openAPIRegistry.registerComponent('securitySchemes', 'Bearer', {...})` and reference them per-route with `security` — the docs UI's "Authorize" button doesn't exist until you do. - Sub-apps compose with `.route()` as usual, and `hc` RPC still works — `OpenAPIHono` is a superset of `Hono`. ## `hono-openapi` — annotate a plain Hono app The community `hono-openapi` package takes the opposite approach: keep plain `Hono` + `zValidator`-style validators, and add a `describeRoute` middleware per route that contributes spec metadata. Less invasive; the spec is only as complete as the annotations you remember to write. ## Decision | Signal | `@hono/zod-openapi` | `hono-openapi` annotations | No OpenAPI | |---|---|---|---| | API is a public/partner contract | Best — spec can't drift | OK | — | | Team consumes docs UI daily | Yes | Yes | — | | Internal API, TS-only consumers | Overkill — `hc` RPC gives types for free (rpc-clients.md) | Overkill | Right call | | Existing large plain-Hono app | Costly migration (every route becomes `createRoute`) | Incremental fit | — | | Hand-rolled validation, curated presenters | Poor fit — schemas ARE the contract here | Poor fit | Right call | The honest default for an internal Worker consumed by your own SPA is **no OpenAPI**: the typed client (RPC or hand-rolled + wire tests) is the contract. Reach for `@hono/zod-openapi` the day an external consumer needs docs — and then adopt it per sub-app (`app.route('/api/public', publicApi)` where only `publicApi` is an `OpenAPIHono`), not across the whole Worker at once. -
routing-and-request.md 6 KB
# Routing and the Request/Response Surface Router internals, path syntax, matching precedence, and the `c.req` / response helper surface — the mechanics under every route you write. ## Routers (what Hono picks and why you care) Hono selects a router automatically (`SmartRouter`): | Router | Character | When it's used | |---|---|---| | `RegExpRouter` | Compiles ALL routes into one regex — fastest match | Default when the route set allows it | | `TrieRouter` | General tree walk — supports everything | Fallback for patterns RegExpRouter can't compile | | `LinearRouter` / `PatternRouter` | Fast-register, small | `hono/quick` / `hono/tiny` presets for one-shot environments | Practical consequences: - On Workers, routes register per isolate boot; matching happens every request. The defaults are right — don't hand-pick a router without a measured reason. - `hono/tiny` (`PatternRouter`) cuts bundle size when you're near the Workers compressed-size limit and have few routes. ## Path syntax ```typescript app.get('/users/:id', …); // named param c.req.param('id') app.get('/users/:id/posts/:postId', …); // multiple params c.req.param() -> object app.get('/files/:name{.+\\.png}', …); // regex-constrained param app.get('/posts/:date{[0-9]+}/:title', …);// digits-only param app.get('/api/*', …); // wildcard (any depth) app.get('/about/:lang?', …); // optional param: /about and /about/en app.on('PURGE', '/cache', …); // custom method app.on(['PUT', 'DELETE'], '/thing', …); // several methods, one handler ``` Matching rules that surprise people: - **Registration order wins among equally-matching routes** — the first registered match handles the request. `app.get('/*', …)` registered early shadows everything after it (handlers don't fall through like middleware). - A handler matches its exact pattern only; middleware (`app.use`) matches by prefix pattern. `app.get('/api')` does not match `/api/`. - Params are URL-decoded; a `:param` never matches across `/`. ## `c.req` — the request surface | Accessor | Returns | Notes | |---|---|---| | `c.req.param('id')` | `string` | Route params; `c.req.param()` for all as an object | | `c.req.query('q')` | `string \| undefined` | First value; `c.req.query()` for all | | `c.req.queries('tag')` | `string[] \| undefined` | Repeated keys (`?tag=a&tag=b`) | | `c.req.header('x-foo')` | `string \| undefined` | Case-insensitive | | `await c.req.json<T>()` | `T` | **`T` is a cast, not a check** — validate (errors-validation.md) | | `await c.req.text()` / `.arrayBuffer()` / `.blob()` | body | Raw body reads — body is consumable once | | `await c.req.parseBody()` | form fields | `multipart/form-data` + urlencoded; files as `File` | | `c.req.valid('json')` | validated type | Only after validator middleware | | `c.req.raw` | `Request` | The real Request — pass to `ASSETS.fetch`, JWT verifiers, anything platform-level | | `c.req.path` / `c.req.url` / `c.req.method` | strings | `path` excludes query; `url` is absolute | - **Uploads:** for raw-body uploads read `c.req.raw.body` (a stream) and hand it straight to R2 (`bucket.put(key, body)`) — don't buffer whole files through `arrayBuffer()` unless you must enforce a byte cap by inspection. Enforce content-type against an **allowlist** and cap size before writing. - The body is a one-shot stream: reading it twice throws. If middleware must inspect the body, `c.req.raw.clone()` — and know that clones buffer. ## Responses | Helper | Produces | |---|---| | `c.json(obj, status?)` | `application/json`; status defaults 200 | | `c.text(s)` / `c.html(s)` | text/plain, text/html | | `c.body(data, status, headers?)` | raw body — `c.body(null, 204)` for no-content | | `c.redirect(url, status?)` | 302 default | | `c.notFound()` | delegates to `app.notFound` handler | | `new Response(...)` returned directly | fully manual — Hono passes it through | - Status codes are typed literals (`StatusCode`); a runtime number needs a cast (`status as 400`) — accept the cast at the single onError mapping site, not scattered through handlers. - Set response headers with `c.header('x-foo', 'bar')` *before* returning the helper, or build a manual `Response`. - `c.json` serialises with plain `JSON.stringify` — `Date` becomes an ISO string, `undefined` fields vanish, `BigInt` throws. Shape rows through a presenter function first (one place deciding what leaves the Worker per role, rather than serialising DB rows raw). ## Cookies ```typescript import { getCookie, setCookie, deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie'; setCookie(c, 'session_hint', value, { httpOnly: true, secure: true, sameSite: 'Strict', path: '/', maxAge: 60 * 60, }); const v = getCookie(c, 'session_hint'); deleteCookie(c, 'session_hint', { path: '/' }); // path must match the set ``` - Default to `httpOnly + secure + sameSite: 'Strict'`; loosen deliberately. - `deleteCookie` must repeat the `path` (and `domain`) used at set time or the browser keeps the original. - **A cookie the client can write is a hint, not a fact.** Re-verify authority server-side on every request (e.g. an impersonation cookie only takes effect when the *verified* identity is an admin — a forged cookie is then inert). Signed cookies (`setSignedCookie` with a secret) make tampering detectable, but signing doesn't replace the authority check: sign what you must trust client-side, re-verify what the server can decide itself. ## HTML / JSX `hono/jsx` renders server-side JSX (`c.html(<Page/>)`) with zero client runtime — the full treatment (setup, `jsxRenderer` layouts, async/Suspense streaming, `raw()` escaping rules, and the don't-grow-a-SPA-here scope guard) is **jsx-ssr.md**. For an actual SPA, build it separately and serve via the assets binding (workers-runtime.md). `hono/html` offers a `html` template literal with auto-escaping for one-off snippets — never string-concatenate HTML with user input. -
rpc-clients.md 6 KB
# RPC and Typed Clients — hc vs Hand-Rolled Hono ships an end-to-end typed client (`hc`). It is excellent for the apps it fits and quietly costly for the ones it doesn't. This file covers how it works, the inference rules that bite, and when a hand-rolled typed client is the better engineering call. ## The RPC mechanism Server: export the *type* of your routes. Client: `hc<AppType>` derives a typed call surface from it — paths, params, validated inputs, and JSON output types. ```typescript // server.ts import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; // CHAINED definition — the inference requirement (see below) const app = new Hono() .get('/posts/:id', (c) => c.json({ post: { id: c.req.param('id'), title: 't' } })) .post('/posts', zValidator('json', z.object({ title: z.string() })), (c) => { const body = c.req.valid('json'); return c.json({ ok: true, title: body.title }, 201); }); export type AppType = typeof app; // types only cross the boundary export default app; ``` ```typescript // client.ts import { hc } from 'hono/client'; import type { AppType } from './server'; const client = hc<AppType>('https://api.example.com'); const res = await client.posts[':id'].$get({ param: { id: '123' } }); if (res.ok) { const data = await res.json(); // typed: { post: { id: string, title: string } } } await client.posts.$post({ json: { title: 'hello' } }); // input typed from the validator ``` Key mechanics: - **Input types come from validator middleware.** No `zValidator` (or peer) on a route → `$post({ json })` is untyped. RPC and schema validation are a package deal (errors-validation.md). - **Output types come from `c.json(...)` inference**, per status code. - `res` is a real `Response` — check `res.ok`/status before `.json()`. - `hc` accepts a custom `fetch` (pass a Service Binding's fetcher for Worker-to-Worker calls, or a test app's `app.request`). ## The inference rules that bite 1. **Routes must be CHAINED for inference.** `const app = new Hono().get(...).post(...)` captures route types in `typeof app`; separate `app.get(...)` statements return types that are never accumulated. A file refactor from chained to statement style silently degrades the client to `unknown` — guard the shape with a comment at the definition site. 2. **Sub-apps compose via chained `.route()`:** `const routes = app.route('/posts', posts).route('/users', users); export type AppType = typeof routes;` — same chaining rule, one exported type. 3. **Compile-time cost grows with the surface.** Tens of routes with inferred unions can make tsc/editor latency real. Mitigations: split clients per sub-app (`hc<typeof postsApp>`), or precompute the client type once (`type Client = ReturnType<typeof hc<AppType>>`) and reuse it. 4. **The client imports server types** — the client build must resolve the server's TypeScript (monorepo path aliases, project references, or a published types package). Type-only imports (`import type`) keep server *code* out of the client bundle, but the *type graph* still has to compile in the client's tsconfig. ## When a hand-rolled typed client is the better call `hc` optimizes for "the server's inferred types ARE the contract." That's wrong for some real apps: - **The wire contract is curated, not inferred.** When responses pass through a serialization boundary (presenters that strip admin-only fields per role), the honest client type is the *presented* shape, which inference can't see — `c.json(present(row, isAdmin))` infers the union, not the per-role reality. - **No validator middleware** (hand-rolled validation) → no input typing from `hc` anyway, which removes half its value. - **Statement-style route registration** across a large composition root (middleware boundaries, conditional mounts) — restructuring 100+ routes into chained style to please inference is the tail wagging the dog. - **Client and server deliberately decoupled** (separate repos/builds, or a public API where the contract is versioned prose/OpenAPI, not your source). The hand-rolled pattern that scales: ```typescript // web/src/api/types.ts — the wire contract, stated explicitly (shared file or // copied deliberately; drift is caught by wire-level tests, not the compiler) export interface Commission { id: string; period: string; status: CommissionStatus; … } // web/src/api/client.ts — one tiny fetch wrapper + named functions async function request<T>(path: string, init?: RequestInit): Promise<T> { const res = await fetch(`/api${path}`, { headers: { 'content-type': 'application/json' }, ...init }); if (!res.ok) throw await toApiError(res); // parse { error, message } envelope return res.json() as Promise<T>; } export const getCommissions = (q?: { period?: string }) => request<{ commissions: Commission[] }>(`/commissions${qs(q)}`); export const settleCommission = (id: string, body: SettleInput) => request<{ commission: Commission }>(`/commissions/${id}/settle`, { method: 'POST', body: JSON.stringify(body) }); ``` Pair it with **wire-level contract tests** on the server (assert the exact field set a non-privileged role receives from the real route) — that's the drift tripwire the compiler was providing, moved to where the curated contract actually lives. ## Decision table | Signal | Use `hc` RPC | Hand-roll | |---|---|---| | Validation | zValidator/peer on every route | Hand-rolled assertions | | Route style | Chained (or willing to be) | Statement-style composition root | | Response shaping | `c.json` output IS the contract | Presenter/role-based field stripping | | Repo layout | Monorepo, shared tsconfig | Separate builds/repos, versioned contract | | Surface size | Small–medium, or split per sub-app | Very large, latency-sensitive tsc | | Consumers | Your own TS frontend | Multiple/external/non-TS consumers | Middle path: use `hc` for an *internal* sub-app that fits (chained, validated), hand-roll the curated public surface. Nothing forces one client for the whole Worker. -
runtime-adapters.md 4.9 KB
# Runtime Adapters — Node, Bun, Deno Deltas (and Porting Off Workers) Hono's core is Web-standard `Request`/`Response`, so routes, middleware, validation, errors, and RPC are portable verbatim. Everything that differs lives at the edges: how the server starts, env access, static files, WebSockets, and the platform services Workers provides that other runtimes don't. This file is the delta map, plus a porting checklist. ## Starting the server ```typescript // Cloudflare Workers (this skill's default) export default { fetch: app.fetch } satisfies ExportedHandler<Env>; // Node — the one runtime needing a real adapter package import { serve } from '@hono/node-server'; serve({ fetch: app.fetch, port: 3000 }); // Bun — Bun.serve speaks fetch natively export default { port: 3000, fetch: app.fetch }; // Deno Deno.serve({ port: 3000 }, app.fetch); ``` Node's adapter translates Node's `IncomingMessage`/`ServerResponse` to Web `Request`/`Response`; Node 18+ required. Bun and Deno need no translation. ## The per-runtime seams | Concern | Workers | Node | Bun | Deno | |---|---|---|---|---| | Env/config | `c.env` bindings | `process.env` | `process.env` / `Bun.env` | `Deno.env` | | Static files | assets binding (workers-runtime.md) | `serveStatic` from `@hono/node-server/serve-static` | `serveStatic` from `hono/bun` | `serveStatic` from `hono/deno` | | WebSockets | `upgradeWebSocket` from `hono/cloudflare-workers` (or a DO) | `@hono/node-ws` (`createNodeWebSocket` + `injectWebSocket` on the server) | `createBunWebSocket` from `hono/bun` (pass its `websocket` to `Bun.serve`) | `upgradeWebSocket` from `hono/deno` | | Cron | `scheduled()` handler | system cron / node-cron / your scheduler | same as Node | `Deno.cron` | | Post-response work | `ctx.waitUntil` | just don't await (process persists) | same | same | | `caches` API | per-colo cache | absent — in-memory LRU / Redis | absent | partial (`caches` exists on Deploy) | | Install | `npm i hono` | `npm i hono @hono/node-server` | `bun add hono` | JSR: `deno add jsr:@hono/hono` | Two `hono/adapter` helpers keep shared code honest: ```typescript import { env, getRuntimeKey } from 'hono/adapter'; const key = env<{ API_KEY: string }>(c).API_KEY; // reads c.env OR process.env OR Deno.env getRuntimeKey(); // 'workerd' | 'node' | 'bun' | 'deno' | ... ``` Use `env(c)` in any middleware you intend to publish or reuse across runtimes; keep runtime branching (`getRuntimeKey()`) out of route handlers — isolate it in the composition root or an adapter module, or portability rots one `if` at a time. ## Porting a Workers app to Node (the common direction) 1. **Bindings → constructed dependencies.** `c.env.DB`/`c.env.FILES` have no Node equivalent; construct clients (Postgres/SQLite driver, S3 client) at boot and hand them to the app — a `createApp(deps)` factory that `c.set`s them in a first middleware is the least-invasive shape, and it makes the Workers build cleaner too. 2. **`ASSETS.fetch` catch-all → `serveStatic`.** Replace the SPA fallback pair with `serveStatic({ root: './web/dist' })` + a `serveStatic({ path: 'index.html' })` fallback. Keep the JSON-404-for-`/api/*` route — that split is runtime-independent. 3. **`waitUntil` → fire-and-forget or a queue.** On Node the process outlives the response, so `void promise.catch(log)` works; anything needing guaranteed delivery was queue-shaped on Workers anyway. 4. **`scheduled()` → a scheduler.** The cron branches become named jobs invoked by node-cron/systemd — keep them as the same exported functions the Workers `scheduled()` dispatcher called, and only the dispatcher changes. 5. **Per-colo `caches` → explicit cache.** An in-memory LRU reproduces the per-instance semantics honestly; Redis upgrades it to shared. 6. **Re-run the same tests.** `app.request()` tests are runtime-neutral; only the pool-workers suite (real bindings) needs a Node-side equivalent for whatever replaced the bindings. Porting *to* Workers reverses the list — the usual sticking points are long-lived sockets (→ Durable Objects, durable-objects.md), filesystem access (→ R2/KV), and unbounded background work (→ queues + `waitUntil`). ## Bun/Deno notes worth knowing - **Bun:** `bun test` runs `app.request()` suites directly and fast; Vitest also works. `createBunWebSocket` returns both the middleware and the `websocket` handler object you must pass to `Bun.serve` — forgetting the second half compiles and then 500s on upgrade. - **Deno:** import Hono from JSR (`jsr:@hono/hono`), not the npm shim, for first-class types; permissions apply (`--allow-net`, `--allow-env`) — a middleware reading env without `--allow-env` throws at request time, not boot. - Both runtimes run the same `app.request()` test suites unchanged — which is the practical payoff of keeping runtime branching out of handlers. -
streaming-and-realtime.md 5.7 KB
# Streaming, SSE, WebSockets, and Worker-to-Worker Calls Long-lived and incremental responses from a Hono Worker: streamed bodies, server-sent events, WebSockets (and when a Durable Object must own them), plus proxying and service-binding calls. ## Streamed responses (`hono/streaming`) ```typescript import { stream, streamText, streamSSE } from 'hono/streaming'; // Raw bytes — e.g. piping a generated file without buffering it app.get('/export.csv', (c) => stream(c, async (s) => { await s.write(header); for await (const row of rows()) await s.write(encode(row)); }), ); // Incremental text (LLM token relays, progress logs) app.post('/api/generate', (c) => streamText(c, async (s) => { for await (const chunk of model.generate(prompt)) await s.write(chunk); }), ); ``` - The handler returns immediately; the callback keeps writing on the open body. Errors mid-stream can't change the status line (it's already sent) — write an in-band error sentinel the client understands, and pass an `onError` third argument to close cleanly. - `s.writeln`, `s.sleep`, `s.close`, and `c.req.raw.signal.aborted` / `s.onAbort(cb)` cover pacing and client-disconnect cleanup. Check abort in long loops — writing to a gone client is wasted CPU time. - Workers streams responses natively; there's no buffering to disable, but the invocation is still bounded by Workers CPU/duration limits — streaming is for minutes at most, not persistent connections (that's WebSockets/DO territory). ## Server-sent events ```typescript app.get('/api/events', (c) => streamSSE(c, async (s) => { let id = 0; while (!c.req.raw.signal.aborted) { const events = await pollSource(c.env); // or a queue/DO handoff for (const e of events) { await s.writeSSE({ data: JSON.stringify(e), event: e.type, id: String(++id) }); } await s.sleep(5000); } }), ); ``` - SSE through a plain Worker is a **poll relay** — each connected client holds an invocation open. Fine for admin dashboards (few clients); wrong for fanning out to thousands (that's a Durable Object with hibernatable WebSockets, or a push service). - Send a retry hint (`s.writeSSE({ data: '', event: 'ping' })` heartbeats every ~30s) so intermediaries don't reap the idle connection. - `EventSource` can't set headers — cookie auth works, bearer auth doesn't; for token auth use a query-string ticket minted by an authenticated call (short-lived, single-use), not the long-lived token in the URL. ## WebSockets Plain Worker upgrade (stateless per-socket, no cross-socket coordination): ```typescript import { upgradeWebSocket } from 'hono/cloudflare-workers'; app.get('/ws', upgradeWebSocket((c) => ({ onMessage(evt, ws) { ws.send(`echo ${evt.data}`); }, onClose() {}, }))); ``` Reality check before shipping that: - A Worker-held socket ties an invocation to the connection and cannot share state with other sockets. **Any feature described as "broadcast", "room", "presence", or "sync" is a Durable Object feature**: route the upgrade to a DO (`c.env.ROOM.get(id).fetch(c.req.raw)`) and use the DO WebSocket API — with hibernation (`state.acceptWebSocket(ws)` + `webSocketMessage` handlers) so idle sockets don't bill wall-clock duration. - Auth happens at upgrade time (it's a GET through your normal middleware); after upgrade there is no per-message auth — bind identity to the socket at accept and treat the connection as a session. - The `upgradeWebSocket` import is per-runtime (`hono/cloudflare-workers`, `hono/deno`, `hono/bun`, `@hono/node-ws`) — one of the few non-portable seams; the full per-runtime map is runtime-adapters.md. ## Proxying and Worker-to-Worker (service bindings) ```typescript // Pass-through proxy of an upstream (rewrite path, forward body/headers): app.all('/upstream/*', (c) => { const url = new URL(c.req.url); url.hostname = 'internal.example.com'; url.pathname = url.pathname.replace(/^\/upstream/, ''); // New Request from the original: method/headers/body carry over; mutate a COPY // of headers (the original's are immutable). const headers = new Headers(c.req.raw.headers); headers.delete('cookie'); // never leak session cookies upstream return fetch(new Request(url, { method: c.req.method, headers, body: c.req.raw.body })); }); // Service binding: call another Worker with zero network hop interface Env { REPORTS: Fetcher } // [[services]] binding in wrangler config app.get('/api/report', (c) => c.env.REPORTS.fetch(c.req.raw)); ``` - A returned upstream `Response` is streamed through — no buffering — but its headers are immutable; rebuild if you must edit (middleware.md). - Service bindings invoke the target Worker directly (same thread, no egress): prefer them over public-URL `fetch` between your own Workers — faster, free of DNS/TLS, and the target can trust the caller. An `hc` RPC client accepts a binding's fetcher: `hc<AppType>('https://internal', { fetch: c.env.REPORTS.fetch.bind(c.env.REPORTS) })` — note the `.bind()`: an unbound method reference throws "Illegal invocation" (workers-runtime.md). - Forwarding `c.req.raw.body` consumes it — a proxy handler can't also read the body; decide per route. ## Choosing the mechanism | Need | Use | |---|---| | Incremental one-shot response (LLM tokens, big export) | `stream` / `streamText` | | Server→client event feed, few clients, reconnect-tolerant | `streamSSE` (+ heartbeat) | | Bidirectional, or many clients, or shared room state | WebSockets **in a Durable Object** (hibernation) | | Client polling an expensive read | Plain GET + per-colo `caches` collapse (workers-runtime.md) | | Worker calling your other Worker | Service binding, not public fetch | -
testing.md 7.4 KB
# Testing — app.request, vitest-pool-workers, Middleware Isolation Hono apps are directly invokable — no server, no port. This file covers unit calls, the full vitest-pool-workers setup (real bindings inside workerd), an auth-harness pattern for JWT-protected apps, and testing middleware alone. ## `app.request()` / `app.fetch()` — the unit seam ```typescript // Simple: path + RequestInit + env (the Bindings object) const res = await app.request('/api/health', {}, env); expect(res.status).toBe(200); await expect(res.json()).resolves.toEqual({ ok: true }); // Full control (method, headers, host — needed when auth branches on hostname): const res2 = await app.fetch( new Request('https://app.example.com/api/me', { method: 'GET', headers: { 'authorization': `Bearer ${key}` }, }), env, ); ``` - The third argument is `c.env` — pass real bindings (pool-workers) or a hand-built stub for pure-logic tests. - `app.fetch(new Request(...))` whenever the URL matters: host-based tenancy, absolute-URL parsing, cookies (set a `cookie` header). - These run the *entire* pipeline — middleware, routing, `onError` — so a test asserting a 403 is testing the real boundary, not a mock of it. - Sub-apps are apps: `timeApi.request('/entries', {}, env)` exercises a feature app mount-relative, without the parent's middleware (useful for isolating behaviour; not a substitute for at least some through-the-parent tests, since the parent's middleware sets the context the sub-app assumes). ## vitest-pool-workers: real bindings inside workerd `@cloudflare/vitest-pool-workers` runs the test file *inside* the Workers runtime, with real D1/KV/R2/DO bindings. (A ready-to-adapt copy of the config below ships as this skill's `assets/vitest.config.template.ts`.) ```typescript // vitest.config.ts import { defineWorkersConfig, readD1Migrations } from '@cloudflare/vitest-pool-workers/config'; export default defineWorkersConfig(async () => { const migrations = await readD1Migrations('./migrations'); return { test: { include: ['test/**/*.test.ts'], // Exclude sibling worktrees: .claude/worktrees/* carry their own stale copy // of test/ + migrations/ and would double-count / fail this run. exclude: ['**/node_modules/**', '**/.claude/**', 'web/**'], setupFiles: ['./test/apply-migrations.ts'], poolOptions: { workers: { singleWorker: true, // one workerd for the suite (faster, shared module state) isolatedStorage: true, // per-TEST-FILE storage; writes don't leak across files miniflare: { compatibilityDate: '2024-12-01', compatibilityFlags: ['nodejs_compat'], d1Databases: { DB: 'my-app-test' }, bindings: { TEST_MIGRATIONS: migrations, // handed to the setup file SOME_CONFIG_VAR: 'test-value', // plain-var bindings for the suite }, }, }, }, }, }; }); ``` ```typescript // test/apply-migrations.ts — run the REAL migrations so tests hit the same // schema (and CHECK constraints) as production. import { applyD1Migrations, env } from 'cloudflare:test'; await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); ``` ```typescript // In tests: `env` is the typed binding set from the config above. import { env } from 'cloudflare:test'; import { app } from '../src/index'; const res = await app.request('/api/things', {}, { ...env, EXTRA_VAR: 'per-suite override' }); ``` Notes: - Type `env` by declaring `interface ProvidedEnv extends Env {}` in a `test/env.d.ts` (`declare module 'cloudflare:test'`). - Spread-and-override (`{ ...env, KEY: 'x' }`) is the idiom for per-test env variation — bindings are just an object at this seam. - `isolatedStorage` isolation is per test *file*; within a file, use `beforeEach` re-seeding for a known DB state. - **workerd version lag:** the local workerd that pool-workers ships is pinned by your `wrangler`/pool-workers package version and can trail (or lead) the deployed runtime. Behaviour keyed to `compatibilityDate` matches; brand-new runtime features/fixes may not. Keep the config's `compatibilityDate` equal to wrangler config's, update the toolchain deliberately, and treat "passes local, fails deployed" as a version-skew suspect. - Cron handlers: pool-workers can't fire real cron; call the export directly — `worker.scheduled({ cron: '*/5 * * * *' } as ScheduledController, env, ctx)` with a stub `ctx` collecting `waitUntil` promises you then `await`. ## Auth harness: testing behind JWT verification For an app whose middleware verifies JWTs against a remote JWKS, generate a throwaway keypair in the suite and intercept the JWKS fetch: ```typescript // test/access-harness.ts (pattern) import { SignJWT, exportJWK, generateKeyPair, importJWK } from 'jose'; // 1. beforeAll: generate an RS256 keypair (+ a mismatched "bad" key for // negative tests) and build a JWKS from the public key. // 2. Patch globalThis.fetch: requests to the JWKS URL return the test JWKS; // everything else passes through to the original fetch. Restore in afterAll. // 3. signAccessToken(email, { aud, issuer, expOffsetSec, badKey }): a SignJWT // helper with correct defaults and overridable claims for negative cases. ``` The payoff is a **negative-auth matrix** against the real app: missing token, garbage token, wrong audience, wrong issuer, expired, wrong-key signature, valid-token-but-unknown-user — each asserted to 403 through `app.fetch`. These tests pin the security boundary at the wire, where it actually holds; a repo-layer test can pass while a route leaks. If the platform verifies for you in production (e.g. Cloudflare Access in front), your middleware must *still* verify — the harness proves it does. ## Testing middleware in isolation Mount just the middleware on a throwaway app with a probe route: ```typescript import { Hono } from 'hono'; import { securityHeaders } from '../src/http/security-headers'; function harness() { const app = new Hono(); app.use('*', securityHeaders()); app.get('/probe', (c) => c.json({ ok: true })); app.get('/custom', (c) => { const res = c.json({ ok: true }); res.headers.set('x-frame-options', 'SAMEORIGIN'); // route-owned header return res; }); return app; } it('fills missing security headers', async () => { const res = await harness().request('/probe'); expect(res.headers.get('x-content-type-options')).toBe('nosniff'); }); it('preserves route-owned headers', async () => { const res = await harness().request('/custom'); expect(res.headers.get('x-frame-options')).toBe('SAMEORIGIN'); }); ``` This is the right level for ordering/onion behaviour (inbound vs outbound, short-circuits, header merging). Auth middleware is the exception: test it through the real composed app (above), because its job *is* the composition. ## What to test at which level | Level | Seam | Use for | |---|---|---| | Pure function | direct call | presenters/serializers, error mapping helpers, validators | | Middleware harness | tiny Hono + probe routes | onion behaviour, header policy, bearer compare | | Sub-app | `subApp.request()` | feature routes with stubbed context/bindings | | Composed app | `app.fetch(new Request, env)` | auth matrix, 404 split, mount topology, wire-level field visibility | | Composed app + real bindings | pool-workers `env` | anything touching D1/KV/R2; migration-schema fidelity | -
workers-runtime.md 7.1 KB
# Workers Runtime Integration — SPA Serving, Cron/Queues, Runtime Gotchas One Worker frequently serves an API, a SPA, cron jobs, and queue consumers. This file covers wiring all of them around one Hono app, plus the Workers runtime behaviours that bite Hono code specifically. ## Serving a SPA + API from one Worker (static assets binding) ```jsonc // wrangler.jsonc { "assets": { "directory": "./web/dist", // built SPA "binding": "ASSETS", // exposes env.ASSETS (Fetcher) "not_found_handling": "single-page-application", // unknown paths -> index.html "run_worker_first": ["/api/*", "/vesper/*", "/ingest/*"] // Worker sees these BEFORE assets } } ``` ```typescript interface Env { ASSETS: Fetcher; /* … */ } // After all API routes: app.all('/api/*', (c) => c.json({ error: 'not_found' }, 404)); // JSON 404, never the shell app.all('*', (c) => c.env.ASSETS.fetch(c.req.raw)); // hand everything else to assets ``` - Without `run_worker_first`, requests matching an asset path are served directly from the asset layer and your middleware (auth, security headers) never runs for them. List every non-asset pattern the Worker owns; keep the asset platform serving the rest (it's free and cached). - `not_found_handling: "single-page-application"` gives deep links (`/app/settings`) the shell with a 200; the SPA router takes over. - Responses from `ASSETS.fetch` have **immutable headers** — outbound middleware must rebuild the Response to add headers (middleware.md). - Cache behaviour: the asset layer sets sane defaults (hashed assets long-lived, HTML no-cache). Add app-owned headers via the outbound middleware if needed. ## `fetch` + `scheduled` + `queue` in one export Hono owns HTTP; the other handlers sit beside it in the default export: ```typescript export default { fetch: app.fetch, scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) { // Branch on the cron expression — one Worker, many schedules (all listed in // wrangler config `triggers.crons`). Keep each branch a thin dispatcher. if (controller.cron === '*/5 * * * *') { ctx.waitUntil(drainNotifications(env)); ctx.waitUntil(generateRecurring(env)); // self-guarded: no-op when already done } else if (controller.cron === '0 2 * * *') { ctx.waitUntil(nightlySync(env)); } else { ctx.waitUntil(weeklyJobs(env)); } }, async queue(batch: MessageBatch<JobMsg>, env: Env, ctx: ExecutionContext) { for (const msg of batch.messages) { try { await handleJob(msg.body, env); msg.ack(); } catch { msg.retry(); } } }, } satisfies ExportedHandler<Env>; ``` Discipline that keeps this maintainable: - **`satisfies ExportedHandler<Env>`** typechecks the whole export against the runtime contract without widening. - **Independent `ctx.waitUntil` per job**, not one chained promise — one job's failure must not suppress its siblings. - **Cron jobs are self-guarding**: gate on config presence (no token → no-op), on state ("already generated this month"), and wrap per-item work in try/catch so one bad item doesn't kill the sweep. Crons re-run; make them idempotent. - Cron/queue code shares the domain layer with HTTP handlers — it just isn't behind Hono, so nothing from the middleware context (identity, scoped repo) exists. Build the equivalent explicitly (a system identity, per-tenant loops). - Local testing: `wrangler dev --test-scheduled` exposes `GET /__scheduled?cron=*+*+*+*+*`; in vitest, call `scheduled()` directly with a stub controller/ctx (testing.md). ## `waitUntil` semantics `ctx.waitUntil(p)` (in Hono: `c.executionCtx.waitUntil(p)`) keeps the invocation alive until `p` settles, *after* the response is sent. - Register **before returning** — a floating promise not passed to `waitUntil` is cancelled when the response completes. - Use it for: notification fan-out, cache writes, audit logs — anything the caller shouldn't wait for and can survive losing. - Don't use it for work the response's correctness depends on, or anything needing a guaranteed outcome (that's a queue's job — `waitUntil` work is lost on isolate eviction/crash and has a post-response time budget). - Errors inside a `waitUntil` promise don't affect the response; they surface in logs/tail only. Wrap in try/catch that records failure somewhere durable if you'd need to know. ## The `caches` API is per-colo `caches.default` / `caches.open()` is a **per-data-center** cache, not a global store: - A `cache.put` in one colo is invisible in every other; hit rate follows traffic locality. - Correct uses: collapsing a poll storm (many clients, one upstream call per ~45s window per colo), response caching where recomputation is cheap-but-annoying. - Wrong uses: anything that must be seen globally after a write (that's KV — eventually consistent — or D1/DO for strong consistency). There is no cross-colo invalidation; design for TTL expiry, not purge. - Cache keys must be derived from **trusted, resolved** values (e.g. the post-authorization resource id), never raw client input — a key built from an unvalidated query param lets one caller poison another's cache line. ## Detached fetch — "Illegal invocation" Storing the global `fetch` on an object (the injectable-fetch testing pattern) and calling it as a method throws in workerd: ```typescript class ApiClient { constructor(private fetchImpl: typeof fetch = fetch) {} async call(url: string) { // BROKEN in Workers: this.fetchImpl(url) invokes fetch with `this` bound to // the ApiClient instance -> TypeError: Illegal invocation. // FIX: detach to a bare local so the receiver is stripped: const doFetch = this.fetchImpl; return doFetch(url); } } ``` Notes: mock `fetchImpl`s in tests are plain functions and never trip this — the bug ships to production while the suite stays green, which is exactly why the detach idiom should be unconditional. `const doFetch = this.fetchImpl ?? fetch` and `fetch.bind(globalThis)` also work; the bare-local detach is the lowest-ceremony fix. (Node ≥18 has the same receiver rule, so the idiom is portable.) ## Assorted runtime traps | Trap | Detail | |---|---| | Module state ≠ per-request state | Module-level variables persist across requests in an isolate (good: JWKS cache; bad: anything request-scoped — that's `c.set`) | | No timers between requests | An isolate may be evicted anytime after the response (+`waitUntil` budget); never rely on `setInterval`/background loops — that's cron's job | | `wrangler dev` host rewrite | Dev rewrites the request host to your route pattern; pin `[dev] host` when middleware branches on hostname | | Bundle size | Workers has a compressed-size limit; validator libs and polyfills add up — prefer tree-shakeable deps (valibot, `zod/mini`) when close to it | | Secrets gating | Optional secrets (`KEY?: string`): the dependent route returns 503 and the cron no-ops while unset. Never log secret values; log presence booleans | | Subrequest limits | Each request has a subrequest budget; per-item external calls inside a big loop belong in a queue consumer, not a request handler |
-
-
scripts
-
check-hono-facts.py 10 KB
#!/usr/bin/env python3 """Staleness verifier for hono-ops: the documented Hono major line and the named ecosystem packages must stay real, stated, and current. hono-ops assumes Hono v4 and names @hono/zod-validator and @cloudflare/vitest-pool-workers as the validation/testing packages. Those are the facts that drift silently (SKILL-RESOURCE-PROTOCOL.md §7): Hono ships a v5 and the middleware/RPC advice quietly rots, or a package is renamed and every install command in the prose 404s. Two modes: --offline (default, safe for PR CI): structural consistency, no network. * assets/hono-facts.json parses, carries the schema + an as_of date * every catalogued fact's prose_token is still named in the skill prose (SKILL.md + references/*.md + assets/worker-template.ts) * SKILL.md still carries a dated "Verified against Hono v<major> (<year>)" currency note whose major matches the catalog --live (scheduled freshness job, never a PR gate): does each package still resolve on npm, and has hono's major moved off the documented line? Usage: check-hono-facts.py [--offline | --live] [--catalog FILE] [--skill DIR] [--json] [--timeout S] [-q] Input: argv flags only (no stdin). Output: stdout = findings (plain rows, or a --json envelope). Data only. Stderr: the verdict line, notices, errors. Exit: 0 ok, 2 usage, 3 catalog/skill missing, 4 catalog unparseable, 7 npm unreachable (live, advisory - never a real failure), 10 drift found (offline: fact no longer named / currency note gone or mismatched; live: package gone from npm or hono major drifted) Examples: check-hono-facts.py --offline # PR CI: facts <-> prose consistency check-hono-facts.py --live # weekly: hono still v4 on npm? check-hono-facts.py --offline --json | jq '.data[]' """ from __future__ import annotations import argparse import json import re import sys import urllib.error import urllib.parse import urllib.request from pathlib import Path EX_OK = 0 EX_USAGE = 2 EX_NOTFOUND = 3 EX_UNPARSEABLE = 4 EX_UNAVAILABLE = 7 EX_DRIFT = 10 SCHEMA = "claude-mods.hono-ops.facts/v1" FACT_KEYS = ("hono", "zod_validator", "pool_workers", "zod_openapi", "node_server") HERE = Path(__file__).resolve().parent DEFAULT_CATALOG = HERE.parent / "assets" / "hono-facts.json" DEFAULT_SKILL = HERE.parent NPM_REGISTRY = "https://registry.npmjs.org" CURRENCY_RE = re.compile(r"Verified against Hono v(\d+)\s*\((\d{4})\)", re.IGNORECASE) AS_OF_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") def load_catalog(path: Path) -> dict: if not path.is_file(): print(f"error: facts catalog not found: {path}", file=sys.stderr) raise SystemExit(EX_NOTFOUND) try: data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict) or data.get("schema") != SCHEMA: raise ValueError(f"schema must be {SCHEMA!r}") if not AS_OF_RE.match(str(data.get("as_of", ""))): raise ValueError(f"as_of must be YYYY-MM-DD, got {data.get('as_of')!r}") for key in FACT_KEYS: fact = data.get(key) if not isinstance(fact, dict) or "prose_token" not in fact or "package" not in fact: raise ValueError(f"fact {key!r} missing prose_token/package") return data except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc: print(f"error: could not parse catalog {path}: {exc}", file=sys.stderr) raise SystemExit(EX_UNPARSEABLE) def read_corpus(skill_dir: Path) -> tuple[str, str]: """Return (skill_md_text, all_prose_text) across SKILL.md + references/*.md + assets/worker-template.ts (the starter names Hono v4 in its header).""" doc = skill_dir / "SKILL.md" if not doc.is_file(): print(f"error: SKILL.md not found under {skill_dir}", file=sys.stderr) raise SystemExit(EX_NOTFOUND) skill_md = doc.read_text(encoding="utf-8", errors="replace") parts = [skill_md] ref_dir = skill_dir / "references" if ref_dir.is_dir(): for ref in sorted(ref_dir.glob("*.md")): parts.append(ref.read_text(encoding="utf-8", errors="replace")) template = skill_dir / "assets" / "worker-template.ts" if template.is_file(): parts.append(template.read_text(encoding="utf-8", errors="replace")) return skill_md, "\n".join(parts) def check_offline(catalog: dict, skill_dir: Path) -> list[dict]: skill_md, corpus = read_corpus(skill_dir) lower = corpus.lower() findings: list[dict] = [] m = CURRENCY_RE.search(skill_md) if not m: findings.append({"check": "currency-note", "status": "drift", "detail": "no dated 'Verified against Hono v<major> (<year>)' note in SKILL.md"}) elif m.group(1) != str(catalog["hono"].get("documented_major")): findings.append({"check": "currency-note", "status": "drift", "detail": f"currency note says v{m.group(1)} but catalog documents v{catalog['hono'].get('documented_major')}"}) else: findings.append({"check": "currency-note", "status": "ok", "detail": f"currency note v{m.group(1)} dated {m.group(2)}"}) for key in FACT_KEYS: token = str(catalog[key]["prose_token"]) if token.lower() in lower: findings.append({"check": f"fact:{key}", "status": "ok", "detail": f"{token!r} named in skill prose"}) else: findings.append({"check": f"fact:{key}", "status": "drift", "detail": f"prose_token {token!r} no longer named in skill prose"}) return findings def _npm_latest(pkg: str, timeout: float) -> tuple[str, str]: """Return (status, version-or-detail). status in ok|notfound|unavailable.""" url = f"{NPM_REGISTRY}/{urllib.parse.quote(pkg, safe='@')}/latest" req = urllib.request.Request(url, headers={"User-Agent": "claude-mods-hono-ops-check/1", "Accept": "application/json"}) try: with urllib.request.urlopen(req, timeout=timeout) as resp: payload = resp.read().decode("utf-8", errors="replace") except urllib.error.HTTPError as exc: if exc.code in (404, 410): return "notfound", str(exc.code) return "unavailable", str(exc.code) except (urllib.error.URLError, TimeoutError, OSError): return "unavailable", "" try: return "ok", json.loads(payload).get("version", "") except json.JSONDecodeError: return "unavailable", "bad-json" def check_live(catalog: dict, timeout: float) -> list[dict]: findings: list[dict] = [] for key in FACT_KEYS: fact = catalog[key] pkg = str(fact["package"]) status, ver = _npm_latest(pkg, timeout) if status == "notfound": findings.append({"check": f"npm:{key}", "status": "drift", "detail": f"{pkg} gone from npm - renamed/removed, review skill"}) continue if status != "ok": findings.append({"check": f"npm:{key}", "status": "unavailable", "detail": f"npm registry unreachable for {pkg}"}) continue documented = fact.get("documented_major") m = re.match(r"\s*(\d+)", ver) latest_major = m.group(1) if m else "" if documented is not None and latest_major and latest_major != str(documented): findings.append({"check": f"npm:{key}", "status": "drift", "detail": f"{pkg}@{ver} major {latest_major} != documented v{documented}.x - review skill"}) else: findings.append({"check": f"npm:{key}", "status": "ok", "detail": f"latest {ver}"}) return findings def main(argv: list[str]) -> int: p = argparse.ArgumentParser( prog="check-hono-facts.py", description="Verify hono-ops' Hono major + package facts stay stated (offline) and current (live).", epilog=( "Examples:\n" " check-hono-facts.py --offline\n" " check-hono-facts.py --live\n" " check-hono-facts.py --offline --json | jq '.data[]'\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) mode = p.add_mutually_exclusive_group() mode.add_argument("--offline", action="store_true", help="structural consistency, no network (default)") mode.add_argument("--live", action="store_true", help="probe npm for package/major drift") p.add_argument("--catalog", default=str(DEFAULT_CATALOG), help="facts catalog JSON") p.add_argument("--skill", default=str(DEFAULT_SKILL), help="skill directory (SKILL.md + references/ + assets/)") p.add_argument("--timeout", type=float, default=10.0, help="per-request timeout seconds (live)") p.add_argument("--json", action="store_true", help="emit a JSON envelope") p.add_argument("-q", "--quiet", action="store_true", help="suppress stderr progress/summary") try: args = p.parse_args(argv) except SystemExit as exc: return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK) catalog = load_catalog(Path(args.catalog)) live = args.live findings = (check_live(catalog, args.timeout) if live else check_offline(catalog, Path(args.skill))) drift = [f for f in findings if f["status"] == "drift"] unavailable = [f for f in findings if f["status"] == "unavailable"] if args.json: print(json.dumps({"data": findings, "meta": {"count": len(findings), "mode": "live" if live else "offline", "schema": SCHEMA}}, indent=2)) else: for f in findings: print(f"{f['check']}\t{f['status']}\t{f['detail']}") if not args.quiet: verdict = ("DRIFT" if drift else "UNAVAILABLE" if unavailable else "OK") print(f"check-hono-facts: {verdict} ({len(findings)} checks, {len(drift)} drift)", file=sys.stderr) if drift: return EX_DRIFT if unavailable: return EX_UNAVAILABLE return EX_OK if __name__ == "__main__": sys.exit(main(sys.argv[1:])) -
route-inventory.py 11.4 KB
#!/usr/bin/env python3 """List a Hono app's routes, middleware, and mounts from TypeScript source; lint registration order. Usage: route-inventory.py [OPTIONS] PATH Input: PATH — a .ts/.tsx file or a source directory (scanned recursively; node_modules, dist, build, and dot-directories are skipped) Output: stdout, data only. Default: TSV rows kind<TAB>method<TAB>path<TAB>app<TAB>file:line --json: {"data": [...], "meta": {"count": N, "schema": "claude-mods.hono-ops.route-inventory/v1"}} --check: findings only (same TSV/JSON shape, kind=finding) Stderr: headers, progress, warnings, errors Exit: 0 ok/clean, 2 usage, 3 path not found, 10 --check produced findings (confirm each is deliberate) Notes: Pure-regex static analysis (no TypeScript compiler API required — the native TS 7 toolchain ships none). Per-file: `app.route()` mounts are listed but sub-app files are not expanded into their mount prefix. The --check linter runs three registration-order lints (Hono matches in registration order; each finding carries an `issue` field in --json): `bypass` — a route registered before a middleware whose pattern covers it silently skips that middleware; `duplicate` — the same (method, path) registered twice, second is dead; `shadowed` — a route after an earlier broader same-method route can never match. A bypass is either the #1 Hono ordering bug or a deliberate pre-auth exception; the linter's job is to make you say which. Examples: route-inventory.py src/ route-inventory.py --json src/ | jq '.data[] | select(.kind=="mount")' route-inventory.py --check src/ # exit 10 = order findings route-inventory.py --check --json src/index.ts """ import argparse import json import re import sys from pathlib import Path SCHEMA = "claude-mods.hono-ops.route-inventory/v1" METHODS = ("get", "post", "put", "patch", "delete", "options", "all") SKIP_DIRS = {"node_modules", "dist", "build", "coverage"} # Statement-style: `app.get(` / `export const x = app.route(` etc. STMT_RE = re.compile( r"^\s*(?:export\s+)?(?:const\s+\w+\s*=\s*)?(?:await\s+)?" r"(?P<app>[A-Za-z_$][\w$]*)\.(?P<verb>get|post|put|patch|delete|options|all|on|use|route|onError|notFound)\s*\(" ) # Chained-style continuation: ` .get('/x', ...)` CHAIN_RE = re.compile(r"^\s*\.(?P<verb>get|post|put|patch|delete|options|all|on|use|route)\s*\(") # `const app = new Hono<...>()` — anchors chained-call attribution to the right app var. NEW_APP_RE = re.compile(r"^\s*(?:export\s+)?(?:const|let|var)\s+(?P<app>[A-Za-z_$][\w$]*)\s*=\s*new\s+Hono\b") def string_args(line: str, start: int, max_args: int = 2) -> list[str]: """Return up to max_args leading string-literal arguments after position `start`. Stops at the first argument that is not a plain string literal (template literals and identifiers end the scan — we only trust what we can read). """ out: list[str] = [] i = start n = len(line) while len(out) < max_args: while i < n and line[i] in " \t": i += 1 if i >= n or line[i] not in "'\"": break quote = line[i] j = i + 1 buf = [] while j < n and line[j] != quote: if line[j] == "\\" and j + 1 < n: buf.append(line[j + 1]) j += 2 continue buf.append(line[j]) j += 1 if j >= n: # unterminated on this line — bail break out.append("".join(buf)) i = j + 1 while i < n and line[i] in " \t": i += 1 if i < n and line[i] == ",": i += 1 else: break return out def pattern_to_regex(pattern: str) -> re.Pattern: """Compile a Hono path pattern ('*', '/api/*', '/x/:id') to a full-match regex.""" if pattern in ("", "*", "/*"): return re.compile(r".*") esc = re.escape(pattern) esc = esc.replace(r"\*", ".*") esc = re.sub(r"\\:[A-Za-z_][\w]*", r"[^/]+", esc) return re.compile(esc + r"$") def scan_file(path: Path, root: Path) -> list[dict]: entries: list[dict] = [] try: text = path.read_text(encoding="utf-8", errors="replace") except OSError as e: print(f"warning: unreadable {path}: {e}", file=sys.stderr) return entries rel = path.relative_to(root).as_posix() if path.is_relative_to(root) else str(path) last_app = "(chained)" for lineno, line in enumerate(text.splitlines(), 1): stripped = line.lstrip() if stripped.startswith(("//", "*", "/*")): continue new_app = NEW_APP_RE.match(line) if new_app: last_app = new_app.group("app") continue m = STMT_RE.match(line) chained = False if not m: m = CHAIN_RE.match(line) chained = bool(m) if not m: continue verb = m.group("verb") app = m.group("app") if not chained else last_app if not chained: last_app = app args = string_args(line, m.end()) if verb == "on": method = args[0].upper() if args else "?" route_path = args[1] if len(args) > 1 else "?" kind = "route" elif verb in METHODS: method, route_path, kind = verb.upper(), (args[0] if args else "?"), "route" elif verb == "use": method, route_path, kind = "*", (args[0] if args else "*"), "middleware" elif verb == "route": method, route_path, kind = "-", (args[0] if args else "?"), "mount" else: # onError / notFound method, route_path, kind = "-", "-", verb entries.append({ "kind": kind, "method": method, "path": route_path, "app": app, "file": rel, "line": lineno, }) return entries def _finding(r: dict, issue: str, detail: str) -> dict: return {"kind": "finding", "issue": issue, "method": r["method"], "path": r["path"], "app": r["app"], "file": r["file"], "line": r["line"], "detail": detail} def check_order(entries: list[dict]) -> list[dict]: """Three lints per (file, app) group, all consequences of Hono matching in registration order: bypass - a route/mount registered BEFORE a middleware whose pattern covers it (the middleware silently never runs for it) duplicate - the exact same (method, path) route registered twice (the second registration is dead - first match wins) shadowed - a route registered AFTER an earlier same-method (or ALL) route whose broader pattern covers its path (dead route) Mounts are excluded from duplicate/shadowed: two sub-apps on one base path is a legitimate Hono pattern (matching falls through across mounts).""" findings: list[dict] = [] by_file_app: dict[tuple, list[dict]] = {} for e in entries: by_file_app.setdefault((e["file"], e["app"]), []).append(e) for group in by_file_app.values(): middlewares = [e for e in group if e["kind"] == "middleware" and e["path"] != "?"] covered = [e for e in group if e["kind"] in ("route", "mount") and e["path"] not in ("?", "-")] routes = [e for e in group if e["kind"] == "route" and e["path"] not in ("?", "-")] for mw in middlewares: rx = pattern_to_regex(mw["path"]) for r in covered: if r["line"] < mw["line"] and rx.match(r["path"]): findings.append(_finding(r, "bypass", f"registered before middleware use('{mw['path']}') at " f"{mw['file']}:{mw['line']} - this {r['kind']} bypasses it")) seen: dict[tuple, dict] = {} for r in routes: key = (r["method"], r["path"]) if key in seen: first = seen[key] findings.append(_finding(r, "duplicate", f"duplicate of {first['method']} {first['path']} at " f"{first['file']}:{first['line']} - this registration is dead (first match wins)")) else: seen[key] = r for later in routes: for earlier in routes: if earlier["line"] >= later["line"] or earlier["path"] == later["path"]: continue if earlier["method"] != later["method"] and earlier["method"] != "ALL": continue if ("*" in earlier["path"] or ":" in earlier["path"]) \ and pattern_to_regex(earlier["path"]).match(later["path"]): findings.append(_finding(later, "shadowed", f"shadowed by earlier {earlier['method']} '{earlier['path']}' at " f"{earlier['file']}:{earlier['line']} - this route can never match")) break findings.sort(key=lambda f: (f["file"], f["line"])) return findings def main() -> int: ap = argparse.ArgumentParser( prog="route-inventory.py", description="List a Hono app's routes/middleware/mounts; lint registration order.", epilog=( "Examples:\n" " route-inventory.py src/\n" " route-inventory.py --json src/ | jq '.data[]'\n" " route-inventory.py --check src/ # exit 10 on findings\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) ap.add_argument("path", help="Hono TypeScript source file or directory") ap.add_argument("--json", action="store_true", help="emit the JSON envelope instead of TSV") ap.add_argument("--check", action="store_true", help="middleware-order lint: report routes a later middleware would have covered (exit 10)") try: args = ap.parse_args() except SystemExit as e: # argparse exits 2 on usage errors and 0 on --help; preserve both. return int(e.code or 0) target = Path(args.path) if not target.exists(): if args.json: print(json.dumps({"error": {"code": "NOT_FOUND", "message": f"path not found: {target}", "details": {}}})) print(f"error: path not found: {target}", file=sys.stderr) return 3 root = target if target.is_dir() else target.parent files = ( sorted( p for p in target.rglob("*") if p.suffix in (".ts", ".tsx") and not any(part in SKIP_DIRS or part.startswith(".") for part in p.parts) ) if target.is_dir() else [target] ) entries: list[dict] = [] for f in files: entries.extend(scan_file(f, root)) print(f"scanned {len(files)} file(s), {len(entries)} registration(s)", file=sys.stderr) rows = check_order(entries) if args.check else entries if args.json: print(json.dumps({"data": rows, "meta": {"count": len(rows), "schema": SCHEMA}}, indent=2)) else: for r in rows: loc = f"{r['file']}:{r['line']}" tail = f"\t{r['detail']}" if "detail" in r else "" print(f"{r['kind']}\t{r['method']}\t{r['path']}\t{r['app']}\t{loc}{tail}") if args.check and rows: issues = ", ".join(f"{n} {k}" for k, n in sorted( (k, sum(1 for r in rows if r.get("issue") == k)) for k in {r.get("issue") for r in rows})) print(f"{len(rows)} finding(s) ({issues}) - confirm each is deliberate", file=sys.stderr) return 10 return 0 if __name__ == "__main__": sys.exit(main())
-
-
tests
-
fixtures
-
sample-app.ts 1.9 KB
// Test fixture for route-inventory.py — a miniature composition root exercising // every registration shape the scanner claims to parse. NOT runnable code; the // identifiers are stubs. tests/run.sh asserts exact counts against this file, // so adding a registration here means updating those assertions. import { Hono } from 'hono'; export const app = new Hono<{ Bindings: Env; Variables: Vars }>(); app.use('*', securityHeaders()); // Deliberately registered BEFORE the /api/* auth middleware: the --check linter // must flag this route as bypassing it (the fixture's one expected finding). app.get('/api/health', (c) => c.json({ ok: true })); app.use('/api/*', authMiddleware); app.get('/api/me', (c) => c.json({})); app.post('/api/things', (c) => c.json({}, 201)); app.patch('/api/things/:id', (c) => c.json({})); app.delete('/api/things/:id', (c) => c.json({ ok: true })); app.on('PURGE', '/api/cache', (c) => c.json({ ok: true })); // Deliberate --check bait: the exact same (method, path) registered twice — // the second registration is dead (first match wins) => `duplicate` finding. app.get('/api/me', (c) => c.json({ dup: true })); // Deliberate --check bait: the broad wildcard route above the specific one — // the specific route can never match => `shadowed` finding. app.get('/api/reports/*', (c) => c.json({ report: 'any' })); app.get('/api/reports/daily', (c) => c.json({ report: 'daily' })); app.route('/api/time', timeApi); app.route('/vesper', vesper); app.all('/api/*', (c) => c.json({ error: 'not_found' }, 404)); app.all('*', (c) => c.env.ASSETS.fetch(c.req.raw)); app.onError((err, c) => c.json({ error: 'internal' }, 500)); // Chained definition (RPC style) — attribution must follow the new app var, // not stick to `app` from the statements above. const chained = new Hono() .get('/posts/:id', (c) => c.json({ post: null })) .post('/posts', (c) => c.json({ ok: true }, 201)); export type ChainedType = typeof chained;
-
-
run.sh 8.1 KB
#!/usr/bin/env bash # Offline self-test for the hono-ops skill — structure, frontmatter, and the # script contracts (SKILL-RESOURCE-PROTOCOL §2, §5, §7, §10). # # Usage: tests/run.sh # Input: none (self-contained; no network, no node/wrangler install required) # Output: TAP-ish progress on stderr; final PASS/FAIL line. # Exit: 0 all pass (or skipped on unsupported platform), 1 any failure. # # Examples: # tests/run.sh # bash skills/hono-ops/tests/run.sh set -uo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" fail=0 pass=0 note() { printf ' %s %s\n' "$1" "$2" >&2; } ok() { pass=$((pass+1)); note "ok " "$1"; } bad() { fail=$((fail+1)); note "FAIL" "$1"; } # Resolve a *working* python (python3, else python). The bare `command -v` is not # enough on Windows, where `python3` is a Microsoft Store stub that exits nonzero. PY="" for cand in python3 python; do if command -v "$cand" >/dev/null 2>&1 && "$cand" --version >/dev/null 2>&1; then PY="$cand"; break fi done if [ -z "$PY" ]; then echo "SKIP: no working python interpreter on this platform" >&2 exit 0 fi # 1. Required directories exist for d in scripts references assets tests; do [ -d "$here/$d" ] && ok "dir $d/ exists" || bad "missing dir $d/" done # 2. SKILL.md frontmatter house rules # CONTRACT: these assertions require the frontmatter to keep `name: hono-ops`, # `license: MIT`, and `metadata.author: claude-mods` — a trim/cleanup lane that # edits the frontmatter must keep them or update these assertions in the same # commit (see SKILL-CREATION-PROTOCOL Step 5). skill="$here/SKILL.md" if [ -f "$skill" ]; then ok "SKILL.md present" grep -q '^name: hono-ops$' "$skill" && ok "name matches directory" || bad "name != hono-ops" grep -q '^license: MIT$' "$skill" && ok "license: MIT" || bad "missing license: MIT" grep -q '^ author: claude-mods$' "$skill" && ok "metadata.author" || bad "missing metadata.author" else bad "SKILL.md missing" fi # 3. Every reference on disk is cited from SKILL.md (no dead weight) for ref in "$here"/references/*.md; do base="references/$(basename "$ref")" grep -qF "$base" "$skill" && ok "cited: $base" || bad "uncited reference: $base" done # 4. Every SKILL.md-cited bundled resource exists on disk for res in assets/worker-template.ts assets/vitest.config.template.ts \ assets/hono-facts.json \ scripts/route-inventory.py scripts/check-hono-facts.py \ tests/fixtures/sample-app.ts; do [ -f "$here/$res" ] && ok "resource present: $res" || bad "missing resource: $res" done # Helper: assert an exact exit code. ec() { local want="$1" lbl="$2"; shift 2; "$@" >/dev/null 2>&1; local got=$? [ "$got" = "$want" ] && ok "$lbl (exit $got)" || bad "$lbl (want $want got $got)"; } # 5. route-inventory.py — script contract + behaviour on the bundled fixture inv="$here/scripts/route-inventory.py" fixture="$here/tests/fixtures/sample-app.ts" "$PY" -m py_compile "$inv" && ok "route-inventory: py_compile clean" || bad "route-inventory: py_compile failed" "$PY" "$inv" --help 2>/dev/null | grep -q "Examples:" && ok "route-inventory: --help has Examples" || bad "route-inventory: --help missing Examples" ec 0 "route-inventory: --help exits 0" "$PY" "$inv" --help ec 2 "route-inventory: bad flag -> 2" "$PY" "$inv" --bogus "$fixture" ec 3 "route-inventory: missing path -> 3" "$PY" "$inv" /no/such/path ec 0 "route-inventory: fixture inventory ok" "$PY" "$inv" "$fixture" # Behaviour: the fixture registers exactly 18 things; assert the count and a few # load-bearing rows (a mount, the custom method, chained-app attribution). # CONTRACT: tests/fixtures/sample-app.ts and these numbers move together. out="$("$PY" "$inv" "$fixture" 2>/dev/null)" [ "$(printf '%s\n' "$out" | wc -l)" = "18" ] && ok "route-inventory: 18 rows" || bad "route-inventory: row count != 18" printf '%s\n' "$out" | grep -q "mount - /api/time" && ok "route-inventory: mount row" || bad "route-inventory: mount row missing" printf '%s\n' "$out" | grep -q "route PURGE /api/cache" && ok "route-inventory: on() custom method" || bad "route-inventory: on() row missing" printf '%s\n' "$out" | grep -q "route GET /posts/:id chained" && ok "route-inventory: chained attribution" || bad "route-inventory: chained attribution wrong" # --json envelope parses with the documented schema (stdout is data-only) "$PY" "$inv" --json "$fixture" 2>/dev/null \ | "$PY" -c 'import json,sys; d=json.load(sys.stdin); assert d["meta"]["schema"]=="claude-mods.hono-ops.route-inventory/v1"; assert d["meta"]["count"]==18' \ && ok "route-inventory: --json envelope parses" || bad "route-inventory: --json envelope broken" # --check flags exactly the fixture's three deliberate baits: one bypass # (/api/health before the /api/* middleware), one duplicate (GET /api/me twice), # one shadowed (/api/reports/daily under the earlier /api/reports/* wildcard). ec 10 "route-inventory: --check finds issues -> 10" "$PY" "$inv" --check "$fixture" chk="$("$PY" "$inv" --check "$fixture" 2>/dev/null)" [ "$(printf '%s\n' "$chk" | grep -c '^finding')" = "3" ] && ok "route-inventory: exactly 3 findings" || bad "route-inventory: finding count != 3" printf '%s\n' "$chk" | grep -q "/api/health.*bypasses" && ok "route-inventory: bypass finding" || bad "route-inventory: bypass finding missing" printf '%s\n' "$chk" | grep -q "/api/me.*duplicate of" && ok "route-inventory: duplicate finding" || bad "route-inventory: duplicate finding missing" printf '%s\n' "$chk" | grep -q "/api/reports/daily.*shadowed by" && ok "route-inventory: shadowed finding" || bad "route-inventory: shadowed finding missing" # Capture first: --check exits 10, and under pipefail that would sink the pipe. chk_json="$("$PY" "$inv" --check --json "$fixture" 2>/dev/null)" printf '%s' "$chk_json" \ | "$PY" -c 'import json,sys; d=json.load(sys.stdin); assert sorted(f["issue"] for f in d["data"])==["bypass","duplicate","shadowed"]' \ && ok "route-inventory: --check --json issue fields" || bad "route-inventory: --check --json issue fields wrong" # 6. check-hono-facts.py — staleness verifier contract (§7), offline-safe verifier="$here/scripts/check-hono-facts.py" "$PY" -m py_compile "$verifier" && ok "verifier: py_compile clean" || bad "verifier: py_compile failed" grep -qE '^Examples:$' "$verifier" && ok "verifier: has Examples block" || bad "verifier: no Examples block (docstring)" ec 0 "verifier: --help exits 0" "$PY" "$verifier" --help ec 0 "verifier: --offline consistent" "$PY" "$verifier" --offline ec 2 "verifier: bad flag -> 2" "$PY" "$verifier" --bogus ec 2 "verifier: --offline --live -> 2" "$PY" "$verifier" --offline --live ec 3 "verifier: missing catalog -> 3" "$PY" "$verifier" --offline --catalog /no/such/catalog.json "$PY" "$verifier" --offline --json -q 2>/dev/null \ | "$PY" -c 'import json,sys; d=json.load(sys.stdin); assert d["meta"]["schema"]=="claude-mods.hono-ops.facts/v1"' \ && ok "verifier: --json envelope parses (stdout clean)" || bad "verifier: --json envelope broken" # Error paths against synthetic catalogs: malformed -> 4, drifted token -> 10. tmp="$(mktemp -d 2>/dev/null || echo "${TMPDIR:-/tmp}/hono-ops-test.$$")" mkdir -p "$tmp" printf 'not json' > "$tmp/bad.json" ec 4 "verifier: malformed catalog -> 4" "$PY" "$verifier" --offline --catalog "$tmp/bad.json" sed 's/Hono v4/Hono v99/' "$here/assets/hono-facts.json" > "$tmp/drift.json" ec 10 "verifier: drifted token -> 10" "$PY" "$verifier" --offline --catalog "$tmp/drift.json" rm -rf "$tmp" # 7. Template sanity: the starters name their load-bearing sections tmpl="$here/assets/worker-template.ts" for marker in "securityHeaders" "app.onError" "satisfies ExportedHandler" "not_found" "timingSafeEqual"; do grep -qF "$marker" "$tmpl" && ok "worker template carries: $marker" || bad "worker template missing: $marker" done vtmpl="$here/assets/vitest.config.template.ts" for marker in "defineWorkersConfig" "readD1Migrations" "isolatedStorage" "compatibilityDate"; do grep -qF "$marker" "$vtmpl" && ok "vitest template carries: $marker" || bad "vitest template missing: $marker" done echo "hono-ops tests: $pass passed, $fail failed" >&2 [ "$fail" = "0" ] && { echo "PASS" >&2; exit 0; } || { echo "FAIL" >&2; exit 1; }
-
-
SKILL.md 14.5 KB
--- 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
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.