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.