WebSockets avec Socket.IO (React + Node)

Ce cours fait suite aux cours React et Node. On va créer une communication temps‑réel entre un client React et un serveur Node en utilisant Socket.IO.

Objectifs

  • Comprendre WebSocket vs HTTP et pourquoi Socket.IO
  • Mettre en place Socket.IO côté Node (serveur)
  • Connecter React via socket.io-client
  • Utiliser emit / on, broadcast, acknowledgements
  • Créer une chatroom (join/leave, envoyer/recevoir)

Prérequis

  • Backend Node (vous pouvez réutiliser votre projet Hono/Node du cours précédent)
  • Frontend React (Vite + React comme dans les cours React)

Installation

Backend (Node):

		npm i socket.io
	

Frontend (React):

		npm i socket.io-client
	

Serveur Node (Hono): initialisation et connexion

On part d’une app Hono et on y branche Socket.IO, en JavaScript uniquement. Référence: With Hono (Node.js) et discussion: #1781. Intégrez ceci dans votre fichier existant src/index.js.

src/index.js
		import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { cors } from 'hono/cors';
import { Server } from 'socket.io';
 
const app = new Hono();
 
// CORS HTTP pour vos routes REST
app.use(
	'*',
	cors({
		origin: 'http://localhost:5173',
		credentials: true,
		allowMethods: ['GET', 'POST', 'PUT', 'OPTIONS']
	})
);
 
app.get('/', (c) => c.text('OK'));
 
const httpServer = serve({ fetch: app.fetch, port: 3000 }, (info) => {
	console.log(`Server is running: http://${info.address}:${info.port}`);
});
 
// Socket.IO attaché au serveur Hono
const io = new Server(httpServer, {
	cors: {
		origin: 'http://localhost:5173',
		methods: ['GET', 'POST'],
		credentials: true
	}
	// path: '/ws' // optionnel
});
 
io.on('connection', (socket) => {
	console.log('client connecté:', socket.id);
 
	socket.on('disconnect', (reason) => {
		console.log('client déconnecté:', socket.id, reason);
	});
});
	

Client React: se connecter au serveur

Créez un client Socket.IO partagé. Cela crée une connexion persistante entre votre navigateur et le serveur.

src/lib/socket.js
		import { io } from 'socket.io-client';
 
export const socket = io('http://localhost:3000', {
	withCredentials: true,
	autoConnect: true
});
	

Explications rapides:

  • io(url, options) crée un objet socket qui maintient une connexion ouverte avec le serveur.
  • withCredentials: true autorise l’envoi de cookies/credentials pour l'authentification (cookies)
  • autoConnect: true connecte automatiquement la socket à l'initialisation.

Dans votre composant (ex: App.jsx), on va écouter des évènements du socket et les nettoyer correctement:

  • Un évènement est un message nommé, par exemple connect, disconnect, ou pong.
  • Un listener (écouteur) est une fonction qu’on attache à un évènement. Quand l’évènement arrive, la fonction s’exécute.
  • En React, on enregistre ces écouteurs dans useEffect et on les désinscrit dans la fonction de nettoyage (cleanup) retournée par useEffect. Ça évite d’ajouter des doublons à chaque re‑rendu et rend une meilleur perf
src/App.jsx
		import { useEffect, useState } from 'react';
import { socket } from './lib/socket';
 
export default function App() {
	const [connected, setConnected] = useState(socket.connected);
 
	useEffect(() => {
		function onConnect() {
			setConnected(true);
		}
		function onDisconnect() {
			setConnected(false);
		}
 
		socket.on('connect', onConnect);
		socket.on('disconnect', onDisconnect);
		return () => {
			socket.off('connect', onConnect);
			socket.off('disconnect', onDisconnect);
		};
	}, []);
 
	return <div>Socket: {connected ? 'connecté' : 'déconnecté'}</div>;
}
	

emit / on: échanger des événements

Commençons par un ping/pong. L’objectif est de vérifier que la connexion temps‑réel fonctionne et de comprendre le trajet d’un message:

  1. Le client envoie un évènement ping avec socket.emit('ping', payload).
  2. Le serveur écoute (io.on('connection'...) puis socket.on('ping', ...)) et répond à ce même client via socket.emit('pong', ...).
  3. Le serveur peut aussi prévenir les autres clients avec socket.broadcast.emit('someone:pinged', ...) (broadcast = à tout le monde sauf l’émetteur).
  4. Le client écoute pong avec socket.on('pong', handler) et met l’UI à jour. C’est un pattern simple pour tester la latence et le cycle aller‑retour.
