Aller au contenu

Exercice : mini-chat en temps réel avec les WebSockets

💬 Présentation

Un petit chat en temps réel entre plusieurs navigateurs, en local, avec PHP côté serveur et les WebSockets — une technologie différente de tout ce qui a été vu jusqu'ici dans le protocole HTTP classique.

Objectifs pédagogiques

  1. Comprendre la différence entre le modèle requête/réponse de HTTP (une page PHP classique) et une connexion permanente maintenue ouverte
  2. Découvrir le principe de diffusion (broadcast) : un message envoyé par un client est renvoyé à tous les autres
  3. Installer et utiliser une bibliothèque externe via Composer — première rencontre avec un gestionnaire de dépendances PHP
  4. Retrouver, côté JavaScript cette fois, le réflexe déjà vu côté PHP : ne jamais faire confiance à une entrée utilisateur

Exercice local uniquement

Ce mini-chat n'est pas destiné à être déployé sur le site public papinou.org : il nécessite un processus PHP qui tourne en permanence en arrière-plan (php server.php), ce qu'un hébergement web mutualisé classique ne permet pas. C'est un exercice à réaliser en local, en salle, sur les postes des élèves.

🗂️ Fichiers à créer

Fichier Rôle
composer.json Déclare la dépendance vers la bibliothèque Ratchet
server.php Le serveur WebSocket : accepte les connexions et diffuse les messages
chat.html La page cliente : formulaire de pseudo, zone de discussion

⚙️ Prérequis

Environnement nécessaire

  • PHP 7.4+ avec l'extension sockets activée
  • Composer installé (composer --version pour vérifier)
  • Un navigateur récent (Chrome, Firefox…) — aucune extension nécessaire côté client, l'API WebSocket est native en JavaScript

🚀 Installation pas à pas

1. Installer la bibliothèque Ratchet

Dans le dossier contenant composer.json :

composer install

Cette commande télécharge Ratchet (et ses propres dépendances) dans un dossier vendor/ créé automatiquement.

2. Démarrer le serveur

php server.php

Une fenêtre de terminal dédiée

Contrairement à index.php dans les exercices précédents, ce script ne se termine jamais tout seul : il tourne en boucle, en écoutant le port 8080. Gardez ce terminal ouvert pendant toute la durée du chat ; Ctrl+C pour l'arrêter.

3. Ouvrir le chat

chat.html doit être ouvert via Apache — pas en double-cliquant dessus — car son adresse WebSocket s'ajuste automatiquement à celle utilisée pour charger la page (voir plus bas, section HTTP classique vs WebSocket).

  • Pour tester seul, sur une machine : ouvrir http://localhost/minichat/chat.html dans deux ou trois onglets différents, avec un pseudo différent à chaque fois.
  • Pour tester en classe, sur plusieurs postes du réseau local : depuis chaque poste, ouvrir http://<IP-du-serveur>/minichat/chat.html (l'adresse IP de la machine qui fait tourner Apache et php server.php, trouvable avec hostname -I sous Linux) — pas http://localhost/..., qui pointerait vers le mauvais serveur sur chaque poste.

Dans les deux cas, les messages tapés d'un côté doivent apparaître instantanément partout ailleurs.


🔌 HTTP classique vs WebSocket

Page PHP classique (index.php) Mini-chat (WebSocket)
Connexion Une nouvelle connexion à chaque clic/rechargement Une seule connexion, ouverte et maintenue
Qui parle en premier Toujours le navigateur (requête) Le serveur peut envoyer sans qu'on lui demande
Le serveur tourne Sous Apache, à la demande En permanence, en ligne de commande
Analogie Envoyer une lettre et attendre la réponse Un appel téléphonique resté décroché

Ce qui se passe techniquement à la connexion

Le navigateur commence par une requête HTTP presque normale, avec un en-tête spécial (Upgrade: websocket). Le serveur répond en acceptant de « faire évoluer » cette connexion HTTP en connexion WebSocket. Une fois cette poignée de main (handshake) terminée, les deux côtés peuvent s'envoyer des messages à tout moment, dans les deux sens, sans jamais rouvrir de connexion. Ratchet se charge de tout ce mécanisme : c'est ce que fait WsServer dans server.php.


🧩 Comment le message circule

