iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
Formations
Node.js / HTML · Débutant

WebSocket

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.

Node.jswsJavaScriptHTML
01Le protocole WebSocket02Premier serveur WebSocket03Gérer les utilisateurs en mémoire04Chat global et broadcast05Messages privés06Groupes et rooms07Interface client complète
Chapitre 7·30 min

Interface client complète

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.

Le serveur final

Voici server.js complet, avec tous les handlers des chapitres précédents :

server.js
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')

Le client HTML

client.html
<!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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
  }
 
  $('username-input').addEventListener('keydown', e => { if (e.key === 'Enter') register() })
</script>
</body>
</html>

Lancer et utiliser

node server.js

Ouvrir client.html dans plusieurs onglets ou navigateurs. Chaque onglet représente un utilisateur différent.

Flux typique :

  1. Entrer un username → register envoyé
  2. Liste des utilisateurs en ligne apparaît dans la sidebar
  3. Cliquer sur un utilisateur → ouvre un DM
  4. Taper un nom de groupe et cliquer + → crée et rejoint le groupe
  5. Cliquer sur un groupe dans la sidebar → passer dans ce canal

Ce que l'interface ne fait pas (intentionnellement)

  • Pas de persistance — les messages disparaissent si le serveur redémarre
  • Pas d'authentification — n'importe qui peut prendre n'importe quel username
  • Pas de limite de membres par groupe

Ces 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.

Précédent
Groupes et rooms

Développeur fullstack passionné. J'apprends en construisant et je documente tout — front, back, outils. Le code s'apprend mieux en public.

Naviguer

IndexTous les articlesFormationsProfilOutilsBibliothech

Ailleurs

GitHub RSS

Newsletter

Les articles, libs et découvertes. Une fois par semaine, pas plus.

© 2026 William LoreeConçu & codé à la main