DEV Community

loading...

Basic Middleware Pattern in JavaScript

Munif Tanjim
Full Stack Software Engineer with years of professional experience with JavaScript ecosystem. On my free time, I maintain a few open-source libraries and tools for solving various problems.
・3 min read

Ever wondered how the middlewares in popular web frameworks, e.g. Express or Koa, work?

In Express, we have the middleware functions with this signature:

const middleare = (req, res, next) => {
  // do stuffs
  next()
}
Enter fullscreen mode Exit fullscreen mode

In Koa, we have this:

const middleware = (ctx, next) => {
  // do stuffs
  next()
}
Enter fullscreen mode Exit fullscreen mode

Basically, you have some objects (req, res for Express or ctx for Koa) and a next() function as the arguments of the middleware function. When next() is called, the next middleware function is invoked. If you modify the argument objects in the current middleware function, the next middleware will received those modified objects. For example:

// Middleware usage in Koa

app.use((ctx, next) => {
  ctx.name = 'Doe'
  next()
})

app.use((ctx, next) => {
  console.log(ctx.name) // will log `Doe`
})

app.use((ctx, next) => {
  // this will not get invoked
})
Enter fullscreen mode Exit fullscreen mode

And if you don't call the next() function, the execution stops there and the next middleware function will not be invoked.

Implementation

So, how do you implement a pattern like that? With 30 lines of JavaScript:

function Pipeline(...middlewares) {
  const stack = middlewares

  const push = (...middlewares) => {
    stack.push(...middlewares)
  }

  const execute = async (context) => {
    let prevIndex = -1

    const runner = async (index) => {
      if (index === prevIndex) {
        throw new Error('next() called multiple times')
      }

      prevIndex = index

      const middleware = stack[index]

      if (middleware) {
        await middleware(context, () => {
          return runner(index + 1)
        })
      }
    }

    await runner(0)
  }

  return { push, execute }
}
Enter fullscreen mode Exit fullscreen mode

This implementation of middleware pattern is almost the same as Koa. If you want to see how Koa does it, check out the source code of koa-compose package.

Usage

Let's see an example of using it:

// create a middleware pipeline
const pipeline = Pipeline(
  // with an initial middleware
  (ctx, next) => {
    console.log(ctx)
    next()
  }
)

// add some more middlewares
pipeline.push(
  (ctx, next) => {
    ctx.value = ctx.value + 21
    next()
  },
  (ctx, next) => {
    ctx.value = ctx.value * 2
    next()
  }
)

// add the terminating middleware
pipeline.push((ctx, next) => {
  console.log(ctx)
  // not calling `next()`
})

// add another one for fun ¯\_(ツ)_/¯
pipeline.push((ctx, next) => {
  console.log('this will not be logged')
})

// execute the pipeline with initial value of `ctx`
pipeline.execute({ value: 0 })
Enter fullscreen mode Exit fullscreen mode

If you run that piece of code, can you guess what the output will be? Yeah, you guessed it right:

{ value: 0 }
{ value: 42 }
Enter fullscreen mode Exit fullscreen mode

By the way, this would absolutely work with async middleware functions too.

TypeScript

Now, how about giving it some TypeScript love?

type Next = () => Promise<void> | void

type Middleware<T> = (context: T, next: Next) => Promise<void> | void

type Pipeline<T> = {
  push: (...middlewares: Middleware<T>[]) => void
  execute: (context: T) => Promise<void>
}

function Pipeline<T>(...middlewares: Middleware<T>[]): Pipeline<T> {
  const stack: Middleware<T>[] = middlewares

  const push: Pipeline<T>['push'] = (...middlewares) => {
    stack.push(...middlewares)
  }

  const execute: Pipeline<T>['execute'] = async (context) => {
    let prevIndex = -1

    const runner = async (index: number): Promise<void> => {
      if (index === prevIndex) {
        throw new Error('next() called multiple times')
      }

      prevIndex = index

      const middleware = stack[index]

      if (middleware) {
        await middleware(context, () => {
          return runner(index + 1)
        })
      }
    }

    await runner(0)
  }

  return { push, execute }
}
Enter fullscreen mode Exit fullscreen mode

With everything being typed, now you can declare the type of the context object for a specific middleware pipeline, like this:

type Context = {
  value: number
}

const pipeline = Pipeline<Context>()
Enter fullscreen mode Exit fullscreen mode

Okay, that's all for now.


Originally published at muniftanjim.dev on October 4, 2020.

Discussion (2)

Collapse
eomm profile image
Manuel Spigolon

Good explanation!

It would be interesting to cover async behaviour and issue like: what to do if a middleware call next when the following 2 other middleware already runs?

const pipe = Pipeline(
  (ctx, next) => { console.log(1); next(); setImmediate(next) },
  (ctx, next) => { console.log(2); next() }
)
pipe.execute({})
  .then(() => { console.log('done') })
  .catch((err) => { console.log(err) })
Collapse
muniftanjim profile image
Munif Tanjim Author • Edited

Each next function is supposed to be called only 1 time.

If you call it twice then, you get the Error('next() called multiple times') error.

Also, if you're using async middleware functions, you should always do await next().