Understanding Express.js: Creating Your Own Node HTTP Request Router

Written by nas5w | Published 2019/02/22
Tech Story Tags: nodejs | javascript | software-development | programming | node-http-request-router | express | backend | beginners

TLDR Express is a JavaScript framework that serves as the backend for a lot of full stack web applications. Today, we’re going to recreate some of the routing functionality to gain a better understanding of the the context in which the framework operates as well as how response and request can be handled. If you’d like to see the final source code, you can find it on Github here. Let’s start out by emulating Express’ “Hello World” application but pulling in our own module.via the TL;DR App

(Do-It-Yourself: Node HTTP Router)
Express is a terrific JavaScript framework that serves as the backend for a lot of full stack web applications. Many of us use it day-to-day and are proficient in how to use it but may lack an understanding of how it works. Today, without diving into the Express source code, we’re going to recreate some of the routing functionality to gain a better understanding of the the context in which the framework operates as well as how response and request can be handled.
If you’d like to see the final source code, you can find it on Github here. Please do still code along with me for a better learning experience!

Getting Started

Let’s start out by emulating Express’ “Hello World” application. We’ll modify it slightly since we won’t be pulling in
express
but will rather be pulling in a module we create ourselves.
First, create a new project folder and initiate an npm project using the default configuration.
mkdir diy-node-router
cd diy-node-router
npm init -y
Verify your
package.json
file looks as follows:
{
  "name": "diy-node-router",
  "version": "1.0.0",
  "description": "", 
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}
Next, we’ll create our
index.js
file. In this file we’ll replicate the
express
“Hello World” example but pull in our own module (we’ll create this module in short order).
const router = require('./src/diy-router');
const app = router();
const port = 3000;

app.get('/', (req, res) => res.send('Hello World!'));

app.listen(port, () => console.log(`Example app listening on port ${port}!`));
This is essentially the same as the
express
“Hello World” example example. Based on this code, we know our
router
module should be a function that returns an
app
object when called. This object should have a
listen
method to start listening for requests on a port and a
get
method to set up
get
request handling. We’ll also set up a
post
method since we’ll ultimately want our app to handle posts.

Scaffolding the diy-router Module

Now we create the actual router module. Create the
diy-router.js
file inside a new
src
directory.
mkdir src
cd src
touch diy-router.js
We don’t want to bite off too much at once, so let’s first just create a module that exports the requisite methods.
module.exports = (() => {
  const router = () => {
    const get = (route, handler) => {
      console.log('Get method called!');
    };
    
    const listen = (port, cb) => {
      console.log('Listen method called!');
    };

    return {
      get,
      listen
    };
  };

  return router;
})();
Hopefully this all makes sense so far: we created a
router 
function that, when called, returns a
get 
and a
listen 
method. At this point, each method ignores its parameters and simply logs that it has been called. This function is then wrapped in an Immediately Invoked Function Expression (IIFE). If you’re unfamiliar as to why we use an IIFE, we do so for data privacy. This will be a little more obvious is the coming steps when we have variables and functions that we don’t want to expose outside the module itself.
At this point, we can go back to our root directory and run our application using node.
node .
If all is well, you’ll see an output like the following:
Get method called!
Listen method called!
Perfect, everything is wired together! Now, let’s start serving content in response to http requests.

Handling HTTP Requests

To get some basic HTTP request handling functionality, we bring in node’s built-in
http
module to our
diy-router
. The
http
module has a
createServer
method that takes a function with request and response parameters. This function gets executed every time an http request is sent to the port specified in the
listen
method. The sample code below shows how the
http
module can be used to return the text “Hello World” on port
8080
.
http.createServer((req, res) => {
   res.write('Hello World!');
   res.end();
}).listen(8080);
We’ll want to use this kind of functionality it our module, but we need to let the user specify their own port. Additionally, we’ll want to execute a user-supplied callback function. Let’s use this example functionality along with within the
listen
method of our
diy-router
module and make sure to be more flexible with the port and callback function.
const http = require('http');

module.exports = (() => {
  const router = () => {
    const get = (route, handler) => {
      console.log('Get method called!');
    };
    
    const listen = (port, cb) => {
      http.createServer((req, res) => {
        res.write('Hello World!');
        res.end();
      }).listen(port, cb);
    };

    return {
      get,
      listen
    };
  };

  return router;
})();
Let’s run our app and see what happens.
node .
We see the following logged in the console:
Get method called!
Example app listening on port 3000!
This is a good sign. Let’s open our favorite web browser and navigate to http://localhost:3000.
(Simple “Hello World!” App)
Looking good! We’re now serving content over port 3000. This is great, but we’re still not serving route-dependent content. For example, if you navigate to http://localhost:3000/test-route you’ll see the same “Hello World!” message. In any real-world application, we’ll want the content we serve to our user to be dependent on what’s in the provided URL.

