Comprendre le protocole WebSocket de zéro et construire un système de chat complet : connexion, utilisateurs en mémoire, messages privés et groupes — sans base de données.
Un groupe (ou room) est un canal partagé par un sous-ensemble d'utilisateurs. Seuls les membres reçoivent les messages envoyés dans ce groupe. Plusieurs groupes peuvent coexister simultanément, et un utilisateur peut appartenir à plusieurs groupes en même temps.
Un second Map stocke l'état des groupes :
// nom du groupe → Set de usernames membres
const groups = new Map()On utilise un Set de usernames (pas de sockets) pour les membres — les sockets changent à chaque reconnexion, les usernames non.
case 'join':
handleJoin(socket, msg)
breakfunction handleJoin(socket, msg) {
const groupName = (msg.group || '').trim()
if (!groupName) {
socket.send(JSON.stringify({ type: 'error', content: 'Nom de groupe manquant' }))
return
}
// Créer le groupe s'il n'existe pas
if (!groups.has(groupName)) {
groups.set(groupName, new Set())
}
const members = groups.get(groupName)
if (members.has(socket.username)) {
socket.send(JSON.stringify({ type: 'error', content: `Déjà membre de ${groupName}` }))
return
}
members.add(socket.username)
// Confirmer à l'utilisateur
socket.send(JSON.stringify({
type: 'joined',
group: groupName,
members: [...members],
}))
// Notifier les autres membres du groupe
broadcastGroup(groupName, {
type: 'system',
group: groupName,
content: `${socket.username} a rejoint ${groupName}`,
}, socket.username)
console.log(`${socket.username} a rejoint le groupe "${groupName}" (${members.size} membres)`)
}case 'leave':
handleLeave(socket, msg)
breakfunction handleLeave(socket, msg) {
const groupName = (msg.group || '').trim()
const members = groups.get(groupName)
if (!members || !members.has(socket.username)) {
socket.send(JSON.stringify({ type: 'error', content: `Vous n'êtes pas dans ${groupName}` }))
return
}
members.delete(socket.username)
if (members.size === 0) {
groups.delete(groupName) // supprimer le groupe vide
} else {
broadcastGroup(groupName, {
type: 'system',
group: groupName,
content: `${socket.username} a quitté ${groupName}`,
})
}
socket.send(JSON.stringify({ type: 'left', group: groupName }))
}case 'group':
handleGroupMessage(socket, msg)
breakfunction handleGroupMessage(socket, msg) {
const groupName = (msg.group || '').trim()
const content = (msg.content || '').trim()
if (!groupName || !content) return
const members = groups.get(groupName)
if (!members || !members.has(socket.username)) {
socket.send(JSON.stringify({
type: 'error',
content: `Rejoignez d'abord le groupe "${groupName}"`,
}))
return
}
broadcastGroup(groupName, {
type: 'group',
group: groupName,
from: socket.username,
content,
at: new Date().toISOString(),
})
}function broadcastGroup(groupName, msg, excludeUsername = null) {
const members = groups.get(groupName)
if (!members) return
const data = JSON.stringify(msg)
members.forEach((username) => {
if (username === excludeUsername) return
const client = users.get(username)
if (client && client.readyState === WebSocket.OPEN) {
client.send(data)
}
})
}La double lookup (members → usernames, users → socket) garantit qu'on envoie uniquement aux membres actuellement connectés. Un membre hors-ligne ne reçoit pas le message — et c'est attendu, puisqu'on n'a pas de base de données pour le stocker.
case 'groups':
socket.send(JSON.stringify({
type: 'groups',
list: [...groups.entries()].map(([name, members]) => ({
name,
count: members.size,
members: [...members],
})),
}))
breakAlice, Bob et Carol connectés et inscrits.
Alice crée le groupe dev :
ws.send(JSON.stringify({ type: 'join', group: 'dev' }))Bob rejoint :
ws.send(JSON.stringify({ type: 'join', group: 'dev' }))Alice reçoit : "bob a rejoint dev".
Carol ne reçoit rien — elle n'est pas dans le groupe.
Alice envoie un message dans le groupe :
ws.send(JSON.stringify({ type: 'group', group: 'dev', content: 'Quelqu\'un connaît ws ?' }))Alice et Bob reçoivent :
[dev] alice : Quelqu'un connaît ws ?Carol ne reçoit rien.
Quand un utilisateur se déconnecte, le retirer de tous ses groupes :
socket.on('close', () => {
if (!socket.username) return
// Retirer de tous les groupes
groups.forEach((members, groupName) => {
if (members.has(socket.username)) {
members.delete(socket.username)
if (members.size === 0) {
groups.delete(groupName)
} else {
broadcastGroup(groupName, {
type: 'system',
group: groupName,
content: `${socket.username} s'est déconnecté`,
})
}
}
})
users.delete(socket.username)
broadcast({ type: 'system', content: `${socket.username} a quitté le chat` })
})Le système de groupes est complet. Le dernier chapitre assemble tout dans une interface HTML fonctionnelle.