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:
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:
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:
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:
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);
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
:
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.
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.