Adding and Finding Routes

We need to be able to add any number of
routes
to our application and execute the correct route handler function when that route is called. To do this, we’ll add a routes array to our module. Additionally, we’ll create
addRoute
and
findRoute
functions. Notionally, the code might look something like this:
let routes = [];

const addRoute = (method, url, handler) => {
  routes.push({ method, url, handler });
};

const findRoute = (method, url) => {
  return routes.find(route => route.method === method && route.url === url);
}
We’ll use the
addRoute
method from our
get
and
post 
methods. The
findRoute
method simply returns the first element in
routes
that matches the provided
method
and
url
.
In the following snippet, we add the array and two functions. Additionally, we modify our
get
method and add a
post
method, both of which use the
addRoute
function to add user-specified routes to the routes array.
Note: Since the
routes
array and the
addRoute
and
findRoute
methods will only be accessed within the module, we can use our IIFE “revealing module” pattern to not expose them outside the module.
const http = require('http');

module.exports = (() => {
  let routes = [];

  const addRoute = (method, url, handler) => {
    routes.push({ method, url, handler });
  };

  const findRoute = (method, url) => {
    return routes.find(route => route.method === method && route.url === url);
  }
  
  const router = () => {
    const get = (route, handler) => addRoute('get', route, handler);
    const post = (route, handler) => addRoute('post', route, handler);
    
    const listen = (port, cb) => {
      http.createServer((req, res) => {
        res.write('Hello World!');
        res.end();
      }).listen(port, cb);
    };

    return {
      get,
      post,
      listen
    };
  };

  return router;
})();
Finally, let’s employ the
findRoute
function within the function we’re passing to our
createServer
method. When a route is successfully found, we should call the handler function associated with it. If the route isn’t found, we should return a 404 error stating that the route wasn’t found. This code will notionally look like the following:
const method = req.method.toLowerCase();
const url = req.url.toLowerCase();
const found = findRoute(method, url);
if (found) {
  return found.handler(req, res);
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Route not found.');
Now let’s incorporate this into our our module. While we’re at it, we’ll add one extra bit of code that creates a
send
method for our response object.
const http = require('http');

module.exports = (() => {
  let routes = [];

  const addRoute = (method, url, handler) => {
    routes.push({ method, url, handler });
  };

  const findRoute = (method, url) => {
    return routes.find(route => route.method === method && route.url === url);
  }
  
  const router = () => {
    const get = (route, handler) => addRoute('get', route, handler);
    const post = (route, handler) => addRoute('post', route, handler);
    
    const listen = (port, cb) => {
      http.createServer((req, res) => {
        const method = req.method.toLowerCase();
        const url = req.url.toLowerCase();
        const found = findRoute(method, url);
        if (found) {
          res.send = content => {
            res.writeHead(200, { 'Content-Type': 'text/plain' });
            res.end(content);
          };
          return found.handler(req, res);
        }
        
        res.writeHead(404, { 'Content-Type': 'text/plain' });
        res.end('Route not found.');
        
      }).listen(port, cb);
    };

    return {
      get,
      post,
      listen
    };
  };

  return router;
})();
Let’s see this in action! Again, run your application from the root directory.
node .
You should see that the app is being served on port 3000. In your browser, navigate to http://localhost:3000. You should see “Hello World!” But now, if you navigate to http://localhost:3000/test-route, you should get a “Route not found” message. Success!
Now we want to confirm we can actually add
/test-route
as a route in our application. In
index.js
, set up this route.
const router = require('./src/diy-router');
const app = router();
const port = 3000;

app.get('/', (req, res) => res.send('Hello World!'));
app.get('/test-route', (req, res) => res.send('Testing testing'));

app.listen(port, () => console.log(`Example app listening on port ${port}!`));
Restart the server and navigate to http://localhost:3000/test-route. If you see “Testing testing”, you’ve successfully set up routing!
Note: If you’ve had enough fun, you can end here! This was a great primer on routing. If you want to dig a little deeper and be able to extract parameters from ourroutes, read on!

Extracting Route Parameters

In the real world, we’re likely to have parameters in our url strings. For example, say we have a group of users and want to fetch a user based on a parameter in the url string. Our url string might end up being something like
/user/:username
where
username
represents a unique identified associated with a user.
To create this function, we could develop some regular expression rules to match any url parameters. Instead of doing this, I’m going to recommend we pull in a great module called
route-parser
to do this for us. The
route-parser
module creates a new object for each route that has a
match
method with all the regular expression magic baked in. To make the required changes in our module, do the following:
Install the module from the command line:
npm i route-parser
At the top of the
diy-router.js
file, require the module.
const Route = require('route-parser');
In the
addRoute
function, rather than adding the plan url string, add a new instance of the
Route
class.
const addRoute = (method, url, handler) => {
  routes.push({ method, url: new Route(url), handler });
};
Next, we’ll update the
findRoute
function. In this update, we use the
Route
object’s
match
method to match the provided url with a route string. In other words, navigating to
/user/johndoe
will match the route string
/user/:username
.
If we do find a match, we don’t only want to return a match, but we’ll also want to return the parameters extracted from the url.
const findRoute = (method, url) => {
  const route = routes.find(route => {
    return route.method === method && route.url.match(url);
  });
  if (!route) return null;
  return { handler: route.handler, params: route.url.match(url) };
};
To handle this new functionality, we need to revisit where we call
findRoute
in the function we pass to
http.createServer
. We’ll want to make sure that any parameters in our route get added as a property on the request object.
if (found) {
  req.params = found.params;
  res.send = content => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end(content);
};
So our final module will look like this:
const http = require('http');
const Route = require('route-parser');

module.exports = (() => {
  let routes = [];

  const addRoute = (method, url, handler) => {
    routes.push({ method, url: new Route(url), handler });
  };

  const findRoute = (method, url) => {
    const route = routes.find(route => {
      return route.method === method && route.url.match(url);
    });

    if (!route) return null;

    return { handler: route.handler, params: route.url.match(url) };
  };

  const get = (route, handler) => addRoute('get', route, handler);
  const post = (route, handler) => addRoute('post', route, handler);

  const router = () => {
    const listen = (port, cb) => {
      http
        .createServer((req, res) => {
          const method = req.method.toLowerCase();
          const url = req.url.toLowerCase();
          const found = findRoute(method, url);

          if (found) {
            req.params = found.params;
            res.send = content => {
              res.writeHead(200, { 'Content-Type': 'text/plain' });
              res.end(content);
            };

            return found.handler(req, res);
          }

          res.writeHead(404, { 'Content-Type': 'text/plain' });
          res.end('Route not found.');
        })
        .listen(port, cb);
    };

    return {
      get,
      post,
      listen
    };
  };

  return router;
})();
Let’s test this out! In our
index.js
file, we’ll add a new user endpoint and see if we can toggle between users by changing our url query string. Change you
index.js
file as follows. This will filter our
user
array based on the
params
property of the provided request.
const router = require('./src/diy-router');
const app = router();
const port = 3000;

app.get('/', (req, res) => res.send('Hello World!'));
app.get('/test-route', (req, res) => res.send('Testing testing'));
app.get('/user/:username', (req, res) => {
  const users = [
    { username: 'johndoe', name: 'John Doe' },
    { username: 'janesmith', name: 'Jane Smith' }
  ];

  const user = users.find(user => user.username === req.params.username);

  res.send(`Hello, ${user.name}!`);
});

app.listen(port, () => console.log(`Example app listening on port ${port}!`));
Now, restart your app.
node .
Navigate first to http://localhost:3000/user/johndoe, observe the content, and then navigate to http://localhost:3000/user/janesmith. You should receive the following responses, respectively:
Hello, John Doe!
Hello, Jane Smith!

Final Code

The final code for this project can be found on Github here. Thanks for coding along!

Conclusion

In this article we observed that, while Express is an incredible tool, we can replicate its routing functionality through implementation of our own custom module. Going through this kind of exercise really helps to pull back the “curtain” and makes you realize that there really isn’t any “magic” going on. That being said, I definitely wouldn’t suggest rolling your own framework for you next Node project!
One reason frameworks like Express are so incredible is they have received a lot of attention from a lot of terrific developers. They have robust designs and tend to be more efficient and secure than solutions any single developer could deploy.
So what did you think of this exercise? Does this inspire you to try to replicate other aspects of Express’ functionality? Let me know in the comments!

Written by nas5w | Husband, dog dad, coffee monster. Software engineer at the @usds! Opinions are my own
Published by HackerNoon on 2019/02/22