src/index.js (serveur)
		io.on('connection', (socket) => {
	socket.on('ping', (payload) => {
		// Répondre seulement à l’émetteur
		socket.emit('pong', { echoed: payload, at: Date.now() });
 
		// Prévenir les autres clients (broadcast)
		socket.broadcast.emit('someone:pinged', { from: socket.id });
	});
});
	
src/App.jsx (client)
		function PingButton() {
	const [lastPong, setLastPong] = useState(null);
 
	useEffect(() => {
		function onPong(data) {
			setLastPong(data);
		}
		socket.on('pong', onPong);
		return () => socket.off('pong', onPong);
	}, []);
 
	return (
		<div>
			<button onClick={() => socket.emit('ping', { t: Date.now() })}>Ping</button>
			<pre>{JSON.stringify(lastPong, null, 2)}</pre>
		</div>
	);
}
	

Chatroom: join/leave + envoyer/recevoir

Nous allons utiliser les "rooms" pour isoler les conversations.

Idée clé:

  • Une room est comme un salon privé. Joindre une room (socket.join(roomId)) inscrit votre socket dans ce salon.
  • Envoyer un message à une room (io.to(roomId).emit(...)) diffuse uniquement aux membres de ce salon.
  • socket.to(roomId).emit(...) envoie aux autres membres de la room (sans renvoyer à l’émetteur).
  • Flux typique: le client émet room:join → le serveur appelle socket.join(roomId) → tous les membres reçoivent room:user:joined.
src/index.js (serveur)
		io.on('connection', (socket) => {
	socket.on('room:join', (roomId) => {
		socket.join(roomId);
		socket.to(roomId).emit('room:user:joined', { userId: socket.id });
	});
 
	socket.on('room:leave', (roomId) => {
		socket.leave(roomId);
		socket.to(roomId).emit('room:user:left', { userId: socket.id });
	});
 
	socket.on('room:message', ({ roomId, text, username }) => {
		const message = {
			id: `${socket.id}-${Date.now()}`,
			text,
			username,
			sentAt: Date.now()
		};
		// Envoyer à tous les membres de la room
		io.to(roomId).emit('room:message', message);
	});
});
	
src/components/Chat.jsx (client)
		import { useEffect, useState } from 'react';
import { socket } from '../lib/socket';
 
export function Chat({ roomId, username }) {
	const [messages, setMessages] = useState([]);
	const [input, setInput] = useState('');
 
	useEffect(() => {
		socket.emit('room:join', roomId);
		return () => socket.emit('room:leave', roomId);
	}, [roomId]);
 
	useEffect(() => {
		function onMessage(message) {
			setMessages((m) => [...m, message]);
		}
		socket.on('room:message', onMessage);
		return () => socket.off('room:message', onMessage);
	}, []);
 
	function sendMessage(e) {
		e.preventDefault();
		if (!input.trim()) return;
		socket.emit('room:message', { roomId, text: input, username });
		setInput('');
	}
 
	return (
		<div>
			<ul>
				{messages.map((m) => (
					<li key={m.id}>
						<strong>{m.username}</strong>: {m.text}
					</li>
				))}
			</ul>
			<form onSubmit={sendMessage}>
				<input
					value={input}
					onChange={(e) => setInput(e.target.value)}
					placeholder="Votre message"
				/>
				<button type="submit">Envoyer</button>
			</form>
		</div>
	);
}
	

Points d’attention

  • CORS: gardez origin, credentials en phase avec votre front.
  • Nettoyage: socket.off dans les useEffect pour éviter les doubles listeners.
  • Reconnexion: Socket.IO gère la reconnexion auto, mais pensez à ré‑adhérer aux rooms au besoin.
  • Sécurité: rate limiting + auth (Better Auth) + validation d’inputs côté serveur.

Bonus

  1. Persister les messages (Drizzle + SQLite) et renvoyer l’historique à la connexion
  2. Acks pour confirmer la persistance avant d’afficher le message côté client
  3. Authentifier le socket et limiter l’accès aux rooms (Better Auth)

Références