Read OSS

Batteries Included: Middleware, Validation, and Multi-Runtime Adapters

Intermediate

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 ValidationTargetByMethod type (covered in Part 4) prevents you from attaching json or form validators 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: contextStorage relies on AsyncLocalStorage from node:async_hooks, which is available in Node.js, Bun, and Deno but not in Cloudflare Workers. For Workers, consider using the c.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:

  1. API Gateway v1 — has httpMethod and resource
  2. API Gateway v2 — has rawPath and routeKey
  3. ALB (Application Load Balancer) — has requestContext.elb
  4. 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.