DEV Community

Poorshad Shaddel
Poorshad Shaddel

Posted on • Originally published at levelup.gitconnected.com on

Build your First Node.js Web Framework Step by Step

We want to create a super simple web framework in order to get an idea of how web framework libraries are working.


Build Your First Node.js Web Framework

In the previous article, we took a look at Expressjs and tried to understand how this library works. In this article, we want to build something super simple in order to feel the challenges and feel comfortable when we want to take a look at the source code of other libraries.

Let’s get to coding without hesitation.

Handle Requests using Node.js HTTP Library

We are going to use the Node.js HTTP library which is one of the core libraries and you do not need to install anything.

const http = require('node:http');

const server = http.createServer((req, res) => {
  res.end('pong');
});

server.listen(8000);
Enter fullscreen mode Exit fullscreen mode

Now if we send a request to any route in localhost:8000/anything/you/want we get pong as a result. Now let’s implement our features.

Our Strategy for Handling Requests

For handling a request in a normal application most of the time we are doing totally different operations on the same incoming request. As an example, a request wants to update a user. And imagine this is how the request looks like:

Method: POST
URL: "http://ourAddress/users/userId"
JSON Body of request: { name: "something New" }
Enter fullscreen mode Exit fullscreen mode

As a backend service I want to do different operations on this request:

  • Authorization
  • Parse Body of the request
  • Log the operation and the person who did the operation
  • Update the User in the Database
function myHandler(req, res) {
    // Authorization
    const isAuthorizedUser = authorizeRequest(req);
    if (!isAuthorizedUser) {
        // return some bad error
        res.send(...);
        return;
    }

    // Parse Body of the Request
    bodyParser(req);

    // Log the error
    logger(req);


    // Update the user
    updateUser(req);

    res.end();
}
Enter fullscreen mode Exit fullscreen mode

This code is totally OK. But imagine there are more than twenty different routes in the same application and all of them need almost the same operations. All of them need to log every action or authorize the request. So we need a way to reduce code duplications. One common strategy for solving this issue is the concept of middleware. ExpressJS uses this concept and we also want to implement something like this.

Now look at this code:

const http = require('node:http');

const server = http.createServer((req, res) => {
    function authorizationMiddleware(req, res) {
        const isAuthorizedUser = authorizeRequest(req);
        if (!isAuthorizedUser) {
            // return some bad error
            res.send(...);
            // How to tell next functions that we already send the request to the user?
        } else {
            // What to do? should go to the next function
        }
    }
    function bodyParserMiddleware(req, res) {
        bodyParser(req);
    };
    function logger(req) {
        console.log(req);
    };
    function updateUser(req, res) {
        updateUser(req);
        res.end();
    }
    authorizationMiddleware(req, res);
    bodyParserMiddleware(req, res);
    logger(req, res);
    updateUser(req, res);
});

server.listen(8000);
Enter fullscreen mode Exit fullscreen mode

In this case, we only have one route in our application and it updates the user.

Why Do You Need a Next Function?

There is a big difference between authorizationMiddleware and the other ones, if the user is unauthorized we are returning the response to the user and it does not make sense to send the request to other handlers like body-parser, logger, or update user. So we need a mechanism for knowing when the request is sent and we should stop handing over the request and response object to the middlewares.

For doing this the first thing we need to do is to store these middlewares somewhere. Then we can implement the next function to pass the request to these middlewares. At the same time, we can solve the problem of having different handlers for different functions.

In Expressjs they keep middlewares(handler functions) in an array. Let’s do it kind of the same way:

const http = require('node:http');

function FastFramework() {
    // An array for keeping the middlewares
    const middlewares = [];
    const server = http.createServer((req, res) => {
      let middlewareCounter = 0;
      function nextFunction() {
        while(middlewareCounter < middlewares.length) {
          console.log("Next Function While Loop- Current Counter:", middlewareCounter);
          if (match(req, middlewares[middlewareCounter])) {
            return middlewares[middlewareCounter++].handler(req, res, nextFunction);
          } else {
            middlewareCounter++
            }
          }
            // If we are here then no middleware matched!!!!
        }
        nextFunction();
    });
    // A method that we can add middelwares
    server.use = function use(url, method, handler) {
        middlewares.push({
          url,
          method,
          handler
        })
    }
    return server;
}

// if the request is a match for the middleware or not
function match(req, { url, method }) {
    if (req.method !== method) return false;
    if (req.url !== url) return false;
    return true;
}

module.exports = { FastFramework }
Enter fullscreen mode Exit fullscreen mode

The function FastFrameWork is just for returning the HTTP instance.

In line 5 we see an array for keeping all the middlewares. After creating an HTTP server we defined a variable for keeping track of the current middleware that should be handled.

Next Function

In line 8 we implemented the nextFunction and later on, we called this function so it can start processing the request and response, and we intentionally put the variable middlewareCounter outside the next function. In line 10 we have a while loop, it loops over middlewares until it finds a match.

If it is a match in line 12 we are calling the handler function of the middleware. The most important thing happens here:

return middlewares[middlewareCounter++].handler(req, res, nextFunction)

