Communication et notification temps réel au sein des applications web

Magazine
Marque
GNU/Linux Magazine
Numéro
271
Mois de parution
septembre 2024
Spécialité(s)


Résumé

Dans de nombreuses applications, il peut être utile de pouvoir notifier l’utilisateur d’un évènement ou encore de l’informer de la réussite ou l’échec d’une opération. Pour ce faire, il existe différentes options en JavaScript.


Body

HyperText Transfer Protocol (HTTP) est un protocole applicatif sans état qui fonctionne selon un modèle pull où le client envoie une requête au serveur qui lui répond avec le résultat. Cependant, il existe de nombreux cas de figure où il est utile de transmettre des données selon un mode inverse push en (presque) « temps réel » (comme dans une application de conversation, par exemple).

1. Technologies

Au fur et à mesure du temps, différentes technologies se sont développées, uni- ou parfois bidirectionnelles. Pour les illustrer de manière simple, nous prendrons l'exemple (totalement inutile) d'une page web qui affiche l'heure du serveur. Ce dernier est écrit en JavaScript avec Node.js et en utilisant le moins possible de librairies externes, mais il est bien évidemment envisageable d'utiliser d'autres langages de programmation (pour autant qu'ils puissent fonctionner de manière asynchrone). Certains comme Go ou Elixir sont d’ailleurs particulièrement performants à cet égard. Du côté client, chaque illustration sera présentée avec une fonction qui a comme deuxième argument une autre qui sera appelée avec les données reçues (cela peut simplement être console.log).

function show(label, parent) {
  const p = document.createElement("p");
  p.textContent = label;
  const text = document.createTextNode("");
  p.appendChild(text);
  (parent || document.body).appendChild(p);
  return (data) => (text.textContent = data);
}

En pratique, un registre des clients sera conservé dans un objet, lesquels seront ajoutés et retirés au fur et à mesure. Toutes les secondes, une mise à jour contenant l'heure est envoyée à l'ensemble des connexions persistantes (dont les différentes modalités seront détaillées après).

const clients = { sse: {}, long: {}, stream: {} };
 
setInterval(() => {
  const now = new Date();
  const id = now.valueOf();
  const data = now.toLocaleTimeString() + "\n";
  const msg = `event: message\ndata: ${data}id: ${id}\n\n`;
 
  Object.values(clients.sse).forEach((client) => client.write(msg));
  Object.values(clients.stream).forEach((client) => client.write(data));
  wss.clients.forEach((client) => {
    if (client.readyState === 1) client.send(data);
  });
}, 1000);

1.1 Regular polling

La méthode la plus simple est d'interroger le serveur à intervalles réguliers avec setInterval (ou éventuellement une fonction récursive avec setTimeout). L'établissement d'une connexion étant relativement coûteux en termes de ressources, cette technique est par définition très peu efficiente, aussi bien pour le client que le serveur. En effet, un délai court va entraîner une charge inutile et l'allonger va augmenter le temps moyen nécessaire pour recevoir une mise à jour. En résumé, c'est un procédé à éviter autant que faire se peut.

function regular(url, fn) {
  return setInterval(
    () =>
      fetch(url, { signal: timeout(5000) })
        .then((response) => {
          if (!response.ok) throw new Error(response.statusText);
          return response.text();
        })
        .then(fn)
        .catch(console.error),
    1000
  );
}

