Introduction aux Server-Sent Events pour du temps réel

7mn de lecture

De nos jours, il est courant d’écrire des applications web qui réagissent en temps réel pour l’utilisateur. En d’autres termes, le serveur peut envoyer des informations directement aux clients, pour les informer, par exemple, qu’une action est terminée, ou que quelqu’un d’autre a effectué une action. La plupart des notifications que vous recevez sur les sites web sont basées sur du temps réel.

Ce concept permet d’architecturer son application de manière différente. On peut notamment imaginer qu’au lieu d’exécuter du code immédiatement, on planifie un « Queue Job » pour effectuer une action, et celui-ci nous informe une fois que le job a été exécuté.

Les Server-Sent Events (SSE) permettent de maintenir une connexion ouverte entre le serveur et le client, permettant ainsi de diffuser des mises à jour en temps réel sans nécessiter de nouvelles requêtes répétées de la part du client.

En bref, pouvoir envoyer des informations depuis le serveur vers le client sans que celui-ci ait à les demander est très pratique.

Beaucoup de gens utilisent les WebSockets pour réaliser ce type d’action. C’est un protocole qui fonctionne bien, mais qui est bien plus complexe que les Server-Sent Events, qui sont plus simples à mettre en place dans les cas d’utilisation unidirectionnels, comme l'envoi de notifications.

Cet article sera divisé en deux parties : une première partie théorique qui explique le principe des SSE et leur différence avec les WebSockets, et une deuxième partie plus pratique qui expliquera comment les intégrer dans une application Node.js.

Les Server-Sent Events (SSE)

Pour commencer, les Server-Sent Events (SSE) ne sont pas quelque chose de nouveau. Ils existent et sont supportés par les navigateurs depuis bientôt 20 ans.

Les SSE se basent sur l'EventSource, une API JavaScript qui permet au serveur d'envoyer des événements de manière unidirectionnelle vers le client via HTTP. Cette technologie est largement supportée par tous les navigateurs modernes (Chrome, Firefox, Safari, Edge, etc.). De plus, un polyfill est disponible pour les environnements qui ne supportent pas nativement les SSE, comme React Native, permettant ainsi de les utiliser sur d’autres plateformes.

Ce système utilise le protocole HTTP, ce qui le rend simple à comprendre et à utiliser.

À l’époque de HTTP/1, il n'était pas possible pour les navigateurs d’avoir plus de six connexions HTTP ouvertes simultanément pour un même domaine, ce qui rendait l’utilisation des SSE presque impossible.

Ce n'est plus le cas avec HTTP/2 et HTTP/3.

En général, lorsqu’un client envoie une requête, votre backend retourne une réponse comprenant de l'HTML, du JSON ou n’importe quel autre type de donnée. Ensuite, la connexion est "terminée".

Avec les SSE, on garde le même principe, mais la réponse reste ouverte sur le serveur. Cette connexion va être utilisée comme un tunnel, où le serveur peut continuellement pousser de nouvelles informations vers le client.

Pour utiliser les SSE, il vous faut un langage backend capable de garder une connexion ouverte sans être bloquant. C’est le cas du JavaScript (Node.js) ou de Go, mais cela ne fonctionnera pas aussi bien avec PHP. Dans ce cas, une alternative comme Mercure peut être utilisée. C'est un hub écrit en Go qui permet de gérer les connexions SSE.

Quelle différence avec des WebSockets ?

Les Server-Sent Events (SSE) et les WebSockets sont deux technologies utilisées pour établir une communication en temps réel entre le client et le serveur, mais elles diffèrent par leur fonctionnement.

Protocole de Communication

Le WebSocket crée une connexion bidirectionnelle basée sur TCP, permettant au client de transmettre également des données au serveur. En revanche, le SSE est un mécanisme unidirectionnel qui utilise le protocole HTTP. Pour communiquer avec le serveur, le client doit utiliser une requête HTTP.

Les WebSockets sont souvent utilisés pour des applications nécessitant une communication bidirectionnelle constante, comme les chats en temps réel, les jeux multijoueurs, ou des systèmes de collaboration en ligne.

Complexité

Le WebSocket nécessite une gestion plus complexe côté serveur, notamment en ce qui concerne la gestion des connexions (par exemple, les reconnexions) et le suivi des états. Il vous faudra souvent utiliser une librairie tierce pour faciliter cette gestion.

En comparaison, le SSE repose sur HTTP, qui est déjà compris et utilisé par la plupart des technologies actuelles, rendant ainsi sa mise en œuvre plus simple. Le SSE simplifie également la gestion des ressources serveur, car il ne nécessite pas de maintenir des connexions persistantes complexes.

Sécurité

Étant donné que le SSE utilise HTTP, il est facile d’utiliser HTTPS pour chiffrer la connexion et de mettre en place des middlewares pour gérer la sécurité. En revanche, le WebSocket nécessite une authentification lors du handshake initial, et cette information doit être maintenue en mémoire pour lier le tunnel à l’utilisateur concerné.

Pour sécuriser une connexion WebSocket, il est nécessaire d’utiliser wss:// (WebSocket Secure), qui fonctionne de manière similaire à HTTPS, mais avec des étapes supplémentaires lors du handshake.

Implémentation des Server-Sent Events avec Transmit

Dans l'écosystème Node.js, il existe la librairie @boringnode/transmit qui facilite la mise en place des SSE dans n'importe quelle application.

Cette librairie propose un protocole au-dessus des SSE pour gérer un système de "canal" et d'autorisation.

