Aller au contenu
Me contacter

Ouvert à un CDI · Métropole lilloise

Tous les articles
8 min de lecture

Messagerie temps réel avec Socket.IO et Express

Deux messageries Socket.IO, l’une dans un projet étudiant, l’autre dans une plateforme B2B en production. Ce qu’elles m’ont appris sur le ciblage des messages, l’authentification de la connexion, la place du REST et les accusés de lecture.

  • Socket.IO
  • Node.js
  • Express
  • MongoDB
  • JWT
  • Temps réel

J’ai travaillé sur deux messageries Node.js avec Socket.IO, à quelques années d’écart. La première dans StudHelp, mon projet de fin d’études, une application qui fait découvrir aux étudiants les associations et les événements autour d’eux. La seconde dans SRS, une plateforme B2B où les entreprises partenaires d’un grand groupe de la distribution échangent au quotidien : j’en ai repris la messagerie existante, que j’ai corrigée et fait évoluer de l’API Express à la PWA. D’un projet à l’autre, les mêmes questions reviennent. À qui envoyer un message, comment être sûr qu’il n’est pas perdu, comment savoir qu’il a été lu. Cet article les reprend dans l’ordre où elles se posent, avec du code simplifié pour chacune.

Pourquoi Socket.IO plutôt que des WebSockets natifs ?

L’API WebSocket du navigateur ouvre un canal bidirectionnel entre le client et le serveur, et s’arrête là. Tout le reste est à écrire soi-même : se reconnecter après une coupure, distinguer les types de messages, regrouper les destinataires.

Socket.IO ajoute ces briques au-dessus du transport. Le client se reconnecte automatiquement, les échanges prennent la forme d’événements nommés (send-message, message, room-updated…), et les rooms permettent de viser un groupe de connexions côté serveur. Socket.IO peut aussi se replier sur du long-polling HTTP quand un proxy bloque les WebSockets.

En contrepartie, Socket.IO utilise son propre protocole, si bien qu’un client WebSocket natif ne peut pas se connecter à un serveur Socket.IO, et inversement. Le choix engage donc les deux côtés.

Le polling, qui interroge l’API toutes les quelques secondes, reste envisageable pour une boîte de réception peu active. Pour une conversation, l’intervalle se ressent tout de suite, car il faut choisir entre un délai visible et une charge serveur multipliée par le nombre d’utilisateurs connectés. Quand seul le serveur parle, pour des notifications par exemple, les Server-Sent Events suffisent et évitent une dépendance.

Que confier à la socket, et que garder en REST ?

Par défaut, Socket.IO livre un événement au plus une fois. Si le client est hors ligne au moment de l’envoi, l’événement est perdu pour lui, et la reconnexion automatique ne le rejoue pas. Une messagerie ne peut donc pas compter sur la socket pour garder l’historique.

Sur SRS, le partage est net. Les conversations, l’historique, la création, le renommage et la suppression passent par des routes Express protégées par le middleware d’authentification de l’API. À l’ouverture d’une conversation, la PWA charge la conversation et ses messages par deux appels REST lancés en parallèle. La socket transporte seulement ce qui se passe pendant que l’écran est ouvert : un nouveau message, une conversation modifiée, un participant qui se connecte ou se déconnecte.

Les deux projets suivent une autre règle : un message est enregistré avant d’être diffusé. Dans StudHelp, le client l’envoie à l’API Symfony, puis le relaie au serveur Socket.IO une fois la réponse reçue. Dans SRS, le serveur Socket.IO l’enregistre lui-même avant de l’envoyer aux participants. Dans les deux cas, un destinataire ne reçoit que des messages déjà présents en base.

Les pièces jointes passent mieux par un upload HTTP classique, la socket ne transmettant que l’URL du fichier, car Socket.IO limite par défaut un message à 1 Mo (option maxHttpBufferSize).

Pour les coupures, la parade la plus simple consiste à recharger la conversation ouverte à chaque reconnexion (événement connect côté client). Socket.IO propose aussi une option de récupération de l’état de connexion, qui rejoue les événements manqués après une coupure courte, mais elle ne remplace pas la base de données.

Comment rattacher les conversations au métier ?

