Middleware and Platform Adapters — Running Everywhere
Prerequisites
- ›Article 2: The Request Lifecycle (middleware composition)
- ›Article 4: Type-Level Wizardry (ContextVariableMap augmentation)
- ›Basic understanding of serverless platforms (Cloudflare Workers, AWS Lambda)
Middleware and Platform Adapters — Running Everywhere
Hono ships with roughly 25 built-in middleware modules — from CORS and JWT authentication to compression, caching, and ETag generation. Each one follows a consistent pattern rooted in the onion model we explored in Part 2. Beyond middleware, Hono runs on every major JavaScript runtime through platform adapters that convert runtime-specific events into the universal fetch() interface.
This article covers the middleware authoring patterns, the createMiddleware() factory for type safety, the some()/every()/except() combinators for logical composition, and the adapter architecture with a deep dive into the AWS Lambda adapter.
Middleware Authoring Patterns
Every Hono middleware is a function with the signature (c: Context, next: Next) => Promise<Response | void>. The pattern for "before and after" middleware is:
const myMiddleware: MiddlewareHandler = async (c, next) => {
// Before: runs before downstream handlers
const start = Date.now()
await next() // Invoke downstream
// After: runs after downstream handlers have set c.res
c.header('X-Response-Time', `${Date.now() - start}ms`)
}
The CORS middleware is an excellent exemplar of this pattern. It does work both before and after next():
Before: For OPTIONS preflight requests, it sets CORS headers and returns a 204 No Content response immediately, without calling next(). This short-circuits the middleware chain.
After: For all other requests, it calls await next(), then appends the Vary: Origin header to the response that downstream handlers produced.
flowchart TD
REQ["Incoming Request"] --> CHECK{OPTIONS?}
CHECK -->|Yes| PRE["Set CORS headers<br/>Return 204 (skip next())"]
CHECK -->|No| ORIGIN["Set Access-Control-Allow-Origin"]
ORIGIN --> NEXT["await next()"]
NEXT --> VARY["Append Vary: Origin header"]
VARY --> RES["Response"]
Notice how the CORS middleware accesses c.res.headers before calling next() — this works because Context lazily creates a response object with prepared headers. Headers set on this object are merged into the final response via the res setter, as we covered in Part 2.
createMiddleware() for Type-Safe Authoring
The createMiddleware() helper from hono/factory provides better type inference for middleware that needs typed environment variables:
export const createMiddleware = <
E extends Env = any,
P extends string = string,
I extends Input = {},
R extends HandlerResponse<any> | void = void,
>(
middleware: MiddlewareHandler<E, P, I, R extends void ? Response : R>
): MiddlewareHandler<E, P, I, R extends void ? Response : R> => middleware
It's an identity function at runtime — it literally returns its argument unchanged. But at the type level, it constrains the E parameter so that c.env and c.get()/c.set() are properly typed within the middleware body:
const authMiddleware = createMiddleware<{
Variables: { userId: string }
}>(async (c, next) => {
c.set('userId', extractUserId(c)) // Type-safe
await next()
})
The companion createFactory() creates a factory bound to a specific Env type, so you don't repeat the type parameter for every middleware and handler in a project.
Tip: Use
createMiddleware<E>()when your middleware reads fromc.envor callsc.set(). Use the bare(c, next) => {}pattern for middleware that only touches headers or the request — the type inference works fine without the wrapper.
JWT and ContextVariableMap Augmentation
The JWT middleware demonstrates a powerful pattern for type-safe middleware that adds variables to the context. Look at src/middleware/jwt/index.ts:
import type { JwtVariables } from './jwt'
export type { JwtVariables }
export { jwt, verifyWithJwks, verify, decode, sign } from './jwt'
import type {} from '../..'
declare module '../..' {
interface ContextVariableMap extends JwtVariables<unknown> {}
}
The declare module block uses TypeScript's declaration merging to extend ContextVariableMap with JwtVariables<unknown>, which adds jwtPayload as a known key. This means any code that imports from hono/jwt automatically gets type-safe access to c.get('jwtPayload') — without passing explicit type parameters.
classDiagram
class ContextVariableMap {
«interface, initially empty»
}
class JwtVariables~T~ {
jwtPayload: T
}
class Context {
get(key): ContextVariableMap[key]
set(key, value): void
}
ContextVariableMap <|-- JwtVariables : declare module merges
ContextVariableMap --> Context : provides types for get/set
The jwt.ts module itself defines JwtVariables<T> with a generic parameter for the payload type, and the middleware calls c.set('jwtPayload', payload) after successful verification. The import type {} from '../..' line is necessary to trigger module augmentation — without an import from the target module, the declare module block has no effect.
Middleware Combinators: some(), every(), except()
The combine module provides three logical combinators that compose middleware in ways the basic onion model can't express:
some(...middleware) — First-success-wins. Tries each middleware in order; if one succeeds (doesn't throw), execution continues. If one throws, the next is tried. This is ideal for "try bearer auth, fall back to API key" patterns:
app.use('/api/*', some(
bearerAuth({ token }),
myApiKeyAuth(),
))
Internally, some() wraps next to track if it's been called, and iterates through middleware in a loop with try/catch.
every(...middleware) — All-must-pass. Runs all middleware; if any throws, the chain fails. This uses compose() internally to run middleware as a mini onion:
app.use('/api/*', every(
bearerAuth({ token }),
myRateLimit({ limit: 100 }),
))
except(condition, ...middleware) — Conditional bypass. Skips middleware when the condition matches. The condition can be a path pattern (string) or a function:
app.use('/api/*', except(
'/api/public/*', // Skip auth for public endpoints
bearerAuth({ token }),
))
When the condition is a string, except() creates a TrieRouter internally to match paths — reusing Hono's own routing infrastructure for the condition check.
flowchart TD
subgraph some["some(mw1, mw2, mw3)"]
S1["Try mw1"] -->|Throws| S2["Try mw2"]
S2 -->|Throws| S3["Try mw3"]
S1 -->|Success| DONE1["Continue"]
S2 -->|Success| DONE1
S3 -->|Throws| FAIL1["Throw last error"]
S3 -->|Success| DONE1
end
subgraph every["every(mw1, mw2)"]
E1["Run mw1"] -->|Success| E2["Run mw2"]
E1 -->|Throws| FAIL2["Fail immediately"]
E2 -->|Success| DONE2["Continue"]
E2 -->|Throws| FAIL2
end
subgraph except["except('/public/*', mw1)"]
X1{Path matches?}
X1 -->|Yes| SKIP["Skip middleware"]
X1 -->|No| APPLY["Apply middleware"]
end
The Adapter Pattern — Platform Event to fetch()
Hono's multi-runtime story relies on a simple principle: each platform adapter converts its native event format to a standard Request, calls app.fetch(), and converts the Response back. The fetch() method — explored in Part 2 — is the universal bridge.
For platforms that natively speak Request/Response (Cloudflare Workers, Deno, Bun), the adapter is minimal or nonexistent — you just export default app. For platforms with custom event formats (AWS Lambda, Node.js), the adapter does real work.
sequenceDiagram
participant Platform as Platform Runtime
participant Adapter as Adapter Layer
participant App as app.fetch()
participant Response as Platform Response
Platform->>Adapter: Platform-specific event
Adapter->>Adapter: createRequest(event) → Request
Adapter->>App: app.fetch(request, env)
App-->>Adapter: Response
Adapter->>Adapter: createResult(response) → Platform format
Adapter-->>Platform: Platform-specific result
AWS Lambda Adapter Deep Dive
The AWS Lambda adapter is the most complex adapter because AWS Lambda has four different event formats:
- API Gateway v1 (
APIGatewayProxyEvent) — REST APIs - API Gateway v2 (
APIGatewayProxyEventV2) — HTTP APIs and Lambda Function URLs - Application Load Balancer (
ALBProxyEvent) - VPC Lattice (
LatticeProxyEventV2)
The adapter uses the Template Method pattern via an abstract EventProcessor class:
export abstract class EventProcessor<E extends LambdaEvent> {
protected abstract getPath(event: E): string
protected abstract getMethod(event: E): string
protected abstract getQueryString(event: E): string
protected abstract getHeaders(event: E): Headers
protected abstract getCookies(event: E, headers: Headers): void
protected abstract setCookiesToResult(result: APIGatewayProxyResult, cookies: string[]): void
createRequest(event: E): Request { /* ... uses abstract methods ... */ }
async createResult(event: E, res: Response, options): Promise<APIGatewayProxyResult> { /* ... */ }
}
Four concrete subclasses — EventV1Processor, EventV2Processor, ALBProcessor, LatticeV2Processor — implement the abstract methods for each event format. The getProcessor() function detects the event type through duck-typing:
export const getProcessor = (event: LambdaEvent): EventProcessor<LambdaEvent> => {
if (isProxyEventALB(event)) return albProcessor
if (isProxyEventV2(event)) return v2Processor
if (isLatticeEventV2(event)) return latticeV2Processor
return v1Processor
}
The handle() entry point ties it together:
export const handle = <E extends Env = Env, S extends Schema = {}, BasePath extends string = '/'>((
app: Hono<E, S, BasePath>,
{ isContentTypeBinary }: HandleOptions = { isContentTypeBinary: undefined }
) => {
return async (event, lambdaContext?) => {
const processor = getProcessor(event)
const req = processor.createRequest(event)
const requestContext = getRequestContext(event)
const res = await app.fetch(req, { event, requestContext, lambdaContext })
return processor.createResult(event, res, { isContentTypeBinary })
}
}
The streamHandle() variant uses awslambda.streamifyResponse() for streaming responses — it pipes the Response.body ReadableStream to the Lambda response stream.
Tip: The Lambda adapter passes
event,requestContext, andlambdaContextthrough as part of theenvbindings. Access them in handlers viac.env.event,c.env.requestContext, andc.env.lambdaContext. This lets you access Lambda-specific features like the request ID or remaining execution time.
.mount() and ExecutionContext
The .mount() method lets you embed other frameworks inside a Hono application. It registers a wildcard handler that strips the path prefix and forwards the request:
mount(path, applicationHandler, options?) {
// ... option handling ...
replaceRequest ||= (() => {
const mergedPath = mergePath(this._basePath, path)
const pathPrefixLength = mergedPath === '/' ? 0 : mergedPath.length
return (request) => {
const url = new URL(request.url)
url.pathname = url.pathname.slice(pathPrefixLength) || '/'
return new Request(url, request)
}
})()
const handler: MiddlewareHandler = async (c, next) => {
const res = await applicationHandler(replaceRequest(c.req.raw), ...getOptions(c))
if (res) return res
await next()
}
this.#addRoute(METHOD_NAME_ALL, mergePath(path, '*'), handler)
}
The default behavior passes c.env and c.executionCtx to the mounted application, which is correct for Cloudflare Workers where the mounted app needs access to bindings. The replaceRequest option lets you control how the URL is rewritten — or set it to false to pass the original request unchanged.
The ExecutionContext interface provides waitUntil() for background work that continues after the response is sent:
export interface ExecutionContext {
waitUntil(promise: Promise<unknown>): void
passThroughOnException(): void
props: any
exports?: any
}
On Cloudflare Workers, c.executionCtx.waitUntil(analyticsPromise) lets you fire-and-forget async operations. On other platforms, the adapter may provide a polyfill or the property may throw when accessed (which is why the mount() method wraps the access in a try/catch).
Middleware Catalog at a Glance
For reference, here are the major built-in middleware categories:
| Category | Modules | Key Feature |
|---|---|---|
| Security | cors, csrf, secure-headers |
CORS, CSRF protection, security headers |
| Auth | jwt, bearer-auth, basic-auth |
Token/credential verification |
| Caching | cache, etag |
HTTP caching, conditional requests |
| Transform | compress, pretty-json |
Response compression, formatting |
| Logging | logger, timing |
Request logging, Server-Timing headers |
| Composition | combine |
some(), every(), except() combinators |
| Rendering | jsx-renderer |
JSX integration with c.setRenderer() |
| Limiting | timeout |
Request timeout enforcement |
Each middleware is independently importable via its own export path (e.g., hono/cors, hono/jwt), keeping unused middleware out of your bundle.
In the next and final article, we'll explore Hono's dual JSX runtime — server-side rendering with XSS-safe string concatenation, streaming Suspense, and a complete client-side virtual DOM with React-compatible hooks.