Building a NodeJS server like Express from scratch
Tags: node, programming, typescript, tutorial
Published: 2022-01-25 20:26:07

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](https://dev.to/gabrieljm/servers-with-node-js-core-http-module-egf) and [How to build a URL parameters parser](https://dev.to/gabrieljm/how-to-build-a-url-parameters-parser-dn1).

---

## 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.

```tsx
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'))
```

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.

```json
 { "method": "GET", "pathname": "/", "queryParams": {} }
```

### 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.

```tsx
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'))
```

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.

```tsx
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'))
```

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.

```tsx
// 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)
    }
  })
}
```

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

```tsx
// 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'))
```

---

## 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](https://dev.to/gabrieljm/how-to-build-a-url-parameters-parser-dn1) 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`.

```tsx
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
    }
  }
}
```

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:

```tsx
router.get().post().put()
```

And about the implementation you can do something like this:

```tsx
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;
}
```

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`.

```tsx
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'))
```

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:

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

type RouteHandler = (request: RouteHandlerRequest, response: ServerResponse) => void
```

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.

```tsx
// 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()
  })
```

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`.

```tsx
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) => {
...
...
```

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!!!