Événements envoyés par le serveur Node.js depuis zéro

- Read in English

Je suis convaincu que pour comprendre un concept ou une technologie, nous devons tous essayer de cuisiner quelque chose avec en utilisant le plus bas niveau possible. Cela ne concerne pas seulement la programmation, mais tout dans la vie.

En continuation de l'article Temps réel sans WebSocket, je pense que construire une petite application client-serveur utilisant des événements sent par le serveur (SSE) est un bon moyen de comprendre comment cela fonctionne et de nous donner la confiance nécessaire pour l'utiliser dans nos projets.

Cette application client-serveur sera un petit chat où le client enverra un message au serveur et le serveur diffusera le message à tous les clients connectés. Pas de bibliothèque externe, pas de framework, juste Node.js.

Exigences

Pour suivre ce tutoriel, nous devons avoir Node.js installé sur notre machine. C'est tout.

Le Serveur

Créez un fichier nommé server.mjs. Ce fichier contiendra le code du serveur pour effectuer les actions suivantes :

  • Servir le code côté client c'est-à-dire un simple fichier HTML
  • Recevoir des messages des clients
  • Accepter les connexions des événements envoyés par le serveur et diffuser des messages

Mais pour l'instant, commençons par servir le code côté client. Notre serveur sera construit en utilisant le module http natif de Node.js.

js
import { createServer } from 'node:http'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'

const hostname = '127.0.0.1'
const port = 3000

const server = createServer((req, res) => {
  // Servir le fichier index.html
  res.statusCode = 200
  res.setHeader('Content-Type', 'text/html')

  const htmlFile = join(process.cwd(), 'index.html')
  const html = readFileSync(htmlFile, 'utf-8')

  res.write(html)
  res.end()
})

server.listen(port, hostname, () => {
  console.log(`Serveur en cours d'exécution à http://${hostname}:${port}/`)
})

Lorsqu'une requête atteint le serveur, il répondra avec le contenu du fichier index.html en le lisant sur le disque. Le serveur écoute sur le port 3000.

Nous pouvons démarrer notre serveur avec la commande suivante :

sh
node server.mjs

Le Client

Créez un fichier nommé index.html à la racine du projet. Ce fichier contiendra le code côté client :

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Node SSE</title>
  </head>
  <body>
  </body>
</html>

Oui, c'est un fichier HTML très simple, rien de fantaisiste pour l'instant.

Connexion des Événements Envoyés par le Serveur

Maintenant, ajoutons la connexion des événements envoyés par le serveur au serveur. Pour gérer les SSE, nous devons répondre avec certains en-têtes pour garder la connexion ouverte et envoyer des données. Ainsi, le client demandera une ressource et le serveur répondra avec un flux.

Cette ressource sera /events. Mettons à jour le code du serveur :

js
// ...

const server = createServer((req, res) => {
  // Point de terminaison `/events` utilisé pour écouter les messages
  if (req.url === '/events') {
    res.setHeader('Content-Type', 'text/event-stream')
    res.setHeader('Cache-Control', 'no-cache')

    // TODO: Gérer la connexion
  }
  else {
    // Servir le fichier index.html
    res.statusCode = 200
    res.setHeader('Content-Type', 'text/html')

    const htmlFile = join(process.cwd(), 'index.html')
    const html = readFileSync(htmlFile, 'utf-8')

    res.write(html)
    res.end()
  }
})

// ...

Lorsque le client demande la ressource /events, le serveur répondra avec les en-têtes Content-Type: text/event-stream et Cache-Control: no-cache. Cela informe le client que la connexion est un flux, c'est-à-dire que le serveur enverra des données au client à tout moment. La connexion ne doit pas être mise en cache pour éviter des problèmes avec le flux. Il est important de noter que nous n'appelons jamais la fonction res.end(). Cela est dû au fait que la connexion doit rester ouverte pour permettre au serveur d'envoyer des données au client. La fonction res.end() ferme la connexion.

Logique de Chat

C'est très simple et simplifié au maximum, mais la logique sous-jacente reste la même qu'une application plus complexe.

Dans notre serveur, nous aurons une logique pour recevoir des messages des clients.

js
// ...

const server = createServer((req, res) => {
  // Point de terminaison `/events` utilisé pour écouter les messages
  if (req.url === '/events') {
    // ...

    // Point de terminaison `/messages` utilisé pour envoyer des messages
  }
  else if (req.url === '/message') {
    let body = ''

    req.on('data', (chunk) => {
      body += chunk
    })

    req.on('end', () => {
      // TODO: diffuser le message

      // Rediriger vers le fichier index.html
      res.statusCode = 302
      res.setHeader('Location', '/')
      res.end()
    })
  }
  else {
    // Servir le fichier index.html
    // ...
  }
})

Lorsque l'URL demandée est /message, le serveur écoutera les données envoyées par le client, qui sont le corps de la demande. Lorsque la demande est terminée, le serveur redirige le client vers le fichier index.html. C'est un comportement classique lorsque un formulaire est soumis dans une application rendue par le serveur.

