skills/hono-patterns/SKILL.md
Use when building APIs on Hono (Cloudflare Workers, Bun, Deno, Node), debugging route ordering, wiring middleware, validating with @hono/zod-validator, returning streaming responses, configuring CORS with credentials, handling errors via app.onError, or composing typed RPC clients. Triggers: c.env binding types, c.header + c.redirect interaction, missing await next() bugs, Set-Cookie not attaching to redirect, route-precedence surprises, JWT middleware setup, hono/client typed RPC end-to-end. NOT for Express/Fastify/Koa idioms, tRPC/GraphQL paradigms, or Next.js Route Handlers.
npx skillsauth add curiositech/windags-skills hono-patternsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Hono is a small router with a strong middleware model and ergonomic typing. Most surprises come from middleware ordering, deferred header semantics, and the c.env/Variables generic dance.
hono/client).* won't work.app.onError.Hono generic — typed bindings and localstype Bindings = {
DB: D1Database;
KV: KVNamespace;
WINDAGS_INTERNAL_TOKEN: string;
};
type Variables = {
user: { id: string; role: 'admin' | 'member' };
startedAt: number;
};
const app = new Hono<{ Bindings: Bindings; Variables: Variables }>();
Now c.env.DB is typed D1Database; c.var.user is typed; c.set('user', …) and c.get('user') are checked.
Hono matches in registration order. Specific before wildcard:
app.get('/v1/skills/search', searchHandler); // specific
app.get('/v1/skills/:id', getOneHandler); // param
app.all('/v1/*', fallback); // catch-all LAST
Mounting sub-apps:
const v2 = new Hono<{ Bindings: Bindings }>();
v2.get('/skills', listV2);
app.route('/v2', v2);
// Or basePath for the whole app:
const api = new Hono().basePath('/api');
The chain is request → middleware → handler → middleware-after → response. await next() is where you switch direction.
app.use('*', async (c, next) => {
const start = Date.now();
await next();
c.header('Server-Timing', `total;dur=${Date.now() - start}`);
});
app.use('/admin/*', async (c, next) => {
const session = c.req.header('Cookie');
if (!session) return c.text('forbidden', 403);
c.set('user', await loadUser(session));
await next();
});
Forgetting await next() is the most common bug. The handler never runs and the request hangs at middleware.
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const createSkill = z.object({
id: z.string().regex(/^[a-z0-9-]+$/).max(80),
name: z.string().min(1).max(120),
tags: z.array(z.string()).max(20).optional(),
});
app.post(
'/v1/skills',
zValidator('json', createSkill),
async (c) => {
const body = c.req.valid('json'); // typed, narrowed
await c.env.DB.prepare(
'INSERT INTO skills (id, name, tags) VALUES (?, ?, ?)'
).bind(body.id, body.name, JSON.stringify(body.tags ?? [])).run();
return c.json({ ok: true }, 201);
},
);
Validation slots: 'json' | 'form' | 'query' | 'param' | 'header' | 'cookie'. Each adds a typed accessor.
c.header() + c.redirect() — the deferred-headers modelapp.get('/_internal/login', (c) => {
const token = c.req.query('token');
if (token !== c.env.WINDAGS_INTERNAL_TOKEN) return c.text('forbidden', 403);
c.header(
'Set-Cookie',
`session=${token}; HttpOnly; Secure; SameSite=Lax; Path=/_internal; Max-Age=${30*86400}`,
);
return c.redirect('/_internal/dashboard');
});
c.header() registers a deferred header on the context; the final Response (whether from c.json, c.text, or c.redirect) merges those deferred headers. The cookie WILL attach. If a downstream observer says it didn't, the issue is browser-side (SameSite=Strict on a redirect) or upstream (proxy stripping headers), not Hono.
import { streamSSE } from 'hono/streaming';
app.get('/v1/events', (c) => {
return streamSSE(c, async (stream) => {
let id = 0;
while (!stream.aborted) {
await stream.writeSSE({ id: String(id++), event: 'tick', data: JSON.stringify({ now: Date.now() }) });
await stream.sleep(1000);
}
});
});
stream.aborted flips when the client disconnects — check it in any long loop or you'll leak.
For raw bytes/NDJSON:
import { stream } from 'hono/streaming';
app.get('/v1/dump', (c) => stream(c, async (s) => {
for await (const row of fetchRows()) {
await s.write(JSON.stringify(row) + '\n');
}
}));
import { HTTPException } from 'hono/http-exception';
app.onError((err, c) => {
if (err instanceof HTTPException) return err.getResponse();
// Log with the right severity; don't leak internals.
console.error('unhandled', err);
return c.json({ error: 'internal' }, 500);
});
// Throw a typed error from anywhere:
throw new HTTPException(429, { message: 'rate limited', res: new Response('go slow', { status: 429 }) });
onError wraps the whole app. Routes that throw end up here unless an explicit try/catch handles them.
hono/cookieimport { setCookie, getCookie, deleteCookie } from 'hono/cookie';
setCookie(c, 'session', token, {
httpOnly: true,
secure: true,
sameSite: 'Lax',
path: '/_internal',
maxAge: 30 * 86400,
});
const session = getCookie(c, 'session');
deleteCookie(c, 'session', { path: '/_internal' });
Helper-based cookies are easier to read than raw Set-Cookie strings; both work.
import { cors } from 'hono/cors';
app.use('/v1/*', cors({
origin: (origin) => origin?.endsWith('.windags.ai') ? origin : null,
credentials: true, // sends cookies cross-origin
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400,
}));
credentials: true requires the explicit origin echoed back — * won't work and the browser will block.
// server
const route = app
.get('/v1/skills/:id', (c) => c.json({ id: c.req.param('id'), name: 'x' }))
.post('/v1/skills', zValidator('json', createSkill), (c) => c.json({ ok: true as const }));
export type AppType = typeof route;
// client
import { hc } from 'hono/client';
const client = hc<AppType>('https://api.windags.ai');
const res = await client.v1.skills[':id'].$get({ param: { id: 'foo' } });
const data = await res.json(); // typed { id: string; name: string }
The client mirrors the server type tree exactly. Refactor a route → the client breaks at compile time.
Symptom: Specific routes return 404 / wrong handler responses.
Diagnosis: app.all('*', …) registered before specific GETs. First match wins.
Fix: Register catch-alls last. Use app.notFound(handler) for 404 instead of a wildcard.
await next()Symptom: Request hangs or returns nothing; logs show middleware ran but handler didn't.
Diagnosis: Middleware returned without awaiting next().
Fix: Always await next() unless you intentionally short-circuit (auth fail, rate limit). Linting rule helps.
c.set without typed VariablesSymptom: c.get('user') returns unknown; downstream code does string casts.
Diagnosis: Variables generic not declared.
Fix: Add Variables to the Hono<{Bindings, Variables}> generic. Now both set and get are typed.
Symptom: Every endpoint, even health checks, costs 50ms.
Diagnosis: Middleware does DB-loaded user-loading or audit-logging on every path.
Fix: Scope middleware narrowly (app.use('/api/v1/*', loadUser)), or memoize per-request.
Symptom: Browser shows 302 with Set-Cookie; immediate follow-up request to dashboard returns 403.
Diagnosis: Strict drops cookies on cross-context navigation in some browsers, including the auto-followed redirect from the login URL.
Fix: SameSite=Lax for session cookies that ride a redirect.
Response and expecting middleware to mutate itSymptom: Middleware "after next()" can't read body, can't set headers reliably.
Diagnosis: The middleware mutates c.res, but the handler returned a fresh Response that bypassed it.
Fix: Either use c.res = newResponse; in the handler, or return through c.json/c.text/c.html/c.body.
app.notFound set.await next().Bindings and Variables generics declared on the root app.app.onError returns a sanitized response — no stack leaks.credentials: true echoes a typed allowlist of origins, never *.stream.aborted in any long loop.app/api/*/route.ts is a different framework.cloudflare-workers-debugging.webhook-receiver-design for HMAC, raw-body, idempotency.data-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.