Messagerie temps réel (Socket.IO)
Guide d’intégration client pour le namespace WebSocket de messagerie individuelle et de groupe.
Version HTML servie par l’API :
/messaging-realtime· source :docs/messaging-realtime.md
Connexion
| Élément | Valeur |
|---|---|
| URL | http://localhost:{PORT}/messaging (même host/port que l’API HTTP) |
| Transport | Socket.IO v4 (socket.io-client) |
| CORS | origin: true, credentials: true |
Authentification (obligatoire)
Passer le JWT access token au handshake (une des trois formes) :
import { io } from 'socket.io-client';
const socket = io('http://localhost:3000/messaging', {
auth: { token: accessToken },
// alternatives :
// query: { token: accessToken },
// extraHeaders: { Authorization: `Bearer ${accessToken}` },
});
Sans token valide (ou session révoquée / expirée), le serveur coupe immédiatement la connexion.
POST /api/auth/login→ récupéreraccessToken- Ouvrir le socket avec ce token
- En cas de
401/ déconnexion → refresh ou nouveau login, puis reconnect
Rooms
| Room | Format | Rôle |
|---|---|---|
| Utilisateur | user:{accountId} |
Auto-join à la connexion |
| Conversation | conversation:{id} |
Après conversation:join (privé et groupe) |
Les événements message:new et typing:update sont émis dans la room conversation. Il faut donc joindre la conversation avant de recevoir les messages des autres.
Événements client → serveur
conversation:join
socket.emit('conversation:join', { conversationId: 'conv-priv-1' });
- Prérequis : être participant de la conversation
- Ack Socket.IO :
{ conversationId }
conversation:leave
socket.emit('conversation:leave', { conversationId: 'conv-priv-1' });
message:send
Envoi dans une conversation existante ou démarrage d’un privé via destinataire :
// Conversation existante (privée ou groupe)
socket.emit('message:send', {
conversationId: 'conv-grp-1',
content: 'On se retrouve demain',
});
// Nouveau privé
socket.emit('message:send', {
recipientId: '00000000-0000-4000-8000-000000000003',
content: 'Bonjour',
});
// Pièces jointes (URLs / ids fichier) — content optionnel si attachments non vide
socket.emit('message:send', {
conversationId: 'conv-priv-1',
attachments: ['https://…/fichier.pdf'],
});
Ack Socket.IO (même payload aussi broadcast en message:new aux clients déjà dans la room) :
{
"id": "msg-…",
"conversationId": "conv-priv-1",
"authorId": "…",
"content": "Bonjour",
"date": "2026-09-14T16:00:00.000Z",
"attachments": []
}
typing:start / typing:stop
socket.emit('typing:start', { conversationId: 'conv-priv-1' });
socket.emit('typing:stop', { conversationId: 'conv-priv-1' });
Événements serveur → client
message:new
Émis dans conversation:{id} après un envoi WebSocket ou REST (POST /api/messages).
socket.on('message:new', (payload) => {
// payload: { id, conversationId, authorId, content, date, attachments }
});
typing:update
socket.on('typing:update', ({ conversationId, accountId, isTyping }) => {
// …
});
presence:update
Broadcast global à la connexion / déconnexion d’un compte :
socket.on('presence:update', ({ accountId, online }) => {
// online: true | false
});
Flux recommandé (UI chat)
login REST
→ connect /messaging + auth.token
→ GET /api/messages/conversations
→ conversation:join pour la conversation ouverte
→ écouter message:new / typing:update / presence:update
→ message:send (ou POST /api/messages) pour envoyer
→ conversation:leave à la fermeture de la vue
REST complémentaire
| Méthode | Route | Notes |
|---|---|---|
GET |
/api/messages/conversations |
Listes favoris / groups / privées |
GET |
/api/messages/conversations/:id |
Historique |
POST |
/api/messages |
Envoi HTTP — broadcast aussi en message:new |
PATCH |
/api/messages/conversations/:id/* |
favorite, archive, mute, block |
Permission requise : message.view (JWT Bearer).
Conversations privées vs groupes
| Type | Création | Participants |
|---|---|---|
| Privé | message:send / POST /api/messages avec recipientId, ou seed |
participantIds = auteur + destinataire |
| Groupe | Création d’un work group (POST /api/work-groups) |
Membres du groupe ; sync à l’ajout / retrait de membres |
L’accès (join, send, typing) est refusé si accountId ∉ participantIds.
Seed de démo
| Conversation | Id | Participants |
|---|---|---|
| Privé teacher ↔ student | conv-priv-1 |
teacher …0002, student …0003 |
| Groupe A | conv-grp-1 |
idem (+ workGroupId: wg-1) |
Comptes utiles :
- Teacher :
teacher@iwey.io/teacher123 - Student :
shab@gmail.com/dfTS8S5$H
Exemple complet (navigateur / Node)
import { io, Socket } from 'socket.io-client';
async function openChat(accessToken: string, conversationId: string) {
const socket: Socket = io('http://localhost:3000/messaging', {
auth: { token: accessToken },
});
await new Promise<void>((resolve, reject) => {
socket.on('connect', () => resolve());
socket.on('connect_error', reject);
});
socket.on('message:new', (msg) => console.log('nouveau message', msg));
socket.on('typing:update', (t) => console.log('typing', t));
socket.on('presence:update', (p) => console.log('presence', p));
const joined = await socket
.timeout(5000)
.emitWithAck('conversation:join', { conversationId });
console.log(joined); // { conversationId }
socket.emit('message:send', {
conversationId,
content: 'Salut en temps réel',
});
return socket;
}
Si
emitWithAckn’est pas dispo côté client, utiliser le callback Socket.IO classique :socket.emit('conversation:join', payload, (ack) => …).
Erreurs fréquentes
| Symptôme | Cause probable |
|---|---|
| Déconnexion immédiate | Token manquant, invalide ou session inactive |
Pas de message:new des autres |
Room non jointe (conversation:join) |
| Erreur forbidden / exception WS | Compte hors participantIds |
| Message REST reçu mais pas WS | Client non connecté ou pas dans la room |
Fichiers source
- Gateway :
src/modules/communication/gateways/messaging.gateway.ts - Constantes events :
src/modules/communication/constants/messaging-realtime.constants.ts - Broadcast (REST + WS) :
src/modules/communication/adapters/messaging-broadcast.adapter.ts - Service métier :
src/modules/communication/services/conversation.service.ts