CacheLane Logo

Chapter 06

Running Our Server

We have a working implementation of our TrieRouter class, which has enough functionality to handle routing for our server. But, we don't have a way to run our server or listen for requests yet.

In this chapter, we will implement a run function that accepts a router (TrieRouter) and a port (number) argument. This function will serve as the entry point of our server code, allowing us to handle incoming requests and route them to the appropriate handlers defined in our router.

But before that, let's refactor our code a little bit. We're going to rename the TrieRouter class to Router. Secondly, we're going to add JSDoc comments to all the methods in the Router class, to make our lives easier with the auto-completion and documentation.

Refactoring the TrieRouter class

const HTTP_METHODS = { ... } // Remains unchanged

class RouteNode {
  constructor() {
    /** @type {Map<String, RouteNode>} */
    this.children = new Map();

    /** @type {Map<String, Function>} */
    this.handler = new Map();

    /** @type {Array<String>} */
    this.params = [];
  }
}

class Router {
  constructor() {
    /** @type {RouteNode} */
    this.root = new RouteNode();
  }

  /**
   * @param {String} path
   * @param { 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE' } method
   * @param {Function} handler
   */
  #verifyParams(path, method, handler) { ... }

  /**
   * @param {String} path
   * @param { 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE' } method
   * @param {Function} handler
   */
  #addRoute(path, method, handler) { ... }

  /**
   * @param {String} path
   * @param { 'GET' | 'POST' | 'PUT ' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT ' | 'TRACE ' } method
   * @returns { { params: Object, handler: Function } | null }
   */
  findRoute(path, method) { ... }

   /**
   * @param {String} path
   * @param {Function} handler
   */
  get(path, handler) {
    this.#addRoute(path, HTTP_METHODS.GET, handler);
  }

  /** For POST, PUT, DELETE, PATCH, HEAD, OPTIONS, CONNECT, TRACE, we are going to re-use the same JSDoc comment as `get` method */

  /**
   * @param {RouteNode} node
   * @param {number} indentation
   */
  printTree(node = this.root, indentation = 0) { ... }
}

As you can see, we're repeating this JSDoc comment for a couple of methods:

/**
 * @param {String} path
 * @param { 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE' } method
 * @param {Function} handler
 */

This is not a good practice. Instead we can make use of something called Type Aliases.

Type aliases are a way to give a type a name, just like a variable. Each type should be distinct. They are exactly the same as the original type, but with a different name. This is useful when you want to refer to the same type multiple times and don't want to repeat the same type definition.

Type Aliases

Let's add a type alias for the HTTP methods:

/**
 * @typedef { 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE' } HttpMethod
 */

And, to use this type alias in our JSDoc comments:

/**
* @param {String} path
* @param {HttpMethod} method
* @param {Function} handler
*/
##verifyParams(path, method, handler) { ... }

/**
 * @param {String} path
 * @param {HttpMethod } method
 * @param {Function} handler
 */
##addRoute(path, method, handler) { ... }

/** And so on... */

But there's a small issue. We have defined the HttpMethod type alias in the same file as the Router class. This will make it impossible to use the HttpMethod type alias in other files. To fix this, we can create a new file globals.js and move all the global type aliases here.

// file: globals.js

/**
 * @typedef { 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE' } HttpMethod
 */

Now you'd be able to use the HttpMethod type alias in any file. That's enough refactoring for now.

The run function

const { createServer } = require("node:http");

/**
 * Run the server on the specified port
 * @param {Router} router - The router to use as the main request handler
 * @param {number} port - The port to listen on
 */
function run(router, port) {
  if (!(router instanceof Router)) {
    throw new Error("`router` argument must be an instance of Router");
  }

  if (typeof port !== "number") {
    throw new Error("`port` argument must be a number");
  }

  createServer(function _create(req, res) {
    const route = router.findRoute(req.url, req.path);

    if (route?.handler) {
      req.params = route.params;
      route.handler(req, res);
    } else {
      res.writeHead(404, null, { "content-length": 9 });
      res.end("Not Found");
    }
  }).listen(port);
}

Let's go through the code line by line:

const { createServer } = require("node:http");

We're importing the createServer function that the node:http module provides. This function is used to create an HTTP server that listens for requests on a specified port. We already created a demo server using http.createServer() in HTTP Deep Dive.

function run(router, port) { ... }

This is going to be the main entry point of our library. The run function is accepts two arguments: router and port. The router will be an instance of the Router class that we defined earlier, and the port will be the port number on which the server will listen for incoming requests.

if (!(router instanceof Router)) {
  throw new Error("`router` argument must be an instance of Router");
}

if (typeof port !== "number") {
  throw new Error("`port` argument must be a number");
}

Some basic type checking to ensure that the router argument is an instance of the Router class and the port argument is a number.

createServer(function _create(req, res) { ... }).listen(port);

We're creating an HTTP server using the createServer function. To re-iterate, the createServer function takes a single argument*, which is a callback function. The callback function will receive two arguments: req (the Http.IncomingMessage object) and res (the Http.ServerResponse object).

const route = router.findRoute(req.url, req.path);

if (route?.handler) {
  req.params = route.params;
  route.handler(req, res);
} else {
  res.writeHead(404, null, { "Content-Length": 9 });
  res.end("Not Found");
}

We're calling the findRoute method on the router object to find the route that matches the incoming request. The findRoute method will return an object with two properties: handler and params. If a route is found, we'll call the handler function with the req and res objects. If no route is found, we'll return a 404 Not Found response.

Inside the if statement, we're attaching a new property req.params to the req object. This property will contain the parameters extracted from the URL. The client can easily access the parameters using req.params.

You might have noticed that we're using a hard-coded Content-Length header with a value of 9. This is because, if we do not specify the Content-Length header, the response headers will include a header Transfer-Encoding: chunked, which has a performance impact. We discussed about this in a previous chapter - Chunks, oh no!

That's it! We have implemented the run function, which will allow us to run our server and listen for incoming requests. In the next chapter, we'll implement a simple server using our Router class and the run function.

Note

*The callback function has multiple overloads, i.e it has a couple more function signatures. But for now, we're only interested in the one that takes a single callback function.

Previous
Ex. Implementing Dynamic Routing