DEV Community

Cover image for Building a NodeJS server like Express from scratch
Gabriel José
Gabriel José

Posted on • Updated on

Building a NodeJS server like Express from scratch

Here goes a simple tutorial to show you how you can build a NodeJS server with an API similar to the Express one. Just reminding the Express here is only to get the ideia of this tutorial, you can make APIs like Fastify, KOA or create a complete custom one.

First of all, I'll be using typescript and esmodule in this tutorial and will not cover some of basics about the creation of a server like the http module of NodeJS and about the parsing of URL parameters. So I recommend you to see my tutorials about this topics: Servers with Node.js HTTP Module and How to build a URL parameters parser.


Collecting data

Let’s start by getting some values from the request. We’ll first need:

  • Request method
  • Pathname
  • Query Params

For this initial step, we’ll need only this, after it we’ll see about path params and body.

import http from 'http'

const server = http.createServer((req, res) => {
  const { method, url } = req

  const { pathname, searchParams } = new URL(`http://any-host.io${url}`)

  const queryParams = Object.fromEntries(searchParams.entries())

  res.writeHead(200, { 'Content-Type': 'application/json' })
  res.end(JSON.stringify({
    method,
    pathname,
    queryParams: searchParams
  }))
})

server.listen(3500, () => console.log('Server is running at http://localhost:3500'))
Enter fullscreen mode Exit fullscreen mode

Notice that we instantiate an URL object with a http://any-host.io string and concatenate it with the url constant, and then catch the path name and search params from it. This string concatenation is necessary because the URL class expects a valid url string as parameter and the url constant is only one part of it. The pathname is in the url the we destructured, but the url comes with the search params together and we need them separated.

The searchParams is an instance of URLSearchParams, so we use the entries method to get an array of arrays containing the values and then used the Object.fromEntries to transform it into a normal object.

If you run the app and access localhost you will see a json string similiar to this one.

 { "method": "GET", "pathname": "/", "queryParams": {} }
Enter fullscreen mode Exit fullscreen mode

Getting body data

In post, put, patch requests for example, we need the content of the incoming request body. For doing this we have some approaches and I’ll show two of them. The first, we need to use some of the request object events.

import http from 'http'

const server = http.createServer((req, res) => {
  const { method, url } = req

  const { pathname, searchParams } = new URL(`http://any-host.io${url}`)

  const queryParams = Object.fromEntries(searchParams.entries())

  const requestData = []
  req.on('data', chunk => requestData.push(chunk))

  req.on('end', () => {
    const bodyString = Buffer.concat(requestData).toString()
    const body = JSON.parse(bodyString)

    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(JSON.stringify({
      method,
      pathname,
      queryParams,
      body
    }))
  })
})

server.listen(3500, () => console.log('Server is running at http://localhost:3500'))
Enter fullscreen mode Exit fullscreen mode

Notice that we use an auxiliar variable called requestData to store the pieces of the body as it comes, this data comes as a buffer, and when the request finishes the data sending we just need to concatenate it and convert to string. This is string can have many different forms and we can use the content-type header, to know what you need to do to convert it. For now lets just parse it as JSON.

The second, is a much simpler way, but it can be hard to understand if you are not familiar with async iterators, and it uses the same auxiliar variable. Normally this auxiliar variable will only contain one value, it will be more necessary when the request incoming data is too large.

import http from 'http'

const server = http.createServer(async (req, res) => {
  const { method, url } = req

  const { pathname, searchParams } = new URL(`http://any-host.io${url}`)

  const queryParams = Object.fromEntries(searchParams.entries())

  const requestData = []

  for await (const data of req) {
    requestData.push(data)
  }

  const bodyString = Buffer.concat(requestData).toString()
  const body = JSON.parse(bodyString)  

  res.writeHead(200, { 'Content-Type': 'application/json' })
  res.end(JSON.stringify({
    method,
    pathname,
    queryParams,
    body
  }))
})

server.listen(3500, () => console.log('Server is running at http://localhost:3500'))
Enter fullscreen mode Exit fullscreen mode

You can choose which of those ways you like to use to get the request data. In both cases, I would like to create a separate function to do the job. In this separate file we can even check for the length of the requestData array, because in requests of GET method for example, there is no body in request.

// With request object events
function getRequestData(request: IncomingMessage) {
  return new Promise((resolve, reject) => {
    const requestData = []
    request
      .on('error', reject)
      .on('data', chunk => requestData.push(chunk))
      .on('end', () => {
        if (!requestData.length) return resolve({})

        const body = Buffer.concat(requestData).toString()
        resolve(JSON.parse(body))
      })
  })
}

