Build your own expressjs | Part 3

Sunday, July 26, 2020

This blog post is part of a series in which we are going to build a minimal, simple and yet powerful version of Express.js, called Minimal.js. This is the third part. You can check out part 1 here and part 2 here.

We are learning on the go so if you find any mistake or any better way to do certain things or just want to share your feedback then I am all ears and open to collaboration. Let me know your opinions here.

The Road so far

So, till now, we talked about HTTP, explored a couple of Node.js modules, built a simple server using native modules and then we took a deep dive into how we can implement methods like use and middlewares support to our framework.

Part 3

This part would revolve around how routing works under the hood and implementation details regarding standard HTTP verbs like get, post and more.

I would recommend that you should code along. So, go ahead, clone the repo and check out the part-2 branch. Create a new branch part-3 from part-2.

git clone https://github.com/yomeshgupta/minimaljs.git
git checkout part-2
git checkout -b part-3 part-2

Complete code for this part can be found here.

Concepts

Routing System in Express, basically comprises of three major components:

  1. Router
  2. Layer
  3. Route

Let's talk about each but before that take a look at the diagram below.

Routing

Router

Router component is responsible for the entire routing of the application. It exposes various methods (HTTP verbs) like get, post, put and more; which are used by the application to bind handlers to particular paths. An instance of Router contains an array of Layers.

The relationship between Router and Layer is one-to-many i.e. an instance of a Router can have multiple Layers in it.

Layer

Each Layer consists of a path and a Route component which stores more specific information about that path.

Route

Route Component is the one where we implement the HTTP verbs. It contains the path and an array of Layers.

The difference between the Layer in the Router component and the Route component is that in the Route component, a Layer contains a method and its handler.

The relationship between path and method can be one-to-many i.e. a path like /login can have multiple methods attached to it.

GET /login
POST /login

route

In summary, a Router contains an array of layers where each layer represents a path and a route component. Each route component contains the same path along with all the method(s) handling associated with it. When a request is received, each Layer's path is matched against the current request path. If matched then we compare the current request method type with method handling provided at the time of setup, if it is also a match then the apt handler is executed else not found is returned.

Let's code

Create a folder router and create a file layer.js inside it.

cd ./src
mkdir router
cd ./router
touch layer.js

Paste the following code inside the newly created layer.js

const { matchPath } = require("../lib/helpers");

class Layer {
  /*
    Setting up path and handler
  */
  constructor(path, handler) {
    this.handler = handler;
    this.name = handler.name || "<anonymous>";
    this.path = path;
  }

  /*
    If the current request path matches the layer's path
    then handling for the current path
  */
  requestHandler(...args) {
    const handler = this.handler;
    handler ? handler(...args) : null;
  }

  /*
    To match current request path with 
    the path provided at the time of setup

    SETUP: app.get('/login', (req, res) => {})
    CURRENT REQUEST: GET /login
  */
  match(path) {
    return matchPath(this.path, path);
  }
}

module.exports = Layer;

Inside the router folder, create a route.js and paste the following code

touch route.js
const Layer = require("./layer.js");

class Route {
  constructor(path) {
    this.path = path;
    this.stack = [];
    this.methods = {};
  }

  requestHandler(method) {
    const name = method.toLowerCase();
    return Boolean(this.methods[name]);
  }

  get(handler) {
    const layer = new Layer("/", handler);
    layer.method = "get";

    this.methods["get"] = true;
    this.stack.push(layer);
    return this;
  }

  post(handler) {
    const layer = new Layer("/", handler);
    layer.method = "post";

    this.methods["post"] = true;
    this.stack.push(layer);
    return this;
  }

  dispatch(req, res) {
    const method = req.method.toLowerCase();

    this.stack.forEach((item) => {
      if (method === item.method) {
        item.requestHandler(req, res);
      }
    });
  }
}

module.exports = Route;

Let's understand the above code snippet

  1. We created a Route class that takes path as a parameter and maintains an internal array of all layers and a collection of methods attached to it.
  2. Whenever, you do something like
app.get("/login", (req, res) => {
  /* some processing */
});