We are also passing the next function as an argument to the handler, so we can use this argument to call this function and let other middlewares handle the request. If we are sending the response in the implementation of our middleware handlers and we are not calling the next function which means that this is the last stop for the request. If do not call the next function and we do not return any response then the request will timeout after a while. Also, we are increasing the counter variable because we want the next middleware next time we are in this piece of code.

If it was not a match in line 14 we just increase the variable so the while loop will check the next middleware or if it was the last middleware it gets out of the loop and it does nothing.

Use Function

Like Express I named it use method. This function just adds the middleware to the array of middlewares. The arguments are url , method and handler should be a function like this: (req, res, next) => {...} .

Match Function

Now let’s implement something for this match function. We can check if the method and URL are the same or not.

function match(req, { url, method }) {
    if (req.method !== method) return false;
    if (req.url !== url) return false;
    return true;
}
Enter fullscreen mode Exit fullscreen mode

We know that right now it cannot handle URL patterns like this:

/api/users/:id but that is OK.

Usage

We can import this library like this:

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

const app = FastFramework();

app.use('/users', 'GET', (req, res, next) => {
    console.log('authrized the user...');
    next();
});
app.use('/users', 'GET', (req, res, next) => {
    console.log('logged the user...');
    next();
});
app.use('/users', 'GET', (req, res, next) => {
    console.log('prepating the list of users...');
    res.end('some users...');
});
app.use('/otherRoute', 'GET', (req, res, next) => {
    console.log('other route');
    res.end('other info');
});
app.listen(8000);
Enter fullscreen mode Exit fullscreen mode

If we send a request we must see some users...


Response to the request.

We expect to see logs of all the routes in order except the /otherRoute which does not match the request:


Log of response to the request: GET /users

As expected first middleware that is handled is the authorization, then the log, and finally the users.

Now if we send a request to /otherRoute we should only see the log of otherRoute:


Log of the request to otherRoute

How to Handle 404

For handling not found requests we need a middleware that matches every request. And we put this middleware as the last middleware so if we know that this request did not match with any of the middlewares. Let’s do this small change to our framework. It is just your choice how you want to implement this:

function match(req, { url, method }) {
    if(!url && !method) return true;
    if (req.method !== method) return false;
    if (req.url !== url) return false;
    return true;
}
Enter fullscreen mode Exit fullscreen mode

Now if we do not pass URL and Method to the use function it returns true which means that it going to be a match.

Let’s add our not found middleware as the last middleware.

app.use(null, null, (req, res) => {
    res.end('NOT_FOUND');
});
Enter fullscreen mode Exit fullscreen mode

Now if we send a request to an unknown URL:


Request to a route that does not exist

Implement res.json Method

We need to add this code after creating the server(where we have the request and response):

res.json = function(data) {
  res.setHeader('Content-Type', 'application/json');
  res.end(JSON.stringify(data));
}
Enter fullscreen mode Exit fullscreen mode

Our FastFramework function looks like this after this change:

function FastFramework() {
    // An array for keeping the middlewares
    const middlewares = [];
    const server = http.createServer((req, res) => {
        res.json = function(data) {
            res.setHeader('Content-Type', 'application/json');
            res.end(JSON.stringify(data));
        }
        let middlewareCounter = 0;
        function nextFunction() {
            while(middlewareCounter < middlewares.length) {
                console.log("Next Function While Loop- Current Counter:", middlewareCounter);
                if (match(req, middlewares[middlewareCounter])) {
                    return middlewares[middlewareCounter++].handler(req, res, nextFunction);
                } else {
                    middlewareCounter++
                }
            }
            // If we are here then no middleware matched!!!!
        }
        nextFunction();
    });
    // A method that we can add middelwares
    server.use = function use(url, method, handler) {
        middlewares.push({
          url,
          method,
          handler
        })
    }
    return server;
}
Enter fullscreen mode Exit fullscreen mode

Let’s use this function in a simple middleware:

app.use('/users', 'GET', (req, res, next) => {
    console.log('prepating the list of users...');
    res.json({ list: ['user1', 'user2'], count: 2 });
})
Enter fullscreen mode Exit fullscreen mode

This is the result.

Body Parser

We can use the body-parser library under the Expressjs project:

npm i body-parser

It is usable because our Framework also has the next function:

const bodyParser = require('body-parser');

const app = FastFramework();

app.use(null, null, bodyParser);
Enter fullscreen mode Exit fullscreen mode

Note that we did not pass a URL or method body parser will be used for every request after this middleware.

Let’s see the request body before and after using body-parser.

const { FastFramework } = require("./lib");
const bodyParser = require('body-parser');

const app = FastFramework();

app.use('/user', 'POST', (req, res, next) => {
    console.log("body before:", req.body);
    next();
});

app.use(null, null, bodyParser.json());

app.use('/user', 'POST', (req, res) => {
    console.log("body after:", req.body);
    res.json({});
});
Enter fullscreen mode Exit fullscreen mode

The response is as expected an empty object and as we can see in the logs the body of the request is parsed correctly in it is in the req.body


Logs of request body before and after using body parser.

Summary

As you saw the most difficult thing was to add a concept like middleware for reusing the logic. For that, we implemented the next function and we were able to handle requests and pass the request and response objects between these middlewares. Feel free to add more functionality to your own HTTP Library.


Top comments (0)