Coder un serveur HTTP basique en Node.js

Une bonne méthode pour apprendre et progresser en programmation est d’essayer de re-coder des projets existants. C’est ce que je vous propose dans cet article pour en apprendre plus sur Node.js.

Cet article s’adresse aux débutants sans pour autant s’attarder plus que nécessaire sur les notions de base de cet environnement. N’hésitez pas à visiter la liste des articles sur Node.js et JavaScript pour en apprendre plus.

Lorsque j’ai commencé à apprendre Node.js, le serveur HTTP était l’exemple typique que l’on citait dans tous les articles, blogs et autres cours. Le but était de montrer à quel point il est facile de créer un serveur web avec Node.js.

const http = require('http')

const server = http.createServer(function (req, res) {
  res.writeHead(200)
  res.end('Hello, World!')
})

server.listen(8080)

"Hello, World!" de NodeJS

Bon on se rend vite compte qu’il faut un peu plus de code pour avoir un vrai site web grâce à Node.js. Voilà pourquoi les frameworks backend pour Node.js se sont rapidement multipliés. Le plus connu d’entre eux s’appelle Express.

Express est simple d’utilisation, rapide à mettre en place et possède une très large communauté.

const express = require('express')
const app = express()

app.get('/', (req, res) => {
  res.send('Hello World!')
})

app.listen(3000)

"Hello, World!" de ExpressJS

Sans rendre le code plus compliqué, le code ci-dessus permet de retourner une chaine de caractère pour une méthode HTTP et une route précise. Il est très simple de rajouter des routes de la même façon que pour /.

Express est très léger. Il utilise un système de middlewares qui permettent d’augmenter les capacités du framework.

Par exemple, pour permettre à Express de comprendre le JSON dans le corps d’une requête HTTP, il faut utiliser le middleware bodyParser.

Comment fonctionne Express ?

Pour ceux qui connaissaient déjà Express avant cet article – et même ceux qui le découvre maintenant d’ailleurs –, est-ce que vous vous êtes déjà demandé comment Express fonctionnait ?

Et si nous codions un petit framework web pour comprendre le fonctionnement de nos outils ? Ne vous méprenez pas sur mes intentions. Le but de cet article n’est pas de coder un énième framework dans un écosystème qui en est déjà saturé et encore moins de produire un code prêt pour la production.

D’ailleurs, je vous conseille en général de ne pas "réinventer la roue" dans vos projets. Node.js possède un univers riche de modules open source pour tous vos besoins, n’hésitez pas à piocher dedans.

Par contre, même si dans la vie de tous les jours il vaut mieux utiliser un framework backend existant, cela ne vous empêche pas d’en coder un dans un but purement éducatif !

Codons un framework backend en Node.js

Comme je vous ai mis les "Hello, World!" de Node.js et Express, je vais aussi vous mettre le mien. Cela nous servira d’objectif à atteindre.

import { createServer } from './lib/http/create-server.js'
import { url, router } from './lib/http/middlewares/index.js'
import { get } from './lib/http/methods/index.js'

const server = createServer(
  url(),
  router(
    get('/', () => `Hello, World!`),
  )
)

server.listen(3000)

"Hello, World!" de notre framework from scratch

Ce code fait exactement la même chose que celui d’Express. La syntaxe diffère beaucoup – elle est grandement inspirée du .pipe() de RxJS – mais il s’agit toujours d’afficher « Hello, World! » lorsque l’utilisateur va sur / et de retourner une 404 s’il va sur une route inconnue.

Dans l’idée, nous retrouvons les middlewares d’Express par lesquels la requête va passer pour créer une réponse à retourner au client (le navigateur de l’utilisateur).

C’est un schéma très simplifié, mais vous comprenez l’idée.

Vous l’avez peut-être compris grâce à la syntaxe (et à la référence à RxJS), j’aimerais bien que l’on réussisse à avoir une approche plutôt fonctionnelle avec ce projet. Lorsque c’est bien fait, je trouve que cela produit un code beaucoup plus expressif.

Le serveur HTTP

La première fonction à implémenter est createServer. Il ne s’agit que d’un wrapper de la fonction du même nom dans le module http de Node.js.

import http from 'http'

export function createServer() {
  return http.createServer(() => {
    serverResponse.end()
  })
}

createServer crée le serveur et le retourne. Nous pourrons ensuite utiliser .listen() pour lancer le serveur.

La fonction de callback de http.createServer() peut prendre en paramètre un IncommingMessage (la requête faite par le client) et un ServerResponse (la réponse que l’on veut lui retourner).

Un de nos objectifs est d’avoir un système de middlewares qui vont chacun à leur tour modifier la requête pour construire petit à petit la réponse à retourner au client. Pour cela, il nous faut une liste de middlewares auxquels nous allons passer à chaque fois la requête et la réponse.

import http from 'http'

export function createServer(middlewares = []) {
  return http.createServer((incommingMessage, serverResponse) => {
    for (const middleware of middlewares) {
      middleware(incommingMessage, serverResponse)
    }

    serverResponse.end()
  })
}