Then a route is created with path /login and a layer is pushed inside its array of layers containing the handler provided with it. Route also stores the information about the current method in a collection. It is done to determine if a method of handling is available when a request comes in.

  1. dispatch method is called when a request comes in, it traverses all the layers and if the current request method matches with the layer's method then we execute the apt request handler.

  2. Currently, we are handling two HTTP verbs, get and post.

Now, we have our Layer and Route components ready. Let's consolidate all this in Router.

Create a file index.js in the router folder and paste the following code.

touch router.js
const Layer = require("./layer.js");
const Route = require("./route.js");

class Router {
  constructor() {
    this.stack = [
      new Layer("*", (req, res) => {
        res.statusCode = 404;
        res.setHeader("Content-Type", "text/plain");
        res.end(`Cannot find ${req.url}`);
      }),
    ];
  }

  handle(req, res) {
    const method = req.method;
    let found = false;

    this.stack.some((item, index) => {
      if (index === 0) {
        return false;
      }
      const { matched = false, params = {} } = item.match(req.pathname);
      if (matched && item.route && item.route.requestHandler(method)) {
        found = true;
        req.params = params;
        return item.requestHandler(req, res);
      }
    });

    return found ? null : this.stack[0].requestHandler(req, res);
  }

  route(path) {
    const route = new Route(path);
    const layer = new Layer(path, (req, res) => route.dispatch(req, res));
    layer.route = route;
    this.stack.push(layer);

    return route;
  }

  get(path, handler) {
    const route = this.route(path);
    route.get(handler);
    return this;
  }

  post(path, handler) {
    const route = this.route(path);
    route.post(handler);
    return this;
  }
}

module.exports = Router;
  1. We created a Router class that contains a stack (array of Layers). We are seeding the array with a default layer which will act as the default response if no handler for a path is found.
  2. We are handling GET and POST requests for now.
  3. Whenever we invoke any of the HTTP verb methods then a Route is created with the provided path, a Layer is created with the same path and the route's dispatch method is provided as the handler to the Layer.
  4. handle method goes through each layer in the stack looking for the layer whose setup path matches with the current request path and if that layer has a route and route handler with it then route handler is executed.

Updating Framework

Let's update our framework to accommodate our routing system.

...
const Router = require('./router/index');
...

function Minimal() {
  ...
  const _router = new Router();
  ...
  function findNext(req, res) {
    ...
    const next = () => {
      ...
      if (matched) {
        req.params = params;
        middleware.handler(req, res, next);
      } else if (current <= _middlewares.length) {
        next();
      } else {
        req.handler(req, res);
      }
    };
    ...
  }

  function handle(req, res, cb) {
    ...
    req.handler = cb;
    ...
  }

  function get(...args) {
    const { path, handler } = checkMiddlewareInputs(args);
    return _router.get(path, handler);
  }

  function post(...args) {
    const { path, handler } = checkMiddlewareInputs(args);
    return _router.post(path, handler);
  }

  function listen(port = 8080, cb) {
    return http
      .createServer((req, res) => {
        ...
        handle(req, res, () => _router.handle(req, res));
      })
      ...
  }

  return {
    use,
    listen,
    get,
    post
  }
}
  1. We imported our Router class into our framework and created an instance of it.
  2. We implemented and exposed get and post methods so that our users can use them to bind handlers to a specific path and method types.
  3. We are now sending a callback to the handle method which is assigned to a key handler on the request object.
  4. We have seen in part 2 that we initially execute all the middlewares sequentially. We have updated that to call our method request handler when the middleware execution list is exhausted as no more middleware is left to be executed.

Testing

Now, we can test what we have built so far. Open server.js and make the following changes.

..
app.use(cors());
app.get('/about', (req, res) => {
  res.send('I am the about page');
});
app.get('/', (req, res) => {
  fs.readFile(path.resolve(__dirname, 'public', 'index.html'), (err, data) => {
    if (err) {
      return res.status(500).send('Error Occured');
    }
    return res.status(200).send(data);
  });
});
...
  1. /about route with CORS headers.

about

  1. / route.

home

  1. Not Found Route

not-found

Yaay!! Our framework is working now. I know we haven't tested the post requests and there are some known issues. Let's see if you can find those out. In the next part, we will improve upon our current system, fix some issues and maybe build a simple REST API using our framework. Stay tuned.

Complete code for this part can be found here.

Unsure about your interview prep? Practice Mock Interviews with us!

Book Your Slot Now