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.
Les chapitres précédents ont construit le serveur pièce par pièce. Ce chapitre assemble un client HTML complet qui gère l'inscription, le chat global, les DMs et les groupes dans une interface utilisable.
Voici server.js complet, avec tous les handlers des chapitres précédents :
const { WebSocketServer, WebSocket } = require('ws')
const wss = new WebSocketServer({ port: 8080 })
const users = new Map() // username → socket
const groups = new Map() // groupName → Set<username>
const history = [] // 50 derniers messages globaux
wss.on('connection', (socket) => {
socket.username = null
socket.on('message', (data) => {
let msg
try { msg = JSON.parse(data.toString()) } catch { return }
handleMessage(socket, msg)
})
socket.on('close', () => {
if (!socket.username) return
groups.forEach((members, groupName) => {
if (!members.has(socket.username)) return
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` })
})
})
function handleMessage(socket, msg) {
if (msg.type === 'register') { registerUser(socket, msg); return }
if (!socket.username) {
socket.send(JSON.stringify({ type: 'error', content: 'Non identifié' }))
return
}
switch (msg.type) {
case 'chat': handleChat(socket, msg); break
case 'dm': handleDM(socket, msg); break
case 'join': handleJoin(socket, msg); break
case 'leave': handleLeave(socket, msg); break
case 'group': handleGroupMessage(socket, msg); break
case 'groups':
socket.send(JSON.stringify({ type: 'groups', list: [...groups.entries()].map(([name, m]) => ({ name, count: m.size, members: [...m] })) }))
break
case 'list':
socket.send(JSON.stringify({ type: 'list', users: [...users.keys()] }))
break
}
}
function registerUser(socket, msg) {
const username = (msg.username || '').trim()
if (!username) { socket.send(JSON.stringify({ type: 'error', content: 'Username vide' })); return }
if (users.has(username)) { socket.send(JSON.stringify({ type: 'error', content: 'Username pris' })); return }
socket.username = username
users.set(username, socket)
history.forEach((m) => socket.send(JSON.stringify(m)))
socket.send(JSON.stringify({ type: 'registered', content: `Bienvenue ${username} !`, users: [...users.keys()] }))
broadcast({ type: 'system', content: `${username} a rejoint le chat` }, socket)
}
function handleChat(socket, msg) {
const content = (msg.content || '').trim()
if (!content || content.length > 500) return
const message = { type: 'chat', from: socket.username, content, at: new Date().toISOString() }
history.push(message)
if (history.length > 50) history.shift()
broadcast(message)
}
function handleDM(socket, msg) {
const to = (msg.to || '').trim()
const content = (msg.content || '').trim()
if (!to || !content || to === socket.username) return
const target = users.get(to)
if (!target) { socket.send(JSON.stringify({ type: 'error', content: `${to} n'est pas connecté` })); return }
const message = { type: 'dm', from: socket.username, to, content, at: new Date().toISOString() }
target.send(JSON.stringify(message))
socket.send(JSON.stringify(message))
}
function handleJoin(socket, msg) {
const groupName = (msg.group || '').trim()
if (!groupName) return
if (!groups.has(groupName)) groups.set(groupName, new Set())
const members = groups.get(groupName)
if (members.has(socket.username)) return
members.add(socket.username)
socket.send(JSON.stringify({ type: 'joined', group: groupName, members: [...members] }))
broadcastGroup(groupName, { type: 'system', group: groupName, content: `${socket.username} a rejoint ${groupName}` }, socket.username)
}
function handleLeave(socket, msg) {
const groupName = (msg.group || '').trim()
const members = groups.get(groupName)
if (!members || !members.has(socket.username)) return
members.delete(socket.username)
if (members.size === 0) { groups.delete(groupName) }
else { broadcastGroup(groupName, { type: 'system', group: groupName, content: `${socket.username} a quitté ${groupName}` }) }
socket.send(JSON.stringify({ type: 'left', group: groupName }))
}
function 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 "${groupName}"` })); return }
broadcastGroup(groupName, { type: 'group', group: groupName, from: socket.username, content, at: new Date().toISOString() })
}
function broadcast(msg, exclude = null) {
const data = JSON.stringify(msg)
wss.clients.forEach((c) => { if (c !== exclude && c.readyState === WebSocket.OPEN) c.send(data) })
}
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 c = users.get(username)
if (c && c.readyState === WebSocket.OPEN) c.send(data)
})
}
console.log('WebSocket chat sur ws://localhost:8080')<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>WS Chat</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; font-family: monospace; }
body { display: flex; height: 100vh; background: #111; color: #eee; }
#sidebar { width: 220px; border-right: 1px solid #333; display: flex; flex-direction: column; }
#sidebar h3 { padding: 12px; font-size: 11px; text-transform: uppercase; color: #888; border-bottom: 1px solid #333; }
#users-list, #groups-list { padding: 8px; flex: 1; overflow-y: auto; }
#users-list div, #groups-list div { padding: 4px 8px; font-size: 13px; cursor: pointer; border-radius: 3px; }
#users-list div:hover, #groups-list div:hover { background: #222; }
.active-conv { background: #1a1a2e !important; color: #cdff4f; }
#main { flex: 1; display: flex; flex-direction: column; }
#conv-header { padding: 12px 16px; border-bottom: 1px solid #333; font-size: 13px; color: #888; }
#messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 6px; }
.msg { font-size: 13px; line-height: 1.5; }
.msg .ts { color: #555; margin-right: 6px; }
.msg .from { color: #cdff4f; margin-right: 6px; }
.msg.system { color: #555; font-style: italic; }
.msg.mine .from { color: #7eb8f7; }
#input-bar { display: flex; gap: 8px; padding: 12px; border-top: 1px solid #333; }
#msg-input { flex: 1; background: #1a1a1a; border: 1px solid #333; color: #eee; padding: 8px 12px; font-family: monospace; font-size: 13px; outline: none; }
#msg-input:focus { border-color: #cdff4f; }
button { background: #cdff4f; color: #111; border: none; padding: 8px 16px; cursor: pointer; font-family: monospace; font-weight: bold; }
#login { position: fixed; inset: 0; background: #111; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 16px; }
#login input { background: #1a1a1a; border: 1px solid #333; color: #eee; padding: 10px 16px; font-family: monospace; font-size: 15px; width: 260px; outline: none; }
#login input:focus { border-color: #cdff4f; }
#join-form { display: flex; gap: 8px; padding: 8px; border-top: 1px solid #333; }
#join-input { flex: 1; background: #1a1a1a; border: 1px solid #333; color: #eee; padding: 5px 8px; font-size: 12px; font-family: monospace; outline: none; }
</style>
</head>
<body>
<div id="login">
<div style="color:#cdff4f;font-size:20px;font-weight:bold;">WS Chat</div>
<input id="username-input" placeholder="Choisissez un username" maxlength="20" />
<button onclick="register()">Rejoindre</button>
</div>
<div id="sidebar">
<h3>Utilisateurs</h3>
<div id="users-list"></div>
<h3 style="margin-top:auto;">Groupes</h3>
<div id="groups-list"></div>
<div id="join-form">
<input id="join-input" placeholder="Nom du groupe..." />
<button onclick="joinGroup()" style="padding:5px 10px;font-size:12px;">+</button>
</div>
</div>
<div id="main">
<div id="conv-header">— Sélectionnez une conversation</div>
<div id="messages"></div>
<div id="input-bar">
<input id="msg-input" placeholder="Message..." onkeydown="if(event.key==='Enter')sendMessage()" />
<button onclick="sendMessage()">Envoyer</button>
</div>
</div>
<script>
let ws, myUsername
let activeConv = { type: 'global', name: 'global' }
const $ = (id) => document.getElementById(id)
function register() {
const username = $('username-input').value.trim()
if (!username) return
myUsername = username
ws = new WebSocket('ws://localhost:8080')
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'register', username }))
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
handleMsg(msg)
}
ws.onclose = () => addMessage({ type: 'system', content: 'Déconnecté du serveur' })
}
function handleMsg(msg) {
switch (msg.type) {
case 'registered':
$('login').style.display = 'none'
setConv('global', 'global')
updateUsersList(msg.users)
break
case 'chat':
if (activeConv.type === 'global') renderMessage(msg)
break
case 'dm':
const other = msg.from === myUsername ? msg.to : msg.from
if (activeConv.type === 'dm' && activeConv.name === other) renderMessage(msg)
else showNotif(`DM de ${msg.from}`)
break
case 'group':
if (activeConv.type === 'group' && activeConv.name === msg.group) renderMessage(msg)
else showNotif(`[${msg.group}] ${msg.from}`)
break
case 'joined':
updateGroupsList()
addMessage({ type: 'system', content: `Vous avez rejoint "${msg.group}"` })
break
case 'left':
updateGroupsList()
break
case 'system':
if (!msg.group || (activeConv.type === 'group' && activeConv.name === msg.group)) {
addMessage(msg)
}
if (!msg.group) updateUsersList()
break
case 'list':
updateUsersList(msg.users)
break
case 'groups':
renderGroupsList(msg.list)
break
case 'error':
addMessage({ type: 'system', content: '⚠ ' + msg.content })
break
}
}
function sendMessage() {
const content = $('msg-input').value.trim()
if (!content) return
$('msg-input').value = ''
if (activeConv.type === 'global') {
ws.send(JSON.stringify({ type: 'chat', content }))
} else if (activeConv.type === 'dm') {
ws.send(JSON.stringify({ type: 'dm', to: activeConv.name, content }))
} else if (activeConv.type === 'group') {
ws.send(JSON.stringify({ type: 'group', group: activeConv.name, content }))
}
}
function joinGroup() {
const name = $('join-input').value.trim()
if (!name) return
$('join-input').value = ''
ws.send(JSON.stringify({ type: 'join', group: name }))
setConv('group', name)
ws.send(JSON.stringify({ type: 'groups' }))
}
function setConv(type, name) {
activeConv = { type, name }
$('conv-header').textContent = type === 'global' ? '# global' : type === 'dm' ? `@ ${name}` : `# ${name}`
$('messages').innerHTML = ''
document.querySelectorAll('.active-conv').forEach(el => el.classList.remove('active-conv'))
const id = type === 'global' ? 'conv-global' : `conv-${type}-${name}`
const el = document.getElementById(id)
if (el) el.classList.add('active-conv')
}
function renderMessage(msg) {
const div = document.createElement('div')
div.className = 'msg' + (msg.from === myUsername ? ' mine' : '')
const ts = msg.at ? `<span class="ts">${msg.at.slice(11, 19)}</span>` : ''
const from = msg.from ? `<span class="from">${msg.from}</span>` : ''
div.innerHTML = `${ts}${from}${escHtml(msg.content)}`
$('messages').appendChild(div)
$('messages').scrollTop = $('messages').scrollHeight
}
function addMessage(msg) {
const div = document.createElement('div')
div.className = 'msg system'
div.textContent = msg.content
$('messages').appendChild(div)
$('messages').scrollTop = $('messages').scrollHeight
}
function showNotif(text) {
const div = document.createElement('div')
div.className = 'msg system'
div.textContent = `🔔 ${text}`
$('messages').appendChild(div)
$('messages').scrollTop = $('messages').scrollHeight
}
function updateUsersList(userList) {
if (!userList) { ws.send(JSON.stringify({ type: 'list' })); return }
const el = $('users-list')
el.innerHTML = `<div id="conv-global" onclick="setConv('global','global')" class="${activeConv.type==='global'?'active-conv':''}"># global</div>`
userList.filter(u => u !== myUsername).forEach(u => {
const d = document.createElement('div')
d.id = `conv-dm-${u}`
d.textContent = `@ ${u}`
d.onclick = () => setConv('dm', u)
if (activeConv.type === 'dm' && activeConv.name === u) d.classList.add('active-conv')
el.appendChild(d)
})
}
function updateGroupsList() {
ws.send(JSON.stringify({ type: 'groups' }))
}
function renderGroupsList(list) {
const el = $('groups-list')
el.innerHTML = ''
list.forEach(g => {
const d = document.createElement('div')
d.id = `conv-group-${g.name}`
d.textContent = `# ${g.name} (${g.count})`
d.onclick = () => setConv('group', g.name)
if (activeConv.type === 'group' && activeConv.name === g.name) d.classList.add('active-conv')
el.appendChild(d)
})
}
function escHtml(str) {
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
}
$('username-input').addEventListener('keydown', e => { if (e.key === 'Enter') register() })
</script>
</body>
</html>node server.jsOuvrir client.html dans plusieurs onglets ou navigateurs. Chaque onglet représente un utilisateur différent.
Flux typique :
register envoyé+ → crée et rejoint le groupeCes limites sont correctes pour une formation sur le protocole. En production, on ajouterait Redis pour la persistance des messages, une vraie authentification JWT, et un mécanisme de reconnexion automatique côté client.
La formation est complète. Vous avez un serveur WebSocket fonctionnel avec chat global, messages privés et groupes — ~120 lignes de serveur, zéro dépendance autre que ws.