Client A tape "Salut" et clique sur Envoyer
socket.send(JSON.stringify({pseudo, message}))   (chat.html)
onMessage() reçoit le message côté serveur           (server.php)
Le serveur reconstruit un paquet propre (pseudo, message, heure)
foreach ($this->clients as $client) { $client->send($paquet); }
Tous les clients (A, B, C…) reçoivent l'événement "message"
afficherMessage() l'ajoute à la page                  (chat.html)

🔐 Sécurité

  • Le serveur ne fait jamais confiance aux données reçues : onMessage() vérifie que pseudo et message existent et ne sont pas vides avant de les traiter
  • Longueurs limitées côté serveur (mb_substr) : ne pas se fier uniquement au maxlength du champ HTML, qui ne protège que l'interface, pas ce qu'un client mal intentionné pourrait envoyer directement au serveur
  • Le serveur reconstruit lui-même le paquet diffusé plutôt que de retransmettre tel quel ce qu'il a reçu — un réflexe déjà vu avec les requêtes préparées PDO : ne jamais renvoyer une donnée brute non contrôlée
  • textContent plutôt que innerHTML côté JavaScript pour afficher les messages : c'est l'équivalent exact du htmlspecialchars() utilisé côté PHP dans les exercices précédents. Sans cela, un pseudo comme <script>...</script> s'exécuterait chez tous les autres participants

À montrer en classe

Remplacer temporairement contenu.textContent = donnees.message par contenu.innerHTML = donnees.message dans chat.html, puis envoyer un message contenant une balise <img src=x onerror="alert(1)">. Un bon moyen de rendre concret, après l'avoir vu côté PHP, ce que signifie une faille XSS — à annuler juste après la démonstration !


🎓 Concepts pédagogiques abordés

  1. Connexion persistante vs requête/réponse HTTP
  2. Diffusion (broadcast) à plusieurs clients simultanés
  3. SplObjectStorage : une structure PHP adaptée pour stocker un ensemble d'objets (ici, les connexions) sans doublons
  4. Composer : premier contact avec un gestionnaire de dépendances et l'autoload (vendor/autoload.php)
  5. JSON comme format d'échange entre client et serveur, déjà familier si vos élèves ont manipulé des API
  6. XSS côté JavaScript, symétrique de ce qui a été vu côté PHP avec htmlspecialchars()

🐛 Dépannage

« Erreur de connexion » dans le statut du chat

Vérifier que php server.php tourne toujours dans son terminal — s'il a été fermé ou a planté, aucun onglet ne peut se connecter.

composer install échoue

Vérifier que Composer est bien installé (composer --version) et que le fichier composer.json est présent dans le dossier courant.

Le port 8080 est déjà utilisé

Un autre exercice (façon tableur, servi par php -S localhost:8080) utilise le même port. Arrêter l'autre serveur, ou changer le port dans server.php (le 8080 dans IoServer::factory(..., 8080)) et dans chat.html (ws:// + window.location.hostname + :8080).

Ça fonctionne entre plusieurs onglets, mais pas entre plusieurs machines du réseau local

chat.html utilise window.location.hostname pour deviner l'adresse du serveur : il faut donc que les élèves ouvrent la page via son adresse réseau (http://192.168.x.x/minichat/chat.html), pas via http://localhost/..., sans quoi chaque machine cherche son propre serveur local. Vérifier aussi que le pare-feu de la machine qui fait tourner php server.php autorise les connexions entrantes sur le port 8080 depuis le réseau local — sous Linux, par exemple : sudo ufw allow 8080/tcp.


🔧 Pistes d'extension

  • Afficher la liste des pseudos actuellement connectés
  • Ajouter une notification « X a rejoint le chat » / « X a quitté »
  • Limiter le nombre de connexions simultanées
  • Enregistrer l'historique des messages dans une table MariaDB (retour au PDO des exercices précédents !)
  • (Plus avancé) Créer des salons (rooms) séparés

💻 Code source complet

Copiez chacun des blocs ci-dessous dans un fichier du même nom, dans le même dossier.

1
2
3
4
5
6
7
8
{
    "name": "papinou/mini-chat-websocket",
    "description": "Exercice pédagogique : mini-chat en temps réel avec PHP et WebSockets",
    "require": {
        "php": ">=7.4",
        "cboden/ratchet": "^0.4.4"
    }
}
<?php
/**
 * ============================================================
 *  server.php — Serveur du mini-chat WebSocket
 * ============================================================
 *
 *  Ce script se lance en ligne de commande et tourne en tâche
 *  de fond tant que le chat est utilisé :
 *
 *      php server.php
 *
 *  Contrairement à index.php dans les exercices précédents, ce
 *  fichier n'est PAS servi par Apache : il n'y a pas de requête
 *  HTTP classique, mais une connexion permanente, maintenue
 *  ouverte, entre chaque client et ce serveur.
 * ============================================================
 */