De manière générale, il est important de mettre en place un délai maximal de réponse, pour ne pas attendre cette dernière indéfiniment (et d'autant plus si une fonction récursive est choisie). Pour ce faire, ce sont les objets AbortController et leurs signaux qui sont utilisés avec l'API Fetch :

function timeout(time) {
  if ("timeout" in AbortSignal) return AbortSignal.timeout(time);
  const controller = new AbortController();
  setTimeout(() => controller.abort(), time);
  return controller.signal;
}

Enfin, il est important de noter que l'ordre des réponses n'est pas forcément garanti, ce qui peut introduire des erreurs subtiles et difficiles à détecter. Pour les éviter, l'utilisation d'un identifiant incrémenté ou d'un horodatage est requise si cette technique est malgré tout utilisée. L'architecture du serveur doit donc prévoir d’enregistrer les dernières mises à jour, afin de pouvoir les restituer aux différents clients, qui seront forcément désynchronisés.

Du côté serveur, l'implémentation est triviale :

const PLAIN = { "Content-Type": "text/plain" };
 
function poll(req, res) {
  res.writeHead(200, PLAIN).end(new Date().toLocaleTimeString());
}

1.2 Long polling

Il s'agit d'une technique plus efficiente, où le serveur attend pour répondre d’avoir une mise à jour à transmettre. Dès que c'est le cas, la connexion se termine et une nouvelle est initiée à l'aide d'une fonction récursive. Le nombre d’appels est ainsi drastiquement réduit, et le délai est quasiment instantané. Il faut cependant ici aussi s'assurer de ne perdre aucune information, particulièrement dans l'intervalle entre la fermeture de la précédente et l'ouverture de la suivante. Encore une fois, l'utilisation d'un identifiant incrémenté (ou d'un horodatage) peut être indispensable pour ce faire. Si une erreur survient, une nouvelle tentative est lancée après un court délai (ici, 500 millisecondes).

function long(url, fn) {
  const update = () =>
    fetch(url)
      .then((response) => {
        if (!response.ok) throw new Error(response.statusText);
        return response.text();
      })
      .then(fn)
      .then(update)
      .catch((err) => {
        console.error(err);
        setTimeout(update, 500);
      });
  update();
}

Du côté serveur, l'implémentation est aussi facile (et la référence au client est supprimée du registre lors de la clôture de la connexion) :

function long(req, res) {
  res.writeHead(200, PLAIN);
  const id = Date.now();
  clients.long[id] = res;
  req.on("close", () => delete clients.long[id]);
}

Pour simuler l'attente de données, on va utiliser une fonction setTimeout récursive avec des délais aléatoires (ici, entre 3 et 10 secondes) pour envoyer les mises à jour en terminant les connexions :

function flush() {
  setTimeout(() => {
    const data = new Date().toLocaleTimeString();
    Object.values(clients.long).forEach((client) => client.end(data));
    flush();
  }, 3000 + Math.random() * 7000);
}

1.3 Server-Sent Events (SSE)

Simples et efficaces, les Server-Sent Events permettent avec une connexion de longue durée de recevoir les mises à jour. Le navigateur va même gérer les reconnexions de manière transparente. Lors de celles-ci, si un identifiant accompagne chaque message, le dernier sera renvoyé dans l'en-tête HTTP Last-Event-ID afin de permettre de resynchroniser facilement le client en lui retransmettant ce qui a été perdu. Il est aussi possible d'envoyer différents types de messages, avec un traitement spécifique pour chacun.

function sse(url, fn) {
  const evtSource = new EventSource(url, { withCredentials: true });
  evtSource.addEventListener("message", (event) => fn(event.data));
  evtSource.addEventListener("open", console.info);
  evtSource.addEventListener("error", console.error);
  return evtSource;
}

Le protocole en lui-même est extrêmement simple, chaque message étant présenté sous la forme suivante (qui peut aussi être allégée) :

event: message
data: Hello, world!
id: 123

S'agissant d'un protocole textuel reposant sur du HTTP conventionnel, l'implémentation côté serveur est extrêmement aisée, l'essentiel reposant sur l'envoi des en-têtes spécifiques, de même qu'un message vide pour que le client puisse détecter immédiatement l'ouverture de la connexion. Ce dernier peut aussi être renvoyé régulièrement pour maintenir la connexion ouverte (si nécessaire) et vérifier qu’elle est toujours active.

function sse(req, res) {
  const id = Date.now();
  clients.sse[id] = res;
 
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Connection": "keep-alive",
    "Cache-Control": "no-cache",
  });
  res.write(":\n\n");
  req.on("close", () => delete clients.sse[id]);
}

