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.
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.
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.
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.
*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.