[NodeJS] Building a simplified Express.js clone

·

12 min read

In the context of an optional assignment in the full-stack program at Integrify I set out to build a simplified Express.js clone using only the base modules provided by Node.js along with the path-to-regexp module over the course of one Sunday.

The aim was to provide a few simple features of the Express API, namely the ability to handle GET, POST, PUT, and DELETE requests, as well as implement a few of the convenience methods the framework makes available on the response object.

Yet how fun is it to try to emulate Express without what's probably its more appealing feature - the ability to register and chain middleware handlers so that a request will be processed by the full pipeline.

I set two restrictions for approaching this: not looking at the Express source code and not looking up any solutions for middleware implementation. I wanted to try to come up with it myself. (I'll certainly be looking in more detail into how projects like Express and Redux handle it.)

This post is simply documenting the approach taken for a mix of self-satisfaction and hopefully having it be helpful and/or fun to somebody else. So let's get started!

Defining our server class

We want to be able to do some basic things. We need a class that'll create and hold a server instance, and we need methods that'll enable us to register the method specific handlers, as well as our handlers for generic middleware with the use() method. That one is slightly different and we'll get to the why in a bit. We also want to be able to define more than one handler function at once so we'll be using a rest parameter for that:

initialServerClass.png

Creating the server is self-explanatory, as is the listen() method and we know we want to be able to provide handlers for the supported http methods, so we declare those in the class as well. As for the method signatures we'll have a path and the handler function we declare. Since we are supporting registering several handlers we need to keep an internal representation of the currently registered handlers and this representation has to be ordered since the order of registering them will be the order of execution. For that we're using a simple array, _pathHandlers.

As for the type definitions, they come as such:

handlerTypes.png

The ServerRequestHandler is just the signature for the functions we want to pass as our handlers. We're using the http module's interfaces for the req and res parameters for now but since we will want to add out own properties and methods to those objects we will later define our own as extensions of those.

The more interesting part is the PathHandler interface. It's basically just all the information we need to decide, upon receiving a request, what handlers we want to have it passed to:

  • What path will this handler apply to?
  • What type does this handler deal with?
  • What is the handler function? Those are the questions the three properties of a PathHandler object are there to answer.

For deciding on whether the path will be a match for the handler we're using path-to-regexp's match method, which will both tell us whether the path is a match and extract the parameters for us.

The use() method

The use method is slightly different from the others for two reasons. One is very simple. Upon checking Express' documentation I realized that, while the http method specific handlers will only be called upon an exact path match, for the app.use() method things are a little different:

A route will match any path that follows its path immediately with a “/”. For example: app.use('/apple', ...) will match “/apple”, “/apple/images”, “/apple/images/news”, and so on.

Wanting to match that behavior, all we need to do is provide an extra option to the match method.

The second reason is that we want to be able to not provide a path to the use() method and have it apply to all requests. This is achieved by defining an overload.

Actually handling the requests

We have a bunch of methods that register our middleware by adding the handler functions and associated data to an array. So far so good. But now what exactly do we do with them when we receive a request, and more importantly how do we expose a way to control how and whether to move on to the next middleware as something the consumer can access from the handler function?

The key for how I approached this is the parameter's name - next. When I think next I think iterators (and generators, but yeah).

So let's start by converting making our Array an instance of IterableIterator:

const handlerIterator = this._pathHandlers[Symbol.iterator]();

Now once we have this we can get the next PathHandler object by calling handlerIterator.next() and extracting its value property. We can check if we have cycled through all of them with the done property. So now we we can easily expose the next handler function when calling the previous!

However, that happens to not be exactly what we want to do. Because we only want to be able to call the handler functions that apply to the particular request the server is currently processing, so that logic needs to be included. As such, we end up with something like this:

handleMiddleware.png Where we're passing the callNextMiddleware function as our argument for the next parameter, so we will be continuing this recursion only when a handler calls it. And thus our middleware logic is now complete.

As you might have noticed, there are some things in there I didn't mention. One is the casts to RequestType and ResponseType. As said earlier, these are simply extensions of the IncomingMessage and ServerResponse interfaces respectively and account for the properties added with the next unmentioned thing, the call to addBasicRequestParams(). Here is the definition for both of those things:

requestType.png

addBasicRequestParams.png

Note that since we have the middleware logic working we could have instead registered that as the first application-wide middleware at the end of our class constructor, but since some of those properties (such as the parameters) are actually handler based and not just request based, and since having them implemented that way would imply replicating already executed logic (such as the url parsing and path matching), the decision was to have them defined directly as they are.

We do, however, use this principle to enhance the response object, by writing addBasicResponseParams as middleware and registering it at the end of the constructor with this.use(addBasicResponseParams);

addBasicResponseParams.png

Finishing touches

Because one of the things to provide was a req.payload object for a POST request, I also went ahead and wrote a simplistic piece of middleware that enhances our request object with that data in the case of a ContentType of application/json:

jsonPayload.png

And that's all fine and good but you know what's cool about all this? It's that wasn't even needed. We could simply just install body-parser like we would in any Express app and it would work. Or rather, it does work.

3iPub0QHsX.gif

In addition to that also wrote an example little static content middleware using Node's fs module because... there's no reason for it, really, just for the heck it and for the sake of exemplification just like this whole thing in the end. (integrify people can check the repo I guess!)

Limitations, disclaimer, etc...

Obviously, this was a one-day (a day and a half if you count thinking about it while going about my life on Saturday afternoon) project with the purpose of having fun and understanding how one could potentially build something like Express. There are plenty of edge cases it doesn't handle, checks and error handling that is neglected, and the api it provides is but a fraction of what Express offers. This is by no means production-ready code as should be obvious to anybody and shouldn't be taken as such.

If, however, you somehow read this far and got something out of it, then I'm glad. If not, I had fun all the same. While there wasn't much effort into making this code robust and error-proof, feedback and suggestions are still appreciated as they're always valuable.

Also, funny as it might be for somebody who comes from C#... I'm absolutely terrible at handling TypeScript. With something as flexible as the JavaScript language where you can just randomly add properties to objects having to keep track of what you can allow on your own is something I do find myself struggling with and that was entirely the case over the course of coming up with this.