Si vous souhaitez comprendre en détail comment les SSE fonctionnent, je vous invite à lire le code source de cette librairie. Dans cet section, nous allons mettre en place Transmit dans une application construite avec Fastify.

Si vous utilisez AdonisJS, cette librairie possède un adaptateur officiel. Utilisez-le !

Protocole Transmit

Le protocole Transmit repose sur trois endpoints à implémenter.

  1. GET /__transmit/events: Cette route est utilisée pour établir une connexion entre le client et le serveur. Elle renvoie un stream qui sera utilisé pour envoyer des données au client.
  2. POST /__transmit/subscribe: Cette route permet d'abonner le client à un canal spécifique.
  3. POST /__transmit/unsubscribe: Cette route permet de désabonner le client d'un canal spécifique.

La librairie @boringnode/transmit inclut presque l’intégralité de la logique métier à implémenter côté serveur. Il vous suffira de relier les différents endpoints au code.

Création de la connexion et gestion des canaux

Création de l'objet Transmit

En premier lieu, il va falloir instancier la classe Transmit. Je vous invite à consulter la documentation pour connaître les paramètres disponibles.

import { Transmit } from '@boringnode/transmit'

const transmit = new Transmit({
  pingInterval: false,
  transport: null
})

Création de la connexion

Comme mentionné plus haut, la connexion se fait après un GET sur l'URL /__transmit/events. Nous aurons besoin de récupérer l'ID du client, un UUID dans notre cas, et d'enregistrer le stream.

fastify.get('__transmit/events', (request, reply) => {
  const uid = request.query.uid as string

  if (!uid) {
    return reply.code(400).send({ error: 'Missing uid' })
  }

  const stream = transmit.createStream({
    uid,
    context: { request, reply }
    request: request.raw, 
    response: reply.raw, 
    injectResponseHeaders: reply.getHeaders()
  })

  return reply.send(stream)
})

Après avoir mis en place ce code, si notre client crée un EventSource sur cette URL, il devrait voir une connexion qui s'ouvre et ne se termine pas.

Ajout du client à un canal

Le client devra effectuer une requête POST sur l'URL /__transmit/subscribe afin de s'enregistrer dans un canal.

fastify.post('__transmit/subscribe', async (request, reply) => {
  const uid = request.body.uid as string
  const channel = request.body.channel as string
  
  const success = await transmit.subscribe({
    uid, 
    channel, 
    context: { request, reply }
  })

  if (!success) {
    return reply.code(400).send({ error: 'Unable to subscribe to the channel' })
  }

  return reply.code(204).send()
})

Une fois cette requête envoyée, Transmit va stocker en mémoire que ce client est connecté au canal demandé (s'il en possède les autorisations).

Désabonnement à un canal

Le client peut également se désabonner d'un canal. Cette action se fait avec une requête POST sur l'URL /__transmit/unsubscribe.

fastify.post('__transmit/unsubscribe', async (request, reply) => {
  const uid = request.body.uid as string
  const channel = request.body.channel as string

  const success = await transmit.unsubscribe({
    uid, 
    channel, 
    context: { request, reply }
  })

  if (!success) {
    return reply.code(400).send({ error: 'Unable to unsubscribe to the channel' })
  }

  return reply.code(204).send()
})

Envoyer un message avec Transmit

La dernière étape consiste à envoyer un message du serveur vers le client. Cette action est réalisable à travers l'instance de la classe Transmit que nous avons créée au début.

transmit.broadcast('chats/1/messages', { message: 'Hello' })

Et voilà, rien de plus simple !

Le seul manque que nous avons, ce sont des clients... mais comment une connexion SSE est-elle effectuée côté client ?

Utilisation du client Transmit

Pour ouvrir une connexion, on utilise l'objet natif EventSource.

const eventSource = new EventSource("/__transmit_events");
const eventList = document.querySelector("ul");

eventSource.onmessage = (e) => {
  const newElement = document.createElement("li");

  newElement.textContent = `message: ${e.data}`;
  eventList.appendChild(newElement);
};

Sachant que le protocole proposé par Transmit permet une gestion des canaux, un client existe pour faciliter son intégration.

import { Transmit } from '@adonisjs/transmit-client'

export const transmit = new Transmit({
  baseUrl: window.location.origin
})

À la création du client, une connexion SSE est directement établie. Il suffit ensuite d'enregistrer le client à un canal.

const subscription = transmit.subscription('chats/1/messages')

subscription.onMessage((data) => {
  console.log(data)
})

await subscription.create()

Dans cet exemple, j'enregistre le client pour écouter tous les événements envoyés par le serveur au canal chats/1/messages.

Conclusion

En résumé, les Server-Sent Events (SSE) sont une technologie simple et efficace pour permettre la communication en temps réel entre le serveur et le client via le protocole HTTP.

Avec l’aide de la librairie @boringnode/transmit, nous avons vu comment configurer un serveur pour envoyer des messages à un client, comment gérer les abonnements à des canaux spécifiques, et comment recevoir ces messages côté client.

Transmit simplifie l'intégration des SSE en permettant une gestion facile des connexions et des canaux, tout en offrant une flexibilité pour s'adapter aux besoins de votre application.

N’hésitez pas à explorer davantage les possibilités offertes par Transmit et à consulter la documentation pour approfondir vos connaissances. Vous êtes maintenant prêt à mettre en place une communication en temps réel efficace et performante dans vos applications Node.js.