1.4 WebSockets

Il s'agit probablement de la technique la plus utilisée, qui a la particularité unique d'être bidirectionnelle. Elle est ainsi particulièrement adaptée aux applications qui nécessitent de nombreux échanges de données, ces dernières pouvant être textuelles et même binaires. Par contre, elle est par définition plus lourde et avait la réputation de mal traverser certains pare-feux, s'agissant d'une mise à niveau du protocole HTTP (même si elle peut être aussi utilisée directement sur du TCP). Ce problème est cependant beaucoup moins présent depuis l'utilisation généralisée du HTTPS. Au besoin, différents sous-protocoles peuvent être négociés entre les deux parties.

Les reconnexions doivent par contre être gérées manuellement (plusieurs librairies existent pour ce faire). Dans ce cas-ci, un exemple est donné avec un délai exponentiel (et un maximum) et l'ajout d'une temporisation aléatoire (ces valeurs peuvent être adaptées). L'objectif est d'éviter une surcharge du serveur dans le cas d'une défaillance provisoire de celui-ci, à la suite de laquelle l'ensemble des clients tenteraient de le rejoindre en même temps, provoquant ainsi une pseudoattaque par déni de service auto-induite qui s'entretient alors.

function ws(url, fn) {
  function connect() {
    let opened = false;
    socket = new WebSocket(url, []);
    socket.addEventListener("message", (event) => fn(event.data));
    socket.addEventListener("error", console.error);
    socket.addEventListener("open", console.info);
    socket.addEventListener("open", () => (opened = true));
    socket.addEventListener("close", (event) => {
      if (event.wasClean) return;
      delay = opened ? 100 : Math.min(5000, delay * 2);
      setTimeout(connect, Math.random() * 500 + delay);
    });
  }
 
  let socket;
  let delay = 100;
  connect();
  return queue(() => socket);
}

La valeur de retour dans cet exemple est une autre fonction qui permet d'envoyer un message tout en le stockant pour réessayer plus tard si la connexion n'est pas disponible, afin d'éviter qu'il ne soit perdu. Un verrou est de plus utilisé pour limiter à une seule occurrence active. Il est important de noter qu'il n'est pas possible de s'assurer formellement du bon envoi des données. Si elles sont critiques, il est nécessaire soit d'implémenter un système d'accusés de réception (avec un identifiant unique de type UUIDv4) ou d'utiliser une requête fetch traditionnelle.

function queue(socket) {
  const msgs = [];
  let lock = false;
 
  const process = () => {
    if (lock) return;
    lock = true;
    while (msgs.length > 0) {
      if (socket().readyState !== 1) {
        lock = false;
        return setTimeout(process, 500);
      }
      socket().send(msgs.shift());
    }
    lock = false;
  };
 
  return (data) => process(msgs.push(data));
}

Du côté serveur, l'utilisation d'une librairie est nécessaire vu la complexité du protocole (ici, ws). Elle vient se greffer au serveur HTTP déjà existant et prend en charge toute connexion WebSocket. Il peut être souhaitable d’envoyer des ping à intervalles réguliers aux clients afin de s’assurer de l’absence de déconnexion.

import http from "node:http";
import { WebSocketServer } from "ws";
 
const HOST = process.env.HOST || "localhost";
const PORT = process.env.PORT || 8080;
const ORIGIN = `http://${HOST}:${PORT}`;
 
const server = http.createServer((req, res) => res.end());
const wss = new WebSocketServer({ server });
wss.shouldHandle = (req) => req.headers.origin === ORIGIN;
 
wss.on("connection", (ws) => {
  ws.on("error", console.error);
  ws.on("message", (data, isBinary) => console.log(data.toString()));
});
 
server.listen(PORT, HOST, () =>
  console.log(`Server is running on http://${HOST}:${PORT}`)
);

1.5 Autres