Dans StudHelp, personne ne crée de conversation à la main. Publier un événement crée automatiquement sa conversation de groupe, grâce à un listener Doctrine côté API Symfony, et accepter une invitation à l’événement ajoute le membre à la discussion. Chaque association a aussi la sienne. Un statut (active, annulée, inactive) permet de clore une conversation sans effacer son historique.

Ce rattachement évite toute une série de questions : qui peut écrire, qui doit recevoir, quand la conversation s’arrête. Les réponses découlent du métier, déjà modélisé ailleurs.

Sur SRS, les membres ouvrent librement des conversations à deux ou en groupe. Chaque conversation porte la liste de ses participants, et chacun y garde son propre état : rôle d’administrateur, archivage, départ, date de dernière lecture.

// Simplified Mongoose schema: per-participant state lives in the conversation
const Participant = new Schema({
  userId: { type: Number, required: true },
  admin: { type: Boolean, default: false },
  archived: { type: Boolean, default: false },
  left: { type: Boolean, default: false },
  readAt: { type: Date, default: null },
});

const Conversation = new Schema(
  { title: String, participants: [Participant] },
  { timestamps: true },
);

Le titre se calcule pour chaque lecteur. Une conversation à deux affiche le nom complet de l’autre personne, un groupe sans nom la liste des prénoms, sauf celui du lecteur. Les événements de la conversation, comme un renommage, passent par le même flux que les messages, avec un type info qui les affiche comme une ligne de contexte, sans bulle.

Côté stockage, les messages sont dans MongoDB, dont le schéma souple convient aux pièces jointes et aux messages d’information, tandis que les utilisateurs restent dans MySQL. Le contenu des messages y est chiffré champ par champ par l’application avec mongoose-field-encryption, ce qui limite l’exposition en cas de fuite d’une sauvegarde ou d’accès direct à la base. Ce n’est pas un chiffrement de bout en bout, puisque le serveur garde la clé.

Comment envoyer un message aux bons destinataires ?

La version la plus courte, io.emit, envoie le message à tous les clients connectés, qui filtrent ensuite ce qui les concerne. Elle suffit pour une démo, mais chaque navigateur reçoit les messages des autres, et le trafic grandit avec le nombre d’utilisateurs.

Les rooms règlent le problème. Une room est un groupe de sockets tenu par le serveur ; le client ne la voit pas et ne peut pas s’y inscrire seul. Deux découpages sont possibles, une room par conversation ou une room par utilisateur.

La room par conversation oblige à la rejoindre et à la quitter au fil de la navigation, et l’utilisateur ne reçoit rien quand il est sur la liste de ses conversations ou sur une autre page, sauf à rejoindre toutes ses conversations dès la connexion et à tenir cette liste à jour. Sur SRS, la messagerie repose sur la room par utilisateur : chaque connexion rejoint une room nommée d’après l’identifiant de la personne, et le serveur envoie le message à chaque participant.

// Simplified example
io.on("connection", (socket) => {
  const { id: userId } = socket.data.user; // set by the auth middleware
  socket.join(`user:${userId}`);

  socket.on("send-message", async ({ conversationId, content }) => {
    const conversation = await Conversations.findById(conversationId);
    if (!conversation?.hasParticipant(userId)) return;

    const message = await Messages.create({ conversationId, userId, content });

    for (const participant of conversation.activeParticipants()) {
      if (participant.userId !== userId) {
        io.to(`user:${participant.userId}`).emit("message", message);
      }
    }
  });
});

Un utilisateur connecté sur son téléphone et sur deux onglets a trois sockets dans la même room : un seul emit par participant les atteint toutes, sans table de correspondance entre utilisateurs et sockets à maintenir. Le même canal sert au compteur de messages non lus ou à une notification, quelle que soit la page ouverte.

Avant la mise en production, il faut savoir que les rooms vivent dans la mémoire du processus Node.js. Dès que l’API tourne sur plusieurs instances, un utilisateur connecté à l’instance A ne reçoit pas un message émis depuis l’instance B. Socket.IO fournit pour cela des adapters (Redis, PostgreSQL, MongoDB…) qui relaient les envois entre serveurs, et le long-polling, s’il reste actif, demande en plus des sticky sessions côté load balancer.