// With async iterators
function getRequestData(request: IncomingMessage) {
  return new Promise(async (resolve, reject) => {
    try {
      const requestData = []

      for await (const data of request) {
        requestData.push(data)
      }

      if (!requestData.length) return resolve({})

      const body = Buffer.concat(requestData).toString()

      resolve(JSON.parse(body))
    } catch(error) {
      reject(error)
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

You can separate this in files too, it will be up to you to choose the way you prefer. I did it like this.

// get-request-data.ts
import { IncomingMessage } from 'http'

function getRequestData(request: IncomingMessage) {
  return new Promise(async (resolve, reject) => {
    try {
      const requestData = []

      for await (const data of request) {
        requestData.push(data)
      }

      if (!requestData.length) return resolve({})

      const body = Buffer.concat(requestData).toString()

      resolve(JSON.parse(body))
    } catch(error) {
      reject(error)
    }
  })
}

// server.ts
import http from 'http'
import { getRequestData } from './get-request-data.js'

const server = http.createServer(async (req, res) => {
  const { method, url } = req

  const { pathname, searchParams } = new URL(`http://any-host.io${url}`)

  const queryParams = Object.fromEntries(searchParams.entries())

  const body = await getRequestData(req)

  res.writeHead(200, { 'Content-Type': 'application/json' })
  res.end(JSON.stringify({
    method,
    pathname,
    queryParams,
    body
  }))
})

server.listen(3500, () => console.log('Server is running at http://localhost:3500'))
Enter fullscreen mode Exit fullscreen mode

Router

With the data we need in hands, now its time to create our Router. This Router class is very simple and in this point we’ll need some features implemented in the How to build a URL parameters parser tutorial.

First we need to export the routes constant and RouteHandler type from the file you put the url parameters parser code, I put it in a file called find-path-match.ts.

The Router code is simple like this. Just to not confuse, I rename the routes constant to routesList.

import { RouteHandler, routesList } from './find-path-match.js'

export class Router {
  get = this.#generateRouteRegisterFor('get')
  post = this.#generateRouteRegisterFor('post')
  put = this.#generateRouteRegisterFor('put')
  delete = this.#generateRouteRegisterFor('delete')

  #generateRouteRegisterFor(method: string) {
    return (path: string, routeHandler: RouteHandler) => {
      routesList[`${method}::${path}`] = routeHandler
      return this
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

You can notice 2 things in this implementation, one is that all four methods are very similar and that all of them returns this. The returning of this is basically useful to chain method calls, like this:

router.get().post().put()
Enter fullscreen mode Exit fullscreen mode

And about the implementation you can do something like this:

type IRouter = Record<
  'get'| 'post'| 'put'| 'delete',
  (path: string, routeHandler: RouteHandler) => IRouter
> 

export function Router(): IRouter {
  const methods = ['get', 'post', 'put', 'delete'] as const
  const router = <IRouter> {}

  methods.forEach(method => {
    function routerFunction(path: string, routeHandler: RouteHandler) {
      routesList[`${method}::${path}`] = routeHandler
      return this
    }

    Object.assign(router, { [method]: routerFunction })
  })

  return router;
}
Enter fullscreen mode Exit fullscreen mode

There is other way make this Router function, using reduce for example, but I chose that one to be more simpler. Although the way using a class seems more repetitive or verbose, I like it, because it is more explicit and easier to understand, but it up to you to choose.


Join everything

Now we need to export the findPathMatch function from the find-path-match.ts file, and use it in our server implementation in server.ts.

import http from 'http'
import { getRequestData } from './get-request-data.js'
import { findPathMatch } from './find-path-match.js'

const server = http.createServer(async (req, res) => {
  const { method, url } = req

  const { pathname, searchParams } = new URL(`http://any-host.io${url}`)

  const queryParams = Object.fromEntries(searchParams.entries())

  const body = await getRequestData(req)

  const { handler, params } = findPathMatch(method, pathname)

  if (handler) {
    const request = {
      headers: req.headers,
      params,
      queryParams,
      body
    }

    return handler(request, res)
  }

  res.writeHead(404, { 'Content-Type': 'application/json' })
  res.end(JSON.stringify({
    error: 'Resource not found'
  }))
})

server.listen(3500, () => console.log('Server is running at http://localhost:3500'))
Enter fullscreen mode Exit fullscreen mode

The handler respects the RouteHandler type that we made in the URL parameters parser and its value in the tutorial is (params: Record<string, string>) => void and I changed it to:

interface RouteHandlerRequest {
  headers: Record<string, unknown>
  queryParams: Record<string, string>
  params: Record<string, string>
  body: any
}

type RouteHandler = (request: RouteHandlerRequest, response: ServerResponse) => void
Enter fullscreen mode Exit fullscreen mode

With it done prepare the request value and pass it with the response object to the handler. If there is no match for the current route it resolve the request with a not found response.

Now its time to register some routes to test it.

// routes.js
import { Router } from './router.js'

const inMemoryData = []

const router = new Router()

router
  .get('/find-all', (req, res) => {
    res.end(JSON.stringify(inMemoryData))
  })
  .post('/create', (req, res) => {
    inMemoryData.push(req.body)

    res.statusCode = 204
    res.end()
  })
  .delete('/:id', (req, res) => {
    const index = inMemoryData.findIndex(item => item.id === req.params.id)

    if (index !== -1) {
      inMemoryData.splice(index, 1)
    }

    res.statusCode = 204
    res.end()
  })
Enter fullscreen mode Exit fullscreen mode

With this code we can test some of the features we created, fell free to change and test it. Just don’t forget, you need to import this file in server.ts.

import http from 'http'
import { getRequestData } from './get-request-data.js'
import { findPathMatch } from './find-path-match.js'
import './routes.js'

const server = http.createServer(async (req, res) => {
...
...
Enter fullscreen mode Exit fullscreen mode

And that’s it, your server should be working fine.


Conclusion

I hope you could understand everything, in a overview it’s not so complex the implementation, and obviously there is much more things that Express do, but its too much to cover all here. Any question leave a comment and thanks for reading!!!

Discussion (0)