Il existe aussi l'API Push qui permet par l'intermédiaire d'un service worker l’envoi de messages à l'application, même si elle n'est ouverte dans aucun onglet du navigateur. Elle est cependant fortement liée à l'API Notifications, et nécessite que chaque réception déclenche l'affichage d'une pastille. Son usage ayant malheureusement été abondamment détourné pour du spam, il a été assez bien restreint. L’utilisation (détournée) de WebRTC pourrait éventuellement se concevoir pour des utilisations très ciblées. Enfin, dans le futur, WebTransport, qui repose surtout sur HTTP/3 devrait se positionner comme alternative aux WebSockets, en étant plus polyvalent.

Pour les applications mobiles natives, chaque fournisseur a développé un système propriétaire (Apple Push Notification service et Firebase Cloud Messaging) qui permet la réception de données en arrière-plan, qui peuvent être ensuite traitées adéquatement. Ceci sort cependant du cadre de l'article.

2. Sécurité

De manière générale, les mises à jour étant envoyées à un nombre potentiellement important de clients, il est important d'être particulièrement attentif de ne pas introduire de fuite de données confidentielles. Pour cela, chaque connexion doit être référencée à un utilisateur (ou à un groupe) précis. Il est primordial de définir une durée maximale pour celles-ci (par exemple une heure), afin que celles qui auraient été mal clôturées (volontairement ou non) ne saturent pas le serveur.

La sécurisation de l'API Fetch et des Server-Sent Events peut se faire facilement avec des cookies. Ces derniers doivent posséder les attributs HttpOnly, Secure et maintenant aussi SameSite (de plus, il est préférable d'utiliser les préfixes __Secure- ou __Host-). Il s'agit là du meilleur moyen de conserver un secret (tel qu’un jeton d'authentification) dans un navigateur. Pour les Server-Sent Events, il est nécessaire d'ajouter l'option withCredentials. La politique du Same-origin Policy (SOP) est d'application, qui empêche à une page située sur un autre domaine d'interagir avec les ressources. Au besoin, elle peut être contournée adéquatement par le Cross-Origin Resource Sharing (CORS).

Malheureusement, les WebSockets ne sont pas soumis à cette politique de Same Origin Policy, ce qui va clairement à l'encontre de l’approche Secure by design. Ceci signifie que n'importe quel site peut se connecter à n'importe quel autre sans restriction et que les cookies seront transmis (sans toutefois pouvoir être lus, évidemment). Il est donc impératif, si on souhaite les utiliser, de toujours vérifier l'en-tête HTTP Origin (qui ne peut être modifié en JavaScript dans un navigateur) ! L'alternative est d'utiliser un jeton d'authentification dans l'URL, qui doit être à usage unique, valable sur une courte durée (idéalement quelques secondes) et renouvelé à chaque reconnexion.

3. Performances

Le choix d'une technologie se portera plutôt sur une WebSocket s'il y a de nombreux messages montants du client vers le serveur et plutôt sur les Server-Sent Events si c'est moins le cas, ou dans un environnement techniquement plus contraignant. Dans certains cas de figure et si les mises à jour sont peu nombreuses ou encore qu’aucune autre option n’est utilisable, le long polling peut être utilisé. Quant au regular polling, on imagine difficilement un usage où il est intéressant. Le nombre de connexions simultanées dans le navigateur est de seulement 6 pour HTTP/1, 100 (ou plus) pour HTTP/2 et 200 (ou plus) pour les WebSockets.

L'établissement d'une connexion TCP étant relativement coûteux en termes de ressources et lent (ce qui est d'autant plus à prendre en compte pour un usage mobile), il était préférable de les réduire avec HTTP/1. La diffusion de HTTP/2 a cependant nettement atténué ces inconvénients (et encore plus avec HTTP/3 qui repose sur de l'UDP). L'écart de performances entre les WebSockets et les Server-Sent Events couplés avec des requêtes fetch s'est ainsi fortement réduit.

