This blog post is part of a series in which we are going to build a minimal, simple and yet powerful version of Express.js, called Minimal.js. This is the third part. You can check out part 1 here and part 2 here.
We are learning on the go so if you find any mistake or any better way to do certain things or just want to share your feedback then I am all ears and open to collaboration. Let me know your opinions here.
The Road so far
So, till now, we talked about HTTP, explored a couple of Node.js modules, built a simple server using native modules and then we took a deep dive into how we can implement methods like use
and middlewares
support to our framework.
Part 3
This part would revolve around how routing works under the hood and implementation details regarding standard HTTP verbs like get
, post
and more.
I would recommend that you should code along. So, go ahead, clone the repo and check out the part-2
branch. Create a new branch part-3
from part-2
.
git clone https://github.com/yomeshgupta/minimaljs.git
git checkout part-2
git checkout -b part-3 part-2
Complete code for this part can be found here.
Concepts
Routing System in Express
, basically comprises of three major components:
- Router
- Layer
- Route
Let's talk about each but before that take a look at the diagram below.
Router
Router component is responsible for the entire routing of the application. It exposes various methods (HTTP verbs) like get
, post
, put
and more; which are used by the application to bind handlers to particular paths. An instance of Router
contains an array of Layers
.
The relationship between Router
and Layer
is one-to-many
i.e. an instance of a Router
can have multiple Layers
in it.
Layer
Each Layer
consists of a path
and a Route
component which stores more specific information about that path
.
Route
Route Component is the one where we implement the HTTP verbs. It contains the path
and an array of Layers
.
The difference between the Layer
in the Router
component and the Route
component is that in the Route
component, a Layer
contains a method
and its handler
.
The relationship between path
and method
can be one-to-many
i.e. a path like /login
can have multiple methods attached to it.
GET /login
POST /login
In summary, a Router contains an array of layers where each layer represents a path and a route component. Each route component contains the same path along with all the method(s) handling associated with it. When a request is received, each Layer's path is matched against the current request path. If matched then we compare the current request method type with method handling provided at the time of setup, if it is also a match then the apt handler is executed else not found is returned.
Let's code
Create a folder router
and create a file layer.js
inside it.
cd ./src
mkdir router
cd ./router
touch layer.js
Paste the following code inside the newly created layer.js
const { matchPath } = require("../lib/helpers");
class Layer {
/*
Setting up path and handler
*/
constructor(path, handler) {
this.handler = handler;
this.name = handler.name || "<anonymous>";
this.path = path;
}
/*
If the current request path matches the layer's path
then handling for the current path
*/
requestHandler(...args) {
const handler = this.handler;
handler ? handler(...args) : null;
}
/*
To match current request path with
the path provided at the time of setup
SETUP: app.get('/login', (req, res) => {})
CURRENT REQUEST: GET /login
*/
match(path) {
return matchPath(this.path, path);
}
}
module.exports = Layer;
Inside the router folder, create a route.js
and paste the following code
touch route.js
const Layer = require("./layer.js");
class Route {
constructor(path) {
this.path = path;
this.stack = [];
this.methods = {};
}
requestHandler(method) {
const name = method.toLowerCase();
return Boolean(this.methods[name]);
}
get(handler) {
const layer = new Layer("/", handler);
layer.method = "get";
this.methods["get"] = true;
this.stack.push(layer);
return this;
}
post(handler) {
const layer = new Layer("/", handler);
layer.method = "post";
this.methods["post"] = true;
this.stack.push(layer);
return this;
}
dispatch(req, res) {
const method = req.method.toLowerCase();
this.stack.forEach((item) => {
if (method === item.method) {
item.requestHandler(req, res);
}
});
}
}
module.exports = Route;
Let's understand the above code snippet
- We created a
Route
class that takespath
as a parameter and maintains an internal array of all layers and a collection of methods attached to it. - Whenever, you do something like
app.get("/login", (req, res) => {
/* some processing */
});
Then a route
is created with path
/login
and a layer is pushed inside its array of layers containing the handler provided with it. Route also stores the information about the current method in a collection. It is done to determine if a method of handling is available when a request comes in.
dispatch
method is called when a request comes in, it traverses all the layers and if the current request method matches with the layer's method then we execute the apt request handler.Currently, we are handling two HTTP verbs,
get
andpost
.
Now, we have our Layer
and Route
components ready. Let's consolidate all this in Router
.
Create a file index.js
in the router folder and paste the following code.
touch router.js
const Layer = require("./layer.js");
const Route = require("./route.js");
class Router {
constructor() {
this.stack = [
new Layer("*", (req, res) => {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain");
res.end(`Cannot find ${req.url}`);
}),
];
}
handle(req, res) {
const method = req.method;
let found = false;
this.stack.some((item, index) => {
if (index === 0) {
return false;
}
const { matched = false, params = {} } = item.match(req.pathname);
if (matched && item.route && item.route.requestHandler(method)) {
found = true;
req.params = params;
return item.requestHandler(req, res);
}
});
return found ? null : this.stack[0].requestHandler(req, res);
}
route(path) {
const route = new Route(path);
const layer = new Layer(path, (req, res) => route.dispatch(req, res));
layer.route = route;
this.stack.push(layer);
return route;
}
get(path, handler) {
const route = this.route(path);
route.get(handler);
return this;
}
post(path, handler) {
const route = this.route(path);
route.post(handler);
return this;
}
}
module.exports = Router;
- We created a Router class that contains a stack (array of Layers). We are seeding the array with a default layer which will act as the default response if no handler for a path is found.
- We are handling
GET
andPOST
requests for now. - Whenever we invoke any of the HTTP verb methods then a
Route
is created with the provided path, aLayer
is created with the same path and the route's dispatch method is provided as the handler to theLayer
. handle
method goes through each layer in the stack looking for the layer whose setup path matches with the current request path and if that layer has a route and route handler with it then route handler is executed.
Updating Framework
Let's update our framework to accommodate our routing system.
...
const Router = require('./router/index');
...
function Minimal() {
...
const _router = new Router();
...
function findNext(req, res) {
...
const next = () => {
...
if (matched) {
req.params = params;
middleware.handler(req, res, next);
} else if (current <= _middlewares.length) {
next();
} else {
req.handler(req, res);
}
};
...
}
function handle(req, res, cb) {
...
req.handler = cb;
...
}
function get(...args) {
const { path, handler } = checkMiddlewareInputs(args);
return _router.get(path, handler);
}
function post(...args) {
const { path, handler } = checkMiddlewareInputs(args);
return _router.post(path, handler);
}
function listen(port = 8080, cb) {
return http
.createServer((req, res) => {
...
handle(req, res, () => _router.handle(req, res));
})
...
}
return {
use,
listen,
get,
post
}
}
- We imported our
Router
class into our framework and created an instance of it. - We implemented and exposed
get
andpost
methods so that our users can use them to bind handlers to a specific path and method types. - We are now sending a
callback
to thehandle
method which is assigned to a keyhandler
on the request object. - We have seen in part 2 that we initially execute all the middlewares sequentially. We have updated that to call our method request handler when the middleware execution list is exhausted as no more middleware is left to be executed.
Testing
Now, we can test what we have built so far. Open server.js
and make the following changes.
..
app.use(cors());
app.get('/about', (req, res) => {
res.send('I am the about page');
});
app.get('/', (req, res) => {
fs.readFile(path.resolve(__dirname, 'public', 'index.html'), (err, data) => {
if (err) {
return res.status(500).send('Error Occured');
}
return res.status(200).send(data);
});
});
...
/about
route withCORS
headers.
/
route.
- Not Found Route
Yaay!! Our framework is working now. I know we haven't tested the post
requests and there are some known issues. Let's see if you can find those out. In the next part, we will improve upon our current system, fix some issues and maybe build a simple REST API using our framework. Stay tuned.
Complete code for this part can be found here.