alien-middleware
v0.10.3
Published
Reusable middleware chains with top-notch TypeScript support. Built upon [Hattip](https://github.com/hattipjs/hattip) to avoid vendor lock-in using Web Standard APIs.
Readme
alien-middleware
Reusable middleware chains with top-notch TypeScript support. Built upon Hattip to avoid vendor lock-in using Web Standard APIs.
Philosophy
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
RequestandResponse, 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.
Quick Start
First, add the package to your project:
pnpm add alien-middlewareCreating a Chain
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)
})Adding Middleware with .use()
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.
Executing the Chain
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, a404 Not Foundresponse is automatically returned.
Middlewares are deduplicated.
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
Request middleware runs sequentially before a Response is generated.
Terminating the Chain: Return a
Responseobject 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
envproperty 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.
Response Callbacks
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.
Modifying Response Headers
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.
Non-Blocking 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))
})
})Merging a Middleware Chain
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
})Isolating a Middleware Chain
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.
Escaping a Middleware 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')
})Safe Environment Variables
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.
Router
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()Path Parameter Type Inference
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}`)
})Handling Specific HTTP Methods
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
pathiclibrary will match the most specific path first. This allows you to split your routes into multiple files for better organization.
Type-Safe Middleware with routes()
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)