Pour récupérer les middlewares et avoir la même syntaxe que le .pipe() de RxJS, j’utilise le paramètre du reste que nous avons vu dans un précédent article.

import http from 'http'

export function createServer(...middlewares) {
  return http.createServer((incommingMessage, serverResponse) => {
    for (const middleware of middlewares) {
      middleware(incommingMessage, serverResponse)
    }

    serverResponse.end()
  })
}

Ainsi les middlewares tels que url et router pourront modifier incommingMessage et/ou serverResponse par référence. À la fin de notre "pipe" de modification, nous déclenchons l’évènement .end() qui enverra la réponse au client.

Quelques petites choses me chiffonnent encore concernant ce code :

  • incommingMessage et serverResponse sont directement modifiés. J’aimerais mieux qu’ils ne le soient pas pour rester au plus proche de la philosophie de la programmation fonctionnelle.
  • Tous les middlewares doivent être appelés dans notre implémentation, mais Express permet de stopper le "pipe" des modifications si besoin. C’est utile pour un middleware d’authentification par exemple.
  • Certains middlewares pourraient avoir besoin de bloquer l’exécution du "pipe" de modifications pour réaliser des tâches un peu plus longues. Actuellement notre code n’attendrait pas et l’exécution de certains middlewares pourraient se superposer dans le temps.

Corrigeons donc tout ça. Tout d’abord, le dernier point. C’est le plus simple à mettre en place.

import http from 'http'

export function createServer(...middlewares) {
  return http.createServer(async (incommingMessage, serverResponse) => {
    for (const middleware of middlewares) {
      await middleware(incommingMessage, serverResponse)
    }

    serverResponse.end()
  })
}

Ainsi, si le middleware s’exécute de façon asynchrone nous attendrons la fin de son exécution pour passer à la suite.

Pour éviter la modification de incommingMessage et serverResponse, je pense que le plus simple, c’est d’utiliser la valeur de retour des middlewares. Ce sont de simples fonctions, utilisons les comme telles : des valeurs d’entrées qui ne doivent pas être modifiées, mais qui servent à construire la valeur de retour. Cette valeur de retour est ensuite utilisée comme valeur d’entrée de la fonction (du middleware) suivante. Ainsi de suite.

import http from 'http'

export function createServer(...middlewares) {
  return http.createServer(async (incommingMessage, serverResponse) => {
    let requestContext = {
      statusCode: 200,
    }

    for (const middleware of middlewares) {
      requestContext = await middleware(incommingMessage, requestContext)
    }

    serverResponse.writeHead(requestContext.statusCode)

    if (requestContext.responseBody != null) {
      serverResponse.end(requestContext.responseBody)
      return
    }

    serverResponse.end()
  })
}

J’ai créé un requestContext qui est un objet de travail pour les middlewares. Il est passé en valeur d’entrée de tous les middlewares aux côtés de la requête.

Le middleware traite ces valeurs et crée un nouveau contexte qu’il retourne ensuite. Nous écrasons l’ancien contexte avec le nouveau qui est plus à jour.

Lorsque nous avons terminé la modification du contexte par les middlewares, nous l’utilisons pour émettre la réponse du serveur.

Enfin, pour pouvoir arrêter le pipe au milieu, je n’ai rien trouvé de mieux qu’un petit boolean. Nous pouvons le rajouter au contexte. Si un middleware le change a true, le pipe est cassé et l’on envoie directement la réponse avec le contexte actuel :

import http from 'http'

export function createServer(...middlewares) {
  return http.createServer(async (incommingMessage, serverResponse) => {
    let requestContext = {
      statusCode: 200,
      closeConnection: false
    }

    for (const middleware of middlewares) {
      if (requestContext.closeConnection === true) {
        break
      }

      requestContext = await middleware(incommingMessage, requestContext)
    }

    serverResponse.writeHead(requestContext.statusCode)

    if (requestContext.responseBody != null) {
      serverResponse.end(requestContext.responseBody)
      return
    }

    serverResponse.end()
  })
}

Parser l’URL de la requête

Passons à l’écriture de notre premier middleware : url. Son but est de parser l’URL de la requête pour nous fournir les informations dont les autres middlewares pourraient avoir besoin ensuite.

export function url() {
  return (incomingMessage, requestContext) => ({ ...requestContext })
}

L’API d’un middleware est simple dans notre framework.

Il s’agit d’une fonction qui peut prendre en paramètre tout ce dont elle peut avoir besoin pour son fonctionnement ou pour permettre à l’utilisateur de modifier son fonctionnement. Ici url ne prend aucun paramètre.

Cette fonction va ensuite retourner une autre fonction qui elle sera exécutée plus tard dans la boucle for (const middleware of middlewares) du callback de createServer. C’est cette fonction qui prend en paramètre incommingMessage et requestContext puis retourne une nouvelle version de requestContext.