La compression n'est directement possible qu'avec les WebSockets, qui peuvent transmettre du binaire par opposition aux Server-Sent Events. Cependant, si le volume à transmettre est vraiment important, il peut être souhaitable d'envoyer une référence à une URL (avec préférentiellement une mise en cache).

4. Architecture

Si les besoins sont minimes, la gestion des Server-Sent Events ou des WebSockets peut être intégrée directement avec le serveur web. Pour des projets plus importants, il est préférable de les en séparer (le routage et la répartition de charge étant effectués avec un reverse proxy). L'utilisation d'un agent de message intermédiaire (ActiveMQ, RabbitMQ, Redis, etc.) simplifiera et renforcera l'architecture. Au besoin, il existe aussi des services commerciaux. Enfin, des proxys GRIP comme Pushpin peuvent être utilisés pour simplifier le développement.

La gestion des déconnexions est primordiale, car elles vont immanquablement survenir (et encore plus dans un usage mobile). Dans le cas contraire, des désynchronisations vont apparaître. Lors de l'ouverture de la connexion ou de son rétablissement, on peut renvoyer soit l'état entier, soit les intermédiaires depuis la dernière réception. Ce dernier cas de figure est parfois préférable, mais nécessite que les mises à jour soient intégralement conservées et que chacune d'entre elles soit accompagnée d'un identifiant (incrémental ou un horodatage), qui est retransmis lors de l'ouverture. Par ailleurs, cette dernière est signalée aux clients par l'évènement open pour les Server-Sent Events et les WebSockets.

Il est aussi préférable de n'avoir qu'une seule connexion par onglet (et même idéalement pour l’ensemble), afin d’économiser les ressources du client et du serveur. De manière générale, les données transmises sont le plus fréquemment du JSON, mais peuvent aussi être du texte, une URL ou encore du HTML. Lors d’une coupure prolongée, il est hautement souhaitable de prévenir l'utilisateur. Une page étant dynamique, un système d'abonnement aux différents sujets, adapté au fur et à mesure que les composants sont ajoutés et supprimés, est particulièrement utile. Pour ce faire, le motif de publication et souscription (PubSub) est parfaitement approprié. L'exemple ci-dessous est simpliste, mais fonctionnel et il est aisé d'y intégrer une WebSocket ou un couple Server-Sent Events avec des requêtes fetch.

class PubSub {
  constructor() {
    this._counter = 0;
    this._topics = {};
    this._tokens = {};
  }
 
  subscribe(topic, fn) {
    if (!(topic in this._topics)) this._topics[topic] = {};
    this._counter += 1;
    this._topics[topic][this._counter] = fn;
    this._tokens[this._counter] = topic;
    return this._counter;
  }
 
  unsubscribe(token) {
    delete (this._topics[this._tokens[token]] || {})[token];
    delete this._tokens[token];
  }
 
  publish(topic, data) {
    Object.values(this._topics[topic] || {}).forEach((fn) => fn(data));
  }
}

Pour découpler de manière asynchrone les composants et un système d'abonnement, il est possible d'utiliser les évènements natifs du navigateur avec les CustomEvents. La récente API Broadcast Channel permet de plus l'envoi de messages entre différents onglets, afin de se limiter à une seule connexion pour plusieurs d’entre eux.

ws(`ws://${window.location.host}`, (detail) =>
  window.dispatchEvent(new CustomEvent("time", { detail }))
);
 
const channel = new BroadcastChannel("time");
channel.addEventListener("message", (event) => console.log(event.data));
window.addEventListener("time", (event) => channel.postMessage(event.detail));

5. Streaming

Du côté JavaScript, il existe une API Stream qui est de plus en plus étoffée et qui permet de délivrer du contenu au fur et à mesure, voire en continu.

5.1 Fetch

L'interface ReadableStream de cette API Stream autorise la lecture en continu du contenu d'une requête fetch. Il est ainsi possible de recevoir des données textuelles ou encodées en JSON séparées par un retour à la ligne.

