Chapter 06
The Router class
To begin with, let's create a basic version of the Router
class for better understanding. We can gradually add more functionality to it as we move forward. The first implementation of the Router
class will allow us to add routes and handle basic HTTP requests.
// file: index.js
class Router {
constructor() {
// Stores our routes
this.routes = {};
}
}
The routes
member variable in our Router
class serves as an internal data structure for managing the mapping between URL paths and corresponding handlers. This approach provides the benefits of encapsulation and efficient route lookups. However, this approach is not suitable for dynamic routes such as /api/:version
or /api/account/:account_id/transactions
, where there are infinite possible URLs that can match. This issue will be addressed later. For now, we will stick to static routes such as /api/users
or /api/account/signup
.
Let's introduce a new helper method called addRoute
in the Router
class. This method will help us bind callback functions to execute whenever a request for a particular endpoint is made.
// file: index.js
class Router {
constructor() {
this.routes = {};
}
addRoute(method, path, handler) {
this.routes[`${method} ${path}`] = handler;
}
}
The first argument, method
is the HTTP method - GET
, POST
, PUT
etc. The second argument is the URL or the path of the request, and the third argument is the callback function that will be called for that particular method
and path
combination. Let's see an example on how will be it called.
// file: index.js
class Router { ... }
const router = new Router();
router.addRoute('GET', '/', () => console.log('Hello from GET /'));
Let us add a method inside our Router
class which will print all the routes, for debugging purposes.
// file: index.js
class Router {
constructor() { ... }
addRoute(method, path, handler) { ... }
printRoutes() {
console.log(Object.entries(this.routes));
}
}
const router = new Router();
router.addRoute('GET', '/', () => console.log('Hello from GET /'));
router.addRoute('POST', '/', () => console.log('Hello from POST /'));
router.printRoutes()
Now, let us give it a try:
$ node index.js
// Outputs
[
[ 'GET /', [Function (anonymous)] ],
[ 'POST /', [Function (anonymous)] ]
]
The Object.entries
method converts an object, with key-value pairs, into a tuple array with only two elements. The first element is the key, and the second element is the corresponding value.
The function (anonymous)
signature isn't helpful at all. Let's change it:
// file: index.js
...
const router = new Router();
router.addRoute('GET', '/', function handleGetBasePath() { console.log(...) });
router.addRoute('POST', '/',function handlePostBasePath() { console.log(...) });
router.printRoutes()
Outputs:
[
[ 'GET /', [Function: handleGetBasePath] ],
[ 'POST /', [Function: handlePostBasePath] ]
]
Much better. I generally tend to avoid anonymous functions as much as I can, and give them a proper name instead. The above can also be written as:
// file: index.js
function handleGetBasePath() { ... }
function handlePostBasePath() { ... }
router.addRoute("GET", "/", handleGetBasePath)
router.addRoute("POST", "/", handlePostBasePath)
Let us try to hook our router with a real HTTP server. We'll have to import the node:http
module, as well as add an utility method inside the Router
class that redirects the incoming request to their appropriate handlers.
Using Router with an HTTP server
// file: index.js
class Router {
...
handleRequest(request, response) {
const { url, method } = request;
this.routes[`${method} ${url}`](request, response);
}
}
Since we only care about the url
and method
of an HTTP request, we're destructuring it out of the request
object. There's a weird looking syntax, that might be foreign to you if you're new to javascript.
this.routes[`${method} ${url}`](request, response);
The above is a short way to write:
let functionToExecute = this.routes[`${method} ${url}`];
functionToExecute(request, response);
You need to be careful with this syntax though. If path
and method
combination isn't registered, it will return undefined
. And the above syntax would resolve to undefined()
, that in fact does not make any sense. Javascript will throw a beautiful error message -
undefined is not a function
To take care of it, let's add a simple check:
class Router {
...
handleRequest(request, response) {
const { url, method } = request;
const handler = this.routes[`${method} ${url}`];
if (!handler) {
return console.log('404 Not found')
}
handler(request, response)
}
}
Now let's forward every request to this method.
// file: index.js
const http = require('node:http')
const PORT = 5255;
class Router { ... }
const router = new Router();
router.addRoute('GET', '/', function handleGetBasePath() { console.log(...) });
router.addRoute('POST', '/', function handlePostBasePath() { console.log(...) });
let server = http.createServer(function serveRequest(request, response) {
router.handleRequest(request, response)
})
server.listen(PORT)
We've imported the node:http
module, to create an HTTP server, and used the http.createServer
method, providing it a callback that takes 2 arguments - first one is the request, and the second one is the response object.
We can still make our code a little better. Instead of passing a callback that has only one job, i.e. calls another method; we can directly pass the target method as an argument:
// file: index.js
class Router {...}
/** __ Add routes __ **/
let server = http.createServer(router.handleRequest);
server.listen(PORT)
Or even shorter, in case you do not wish to access any methods of the http.Server
object returned by http.createServer
.
http.createServer(router.handleRequest).listen(PORT);
Let us test it using cURL
, after starting the server using node index
on a different terminal:
$ curl http://localhost:5255 -v
* Trying 127.0.0.1:5255...
* Connected to localhost (127.0.0.1) port 5255 (#0)
→ GET / HTTP/1.1
→ Host: localhost:5255
→ User-Agent: curl/7.87.0
→ Accept: */*
→ * Empty reply from server
* Closing connection 0
curl: (52) Empty reply from server
An empty reply from server? Let's head over to the node program's console. Oops, a crash!
TypeError: Cannot read properties of undefined (reading 'GET /')
at Server.handleRequest (/Users/ishtmeet/Code/velocy/index.js:16:36)
It's saying we tried to access the key GET /
of an undefined value. It's pointing at this line:
const handler = this.routes[`${method} ${url}`];
It is saying that this.routes
is undefined. Weird, isn't it? No. If you have written some code with javascript previously, you would've already figured out the issue. The culprit is this line:
http.createServer(router.handleRequest);
Let us try to print what the value of this
is, in the handleRequest
method:
class Router {
...
handleRequest(request, response) {
console.log(this.constructor.name);
...
}
}
Send another cURL
request:
## Prints
Server
How?
this is not good
Let's take a moment to understand a huge part of Javascript programming in general: the this
keyword. We won't go deep into the nitty gritty of this
but we'll understand enough to not fall into this weird semantic bug in our programs.
When we tried to log this.constructor.name
, it printed Server
which has nothing to do with our code. We don't have a class or function named Server
in our code. It means that the this
context inside handleRequest
is an instance of Node's native HTTP Server class, not our Router
class.
The reason is how this
works in JavaScript when passing a method as a callback. When we originally had:
// we're passing the router.handleRequest method here as an argument
let server = http.createServer(router.handleRequest);
// The `handleRequest` method is defined as a normal function, not an arrow function
class Router {
handleRequest(request, response) {
this.routes; // Looks good but not good.
...
}
}
The this
value in the handleRequest
method will not be the original object (router
in this case), but will be determined by how it's called — which, in the case of http.createServer
, won't be the router
object. That's why this.routes
is undefined
.
It turns out that there is nothing wrong with the method definition inside the Router
class. However, there is an issue with the way it is being invoked.
The method handleRequest
is passed as a callback to http.createServer()
. When this callback gets invoked by the Node.js HTTP Server, the context (this
) inside handleRequest
is bound to that Server instance, not to the Router
instance.
We're passing a reference to the handleRequest
method, but it loses its context (this
), i.e. it gets dissociated from the router
instance of the Router
class. When the handleRequest
method is invoked by the HTTP server, this is set to the HTTP Server object, not the router
instance.
How do we fix this? There are two ways: an old way and a modern one. Let's see the old way first:
Using .bind()
let server = http.createServer(router.handleRequest.bind(router));
The .bind
method returns a new function that is a "bound" version of the handleRequest
method, such that the this
context within that method is set to the router
instance.
So, .bind()
ensures that when handleRequest
is called, the value of this
inside of it will be our router
object. Before ES6
or EcmaScript 2015
, this was the standard way of solving issues with the this keyword.
Let's take a look at a more convenient way, i.e use an Arrow
function:
Using Arrow function
let server = http.createServer((req, res) => router.handleRequest(req, res));
// or if you prefer named functions
const handleRequest = (req, res) => router.handleRequest(req, res);
const server = http.createServer(handleRequest);
Unlike normal functions, arrow functions don't have their own this
. Instead, they inherit the this
value from the surrounding lexical context where they were defined. This lexical scoping for this
is one of the most useful features of arrow functions.
I'll explain what I mean by the lexical context.
Lexical Context
Lexical context (or lexical scope) is the area where a certain variable is accessible or has meaning. When a variable is defined, it's confined to a particular scope, and it can't be accessed from outside of that scope.
Global Scope
|
|-- const global = "I'm global";
|
|-- function outerFunction() {
| |
| |-- const outer = "I'm in the outer function";
| |
| |-- function innerFunction() {
| | |
| | |-- const inner = "I'm in the inner function";
| | |
| | |-- // Can access inner, outer, and global
| |}
| |
| |-- // Can access outer and global, but NOT inner
|}
|
|-- // Can access global, but NOT outer or inner
Let's look at another example using classes:
class Person {
constructor() {
this.name = "Ishtmeet";
}
regularFunction() {
setTimeout(function () {
// `this` here is not the Person instance, it's either the window object
// in browsers or `global` in Node.js
console.log(this.name); // Undefined or error
}, 1000);
}
arrowFunction() {
setTimeout(() => {
// `this` here is lexically bound, it's the Person instance
console.log(this.name); // Outputs: "Ishtmeet"
}, 1000);
}
}
One more example using classes:
class Player {
constructor() {
this.health = 52;
}
regularFunction() {
// Will output 52 if called as an instance method
console.log(this.health);
}
arrowFunction = () => {
// Will also output 52 if called as an instance method
console.log(this.health);
};
}
For example:
const mainCharacter = new Player();
mainCharacter.regularFunction(); // Outputs 52
mainCharacter.arrowFunction(); // Outputs 52
But, let's see what happens if we extract these methods and call them independently of the class instance:
const extractedRegularFn = mainCharacter.regularFunction;
const extractedArrowFn = mainCharacter.arrowFunction;
extractedRegularFn(); // Outputs undefined or throws an error
extractedArrowFn(); // Outputs 52
In the code above, extractedRegularFn()
outputs undefined
or throws a TypeError depending on strict mode because it loses its original this
context.
On the other hand, extractedArrowFn()
still outputs 52
, because the arrow function doesn't have its own this
; it uses the this
from the lexical scope where it was defined (inside the Player
constructor, because it is bound to mainCharacter
as we specifically called it on mainCharacter
using mainCharacter.arrowFunction
).
Arrow functions are not free
Keep in mind, arrow functions come with a slight performance penalty. It is usually negligible for most applications, but can be heavy on memory if we're creating a lot of objects of a certain class. This won't be an issue with our Router
, but it's worth knowing this.
When arrow functions are defined as class methods/properties, a new function object is created for each instance of the class, rather than each time the function is invoked.
For example, let's look at this example:
class Monster {
regularMethod() { ... }
arrowMethod = () => { ... };
}
const boss = new Monster();
const creep = new Monster()
In this case, both boss
and creep
will have their own copy of arrowMethod
, because it's defined as an instance property using arrow syntax. Each time a new Monster
object is created, new memory is allocated for arrowMethod
.
On the other hand, regularMethod
is defined on Monster.prototype
, meaning that all instances of Monster
share the same regularMethod
function object. This is generally more memory-efficient.
In a game, one can spawn thousands, if not millions of monsters. Or imagine another hypothetical example of a photo editing application, that stores every pixel on the screen as an object of a Pixel
class. There are going to be millions of pixels on the screen, and every extra allocation for the function body may be slight overwhelming for the memory constraints.
Why should we care about memory?
We are focusing on building a high-performance backend framework, it is important to consider the impact of memory allocations. While the Router
class may only have 10-15 instances, we may introduce our custom Response
or Request
class in the future. If we create every function as an arrow function, our framework will allocate unnecessary memory for applications receiving high load, such as those receiving thousands of requests per second. An easy way to picture arrow functions in mind is as follows:
class Response {
constructor() {
// If these arrow functions are created new for every instance of Response,
// and the site is currently in a heavy load situation, receiving 5k requests per second
// we'd be creating 5,000 * num_of_arrow_functions new function instances per second
this.someMethod = () => {
/*... */
};
this.anotherMethod = () => {
/* ... */
};
// ... more arrow functions
}
}
Creating separate function objects for each instance is usually not a concern for most applications. However, in cases where you have a very large number of instances or if instances are frequently created and destroyed, this could lead to increased memory usage and garbage collection activity.
It's also worth noting that if we're building a library or a base class that other developers will extend, using prototype methods (regular methods) allows for easier method overriding and usage of the super
keyword.
Note
Note: Unless we're in a very performance-critical scenario or creating a vast number of instances, the difference is likely negligible. Most of the time, the decision between using arrow functions or regular methods in classes comes down to semantics and specific requirements around this
binding.
Testing the updated code
Our code in the index.js
file should look something like this:
// file: index.js
const http = require("node:http");
const PORT = 5255;
class Router {
constructor() {
this.routes = {};
}
addRoute(method, path, handler) {
this.routes[`${method} ${path}`] = handler;
}
handleRequest(request, response) {
const { url, method } = request;
const handler = this.routes[`${method} ${url}`];
if (!handler) {
return console.log("404 Not found");
}
handler(request, response);
}
printRoutes() {
console.log(Object.entries(this.routes));
}
}
const router = new Router();
router.addRoute("GET", "/", function handleGetBasePath() {
console.log("Hello from GET /");
});
router.addRoute("POST", "/", function handlePostBasePath() {
console.log("Hello from POST /");
});
// Note: We're using an arrow function instead of a regular function now
let server = http.createServer((req, res) => router.handleRequest(req, res));
server.listen(PORT);
Let us try to execute this code, and send a request through cURL
:
We see the output Hello from GET /
on the server console. But, there's still something wrong on the client (cURL
):
$ curl http://localhost:5255/ -v
* Trying 127.0.0.1:5255...
* Connected to localhost (127.0.0.1) port 5255 (#0)
→ GET / HTTP/1.1
→ Host: localhost:5255
→ User-Agent: curl/7.87.0
→ Accept: */*
→
The request headers are shown, means the request was made successfully, although server did not send any response back. Why is it so?
We're observing this behavior because the server has not indicated to the client (in this case, cURL
) that the request has been fully processed and the response has been completely sent. Well, how do we indicate that?
We do that using .end()
method on the response object. But how can we get access to that inside our callback functions handlePostBasePath()
and handleGetBasePath()
? Turns out, they're already supplied to these functions when we did this:
// pass the `request` as the first argument, and `response` as the second.
let server = http.createServer((req, res) => router.handleRequest(req, res));
The http.createServer
method requires a callback function, and provides request object as the first argument, and the response object as the second.
On updating the code:
// file: index.js
...
router.addRoute("GET", "/", function handleGetBasePath(req, res) {
console.log("Hello from GET /");
res.end();
});
router.addRoute("POST", "/", function handlePostBasePath(req, res) {
console.log("Hello from POST /");
res.end()
});
...
Now if you try to make a request to any endpoint, the server will respond back with appropriate response body:
$ curl http://localhost:5255/ -v
* Trying 127.0.0.1:5255...
* Connected to localhost (127.0.0.1) port 5255 (#0)
→ GET / HTTP/1.1
→ Host: localhost:5255
→ User-Agent: curl/7.87.0
→ Accept: */*
→
* Mark bundle as not supporting multiuse
← HTTP/1.1 200 OK
← Date: Thu, 07 Sep 2023 13:04:39 GMT
← Connection: keep-alive
← Keep-Alive: timeout=5
← Content-Length: 0
←
* Connection #0 to host localhost left intact
We also set up a 404 handler, in case a route is not configured. We're also going to add a response.end()
to indicate the client that the request has been processed.
class Router {
...
handleRequest(request, response) {
...
if (!handler) {
console.log("404 Not found");
response.writeHead(404, { 'Content-Type': 'text/plain' })
return response.end('Not found');
}
...
}
}
Let's check whether it returns 404
, if the route is not registered?
$ curl http://localhost:5255/not/found -v
* Trying 127.0.0.1:5255...
* Connected to localhost (127.0.0.1) port 5255 (#0)
→ GET /not/found HTTP/1.1
... request body trimmed ...
→
* Mark bundle as not supporting multiuse
← HTTP/1.1 404 Not Found
← Content-Type: text/plain
... response body trimmed ...
* Connection #0 to host localhost left intact
Not found
Great! In the next section, we will explore how to make our router API even more elegant by eliminating the need to specify the name of the HTTP method every time we define a new endpoint handler. So, instead of writing:
router.addRoute("POST", "/", function handlePostBasePath(req, res) {
console.log("Hello from POST /");
res.end();
});
We could do something like:
router.get("/", function handlePostBasePath(req, res) {
console.log("Hello from POST /");
res.end();
});
This way, we'll provide a clean and clear interface for our clients.