export function url() {
  return (incomingMessage, requestContext) => ({
    ...requestContext,
    url: new URL(incomingMessage.url, `http://${incomingMessage.headers.host}`),
  })
}

Le middleware url ajoute au contexte de la requête un attribut url qui contient un objet URL parsé.

Nous pourrons donc faire requestContext.url.pathname et avoir accès au pathname de la requête.

Le routeur de notre framework

Le routeur de notre framework sera aussi un middleware utilisable avec createServer.

Pour faire simple, nous allons définir une route comme suit :

const route = {
  method: 'GET',
  pathname: '/',
  controller(incommingMessage, requestContext) {
    return `Hello, World!`
  }
}

Cet objet est composé de trois informations indispensables à notre routeur :

  • le pathname concerné
  • le verbe HTTP utilisé
  • la fonction capable de générer quelque chose à retourner au client

En entrée de notre middleware router nous allons prendre un tableau de routes.

export function router(routes = []) {
  return (incommingMessage, requestContext) => {
    for (const route of routes) {
      if (route.pathname !== requestContext.url.pathname) continue
      if (route.method !== requestContext.method) continue

      return {
        ...requestContext,
        responseBody: route.controller(incommingMessage, requestContext),
      }
    }
  }
}

À chaque fois qu’un client fait une requête au serveur, le router va dérouler la liste des routes et vérifier laquelle correspond à la demande du client. Dès qu’il en trouve une il arrête la boucle et exécute la fonction controller de la route.

Ce que le controller retourne peut ensuite être ajouté au contexte comme responseBody. C’est ce que le serveur retourne au client.

export function router(routes = []) {
  return (incommingMessage, requestContext) => {
    for (const route of routes) {
      if (route.pathname !== requestContext.url.pathname) continue
      if (route.method !== requestContext.method) continue

      return {
        requestContext,
        responseBody: route.controller(incommingMessage, requestContext),
        closeConnection: true,
      }
    }

    return {
      ...requestContext,
      statusCode: 404,
    }
  }
}

Pour faire en sorte que le client comprenne ce qu’il se passe lorsque le routeur ne trouve pas la route demandée, j’ai ajouté la gestion du code d’erreur 404 après la boucle. Le return dans la boucle arrête complètement la fonction, donc si la fonction ne s’est pas arrêtée au milieu de la boucle, c’est forcément que l’on n’a pas trouvé de correspondance dans les routes déclarées. Nous retournons donc un contexte avec le statusCode à 404.

Avec cette fonction, nous pouvons déclarer nos routes comme suit :

router([
  { 
    method: 'GET', 
    pathname: '/', 
    controller() { 
      return `Hello, World!` 
    } 
  },
])

Tout fonctionne correctement et nous pourrions nous arrêter là, mais nous n’avons pas exactement le même code que celui que je vous ai montré au début de l’article.

Tout ce qu’il manque, c’est du sucre syntaxique : du code qui ne servira qu’à rendre nos fonctionnalités plus faciles et agréables à utiliser.

export function get(pathname, controller) {
  return {
    method: 'GET',
    pathname,
    controller,
  }
}

Cette fonction ne sert qu’à retourner l’objet "route" dont a besoin la fonction router. Rien de compliqué ici.

export function get(pathname, controller) {
  if (typeof controller !== 'function') {
    throw new Error('The get() HTTP method needs a controller to work as expected')
  }
    
  if (typeof pathname === 'string') {
  	throw new Error('The get() HTTP method needs a pathname to work as expected')
  }

  return { method: 'GET', controller, pathname }
}

Un autre intérêt de cette fonction est qu’elle nous permet assez simplement de tester nos données sans compliquer le code de notre routeur.

Le routeur s’utilise maintenant comme suit :

router([
  get('/', () => `Hello, World!`)
])

Nous sommes très proches du résultat que l’on souhaite atteindre. Les derniers changements sont à faire dans l’implémentation de router lui-même.

export function router(...routes) {
  return (incommingMessage, requestContext) => {
    for (const route of routes) {
      if (route.pathname !== requestContext.url.pathname) continue
      if (route.method !== requestContext.method) continue

      return {
        requestContext,
        responseBody: route.controller(incommingMessage, requestContext),
        closeConnection: true,
      }
    }

    return {
      ...requestContext,
      statusCode: 404,
    }
  }
}

J’ai uniquement modifié le paramètre routes pour qu’il ne soit plus un tableau, mais un paramètre du reste comme pour createServer.

Ainsi, nous avons atteint notre objectif et terminé cet article. Appréciez votre dur travail :

import { createServer } from './lib/http/create-server.js'
import { url, router } from './lib/http/middlewares/index.js'
import { get } from './lib/http/methods/index.js'

const server = createServer(
  url(),
  router(
    get('/', () => `Hello, World!`),
  )
)

server.listen(3000)

Si vous souhaitez approfondir un peu les notions vu rapidement dans cet article ou allez plus loin, je vous conseille :