require __DIR__ . '/vendor/autoload.php';

use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;

class MiniChat implements MessageComponentInterface
{
    /** @var \SplObjectStorage Ensemble des connexions actuellement ouvertes */
    protected $clients;

    public function __construct()
    {
        $this->clients = new \SplObjectStorage();
    }

    /**
     * Appelée automatiquement quand un navigateur ouvre une connexion.
     */
    public function onOpen(ConnectionInterface $conn)
    {
        $this->clients->attach($conn);
        echo "Nouvelle connexion ! ({$conn->resourceId})\n";
    }

    /**
     * Appelée automatiquement à chaque message envoyé par un client.
     * C'est ici que se fait la diffusion (broadcast) à tout le monde.
     */
    public function onMessage(ConnectionInterface $from, $msg)
    {
        $donnees = json_decode($msg, true);

        // On ignore tout message mal formé, vide, ou sans pseudo :
        // ne jamais faire confiance à ce qui vient du client.
        if (!isset($donnees['pseudo'], $donnees['message'])
            || trim($donnees['pseudo']) === ''
            || trim($donnees['message']) === ''
        ) {
            return;
        }

        // On reconstruit nous-mêmes le paquet envoyé, en limitant les
        // longueurs : le serveur ne réexpédie jamais tel quel ce qu'il
        // reçoit sans un minimum de contrôle.
        $paquet = json_encode([
            'pseudo'  => mb_substr(trim($donnees['pseudo']), 0, 20),
            'message' => mb_substr(trim($donnees['message']), 0, 500),
            'heure'   => date('H:i:s'),
        ]);

        // Diffusion à TOUS les clients connectés, y compris l'expéditeur :
        // c'est ce qui permet à chacun de voir apparaître son propre message
        // au même endroit que les autres, dans l'ordre d'arrivée au serveur.
        foreach ($this->clients as $client) {
            $client->send($paquet);
        }

        echo "Message de {$donnees['pseudo']} diffusé à "
            . count($this->clients) . " client(s)\n";
    }

    /**
     * Appelée automatiquement quand un client ferme l'onglet ou se déconnecte.
     */
    public function onClose(ConnectionInterface $conn)
    {
        $this->clients->detach($conn);
        echo "Connexion {$conn->resourceId} fermée\n";
    }

    /**
     * Appelée automatiquement en cas d'erreur sur une connexion.
     */
    public function onError(ConnectionInterface $conn, \Exception $e)
    {
        echo "Erreur : {$e->getMessage()}\n";
        $conn->close();
    }
}

// ── Démarrage du serveur sur le port 8080 ──────────────────────
$server = IoServer::factory(
    new HttpServer(
        new WsServer(
            new MiniChat()
        )
    ),
    8080
);

echo "Serveur mini-chat démarré sur ws://localhost:8080\n";
echo "(laisser cette fenêtre ouverte — Ctrl+C pour arrêter)\n\n";

