DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Cover image for Make A TypeScript Middleware Engine
Lem Canady
Lem Canady

Posted on

Make A TypeScript Middleware Engine

Introduction

The Middleware pattern is prevalent, especially in the NodeJS ecosystem β€” and for a good reason! It gives your users a simple interface for extending your software to fit their particular use case and decoupling the sender and receiver, among many other benefits. If you’ve spent any time working with Node’s HTTP/HTTPS libraries, express, KOA, or just about every Node-based web server, then you’ve seen the pattern before.

module.exports = (req, res, next) => {
    req.property = value;
    next();
}
Enter fullscreen mode Exit fullscreen mode

Today, we’ll dig in and learn more about the middleware pattern by building our very own middleware engine, Typescript edition! In the sections that follow, I’m going to start with code, then offer some explanations into the how and why. Ready? Here we go!

Typing

We’ll start with the types that I made to handle the engine. For the most part, they show how the different elements of our engine are going to work.

export type Next = () => Promise<any>;

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

export type Pipe<T> = {
    use: (...middlewares: Middleware<T>[]) => Promise<void>;
    execute: (context: T) => Promise<T>;
};
Enter fullscreen mode Exit fullscreen mode

First, I started by defining the type for the next function. It’s invoked at the end of every middleware module. The Next is essentially a container. In particular, it’s a container for functions. A crucial fact for later!

The middleware module comes next. The main part of the framework. Each middleware encapsulates work to be executed on our context object before executing the next function, as shown in the first example.

Finally, we have the definition for the actual engine, the pipe. It has two functions, use and execute. Use loads the pipe with middleware, while execute runs through all of the middleware in the pipe, using the context object.

The Code

Instead of using a class, we will use the revealing module pattern to share our public API. First, we need to declare the stack, an array that will hold all of our middleware functions.

/**
 * Declare a new middleware pipeline.
 * @param middlewares A list of middlewares to add to the pipeline on
 * instantiation.
 */
export function pipeline<T>(...middlewares: Middleware<T>[]): Pipe<T> {
  const stack: Middleware<T>[] = middlewares;

  /**
   * Add middlewares to the pipeline.
   * @param middlewares A list of middlewares to add to the current
   * pipeline.
   */
  const use: Pipe<T>["use"] = (...middlewares) => {
    stack.push(...middlewares);
  };
}
Enter fullscreen mode Exit fullscreen mode

This portion of the code is pretty straight forward. First, we declare our pipeline, the middleware engine, that optionally takes a comma-separated middleware list on instantiation. Next, the stack is defined, then use as a constant. Use takes in a list of middleware and adds them to the stack. Pushing it onto the end.

Next is where the magic happens, the execute function. It’s powered by two key components: a recursive algorithm and closure.

const execute: Pipe<T>["execute"] = async (context) => {
    let prevIndex = -1;

    /**
     * Programmatically go through each middleware and apply it to the context before
     * either moving on to the next middleware or returning the final context
     * object.
     * @param index The current count of middleware executions.
     * @param context The context object to send through the
     * middleware pipeline.
     */
    const handler = async (index: number, context: T): Promise<void | T> => {
      if (index === prevIndex) {
        throw new Error("next() already called.");
      }

      if (index === stack.length) return context;

      prevIndex = index;

      const middleware = stack[index];

      if (middleware) {
        await middleware(context, () => handler(index + 1, context));
      }
    };
    const response = await handler(0, context);
    if (response) return response;
};
Enter fullscreen mode Exit fullscreen mode

First, on line 2, we declare our index at -1 since 0 acts as the first index of our stack array. Next, we set up our recursive function, handler. We do some basic error checking first to make sure each middleware is only being called once. Then, if the middleware index matches the length of the stack, return the context.

On line 21, We select a middleware function from the stack based on the current index and run it. Note how along with context, we pass the handler function to next. This means that when next() is called in the middleware function, it’s actually invoking another run of the handler function, at index +1. A notice how context is actually defined as an exec's argument, and we pass it around to our inner functions? That’s closure! When a variable is defined in an outer scope and referenced within a nested function.
Finally, on lines 27 and 28, we kick off the recursive handler and wait for it to return with a value. That value is then passed back to the rest of your program.

Now let’s look at the whole module at once!

// First we declare our types.
export type Next = () => Promise<any> | any;
export type Middleware<T> = (
  context: T,
  next: Next
) => Promise<void>;
export type Pipe<T> = {
  use: (...middlewares: Middleware<T>[]) => void;
  execute: (context: T) => Promise<void | T>;
};

/**
 * Declare a new middleware pipeline.
 * @param middlewares A list of middlewares to add to the pipeline on
 * instantiation.
 */
export function pipeline<T>(...middlewares: Middleware<T>[]): Pipe<T> {
  const stack: Middleware<T>[] = middlewares;

  /**
   * Add middlewares to the pipeline.
   * @param middlewares A list of middlewares to add to the current
   * pipeline.
   */
  const use: Pipe<T>["use"] = (...middlewares) => {
    stack.push(...middlewares);
  };

  /**
   * Execute a pipeline, and move context through each middleware in turn.
   * @param context The contect object to be sent through the pipeline.
   */
  const execute: Pipe<T>["execute"] = async (context) => {
    let prevIndex = -1;

    /**
     * Programmatically go through each middleware and apply it to context before
     * either moving on to the next middleware or returning the final context
     * object.
     * @param index The current count of middleware executions.
     * @param context The context object to send through the
     * middleware pipeline.
     */
    const handler = async (index: number, context: T): Promise<void | T> => {
      if (index === prevIndex) {
        throw new Error("next() already called.");
      }

      if (index === stack.length) return context;

      prevIndex = index;

      const middleware = stack[index];

      if (middleware) {
        await middleware(context, () => handler(index + 1, context));
      }
    };
    const response = await handler(0, context);
    if (response) return response;
  };

  return { use, execute };
}
Enter fullscreen mode Exit fullscreen mode

At the end of the function (line 63), we return use and execute, offering them our public API. Now let’s see this engine at work!

interface Context {
    [key: string]: any
}

const engine = pipeline<Context>((ctx, next) => {
    ctx.foobar="baz";
    next();
})

engine.use((ctx, next) => {
  ctx.another = 123;
  next();
});

(async () => {
  const context: Context = {};
  await engine.exec(context);
  console.log(context);
})();

// => { foobar: "baz", another: 123 }
Enter fullscreen mode Exit fullscreen mode

I hope you enjoyed my little demo here. If you’d like to see the entire project, it’s available on my GitHub!

Thanks for reading!

Top comments (0)

50 CLI Tools You Can't Live Without

The top 50 must-have CLI tools, including some scripts to help you automate the installation and updating of these tools on various systems/distros.