function stream(url, fn) {
  const decoder = new TextDecoder();
  return fetch(url)
    .then((response) => {
      if (!response.ok) throw new Error(response.statusText);
      const reader = response.body.getReader();
      return reader.read().then(function pump({ done, value }) {
        if (done) return;
        fn(decoder.decode(value));
        return reader.read().then(pump);
      });
    })
    .catch(console.error);
}

Il est aussi possible de ré-écrire cette fonction de façon plus moderne avec async/await et le nouveau TextDecoderStream :

async function stream2(url, fn) {
  try {
    const response = await fetch(url);
    if (!response.ok) throw new Error(response.statusText);
    const reader = response.body
      .pipeThrough(new TextDecoderStream())
      .getReader();
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      fn(value);
    }
  } catch (err) {
    console.error(err);
  }
}

Côté serveur, le code est quasiment identique aux exemples précédents :

function stream(req, res) {
  res.writeHead(200, PLAIN);
  const id = Date.now();
  clients.stream[id] = res;
  req.on("close", () => delete clients.stream[id]);
}

Pour tester le streaming en continu et les capacités du navigateur, la fonction suivante peut être utilisée :

import { Readable } from "node:stream";
 
function continuous(req, res) {
  res.writeHead(200, PLAIN);
  const stream = new Readable({
    read(size) {
      this.push(new Date().toISOString() + "\n");
    },
  });
  req.on("close", () => stream.destroy());
  stream.pipe(res);
}

Du côté client, vu le débit élevé de données (plusieurs méga-octets par seconde), il est nécessaire de recourir à requestAnimationFrame pour limiter la fréquence d'affichage en se synchronisant avec celle du navigateur :

function raf(fn) {
  let data = "";
  const next = () => requestAnimationFrame(() => next(fn(data)));
  next();
 
  return (value) => (data = value.split("\n").at(-2));
}

5.2 XMLHttpRequest (XHR)

L'interface ReadableStream n'étant supportée par certains navigateurs que depuis peu, il est possible d'utiliser les anciens XMLHttpRequest comme alternative. Cependant, il est nécessaire de redémarrer la connexion au bout d'un certain volume de données afin de ne pas provoquer un engorgement (l’intégralité de la réponse étant conservée en mémoire) :

function xhr(url, fn) {
  let loaded = 0;
  const req = new XMLHttpRequest();
  req.open("GET", url);
  req.addEventListener("error", console.error);
  req.addEventListener("progress", function (event) {
    fn(req.responseText.substring(loaded, event.loaded));
    loaded = event.loaded;
    if (loaded > 2 << 16) {
      req.abort();
      xhr(url, fn);
    }
  });
  req.send();
  return req;
}

5.3 HTML

Le streaming HTML est utilisé par de nombreux frameworks JavaScript. Le principe est un peu différent, puisqu'il consiste à envoyer d'abord le squelette de la page, en s'arrêtant juste avant </body>. La page est alors affichée progressivement par le navigateur. Après réception des informations de la base de données (par exemple), ces dernières sont transmises en étant encapsulées dans des balises <script>, qui vont à leur tour les insérer dans le canevas en s’exécutant. Pour que cela fonctionne avec Safari, un certain volume est nécessaire (de l'ordre de 512 octets). Cela ne pose normalement aucun problème, mais des espaces sans chasse (ZWSP) sont utilisés ici pour émuler cette taille minimale.

const SPACES = "\u200B".repeat(512);
const NUMBERS = ["one", "two", "three", "four", "five"];
 
function html(req, res) {
  const start = new Date().toLocaleTimeString();
  let i = NUMBERS.length;
 
  const li = (n) => `      <li id="${n}">Loading @ ${start}</li>\n`;
  const script = (n) => `    <script>
      document.getElementById("${n}").textContent
        = "Loaded @ ${new Date().toLocaleTimeString()}";
    </script>\n`;
 
  res.writeHead(200, { "Content-Type": "text/html; charset=UTF-8" });
  res.write(`<!DOCTYPE html>\n<html lang="en">\n <head>
    <title>Streaming</title>\n </head>\n <body>
    <ol>\n${NUMBERS.map(li).join("")}    </ol>\n${SPACES}`);
 
  NUMBERS.forEach((n) =>
    setTimeout(() => {
      res.write(script(n));
      if ((i -= 1) < 1) res.end(` </body>\n</html>`);
    }, 1000 + Math.random() * 4000)
  );
}