Comment authentifier une connexion Socket.IO ?

Une socket s’ouvre une fois puis reste ouverte. Le bon moment pour vérifier l’identité est donc le handshake, dans un middleware io.use qui s’exécute avant l’événement connection. S’il appelle next avec une erreur, la connexion est refusée et le client reçoit un connect_error.

Le plus simple est d’y vérifier le JWT que l’API REST utilise déjà. Le client le transmet dans l’option auth. Si l’API le stocke en cookie httpOnly, le navigateur l’envoie avec la requête de handshake, à condition d’activer withCredentials quand la socket est sur un autre domaine, et le middleware le lit dans socket.request.headers.cookie.

// Client: a function, so each reconnection sends the current token
const socket = io(SOCKET_URL, { auth: (cb) => cb({ token: getToken() }) });

// Server: runs once per connection, before "connection"
io.use(async (socket, next) => {
  try {
    const payload = jwt.verify(socket.handshake.auth.token, JWT_SECRET);
    socket.data.user = { id: payload.sub };
    next();
  } catch {
    next(new Error("unauthorized"));
  }
});

L’identité utilisée ensuite, pour rejoindre la room ou signer un message, vient de socket.data.user, rempli à partir du token vérifié. Un identifiant envoyé par le client dans l’événement ne sert qu’à désigner la conversation visée.

Deux points restent à couvrir. L’authentification ne dit pas à quelles conversations l’utilisateur a droit : chaque événement vérifie qu’il en est participant, comme le fait la ligne hasParticipant de l’exemple précédent. Et une connexion peut durer plus longtemps que le token qui l’a ouverte. On peut programmer la déconnexion à l’expiration du token ; le client se reconnecte alors avec un token rafraîchi.

Comment savoir qu’un message a été lu, et prévenir les absents ?

Stocker un statut « lu » sur chaque message multiplie les écritures dans les conversations de groupe. SRS garde une seule date par participant, readAt, mise à jour quand il ouvre la conversation ou y reçoit un message. Un message est vu par un participant dès que son readAt est postérieur à la date du message, et l’interface en déduit, sous chaque message envoyé, qui l’a vu.

La lecture remonte par un événement read-message. Le serveur met à jour le readAt, puis renvoie la conversation à jour à chaque participant, si bien que l’expéditeur voit le « vu » apparaître sans recharger la page.

Reste le destinataire qui n’ouvre pas l’application. Un cron node-cron passe chaque jour à 9 h sur les utilisateurs qui ont activé l’option, cherche dans leurs conversations un message d’un autre participant plus récent que leur readAt, et leur envoie un seul e-mail, quel que soit le nombre de conversations concernées.

Un piège attend ce genre de tâche dès que l’API tourne en cluster : chaque instance lance le cron, et l’utilisateur reçoit l’e-mail en plusieurs exemplaires. Avec PM2, la variable NODE_APP_INSTANCE numérote les instances, ce qui permet de ne planifier les tâches que sur la première.

const cron = require("node-cron");

// PM2 cluster: only the first instance schedules jobs
const isMainInstance =
  process.env.NODE_APP_INSTANCE === undefined ||
  process.env.NODE_APP_INSTANCE === "0";

if (isMainInstance) {
  cron.schedule("0 9 * * *", sendUnreadMessagesDigest);
}

La présence en ligne demande la même attention. Marquer un utilisateur hors ligne à la déconnexion d’une socket devient faux dès qu’il a deux onglets ouverts : il faut attendre que sa room soit vide, ce que io.in(room).fetchSockets() permet de vérifier.

Entre StudHelp et SRS, l’outil n’a pas changé, mais la manière de l’utiliser, si. La socket est devenue un simple canal de notification devant une base qui fait foi, les messages visent des rooms d’utilisateurs et non plus tout le monde, et ce qui se passe hors connexion (lecture, rappel par e-mail) compte autant que le direct. L’étape suivante que j’ajouterais est un accusé de réception (acknowledgement) à l’envoi, pour afficher l’état « envoyé » et renvoyer automatiquement un message qui n’a pas abouti. Les interfaces sont visibles sur les fiches SRS et StudHelp ; pour en discuter, écrivez-moi.

Sources