$server->run();
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mini-chat WebSocket</title>
<style>
    * { box-sizing: border-box; }
    body {
        font-family: "Segoe UI", Arial, sans-serif;
        max-width: 600px;
        margin: 2rem auto;
        padding: 0 1rem;
        color: #222;
    }
    h1 { font-size: 1.3rem; margin-bottom: 1rem; }

    #ecran-pseudo { display: flex; gap: .6rem; }
    #ecran-pseudo input {
        flex: 1;
        padding: .5rem .7rem;
        border: 1px solid #ccc;
        border-radius: 4px;
        font-size: 1rem;
    }

    #messages {
        height: 320px;
        overflow-y: auto;
        border: 1px solid #ddd;
        border-radius: 8px;
        padding: .8rem;
        background: #fafafa;
        margin-bottom: .8rem;
    }
    .message { margin-bottom: .4rem; font-size: .92rem; }
    .message .heure { color: #999; font-size: .78rem; }
    .message strong { color: #2c6e49; }

    #formulaire-message { display: flex; gap: .6rem; }
    #formulaire-message input {
        flex: 1;
        padding: .5rem .7rem;
        border: 1px solid #ccc;
        border-radius: 4px;
        font-size: 1rem;
    }
    button {
        background: #2c6e49;
        color: white;
        border: none;
        padding: .5rem 1.1rem;
        border-radius: 4px;
        cursor: pointer;
        font-size: .95rem;
    }
    button:hover { background: #1f4f34; }

    #statut { font-size: .82rem; color: #777; margin-top: .5rem; }
</style>
</head>
<body>

<!-- Écran 1 : choix du pseudo, avant d'ouvrir la connexion WebSocket -->
<h1>💬 Mini-chat</h1>
<div id="ecran-pseudo">
    <input type="text" id="champ-pseudo" placeholder="Votre pseudo" maxlength="20">
    <button id="bouton-entrer">Entrer dans le chat</button>
</div>

<!-- Écran 2 : le chat lui-même, affiché une fois le pseudo choisi -->
<div id="ecran-chat" style="display:none;">
    <h1>💬 Mini-chat — connecté en tant que <span id="pseudo-affiche"></span></h1>
    <div id="messages"></div>
    <form id="formulaire-message">
        <input type="text" id="champ-message" placeholder="Votre message" maxlength="500" autocomplete="off">
        <button type="submit">Envoyer</button>
    </form>
    <p id="statut">🔴 Non connecté</p>
</div>

<script>
    let pseudo = '';
    let socket = null;

    document.getElementById('bouton-entrer').addEventListener('click', demarrerChat);
    document.getElementById('champ-pseudo').addEventListener('keydown', (e) => {
        if (e.key === 'Enter') demarrerChat();
    });

    function demarrerChat() {
        const champ = document.getElementById('champ-pseudo');
        pseudo = champ.value.trim();
        if (!pseudo) {
            alert('Merci de saisir un pseudo.');
            return;
        }

        document.getElementById('ecran-pseudo').style.display = 'none';
        document.getElementById('ecran-chat').style.display = 'block';
        document.getElementById('pseudo-affiche').textContent = pseudo;

        connecter();
    }

    function connecter() {
        // On utilise l'adresse depuis laquelle la page a été chargée
        // (window.location.hostname) plutôt que "localhost" en dur :
        // ainsi, la même page fonctionne aussi bien ouverte sur la
        // machine du serveur que depuis un autre poste du réseau local.
        socket = new WebSocket('ws://' + window.location.hostname + ':8080');

        socket.addEventListener('open', () => {
            document.getElementById('statut').textContent = '🟢 Connecté';
        });

        socket.addEventListener('message', (evenement) => {
            const donnees = JSON.parse(evenement.data);
            afficherMessage(donnees);
        });

        socket.addEventListener('close', () => {
            document.getElementById('statut').textContent = '🔴 Déconnecté';
        });

        socket.addEventListener('error', () => {
            document.getElementById('statut').textContent =
                '⚠️ Erreur de connexion — le serveur est-il démarré ? (php server.php)';
        });
    }

    document.getElementById('formulaire-message').addEventListener('submit', (e) => {
        e.preventDefault();
        const champ = document.getElementById('champ-message');
        const texte = champ.value.trim();
        if (!texte || !socket || socket.readyState !== WebSocket.OPEN) return;

        socket.send(JSON.stringify({ pseudo: pseudo, message: texte }));
        champ.value = '';
    });

    function afficherMessage(donnees) {
        const zone = document.getElementById('messages');
        const ligne = document.createElement('div');
        ligne.classList.add('message');

        // On construit la ligne avec des nœuds texte (textContent), jamais
        // avec innerHTML : c'est l'équivalent, côté JavaScript, du
        // htmlspecialchars() utilisé côté PHP dans les exercices précédents.
        // Sans cela, un pseudo contenant du code HTML/JavaScript s'exécuterait
        // chez tous les autres participants du chat.
        const heure = document.createElement('span');
        heure.classList.add('heure');
        heure.textContent = '(' + donnees.heure + ') ';

        const auteur = document.createElement('strong');
        auteur.textContent = donnees.pseudo + ' ';

        const contenu = document.createElement('span');
        contenu.textContent = donnees.message;

        ligne.appendChild(heure);
        ligne.appendChild(auteur);
        ligne.appendChild(contenu);
        zone.appendChild(ligne);
        zone.scrollTop = zone.scrollHeight;
    }
</script>
</body>
</html>