Dans notre fichier index.html, ajoutons un formulaire pour envoyer des messages au serveur :

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Node SSE</title>
  </head>
  <body>
    <form action="/message" method="post">
      <input type="text" name="message" />
      <button type="submit">Envoyer</button>
    </form>
  </body>
</html>

Diffuser le Message

La fonctionnalité manquante est la diffusion du message. Comment diffusons-nous le message et qu'est-ce que cela signifie ?

Une diffusion consiste à envoyer un message à tout le monde. Sinon, c'est unicast, lorsque un message est envoyé à une personne spécifique.

Diffusion vs Unicast
Diffusion vs Unicast

Dans notre cas, le client A enverra un message au serveur (unicast). Ensuite, le serveur enverra ce message à chaque client (diffusion).

Le unicast a été mis en œuvre dans la section précédente. Pour la diffusion, nous devons garder une trace des clients connectés afin de passer en revue lorsque un message est reçu.

js
// ...

// Garder une trace des clients connectés
const connection = new Set()

const server = createServer((req, res) => {
  // Point de terminaison `/events` utilisé pour écouter les messages
  if (req.url === '/events') {
    res.setHeader('Content-Type', 'text/event-stream')
    res.setHeader('Cache-Control', 'no-cache')

    connection.add(res)

    req.on('close', () => {
      connection.delete(res)
    })

    // Point de terminaison `/messages` utilisé pour envoyer des messages
  }
  else if (req.url === '/message') {
    let body = ''

    req.on('data', (chunk) => {
      body += chunk
    })

    req.on('end', () => {
      // Diffuser le message à tous les clients connectés
      for (const client of connection)
        client.write(`data: ${body}\n\n`)

      // Rediriger vers le fichier index.html
      res.statusCode = 302
      res.setHeader('Location', '/')
      res.end()
    })
  }
  else {
    // Servir le fichier index.html
    // ...
  }
})

// ...

Lorsqu'un client se connecte à l'endpoint /events, le serveur ajoute l'objet de réponse à l'ensemble connection. Lorsque la connexion est fermée, l'objet de réponse est supprimé de l'ensemble. Facile !

Nous gardons cet objet pour pouvoir envoyer des données au client en utilisant la méthode write. C'est le cœur du système.

Lorsqu'un message est reçu sur l'endpoint /message, le serveur parcourt la connection précédemment sauvegardée pour envoyer le message à chaque client.

Nous pouvons observer le mot clé data dans le message et les doubles \n à la fin. C'est le format des événements envoyés par le serveur. Le mot clé data est utilisé pour envoyer des données au client, et le double \n est utilisé pour indiquer au client que le message est terminé. Le client n'exposera que le contenu après le mot clé data et avant le double \n.

Par exemple, si le serveur envoie data: Bonjour\n\n, le client verra Bonjour.

API EventSource

Notre serveur gère la connexion et diffuse des messages, mais le client n'est toujours pas capable de se connecter à l'endpoint SSE, ressource /events.

Pour se connecter au serveur, nous utiliserons l'API EventSource. Cette API est une API JavaScript native pour gérer les événements envoyés par le serveur, et elle est très simple.

Au début de notre fichier, nous devons avoir un petit script pour établir la connexion et gérer les messages :

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Node SSE</title>
    <script>
      // Se connecter au serveur
      const eventSource = new EventSource("/events");

      const ul = document.querySelector("ul");

      // Écouter le message
      eventSource.onmessage = (event) => {
        // Ajouter le message à la liste
        const li = document.createElement("li");
        li.innerText = event.data;

        ul.appendChild(li);
      };
    </script>
  </head>
  <body>
    <ul></ul>

    <form action="/message" method="post">
      <input type="text" name="message" />
      <button type="submit">Envoyer</button>
    </form>
  </body>
</html>

Le script commence par demander la ressource /events au serveur en utilisant l'API EventSource. Cela gérera automatiquement la reconnexion.

Ensuite, nous enregistrons une méthode onmessage qui sera déclenchée chaque fois qu'un message est reçu par le client depuis le serveur.

Pour avoir quelque chose de plus semblable à un chat, nous allons ajouter une liste pour afficher les messages. Lorsqu'un message est reçu, nous créerons un élément li et l'ajouterons à la liste.

Maintenant, nous pouvons ouvrir deux onglets dans notre navigateur et envoyer des messages. Ils apparaîtront dans l'autre onglet. :magie:

Chat Node SSE
Chat Node SSE

Derniers Mots

Même si c'est un exemple très simple, rappelez-vous que la logique de stockage des clients et de diffusion avec une boucle est la même qu'une application plus complexe.

Il y a deux choses à retenir avec les événements envoyés par le serveur :

  • Utilisez l'API EventSource pour vous connecter au serveur
  • Répondez avec Content-Type: text/event-stream depuis le serveur

Rien de plus. C'est simple, c'est HTTP, et c'est puissant.

J'espère que cet article vous aidera à comprendre comment fonctionnent les événements envoyés par le serveur et vous donnera la confiance nécessaire pour les utiliser dans vos projets. C'est un outil très puissant pour construire des applications en temps réel sans la complexité des WebSockets.

Retour aux articles
Soutenez mon travail
Suivez-moi sur