L’objectif est d'afficher au visiteur le plus rapidement possible la partie statique du site, et par la suite le contenu dynamique quand il est disponible. La mise en œuvre est cependant un peu lourde et a aussi comme inconvénient de retarder les deux évènements DOMContentLoaded et load de window. Pour arriver à un résultat similaire, il peut être plus simple de recourir à un Custom Element :

customElements.define(
  "inner-fetch",
  class extends HTMLElement {
    static observedAttributes = ["data-src"];
 
    attributeChangedCallback(name, oldValue, newValue) {
      if (name === "data-src")
        fetch(newValue, { cache: "reload" })
          .then((response) => {
            if (!response.ok) {
              this.innerHTML = this.getAttribute("data-error") || "";
              throw new Error(response.statusText);
            }
            return response.text();
          })
          .then((text) => (this.innerHTML = text))
          .catch(console.error);
    }
  }
);

Il est alors utilisé de manière très simple dans le code HTML :

<inner-fetch data-src="/poll" data-error="Error..."></inner-fetch>

Les nouveaux Shadow DOM déclaratifs permettent du streaming directement en HTML, sans intervention de code JavaScript. Cela consiste en une balise template avec un attribut shadowrootmode défini à open contenant un ou plusieurs slot nommés. Par la suite, d'autres éléments s'y référant sont envoyés et remplacent le contenu initial. Cette technique commence seulement à être supportée dans les dernières versions des navigateurs (une prothèse d'émulation existe cependant) :

function shadow(req, res) {
  const now = new Date().toLocaleTimeString();
  let i = NUMBERS.length;
  const li = (n) =>
    ` <li><slot name="${n}">Loaded @ ${now}</slot></li>\n      `;
  const slot = (n) =>
    `    <span slot="${n}">Loaded @ ${new Date().toLocaleTimeString()}</span>\n`;
 
  res.writeHead(200, { "Content-Type": "text/html; charset=UTF-8" });
  res.write(`<!DOCTYPE html>\n<html lang="en">\n <head>
    <title>Streaming</title>\n </head>\n <body>
    <template shadowrootmode="open">\n      <ol>
      ${NUMBERS.map(li).join("")}</ol>\n    </template>\n${SPACES}`);
 
  NUMBERS.forEach((n) =>
    setTimeout(() => {
      res.write(slot(n));
      if ((i -= 1) < 1) res.end(` </body>\n</html>`);
    }, 1000 + Math.random() * 4000)
  );
}

Conclusion

Il existe de nombreuses techniques pour envoyer des mises à jour en temps réel aux clients, chacune avec ses avantages et inconvénients respectifs. Ceci laisse le choix au développeur, selon les besoins de l’application. Le fonctionnement de chacune d’entre elles étant relativement simple une fois pris en main, c’est surtout une bonne conception de l’architecture qui déterminera la réussite du projet !

Références

[1] Node.js HTTP API : https://nodejs.org/api/http.html

[2] MDN Fetch : https://developer.mozilla.org/fr/docs/Web/API/Fetch_API

[3] MDN Server-Sent Events : https://developer.mozilla.org/fr/docs/Web/API/Server-sent_events

[4] MDN WebSockets : https://developer.mozilla.org/fr/docs/Web/API/WebSockets_API

[5] Pushpin : https://pushpin.org/

[6] web.dev streams : https://web.dev/articles/streams

[7] Declarative Shadow DOM : https://developer.chrome.com/docs/css-ui/declarative-shadow-dom



Article rédigé par