Skip to content

Middleware ​

A route is a chain of middleware: small async functions, run in order, sharing one context.

ts
const route = defineMiddleware([
  useLogRequest(),
  useUser,
  async ({ user, serverResponse }) => {
    serverResponse.sendOKJSONResponse({ hello: user.name })
  },
])

Each middleware can do any of three things:

  • add to the context for the middleware after it (context.user = …);
  • send the response, which ends the chain;
  • neither, such as only setting headers or logging.

The context ​

Every chain starts with the same three properties:

PropertyWhat it is
incomingMessageThe request: Node's IncomingMessage with helpers added.
serverResponseThe response: Node's ServerResponse with send methods added.
loggerThe server's logger.

Each middleware can add to it, and everything after it in the chain can use what it added.

Writing middleware ​

A middleware's type says what it needs and what it adds:

ts
import type { Middleware, ServiceRequestContext } from '@webframe/core'

type User = { id: string; name: string; roles: string[] }

// What the context holds once useUser has run
export type WithUser = ServiceRequestContext & { user: User }

export const useUser: Middleware<ServiceRequestContext, WithUser> = async (context) => {
  const user = await findUserBySession(context.incomingMessage.headers.cookie)
  if (user === null) {
    return context.serverResponse.sendUnauthorizedJSONResponse()
  }
  context.user = user
}

Middleware<Input, Output> takes the context it needs (Input) and the context it leaves behind (Output). Inside it, the properties it adds are optional until it sets them.

Middleware that needs options is usually a factory, a function that returns the middleware. All of webframe's own middleware is written this way, and their names start with use:

ts
export const useRequireRole = (role: string): Middleware<WithUser> => async (context) => {
  if (context.user.roles.includes(role) === false) {
    return context.serverResponse.sendForbiddenTextResponse()
  }
}

A factory runs once, when the route is defined, so it's the place to check its options. usePaginationQueryParameters throws at that point if its defaults are invalid, rather than on each request.

Needing something earlier in the chain ​

useRequireRole above takes WithUser as its input, so it only fits after useUser. If you put it before useUser, or leave useUser out, TypeScript reports an error on the chain.

How the types flow ​

defineMiddleware types each middleware in the list by everything before it:

ts
defineMiddleware([
  useUser,                                  // adds user
  usePaginationQueryParameters({ … }),      // adds pagination
  async ({ user, pagination }) => { … },    // both are typed here
])

defineMiddleware([
  async ({ user }) => { … },                // error: nothing added user
])

Long chains ​

The first 30 middleware in a chain are typed. A chain can be longer: the ones after the 30th still run, and they see everything the first 30 added, but anything they add themselves is untyped.

Stopping the chain ​

Before each middleware runs, the chain checks serverResponse.sendingOrSent. Once a response has been sent, the rest of the chain is skipped. This counts responses sent with the send methods and also ones written directly with writeHead or end.

So a check that fails just sends its response and returns, with no next() to call or forget:

ts
const useLoginEnabledGate: Middleware = async ({ serverResponse }) => {
  if (configs.read('login.enabled') !== 'true') {
    return serverResponse.sendNotFoundTextResponse()
  }
}

When nothing responds ​

If the whole chain runs without sending a response, it's a bug, so webframe logs an error and sends 500 Internal Server Error. The client is never left waiting. Errors thrown by middleware are handled the same way: see Errors and logging.

Released under the MIT Licence.