Reusable middleware chains with top-notch TypeScript support. Built upon Hattip to avoid vendor lock-in using Web Standard APIs.
alien-middleware is built on a few key principles:
-
Best-in-class TypeScript support - Type safety is a first-class citizen. The library provides strong type inference for middleware chains, context extensions, and environment variables.
-
Web Standards - Built on standard Web APIs like
Request
andResponse
, allowing you to write idiomatic code that follows established patterns and conventions. -
No vendor lock-in - Thanks to Hattip's adapter system, your middleware can run anywhere: Node.js, Deno, Bun, Cloudflare Workers, and more.
-
Linear middleware flow - Unlike Express-style middleware, there's no
next()
function to call. Middleware either returns aResponse
(ending the chain) or doesn't (continuing to the next middleware). This makes the flow easier to reason about and eliminates common bugs like forgetting to callnext()
. -
Immutable chains - Middleware chains are immutable, making them easier to compose, extend, and reason about.
First, add the package to your project:
pnpm add alien-middleware
Import the chain
function and initialize a middleware chain. You can optionally provide an initial middleware directly to chain
.
import { chain } from 'alien-middleware'
// Create an empty chain
const app = chain()
// Or create a chain with an initial middleware
const appWithInitial = chain(context => {
console.log('Initial middleware running for:', context.request.url)
})
Use the .use()
method to add middleware functions to the chain.
import type { RequestContext } from 'alien-middleware'
const firstMiddleware = (context: RequestContext) => {
console.log('First middleware')
// Doesn't return anything, so the chain continues
}
const secondMiddleware = (context: RequestContext) => {
console.log('Second middleware')
return new Response('Hello from middleware!', { status: 200 })
// Returns a Response, terminating the request-phase chain
}
// Add middleware sequentially
const app = chain().use(firstMiddleware).use(secondMiddleware)
Note
Middleware chains are immutable. Each call to .use()
returns a new chain instance.
To run the middleware chain, pass it to a Hattip adapter like the Node adapter. The middleware chain is a valid Hattip handler.
import { createServer } from '@hattip/adapter-node'
const app = chain()
.use(mySessionMiddleware)
.use(myAuthMiddleware)
.use(myLoggerMiddleware)
// Create a server
const server = createServer(app)
// Start the server
server.listen(3000, () => {
console.log('Server is running on port 3000')
})
Note
If no middleware in the chain returns a Response
, a 404 Not Found
response
is automatically returned.
If you add the same middleware multiple times, it will only run once. This is a safety measure that allows you to use the same middleware in different places without worrying about it running multiple times.
const app = chain().use(myMiddleware).use(myMiddleware)
Request middleware runs sequentially before a Response
is generated.
-
Terminating the Chain: Return a
Response
object to stop processing subsequent request middleware.import type { RequestContext } from 'alien-middleware' const earlyResponder = (context: RequestContext) => { if (context.request.url.endsWith('/forbidden')) { return new Response('Forbidden', { status: 403 }) } // Otherwise, continue the chain }
-
Extending Context: Return an object (known as a "request plugin") to add its properties to the context for downstream middleware. Getter syntax is supported.
import type { RequestContext } from 'alien-middleware' type User = { id: number; name: string } const addUser = (context: RequestContext) => { // In a real app, you might look up a user based on a token const user: User = { id: 1, name: 'Alice' } return { user } } const greetUser = (context: RequestContext<{}, { user: User }>) => { // The `user` property is now available thanks to `addUser` return new Response(`Hello, ${context.user.name}!`) } const app = chain().use(addUser).use(greetUser)
-
Extending Environment: Request plugins may have an
env
property to add environment variables accessible viacontext.env()
.import type { RequestContext } from 'alien-middleware' const addApiKey = (context: RequestContext) => { return { env: { API_KEY: 'secret123' } } } const useApiKey = (context: RequestContext<{ API_KEY: string }>) => { const key = context.env('API_KEY') console.log('API Key:', key) // Output: API Key: secret123 } const app = chain().use(addApiKey).use(useApiKey)
-
Setting Response Headers: Call the
context.setHeader()
method to set a response header.const app = chain().use(context => { context.setHeader('X-Powered-By', 'alien-middleware') })
Note
If you're wondering why you need to return an object to define properties (rather than simply assigning to the context object), it's because TypeScript is unable to infer the type of the context object downstream if you don't do it like this.
Another thing to note is you don't typically define middlewares outside the
.use(…)
call expression, since that requires you to unnecessarily declare
the type of the context object. It's better to define them inline.
Request middleware can register a response callback to receive the Response
object. This is done by either returning an onResponse
method or by calling context.onResponse(callback)
. Response callbacks may return a new Response
object.
Response callbacks are called even if none of your middlewares generate a Response
. In this case, they receive the default 404 Not Found
response. Note that isolated middleware chains are an exception to this rule, since a default response is not generated for them.
// Approach 1: Returning an `onResponse` method
const poweredByMiddleware = (context: RequestContext) => ({
onResponse(response) {
response.headers.set('X-Powered-By', 'alien-middleware')
},
})
const mainHandler = (context: RequestContext) => {
// Approach 2: Calling `context.onResponse(callback)`
context.onResponse(response => {
assert(response instanceof Response)
})
return new Response('Main content')
}
const app = chain().use(poweredByMiddleware).use(mainHandler)
const response = await app({…})
console.log(response.headers.get('X-Powered-By')) // Output: alien-middleware
Note
Remember that request middlewares may not be called if a previous middleware
returns a Response
. In that case, any response callbacks added by the
uncalled middleware will not be executed. Therefore, order your middlewares
carefully.
Even if a middleware returns an immutable Response
(e.g. from a fetch()
call), your response callback can still modify the headers. We make sure to clone the response before processing it with any response callbacks.
To ensure the client receives a response as soon as possible, your response callbacks should avoid using await
unless absolutely necessary. Prefer using context.waitUntil()
to register independent promises that shouldn't block the response from being sent.
const app = chain().use(context => {
context.onResponse(async response => {
// ❌ Bad! This blocks the response from being sent.
await myLoggingService.logResponse(response)
// ❌ Bad! This may be interrupted by serverless runtimes.
myLoggingService.logResponse(response).catch(console.error)
// ✅ Good! This doesn't block the response from being sent.
context.waitUntil(myLoggingService.logResponse(response))
})
})
By passing a middleware chain to .use()
, you can merge it with the existing chain. Its middlewares will be executed after any existing middlewares in this chain and before any new middlewares you add later.
const innerChain = chain((context: RequestContext) => {
return { helloFromInner: true }
})
const app = chain()
.use(innerChain)
.use(context => {
context.helloFromInner // Output: true
})
When adding a middleware chain to another, you may use the isolate()
method to isolate the nested chain from the outer chain.
const isolatedChain = chain().isolate()
This prevents the nested chain from affecting middleware in the outer chain (e.g. through request plugins).
const innerChain = chain()
.use(() => ({
foo: true,
}))
.use(context => {
context.foo // Output: true
})
const outerChain = chain()
.use(innerChain.isolate())
.use(context => {
context.foo // Output: undefined
})
If an isolated chain does not return a Response
, execution continues with the next middleware in the outer chain.
To stop processing a request (e.g. skip any remaining middlewares), use the context.passThrough()
method. The Hattip adapter is responsible for deciding the appropriate action based on the request.
In the context of an isolated chain, context.passThrough()
will skip remaining middlewares in the isolated chain, but the outer chain will continue execution with the next middleware.
const app = chain()
.use(context => {
if (!context.request.headers.has('Authorization')) {
// It's best practice to return immediately after
// calling passThrough()
return context.passThrough()
}
return new Response('Authorized', { status: 200 })
})
.use(context => {
// This will never run, since the previous middleware
// either returns a Response or calls passThrough()
throw new Error('This request middleware will never run')
})
When writing a Hattip handler without this package, the context.env()
method is inherently unsafe. Its return type is always string | undefined
, which means you either need to write defensive checks or use type assertions. Neither is ideal.
With alien-middleware, you must declare an environment variable's type in order to use it.
import { chain } from 'alien-middleware'
// A common pattern is to declare a dedicated type for the environment variables.
type Env = {
API_KEY: string
}
const app = chain<Env>().use(context => {
const key = context.env('API_KEY')
// ^? string
})
When defining a middleware, you can declare env types that the middleware expects to use.
import type { RequestContext } from 'alien-middleware'
// Assuming `Env` is defined like in the previous example.
const myMiddleware = (context: RequestContext<Env>) => {
const key = context.env('API_KEY')
}
In both examples, we skip declaring any additional context properties (the first type parameter) because we're not using any. The second type parameter is for environment variables. The third is for the special context.platform
property, whose value is provided by the host platform (e.g. Node.js, Deno, Bun, etc). On principle, a middleware should avoid using the context.platform
property, since that could make it non-portable unless you write extra fallback logic for other hosts.
The routes
function provides a way to create a router instance for handling different paths and HTTP methods.
import { routes } from 'alien-middleware/router'
const router = routes()
The routes
function leverages pathic
to provide type inference for path parameters.
import { routes } from 'alien-middleware/router'
const router = routes()
router.use('/users/:userId', context => {
// context.params.userId is automatically typed as string
const userId = context.params.userId
return new Response(`User ID: ${userId}`)
})
You can specify one or more HTTP methods for a route by providing the method(s) as the first argument to .use()
.
import { routes } from 'alien-middleware/router'
const router = routes()
// This handler will only run for GET requests to /api/items
router.use('GET', '/api/items', context => {
return new Response('List of items')
})
// This handler will only run for POST requests to /api/items
router.use('POST', '/api/items', context => {
return new Response('Create a new item', { status: 201 })
})
// This handler will run for both PUT and PATCH requests to /api/items/:id
router.use(['PUT', 'PATCH'], '/api/items/:id', context => {
const itemId = context.params.id
return new Response(`Update item ${itemId}`)
})
// This handler will run for any method to /status
router.use('/status', context => {
return new Response('Status: OK')
})
Note
Your routes don't need to be in any particular order, unless their path
patterns are exactly the same. The pathic
library will match the most
specific path first. This allows you to split your routes into multiple files
for better organization.
You can pass a middleware chain to the routes()
function to apply middleware specifically to the routes defined by that router instance. This provides type safety for context extensions within the router.
import {
chain,
type RequestContext,
type RequestPlugin,
} from 'alien-middleware'
import { routes } from 'alien-middleware/router'
// Define a middleware that adds a user to the context
const addUserMiddleware = (context: RequestContext): RequestPlugin => {
const user = { id: 123, name: 'Alice' }
return { user }
}
// Create a chain with the middleware
const authMiddlewares = chain(addUserMiddleware)
// Pass the chain to the routes function
const authenticatedRouter = routes(authMiddlewares)
// Define a route that uses the context property added by the middleware
authenticatedRouter.use('/profile', context => {
// context.user is now type-safe and available
return new Response(`Welcome, ${context.user.name}!`)
})
// Routes defined on a router without a chain won't have the user property
const publicRouter = routes()
publicRouter.use('/public', context => {
// context.user is not available here
// @ts-expect-error - user is not defined on this context
console.log(context.user)
return new Response('Public content')
})
// You can combine routers using .use()
const app = chain().use(authenticatedRouter).use(publicRouter)