Claude Skill

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.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 0xdarkmatter-claude-mods-skills_hono-ops-3dfaf0b.zip · 58 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/hono-ops
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git 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 via Bindings.
  • c.set('identity', id) / c.get('identity') / c.var.identity — per-request state, typed via Variables. Middleware writes it; handlers read it.
  • Prefer the per-app Variables generic over global ContextVariableMap augmentation; the map is app-wide and leaks types across unrelated sub-apps (see references/app-composition.md).

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

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

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

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

Middleware: Order Is the Contract

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

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

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

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

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

Two load-bearing rules:

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

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

Errors: One Typed Boundary

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

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

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

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

Testing Quickstart

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

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

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

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

Route Inventory Script

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

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

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

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

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

Gotchas (Workers-Specific)

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/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
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.

No comments yet.

Reviews (0)

No reviews yet.

Related