Les Server-Sent Events (SSE)
Analyse du protocole SSE, une alternative simple et performante aux WebSockets pour des flux de données unidirectionnels.Le protocole Server-Sent Events (SSE), comme son nom l’indique, permet au serveur d’envoyer des événements à ses clients sans que ceux-ci n’aient à faire de requête. Le protocole prend en compte l’auto-reconnexion.
Ces échanges sont unidirectionnels : à l’inverse des WebSockets, seul le serveur peut envoyer des événements. Les interactions de l’utilisateur passent par des requêtes HTTP classiques (GET
, POST
) et le serveur peut envoyer des notifications en temps réel (par exemple pour la mise à jour d’une valeur sur l’interface, un nouveau message, ou le status d’une tâche qui prend beaucoup de temps).
Histoire du protocole SSE
Le protocole SSE est en fait assez vieux, il remonte à 2008, et est disponible dans tous les navigateurs depuis 2011. Ce protocole visait à remplacer et standardiser les techniques dites ”Comet” pour les notifications serveur : en particulier le long polling, où le navigateur envoie une requête que le serveur accepte mais ne ferme que quand il a une notification à envoyer (d’où le terme “long polling”). Une fois la notification reçue (ou timeout), le navigateur envoie une nouvelle requête pour attendre la prochaine notification.
Les WebSockets, développés et standardisés à peu près au même moment permettent quant à eux une communication bidirectionnelle en temps réel pour palier à un problème de latence : en effet, avec HTTP/1.1 les requêtes ne sont pas multiplexées sur une même connexion TCP et chaque nouvelle requête subit alors la latence de l’établissement de la connexion. Pour palier à ce problème, le protocole WebSocket maintient la connexion TCP et permet donc des interactions utilisateur plus rapide.
En HTTP/2, les connexions sont multiplexées : plusieurs requêtes-réponses HTTP passent par la même connexion TCP. Il n’y a donc plus1 de latence liée à l’ouverture d’une nouvelle connexion pour chaque nouvelle requête (même si il y a toujours l’overhead du protocole HTTP : les headers, etc).
Selon caniuse, HTTP/2 est supporté par 98% des navigateurs utilisés. Il est aussi géré par tous les grands serveurs web (Nginx, Apache, Caddy 💘…)
Comprendre le protocole SSE
La spécification du protocole SSE est relativement simple :
- Le header
Content-Type
esttext/event-stream
- Le header
Connection
enkeep-alive
Un message est composé de un ou plusieurs champs, de la donnée et se termine par deux sauts de ligne (\n
). Le seul champ requis dans un message est data
: chaque ligne de donnée doit être préfixée par ce champ et le client les concaténera ensemble.
Les autres champs possibles (mais optionnels) sont :
event
permet de séparer les événements en leur donnant un typeid
permet au client de demander les événements manqués lors de la reconnexionretry
permet au serveur de définir le temps d’attente en secondes avant reconnexion
Les lignes qui commencent par :
sont ignorées et peuvent servir de ping pour s’assurer que la connexion est toujours ouverte.
Attention, une ligne qui ne commence pas par un de ces champs déclenchera une erreur !
Ceci peut survenir en particulier si vous essayez d’envoyer de la donnée sur plusieurs lignes, mais que votre implémentation de serveur SSE ne rajoute pas le pseudo-header data
devant chaque ligne.
Réceptionner des SSE avec Javascript
Un navigateur web peut recevoir des SSE via l’interface EventSource
:2
const es = new EventSource("http://localhost:8080");
es.addEventListener("open", () => document.body.innerText += "\nConnected to SSE server");
es.addEventListener("error", (err) => console.error("Error", err));
es.addEventListener("message" ({ data }) => document.body.innerText += "\n" + data);
es.addEventListener("notification" ({ data }) => {
const msg = JSON.parse(data);
document.body.innerText += `\n${msg.username}: ${msg.message}`;
});
Lors d’une déconnexion, l’EventSource
essayera de se reconnecter après le délai retry
. Si spécifié, il enverra aussi le header Last-Event-Id
avec la valeur du dernier id
reçu. Ceci permet au serveur d’envoyer tous les événements qui se sont produits pendant que le client était déconnecté.
Jouer au serveur SSE
Le protocole SSE étant basé sur une connexion HTTP, il est facile ici aussi de jouer au serveur. Nous allons à nouveau utiliser netcat
pour accepter une connexion TCP et suivre le protocole HTTP et SSE pour envoyer des événements. Cet enregistrement asciinema est accompagné d’une représentation du navigateur faisant tourner le code ci-dessus.
La commande nc -l 8080
va ouvrir le port 8080 (-l pour listen). On reçoit la requête du navigateur pour la source SSE, à laquelle on répond avec les headers mentionnés au dessus.On envoie deux messages sur une seule ligne avec le champ data
seulement, puis un message sur deux lignes. On peut voir que chaque message est délimité par un double saut de ligne\n\n
.On envoie ensuite un message avec le champ event: notification
et une payload en JSON. Comme vu plus haut, notre code JS va parser cette payload pour afficher le nom d’utilisateur et le message.Quand le serveur ferme la connexion TCP (ici en fermant nc), l’EventSource est déconnectée.
Les pièges à connaître avant d’utiliser les SSE
Bien que le protocole SSE soit très pratique, facile à mettre en place et utilisé par de nombreuses application en production, il y a quand même quelques pièges à connaître.
Protocole texte uniquement
Le protocole ne permet d’envoyer que du texte, là où on peut envoyer de la donnée binaire via WebSockets. Cette limite n’est en général pas un problème pour les performances comme la majorité des données sont échangées en JSON et le protocole profite nativement de la compression au niveau HTTP. Cependant pour envoyer de la donnée binaire (par exemple un fichier), il faudra soit recourir à des URLs externes, soit encoder la donnée en base64.
Impossibilité d’ajouter des headers (Auth)
Bien que les SSE soient basés sur HTTP, l’interface EventSource
ne permet pas d’envoyer de headers. En particulier, il est impossible d’ajouter un header Authorisation
si votre application est sécurisée via JWT.
Il y a une discussion en cours à ce sujet sur le github de la spec. Certains polyfills réimplémentent l’interface EventSource via les requêtes fetch
en stream3.
Plusieurs hacks ont vu le jour pour contourner le problème :
- Envoyer le token via le header
Last-Seen-Id
- Envoyer le token via les Query Params (mais ceux-ci peuvent alors être visible entre autres dans les logs de serveurs webs non sanitized4)
- Générer une URL ou un un token temporaire pour une connexion
À noter que ceci n’est pas un problème si vous authentifiez vos utilisateurs via cookies, ou si votre JWT est stocké dans un cookie (puisque les cookies sont envoyés automatiquement par le navigateur à chaque requête HTTP).
Limite de 6 connexions par domaine en HTTP/1.1
En HTTP/1.1, le navigateur limite les connexions HTTP à 6 par domaine et un EventSource en consommera une constamment. Ceci peut devenir problématique si l’utilisateur ouvre plusieurs onglets et que chaque onglet se connecte en SSE !
Ceci n’est pas un problème en HTTP/2, puisque les connexions sont multiplexées et la limite est beaucoup plus élevée (200, 250). Certains mécanismes permettent de mutualiser une connexion SSE entre plusieurs onglets via des SharedWorkers, BroadcastChannel ou autre.
Interactions avec les pare-feux, proxies et anti-virus
Certains développeurs notent que certains pare-feux, proxies ou anti-virus bloquent le protocole SSE tandis que d’autres remontent les mêmes problèmes avec les WebSockets.
L’évolution du web et le fait que de nombreuses solutions en production utilisent les SSE rassurent sur ce point. Si votre application est soumise à des contraintes spécifiques (applications internes, intranets, pare-feu/antivirus d’entreprise avec DPI, …), il est toujours bon de faire un Proof of Concept pour valider le fonctionnement de chaque solution !
Alternatives et futur du protocole SSE
Comme nous l’avons vu, pouvoir push de la donnée depuis le serveur vers le client est un mécanisme très utile pour de nombreux cas d’usages et les mécanismes pour faire du temps réel sont en constante évolution.
Si votre application web envoie beaucoup d’événements au serveur ou si vous souhaitez envoyer des données binaire, il peut être avantageux de passer par des websockets. En particulier, la librairie socket.io
simplifie grandement l’utilisation et la fiabilité des websockets, entre autres en gérant le fall back automatiquement vers du long polling si la connexion websocket est impossible, et en automatisant les heartbeats/reconnexions.
Récemment5, fetch
permet de gérer les streams sur les objets Response
. Ceci permet en théorie d’implémenter le protocole SSE ou un autre système de notification ou streaming de données in-house.
Dans le futur, la spec WebTransport permettra aussi aux développeurs d’implémenter leurs propres protocoles de transport via QUIC (HTTP/3.) On peut donc imaginer voir apparaître de nouveaux protocoles basés sur cette technologie, ainsi que l’adoption de cette technologie dans les solutions existantes.
De manière plus globale des protocoles de plus haut niveaux permettent de mailler ces protocoles de notifications. Par exemple nchan ou la proposition pour le Solid Notifications Protocol.
Références et autres liens sur les SSE
- La documentation MDN sur les SSE (français)
- L’article sur les SSE sur web.dev (englais)
- L’article https://germano.dev/sse-websockets/ (anglais) propose une comparaison entre WS et SSE.
- La discussion associée sur HackerNews (anglais) contient de nombreux retours d’usage de développeurs. En particulier :
- Le MMO Meadow utilise les SSE comme mécanisme de syncro avec leur backend : http://fuse.rupy.se/about.html
- La spécification JMAP utilise les SSE pour notifier ses clients de nouveaux mails
- Kévin Dunglas (créateur de Mercure, FrankenPHP et API Platform!), a mis en place Hotwired Turbo avec SSE
- Hasses, un serveur de notifications écrit en C
Mercure : un hub de notifications SSE piloté par API
Mercure est un serveur et un protocole de notification de mise à jour de données en temps réel. Les notifications sont envoyées via SSE. Grâce à son protocole, Mercure peut réutiliser la même connexion SSE pour tous les flux auxquels l’utilisateur souscrit. Conçu à la base pour l’écosystème PHP, il est utilisé par défaut dans API Platform et s’ajoute très facilement à Symfony.
Déléguer la gestion des SSE à un processus externe permet aussi de s’assurer que notre API n’est pas surchargée et de profiter de toutes les optimisations mises en place. Cela permet aussi aux langages qui ne maintiennent pas de processus permanents (PHP !) d’utiliser le protocole SSE.
Footnotes
-
Un dernier problème persiste : le “Head of Line Blocking”. Si le tout premier packet d’une connexion est perdu, les packets suivants doivent attendre la retransmission. Ce problème est amplifié par l’aspect multiplexé d’HTTP/2. HTTP/3 mitige le problème en utilisant plusieurs streams. ↩
-
Ce code utilise
.innerText
, qui sanitize automatiquement le HTML pour éviter les injections XSS. Pensez à toujours sanitizer la donnée que vous recevez depuis un utilisateur ! ↩ -
Voir par exemple https://github.com/Azure/fetch-event-source ou https://github.com/lukas-reining/eventsource. ↩
-
Par exemple, Caddy peut expurger certaines valeurs de ses logs, comme les cookies ou les query params. ↩
-
Récemment à l’échelle de temps de la spec. 2018 ! ↩