Batteries Included: Middleware, Validation, and Multi-Runtime Adapters
Prerequisites
- ›Articles 1–2
- ›Familiarity with at least one runtime target (Cloudflare Workers, Node.js, or Bun)
Batteries Included: Middleware, Validation, and Multi-Runtime Adapters
Hono's core is deliberately minimal — as we explored in Part 2, the dispatch pipeline is about 130 lines. Everything else is opt-in via the 150+ export paths we mapped in Part 1. This article covers three layers of that opt-in surface: the middleware system (how to write and compose middleware), the validator (how six parsing targets integrate with the type system from Part 4), and the runtime adapters (how platform-specific events become standard Web Requests).
The MiddlewareHandler Contract
Every middleware in Hono conforms to a single type:
export type MiddlewareHandler<E, P, I, R> =
(c: Context<E, P, I>, next: Next) => Promise<R | void>
The contract is simple: receive a Context and a next function. You can do work before calling next() (the "before-phase"), call await next() to pass control downstream, then do work after (the "after-phase"). Or short-circuit by returning a Response without calling next().
The CORS middleware at src/middleware/cors/index.ts is the canonical example of the onion model:
sequenceDiagram
participant C as Client
participant CORS as cors()
participant H as Handler
C->>CORS: Request
Note over CORS: Before-phase: set origin headers
alt OPTIONS preflight
CORS-->>C: 204 No Content (short-circuit)
else Other methods
CORS->>H: await next()
H-->>CORS: Response
Note over CORS: After-phase: add Vary header
CORS-->>C: Response with CORS headers
end
Notice how the CORS middleware short-circuits for OPTIONS requests (returning a 204 without calling next()), but for all other methods, it sets headers before and after the downstream handler runs.
The Validator System: Six Targets
The validator() function accepts a target and a validation function. The target determines how raw data is extracted:
switch (target) {
case 'json': value = await c.req.json(); break
case 'form': /* parse multipart/urlencoded formData */ break
case 'query': value = Object.fromEntries(Object.entries(c.req.queries())...); break
case 'param': value = c.req.param(); break
case 'header': value = c.req.header(); break
case 'cookie': value = getCookie(c); break
}
Each target maps to a different part of the HTTP request. After extraction, the validation function receives the raw data and can return either the validated output (stored via c.req.addValidatedData()) or a Response to short-circuit.
flowchart TD
A["validator(target, fn)"] --> B{"target?"}
B -->|json| C["c.req.json()"]
B -->|form| D["Parse multipart/urlencoded"]
B -->|query| E["c.req.queries()"]
B -->|param| F["c.req.param()"]
B -->|header| G["c.req.header()"]
B -->|cookie| H["getCookie(c)"]
C --> I["validationFunc(value, c)"]
D --> I
E --> I
F --> I
G --> I
H --> I
I --> J{"Returns Response?"}
J -->|Yes| K["Short-circuit: return Response"]
J -->|No| L["c.req.addValidatedData(target, result)"]
L --> M["await next()"]
The form parsing at lines 105–142 deserves attention. Rather than using the native Request.formData() directly, it goes through arrayBuffer() first and then bufferToFormData() — this allows body caching to work correctly across multiple validators.
Tip: The
ValidationTargetByMethodtype (covered in Part 4) prevents you from attachingjsonorformvalidators to GET or HEAD routes at compile time. If you find yourself needing body validation on a GET, reconsider your API design — it's a sign the route should be a POST.
Module Augmentation: Extending ContextVariableMap
The JWT middleware demonstrates a powerful pattern for typed middleware. Look at src/middleware/jwt/index.ts:
import type { JwtVariables } from './jwt'
export type { JwtVariables }
export { jwt, verify, decode, sign } from './jwt'
declare module '../..' {
interface ContextVariableMap extends JwtVariables<unknown> {}
}
The declare module block augments Hono's ContextVariableMap interface. Once you import hono/jwt, c.get('jwtPayload') becomes fully typed throughout your application — no generic parameter needed.
This pattern works because TypeScript's interface merging is global: importing the module triggers the declaration, extending the interface everywhere. If you're building your own middleware that sets context variables, this is the pattern to follow.
context-storage: AsyncLocalStorage Integration
The contextStorage middleware is remarkably simple — 17 lines of runtime code:
const asyncLocalStorage = new AsyncLocalStorage<Context>()
export const contextStorage = (): MiddlewareHandler => {
return async function contextStorage(c, next) {
await asyncLocalStorage.run(c, next)
}
}
export const getContext = <E extends Env = Env>(): Context<E> => {
const context = tryGetContext<E>()
if (!context) throw new Error('Context is not available')
return context
}
By wrapping next() inside asyncLocalStorage.run(c, next), the current Context becomes available anywhere in the async call stack via getContext(). This eliminates the need to pass the context through function arguments — critical for repository-pattern services or utility functions.
sequenceDiagram
participant MW as contextStorage()
participant ALS as AsyncLocalStorage
participant H as Handler
participant SVC as Service Layer
participant DB as Database
MW->>ALS: run(context, next)
ALS->>H: handler(c, next)
H->>SVC: getUserById(id)
SVC->>ALS: getContext()
ALS-->>SVC: Context (with env, vars)
SVC->>DB: query with env bindings
DB-->>SVC: result
SVC-->>H: user
Tip:
contextStoragerelies onAsyncLocalStoragefromnode:async_hooks, which is available in Node.js, Bun, and Deno but not in Cloudflare Workers. For Workers, consider using thec.set()/c.get()pattern with middleware instead.
Middleware Combinators: some() and every()
The combine middleware provides three combinators:
some() — runs middleware sequentially until one succeeds (like logical OR):
export const some = (...middleware): MiddlewareHandler => {
return async function some(c, next) {
let lastError
for (const handler of middleware) {
try {
const result = await handler(c, wrappedNext)
if (result === true && !c.finalized) await wrappedNext()
lastError = undefined
break
} catch (error) {
lastError = error
}
}
if (lastError) throw lastError
}
}
every() — runs all middleware, failing if any fails (like logical AND). It uses compose() internally.
except() — runs middleware except when a condition matches. It combines some() with a condition check and every() for the middleware chain.
flowchart TD
subgraph "some(bearerAuth, rateLimit)"
S1["Try bearerAuth"]
S1 -->|"success"| PASS["Pass through"]
S1 -->|"fails"| S2["Try rateLimit"]
S2 -->|"success"| PASS
S2 -->|"fails"| FAIL["Throw last error"]
end
subgraph "every(auth, rateLimit)"
E1["Run auth"]
E1 -->|"success"| E2["Run rateLimit"]
E1 -->|"fails"| EFAIL["Throw error"]
E2 -->|"success"| EPASS["Pass through"]
E2 -->|"fails"| EFAIL
end
A common use case: "if the client has a valid bearer token, skip rate limiting; otherwise, apply rate limiting." That's some(bearerAuth({ token }), rateLimit({ limit: 100 })).
createFactory and createMiddleware Helpers
The factory helper provides createFactory() and the createHandlers interface for building typed, reusable middleware with correct Env propagation.
createFactory<E>() binds an Env type to a Hono instance and returns helpers that carry that type through all handler and middleware definitions. The createHandlers interface (the 150+ line overload block in the factory) mirrors HandlerInterface — providing type inference for up to 10 chained handlers.
When building a middleware library, these helpers save you from manually threading generic parameters. Rather than const myMiddleware: MiddlewareHandler<MyEnv, string, MyInput> = ..., you get correct inference from the factory.
Runtime Adapters: Translating Platform Events
Each runtime has its own event format. Hono's adapters translate these into standard Web Request objects before calling app.fetch().
The AWS Lambda adapter at src/adapter/aws-lambda/handler.ts is the most complex because it must discriminate between four event shapes:
- API Gateway v1 — has
httpMethodandresource - API Gateway v2 — has
rawPathandrouteKey - ALB (Application Load Balancer) — has
requestContext.elb - VPC Lattice — has
requestContext.serviceNetworkArn
flowchart TD
A["Lambda Event"] --> B{"Event type?"}
B -->|"has routeKey + rawPath"| C["API Gateway v2"]
B -->|"has httpMethod + resource"| D["API Gateway v1"]
B -->|"has requestContext.elb"| E["ALB"]
B -->|"has requestContext.serviceNetworkArn"| F["VPC Lattice"]
C --> G["Construct Web Request"]
D --> G
E --> G
F --> G
G --> H["app.fetch(request)"]
H --> I["Convert Response back to Lambda format"]
By contrast, the Bun and Cloudflare Workers adapters are minimal — those runtimes natively provide Request objects, so the adapter is essentially a re-export with some helper utilities.
This adapter architecture is why Hono can claim "runs everywhere" without compromising its Web Standards core. The framework never sees platform-specific types; the adapter handles all translation at the boundary.
What's Next
We've covered the middleware ecosystem, validation pipeline, and runtime adapters — the practical surface area developers interact with daily. In the final article, we'll explore the most surprising part of Hono's codebase: a complete JSX engine with server-side rendering, streaming SSR with Suspense semantics, a client-side virtual DOM, and React-compatible hooks — all built from scratch with zero dependencies.