CacheLane Logo

Chapter 06

Building our first web-server

Our Router implementation has enough functionality to handle basic HTTP requests. In this chapter, we're going to spin up our first web server with this little toy Router and put it to test.

More refactoring

Till now, our entire Router implementation and the helper function stayed in a single file. As we're going to build a web server, it's a good idea to separate the Router implementation into its own module, as well as the helper functions.

Here's the updated file structure:

 ./
├──  lib/                # Our library code
│  ├──  constants.js     # Constants used in our library
│  ├──  index.js         # Entry point of our library
│  └──  router.js        # Router implementation
├──  globals.js          # Global Typedefs
└──  test.js             # We'll write our code for testing here.

lib/router.js

const { HTTP_METHODS } = require("./constants");

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

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

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

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

  /**
   * @param {String} path
   * @param {HttpMethod} method
   * @param {RequestHandler} handler
   */
  #verifyParams(path, method, handler) {
    if (typeof path !== "string" || path[0] !== "/") throw new Error("Malformed path provided.");
    if (typeof handler !== "function") throw new Error("Handler should be a function");
    if (!HTTP_METHODS[method]) throw new Error("Invalid HTTP Method");
  }

  /**
   * @param {String} path
   * @param {HttpMethod } method
   * @param {RequestHandler} handler
   */
  #addRoute(path, method, handler) {
    this.#verifyParams(path, method, handler);

    let currentNode = this.root;
    let routeParts = path.split("/").filter(Boolean);
    let dynamicParams = [];

    for (const segment of routeParts) {
      if (segment.includes(" ")) throw new Error("Malformed `path` parameter");

      const isDynamic = segment[0] === ":";
      const key = isDynamic ? ":" : segment.toLowerCase();

      if (isDynamic) {
        dynamicParams.push(segment.substring(1));
      }

      if (!currentNode.children.has(key)) {
        currentNode.children.set(key, new RouteNode());
      }

      currentNode = currentNode.children.get(key);
    }

    currentNode.handler.set(method, handler);
    currentNode.params = dynamicParams;
  }

  /**
   * @param {String} path
   * @param {HttpMethod} method
   * @returns { { params: Object, handler: RequestHandler } | null }
   */
  findRoute(path, method) {
    let segments = path.split("/").filter(Boolean);
    let currentNode = this.root;
    let extractedParams = [];

    for (let idx = 0; idx < segments.length; idx++) {
      const segment = segments[idx];

      let childNode = currentNode.children.get(segment.toLowerCase());
      if (childNode) {
        currentNode = childNode;
      } else if ((childNode = currentNode.children.get(":"))) {
        extractedParams.push(segment);
        currentNode = childNode;
      } else {
        return null;
      }
    }

    let params = Object.create(null);

    for (let idx = 0; idx < extractedParams.length; idx++) {
      let key = currentNode.params[idx];
      let value = extractedParams[idx];

      params[key] = value;
    }

    return {
      params,
      handler: currentNode.handler.get(method),
    };
  }

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

  /**
   * @param {String} path
   * @param {RequestHandler} handler
   */
  post(path, handler) {
    this.#addRoute(path, HTTP_METHODS.POST, handler);
  }

  /**
   * @param {String} path
   * @param {RequestHandler} handler
   */
  put(path, handler) {
    this.#addRoute(path, HTTP_METHODS.PUT, handler);
  }

  /**
   * @param {String} path
   * @param {RequestHandler} handler
   */
  delete(path, handler) {
    this.#addRoute(path, HTTP_METHODS.DELETE, handler);
  }

  /**
   * @param {String} path
   * @param {RequestHandler} handler
   */
  patch(path, handler) {
    this.#addRoute(path, HTTP_METHODS.PATCH, handler);
  }

  /**
   * @param {String} path
   * @param {RequestHandler} handler
   */
  head(path, handler) {
    this.#addRoute(path, HTTP_METHODS.HEAD, handler);
  }

  /**
   * @param {String} path
   * @param {RequestHandler} handler
   */
  options(path, handler) {
    this.#addRoute(path, HTTP_METHODS.OPTIONS, handler);
  }

  /**
   * @param {String} path
   * @param {RequestHandler} handler
   */
  connect(path, handler) {
    this.#addRoute(path, HTTP_METHODS.CONNECT, handler);
  }

  /**
   * @param {String} path
   * @param {RequestHandler} handler
   */
  trace(path, handler) {
    this.#addRoute(path, HTTP_METHODS.TRACE, handler);
  }

  /**
   * @param {RouteNode} node
   * @param {number} indentation
   */
  printTree(node = this.root, indentation = 0) {
    const indent = "-".repeat(indentation);

    node.children.forEach((childNode, segment) => {
      console.log(`${indent}(${segment}) Dynamic: ${childNode.params}`);
      this.printTree(childNode, indentation + 1);
    });
  }
}

module.exports = Router;

lib/constants.js

const HTTP_METHODS = Object.freeze({
  GET: "GET",
  POST: "POST",
  PUT: "PUT",
  DELETE: "DELETE",
  PATCH: "PATCH",
  HEAD: "HEAD",
  OPTIONS: "OPTIONS",
  CONNECT: "CONNECT",
  TRACE: "TRACE",
});

module.exports = {
  HTTP_METHODS,
};

lib/index.js

const { createServer } = require("node:http");
const Router = require("./router");

/**
 * Run the server on the specified port
 * @param {Router} router - The router to use for routing requests
 * @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.method);

    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);
}

module.exports = { Router, run };

globals.js

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

/**
 * @typedef {import("http").RequestListener} RequestHandler
 */

We've added a new typedef, i.e RequestHandler in globals.js. This typedef is used to define the type of the handler function that we pass to the Router instance. By default, we're using the RequestListener type from the http module, which is the type of the handler function that the http.createServer function expects.

Your first web server

Now that we've refactored our code, it's time to jump in and put our little project to test. Let's create a simple web server that listens on port 3000 and has a couple of endpoints.

"GET /"                             -> Hello from the root endpoint
"GET /hello/:name"                  -> Hello, {name}!
"GET /user/:age/class/:subject"     -> You're {age} years old and you're studying {subject}.

Here's the code for the web server:

// Get the `Router` and `run` function from our library
const { Router, run } = require("./lib");

// Create a new instance of the `Router` class
const router = new Router();

// Define the routes
router.get("/", (req, res) => {
  res.end("Hello from the root endpoint");
});

router.get("/user/:name", (req, res) => {
  res.end(`Hello, ${req.params.name}!`);
});

router.get("/user/:age/class/:subject", (req, res) => {
  res.end(`You're ${req.params.age} years old, and you're studying ${req.params.subject}.`);
});

// Start the server at port 3000
run(router, 3000);

To test our server, we'll make some cURL requests from the terminal.

$ curl http://localhost:3000
Hello from the root endpoint

$ curl http://localhost:3000/user/Ishtmeet
Hello, Ishtmeet!

$ curl http://localhost:3000/user/21/class/Mathematics
You're 21 years old, and you're studying Mathematics.

Everything looks good! Our server is up and running, and it's handling the requests as expected.

Previous
Running Our Server