Creating your own ExpressJS from scratch (Part 1) - Basics, Methods, and Routing

wesleymreng7

Wesley Miranda

Posted on April 2, 2023

Creating your own ExpressJS from scratch (Part 1) - Basics, Methods, and Routing

Hey, have you ever thought about creating your web framework using NodeJS, to understand what is happening under the hood, the methods, the middlewares, controllers, and so on...

I know we have a lot of great frameworks to create awesome applications, like ExpressJS, NestJS, Fastify, etc. But you know what are they doing, I think the majority of the developers even don't care about it, only want to create their applications, and that's ok, but might they have a better experience creating applications and understanding what the framework is doing, allowing extends the framework's functionalities, fix some unexpected errors, improve performance issues, in general, make their life easier.

Along the way to creating our Web Framework, We are going to work with several concepts related to Javascript, NodeJS, and programming in general, like design patterns and regular expressions.

Application: Today we are going to create the first part of our Web Framework, to make us able to create routes and handle these routes.

Requirements:

  • NodeJS 16.16 version

Setup

We are going to create a basic setup here, only to be able to continue working.

mkdir web-framework

cd web-framework

npm init -y
Enter fullscreen mode Exit fullscreen mode
  • Open the terminal you prefer
  • Create a folder with the name that you want.
  • Enter in the folder
  • Start a new npm project

Imports

src/app.js

const { createServer } = require('http')
const { match } = require('path-to-regexp')
Enter fullscreen mode Exit fullscreen mode

The first line imports the createServer function from Node.js's built-in http module, which allows us to create an HTTP server. The second line imports the match function from the path-to-regexp package, which allows us to match URLs to specific routes.

To install path-to-regex package:

npm install path-to-regex
Enter fullscreen mode Exit fullscreen mode

Application

src/app.js

const App = () => {
    const routes = new Map()
    const createMyServer = () => createServer(serverHandler.bind(this))
Enter fullscreen mode Exit fullscreen mode

The App function is the main entry point for our web application framework. It creates a new Map object called routes to store all our defined routes. It also defines a helper function called createMyServer that creates an HTTP server using the serverHandler function, a function that We will create soon, will be the main handler.


Route Definition

It is not my intention to cover all the HTTP methods, but only show some of them to serve as an example.

src/app.js

    const get = (path, ...handlers) => {
        const currentHandlers = routes.get(`${path}/GET`) || []
        routes.set(`${path}/GET`, [...currentHandlers, ...handlers])
    }

    const post = (path, ...handlers) => {
        const currentHandlers = routes.get(`${path}/POST`) || []
        routes.set(`${path}/POST`, [...currentHandlers, ...handlers])
    }

    const put = (path, ...handlers) => {
        const currentHandlers = routes.get(`${path}/PUT`) || []
        routes.set(`${path}/PUT`, [...currentHandlers, ...handlers])
    }

    const patch = (path, ...handlers) => {
        const currentHandlers = routes.get(`${path}/PATCH`) || []
        routes.set(`${path}/PATCH`, [...currentHandlers, ...handlers])
    }

    const del = (path, ...handlers) => {
        const currentHandlers = routes.get(`${path}/DELETE`) || []
        routes.set(`${path}/DELETE`, [...currentHandlers, ...handlers])
    }
Enter fullscreen mode Exit fullscreen mode

These are the route definition functions, which define a new route and specify the handlers for each HTTP method (GET, POST, PUT, PATCH, DELETE). They take a path argument and any number of handlers. Each function retrieves the current handlers for the specified method and path from the routes object, adds any new handlers, and sets the updated handlers back into the routes object.

the pattern to match with the route and find the right handler must contain the route and at the end the name of the method. like the examples below:

For /test/1/GET Path is /test/1 and the method is GET

For /test1/2/test2/POST Path is /test1/2/test2 and the method is POST

The Map is a good way to store the handlers because here we can use some useful functions like get and keys.


Treating the URLs

For our routing system works well We need to treat the URLs, because they don't come as we expect.

src/app.js

    const sanitizeUrl = (url, method) => {
        // Get the last part of the URL, removing the domain
        const urlParams = url.split('/').slice(1)

        // Remove querystrings from the last parameter
        const [lastParam] = urlParams[urlParams.length - 1].split('?')
        urlParams.splice(urlParams.length - 1, 1)

        // Create the URL with our pattern
        const allParams = [...urlParams, lastParam].join('/')
        const sanitizedUrl = `/${allParams}/${method.toUpperCase()}`

        return sanitizedUrl
    }

    const matchUrl = (sanitizedUrl) => {
        for (const path of routes.keys()) {
            const urlMatch = match(path, {
                decode: decodeURIComponent,
            })

            const found = urlMatch(sanitizedUrl)

            if (found) {
                return path
            }
        }
        return false
    }
Enter fullscreen mode Exit fullscreen mode

These are helper functions used to sanitize and match URLs to their corresponding routes. sanitizeUrl takes a URL and an HTTP method and removes any query strings, then concatenates the parameters into a pattern matching the structure of the route. matchUrl iterates through the routes checking if the URL matches with one of our current routes.


Handling the Server

src/app.js

    const serverHandler = async (request, response) => {
        const sanitizedUrl = sanitizeUrl(request.url, request.method)

        const match = matchUrl(sanitizedUrl)

        if (match) {
            const middlewaresAndControllers = routes.get(match)
            console.log(middlewaresAndControllers)
            response.statusCode = 200
            response.end('Found')
        } else {
            response.statusCode = 404
            response.end('Not found')
        }
    }
Enter fullscreen mode Exit fullscreen mode

The function first calls sanitizeUrl() with the request.url and request.method as arguments to create a sanitized URL, which is used to check against the routes set up in the framework.

The function then calls matchUrl() with the sanitized URL to try and find a matching route set up in the framework. If a match is found, it retrieves the list of middlewares and controllers associated with that route from the routes Map.

If there are middlewares and/or controllers associated with the route, the function logs them to the console and sets the response status code to 200, indicating that the request was successful. Finally, it ends the response with the message 'Found'.

If a match is not found, the function sets the response status code to 404, indicating that the requested resource was not found. Finally, it ends the response with the message 'Not found'.


The Run function and Exports

src/app.js

    const run = (port) => {
        const server = createMyServer()
        server.listen(port)
    }


    return {
        run,
        get,
        post,
        patch,
        put,
        del
    }
Enter fullscreen mode Exit fullscreen mode

The run function is responsible for starting the server by calling the listen method of the server instance. The listen method accepts a port number as an argument, which is the port on which the server will listen for incoming requests.

In this function, a new instance of the server is created by calling the createMyServer function, which is defined earlier in the code. Then, the listen method is called on the server instance, passing in the port argument provided to the run function.

Finally, we can export all the public functions as a closure returning them.


Entire code

src/app.js

const { createServer } = require('http')
const { match } = require('path-to-regexp')

const App = () => {
    const routes = new Map()
    const createMyServer = () => createServer(serverHandler.bind(this))


    const get = (path, ...handlers) => {
        const currentHandlers = routes.get(`${path}/GET`) || []
        routes.set(`${path}/GET`, [...currentHandlers, ...handlers])
    }

    const post = (path, ...handlers) => {
        const currentHandlers = routes.get(`${path}/POST`) || []
        routes.set(`${path}/POST`, [...currentHandlers, ...handlers])
    }

    const put = (path, ...handlers) => {
        const currentHandlers = routes.get(`${path}/PUT`) || []
        routes.set(`${path}/PUT`, [...currentHandlers, ...handlers])
    }

    const patch = (path, ...handlers) => {
        const currentHandlers = routes.get(`${path}/PATCH`) || []
        routes.set(`${path}/PATCH`, [...currentHandlers, ...handlers])
    }

    const del = (path, ...handlers) => {
        const currentHandlers = routes.get(`${path}/DELETE`) || []
        routes.set(`${path}/DELETE`, [...currentHandlers, ...handlers])
    }

    const sanitizeUrl = (url, method) => {
        const urlParams = url.split('/').slice(1)

        // remove querystrings from the last parameter
        const [lastParam] = urlParams[urlParams.length - 1].split('?')
        urlParams.splice(urlParams.length - 1, 1)

        // create the URL with our pattern
        const allParams = [...urlParams, lastParam].join('/')
        const sanitizedUrl = `/${allParams}/${method.toUpperCase()}`

        return sanitizedUrl
    }

    const matchUrl = (sanitizedUrl) => {
        for (const path of routes.keys()) {
            const urlMatch = match(path, {
                decode: decodeURIComponent,
            })

            const found = urlMatch(sanitizedUrl)

            if (found) {
                return path
            }
        }
        return false
    }


    const serverHandler = async (request, response) => {
        const sanitizedUrl = sanitizeUrl(request.url, request.method)

        const match = matchUrl(sanitizedUrl)

        if (match) {
            const middlewaresAndControllers = routes.get(match)
            console.log(middlewaresAndControllers)
            response.statusCode = 200
            response.end('Found')
        } else {
            response.statusCode = 404
            response.end('Not found')
        }
    }

    const run = (port) => {
        const server = createMyServer()
        server.listen(port)
    }


    return {
        run,
        get,
        post,
        patch,
        put,
        del
    }
}

module.exports = App
Enter fullscreen mode Exit fullscreen mode

Using and Testing

I created a separate file called index.js outside the src folder to test our framework.

index.js

const App = require('./src/app')

const app = App()


app.get('/test/test2', function test() { }, function test2() { })
app.post('/test', (req, res) => console.log('test'))
app.patch('/test', (req, res) => console.log('test'))
app.put('/test', (req, res) => console.log('test'))
app.del('/test', (req, res) => console.log('test'))



const start = async () => {
    app.run(3000)
}

start()
Enter fullscreen mode Exit fullscreen mode

You can execute this file and test the application using your favorite HTTP client.

node index.js
Enter fullscreen mode Exit fullscreen mode

Next Steps

In the next tutorial, we are working with middlewares and controllers. If you liked this tutorial don't hesitate to give your reaction and follow me to see at the first hand my next tutorials. I am trying to write at least one tutorial per week.

Thanks, see you soon!

💖 💪 🙅 🚩
wesleymreng7
Wesley Miranda

Posted on April 2, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related