WebSockets et Server-Sent Events#

HTTP est fondamentalement un protocole requête-réponse : le client initie toujours la communication, le serveur répond, et la connexion peut être fermée. Ce modèle convient parfaitement aux pages web statiques ou aux API REST, mais se révèle inadapté pour les applications nécessitant du temps réel : messagerie instantanée, tableaux de bord en direct, jeux en ligne, flux de cotations boursières, notifications push.

Ce chapitre présente les deux approches majeures pour dépasser la barrière requête-réponse de HTTP : les WebSockets pour la communication bidirectionnelle plein-duplex, et les Server-Sent Events (SSE) pour les flux unidirectionnels serveur-vers-client. Nous commençons par analyser les stratégies historiques (polling, long polling) et leurs limites.

Hide code cell source

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import matplotlib.lines as mlines
import seaborn as sns
import pandas as pd

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
np.random.seed(42)

Les limites de HTTP pour le temps réel#

Polling classique#

La stratégie la plus simple pour simuler du temps réel avec HTTP est le polling : le client envoie périodiquement des requêtes pour vérifier si de nouvelles données sont disponibles.

Client → Serveur : GET /nouvelles-donnees (t=0s)
Serveur → Client : 200 OK, rien de nouveau
Client → Serveur : GET /nouvelles-donnees (t=5s)
Serveur → Client : 200 OK, rien de nouveau
Client → Serveur : GET /nouvelles-donnees (t=10s)
Serveur → Client : 200 OK, nouvelle donnée ! {"valeur": 42}

Inconvénients majeurs :

  • Latence : la donnée ne peut être reçue qu’au prochain cycle de polling. Avec un intervalle de 5s, la latence moyenne est de 2,5s.

  • Charge serveur : des milliers de clients qui interrogent toutes les secondes génèrent un trafic énorme, même quand il n’y a rien de nouveau.

  • Gaspillage réseau : la majorité des réponses sont vides ou indiquent « rien de nouveau ».

Long polling#

Le long polling (ou Comet) est une amélioration : le serveur maintient la connexion ouverte jusqu’à ce qu’une nouvelle donnée soit disponible ou qu’un délai expire.

Client → Serveur : GET /evenements (maintient connexion ouverte)
... (le serveur attend qu'un événement se produise) ...
Serveur → Client : 200 OK {"message": "nouveau message"} (après 3s d'attente)
Client → Serveur : GET /evenements (immédiatement rouvert)

Avantages : latence réduite, moins de requêtes inutiles. Inconvénients :

  • Le serveur doit maintenir de nombreuses connexions HTTP en attente, consommant des ressources (threads, descripteurs de fichiers).

  • La gestion du timeout, de la reconnexion, et des erreurs est complexe.

  • HTTP/1.1 est conçu pour des connexions courtes ; les serveurs HTTP classiques (Apache prefork) allouent un thread par connexion.

Hide code cell source

# Comparaison visuelle : polling, long polling, WebSocket, SSE
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
fig.suptitle("Stratégies de communication temps réel avec HTTP", fontsize=14, fontweight="bold")

def dessiner_diagramme(ax, titre, messages, couleur_client="#2196F3", couleur_serveur="#F44336"):
    ax.set_xlim(0, 10)
    ax.set_ylim(-0.5, len(messages) + 0.5)
    ax.set_title(titre, fontsize=11, fontweight="bold")
    ax.axis("off")

    ax.axvline(x=2, color=couleur_client, linewidth=2, ymin=0, ymax=1)
    ax.axvline(x=8, color=couleur_serveur, linewidth=2, ymin=0, ymax=1)
    ax.text(2, len(messages) + 0.3, "Client", ha="center", fontsize=10,
            fontweight="bold", color=couleur_client)
    ax.text(8, len(messages) + 0.3, "Serveur", ha="center", fontsize=10,
            fontweight="bold", color=couleur_serveur)

    for i, (direction, label, color, style) in enumerate(messages):
        y = len(messages) - 1 - i
        if direction == "→":
            x1, x2 = 2.1, 7.9
        else:
            x1, x2 = 7.9, 2.1
        ax.annotate("", xy=(x2, y), xytext=(x1, y),
                    arrowprops=dict(arrowstyle="->", color=color, lw=1.8,
                                   linestyle=style if style != "solid" else "-"))
        mid_x = 5
        ax.text(mid_x, y + 0.15, label, ha="center", fontsize=7.5, color=color)

# Polling
msgs_polling = [
    ("→", "GET /data (t=0s)", "#FF9800", "solid"),
    ("←", "204 No Content", "#9E9E9E", "solid"),
    ("→", "GET /data (t=1s)", "#FF9800", "solid"),
    ("←", "204 No Content", "#9E9E9E", "solid"),
    ("→", "GET /data (t=2s)", "#FF9800", "solid"),
    ("←", "204 No Content", "#9E9E9E", "solid"),
    ("→", "GET /data (t=3s)", "#FF9800", "solid"),
    ("←", "200 OK {\"msg\": \"hello\"}", "#4CAF50", "solid"),
    ("→", "GET /data (t=4s)", "#FF9800", "solid"),
    ("←", "204 No Content", "#9E9E9E", "solid"),
]
dessiner_diagramme(axes[0][0], "Polling (requêtes périodiques)", msgs_polling)

# Long Polling
msgs_longpoll = [
    ("→", "GET /data (connexion maintenue)", "#FF9800", "solid"),
    ("←", "... attente (3s) ...", "#9E9E9E", "dashed"),
    ("←", "200 OK {\"msg\": \"hello\"}", "#4CAF50", "solid"),
    ("→", "GET /data (reconnexion immédiate)", "#FF9800", "solid"),
    ("←", "... attente (2s) ...", "#9E9E9E", "dashed"),
    ("←", "200 OK {\"msg\": \"world\"}", "#4CAF50", "solid"),
    ("→", "GET /data (reconnexion immédiate)", "#FF9800", "solid"),
    ("←", "... attente ...", "#9E9E9E", "dashed"),
]
dessiner_diagramme(axes[0][1], "Long Polling (connexion maintenue)", msgs_longpoll)

# WebSocket
msgs_ws = [
    ("→", "GET /ws (Upgrade: websocket)", "#4CAF50", "solid"),
    ("←", "101 Switching Protocols", "#4CAF50", "solid"),
    ("→", "Frame: PING", "#2196F3", "solid"),
    ("←", "Frame: PONG", "#2196F3", "solid"),
    ("←", "Frame: TEXT {\"msg\": \"hello\"}", "#4CAF50", "solid"),
    ("→", "Frame: TEXT {\"ack\": true}", "#2196F3", "solid"),
    ("←", "Frame: TEXT {\"msg\": \"world\"}", "#4CAF50", "solid"),
    ("→", "Frame: CLOSE", "#F44336", "solid"),
    ("←", "Frame: CLOSE", "#F44336", "solid"),
]
dessiner_diagramme(axes[1][0], "WebSocket (full-duplex bidirectionnel)", msgs_ws)

# SSE
msgs_sse = [
    ("→", "GET /events (Accept: text/event-stream)", "#9C27B0", "solid"),
    ("←", "200 OK (Content-Type: text/event-stream)", "#9C27B0", "solid"),
    ("←", "data: {\"msg\": \"hello\"}\\n\\n", "#4CAF50", "solid"),
    ("←", "data: {\"msg\": \"world\"}\\n\\n", "#4CAF50", "solid"),
    ("←", "id: 42\\ndata: {\"value\": 99}\\n\\n", "#4CAF50", "solid"),
    ("←", ": ping (commentaire keepalive)", "#9E9E9E", "dashed"),
    ("←", "data: {\"msg\": \"fin\"}\\n\\n", "#4CAF50", "solid"),
]
dessiner_diagramme(axes[1][1], "Server-Sent Events (flux serveur → client)", msgs_sse)

plt.tight_layout()
plt.show()
_images/fe91d18b51c582ae45d41425453b270599f1e593dba5110d7a94f3af030df1d4.png

WebSocket : protocole et handshake#

Le handshake HTTP Upgrade#

WebSocket (RFC 6455) démarre par un handshake HTTP ordinaire, puis upgraide la connexion vers le protocole WebSocket. L’avantage est que WebSocket peut passer à travers les proxys HTTP et les pare-feux qui autorisent HTTP/HTTPS sur les ports 80/443.

Requête du client :

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Extensions: permessage-deflate

Réponse du serveur :

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

Le code de statut 101 Switching Protocols signale que la connexion HTTP est désormais une connexion WebSocket plein-duplex.

Calcul du Sec-WebSocket-Accept#

La validation cryptographique du handshake utilise SHA-1 :

Sec-WebSocket-Accept = Base64(SHA1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))

La valeur 258EAFA5-... est un GUID fixé dans la RFC. Ce mécanisme évite qu’un serveur HTTP classique accepte accidentellement une connexion WebSocket.

Hide code cell source

import hashlib
import base64

def calculer_ws_accept(key: str) -> str:
    """Calcule le Sec-WebSocket-Accept à partir du Sec-WebSocket-Key."""
    GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
    concatene = key + GUID
    sha1 = hashlib.sha1(concatene.encode("utf-8")).digest()
    return base64.b64encode(sha1).decode("utf-8")

# Exemple de la RFC 6455
key_exemple = "dGhlIHNhbXBsZSBub25jZQ=="
accept = calculer_ws_accept(key_exemple)
print(f"Sec-WebSocket-Key  : {key_exemple}")
print(f"Sec-WebSocket-Accept : {accept}")
print(f"RFC 6455 attendu   : s3pPLMBiTxaQ9kYGzzhZRbK+xOo=")
print(f"Correspondance     : {accept == 's3pPLMBiTxaQ9kYGzzhZRbK+xOo='}")
Sec-WebSocket-Key  : dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Accept : s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
RFC 6455 attendu   : s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Correspondance     : True

Format des frames WebSocket#

Après le handshake, les données sont échangées sous forme de frames WebSocket. Le format binaire d’une frame est :

 0               1               2               3
 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)    |             (16/64)           |
|N|V|V|V|       |S|             |                               |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+-------------------------------+
|     Extended payload length continued, if payload len == 127  |
+--------------------------------+-------------------------------+
|          Masking-key (si MASK=1, 4 octets)                    |
+--------------------------------+-------------------------------+
|          Payload Data                                         |
+---------------------------------------------------------------+

Champs principaux :

  • FIN : 1 si c’est le dernier fragment

  • Opcode : type de frame (voir tableau)

  • MASK : 1 si les données sont masquées (obligatoire pour client→serveur)

  • Payload length : longueur des données

Opcodes principaux :

Opcode

Nom

Description

0x0

Continuation

Fragment de message

0x1

Text

Texte UTF-8

0x2

Binary

Données binaires

0x8

Close

Fermeture de connexion

0x9

Ping

Keepalive (ping)

0xA

Pong

Réponse au ping

Hide code cell source

# Visualisation : format binaire d'une frame WebSocket
fig, ax = plt.subplots(figsize=(13, 4))
ax.set_xlim(0, 32)
ax.set_ylim(-1, 5)
ax.axis("off")
ax.set_title("Format d'une frame WebSocket (message texte court)", fontsize=12, fontweight="bold")

# Champs de la frame
champs = [
    # (x_start, largeur, label, valeur, couleur)
    (0,  1, "FIN\n(1b)", "1", "#4CAF50"),
    (1,  3, "RSV\n(3b)", "000", "#9E9E9E"),
    (4,  4, "Opcode\n(4b)", "0001\n(Text)", "#2196F3"),
    (8,  1, "MASK\n(1b)", "1\n(client)", "#FF9800"),
    (9,  7, "Payload\nlen (7b)", "0000101\n(=5)", "#9C27B0"),
    (16, 16, "Masking Key (32 bits = 4 octets)", "0x37FA213D", "#F44336"),
]

for x, w, label, valeur, color in champs:
    rect = mpatches.FancyBboxPatch((x, 2.2), w - 0.1, 1.5,
                                    boxstyle="round,pad=0.05",
                                    facecolor=color, edgecolor="white",
                                    alpha=0.85, linewidth=2)
    ax.add_patch(rect)
    ax.text(x + w/2, 3.5, label, ha="center", va="center",
            fontsize=7, color="white", fontweight="bold")
    ax.text(x + w/2, 2.6, valeur, ha="center", va="center",
            fontsize=7, color="white")

# Payload masqué
rect_payload = mpatches.FancyBboxPatch((0, 0.3), 32 - 0.1, 1.5,
                                        boxstyle="round,pad=0.05",
                                        facecolor="#607D8B", edgecolor="white",
                                        alpha=0.85, linewidth=2)
ax.add_patch(rect_payload)
ax.text(16, 1.05, "Payload Data (5 octets, masqués XOR avec Masking Key)\n\"Hello\" → 0x7f 0x9f 0x4d 0x51 0x58",
        ha="center", va="center", fontsize=9, color="white")

ax.text(16, -0.5, "Taille totale de la frame : 2 (header) + 4 (masque) + 5 (payload) = 11 octets",
        ha="center", fontsize=9, color="#37474F", style="italic")

plt.tight_layout()
plt.show()
_images/5b27bb215f15507b6146228d24394f977d2a04addf0d9b080e916b7f178cc51d.png

Masquage des frames client→serveur#

Le standard RFC 6455 impose que toutes les frames envoyées par le client soient masquées avec un masque de 4 octets aléatoires. Ce masque est inclus dans la frame et chaque octet de payload est XORé avec l’octet correspondant du masque (cycliquement).

Ce masquage protège contre les attaques par cache poisoning : un attaquant contrôlant du contenu dans le navigateur ne peut pas injecter des données WebSocket qui ressembleraient à des réponses HTTP valides, même si un proxy cache est interposé.

Le serveur ne masque jamais ses frames.

Code Python : serveur et client WebSocket#

Serveur broadcast avec la bibliothèque websockets#

import asyncio
import websockets
import json
from datetime import datetime

# Ensemble des connexions actives
CONNEXIONS = set()

async def gestionnaire(websocket):
    """Gère une connexion WebSocket individuelle."""
    CONNEXIONS.add(websocket)
    client_addr = websocket.remote_address
    print(f"[+] Nouveau client : {client_addr}{len(CONNEXIONS)} connecté(s)")

    try:
        # Envoi d'un message de bienvenue
        await websocket.send(json.dumps({
            "type": "bienvenue",
            "message": f"Connecté au serveur. {len(CONNEXIONS)} client(s) en ligne.",
            "timestamp": datetime.now().isoformat(),
        }))

        async for message in websocket:
            # Diffusion à tous les clients connectés
            data = json.loads(message)
            print(f"[{client_addr}] → {data}")

            # Broadcast à tous sauf l'expéditeur
            reponse = json.dumps({
                "type": "message",
                "de": str(client_addr),
                "contenu": data.get("contenu", ""),
                "timestamp": datetime.now().isoformat(),
            })

            if CONNEXIONS:
                await asyncio.gather(
                    *[ws.send(reponse) for ws in CONNEXIONS if ws != websocket],
                    return_exceptions=True,
                )

    except websockets.exceptions.ConnectionClosed as e:
        print(f"[-] Client déconnecté : {client_addr} (code={e.code})")
    finally:
        CONNEXIONS.discard(websocket)

async def main():
    async with websockets.serve(gestionnaire, "localhost", 8765) as server:
        print("Serveur WebSocket en écoute sur ws://localhost:8765")
        await server.wait_closed()

asyncio.run(main())

Client WebSocket avec ping/pong#

import asyncio
import websockets
import json
import time

async def client_chat():
    """Client WebSocket avec keepalive ping/pong automatique."""
    uri = "ws://localhost:8765"

    async with websockets.connect(
        uri,
        ping_interval=20,    # Envoi d'un PING toutes les 20s
        ping_timeout=10,     # Timeout si PONG non reçu en 10s
        close_timeout=5,
    ) as ws:
        print(f"Connecté à {uri}")

        # Réception du message de bienvenue
        bienvenue = await ws.recv()
        print(f"Serveur : {json.loads(bienvenue)['message']}")

        # Envoi de messages et réception simultanée
        async def envoyer():
            for i in range(5):
                msg = json.dumps({"contenu": f"Message #{i+1}"})
                await ws.send(msg)
                print(f"Envoyé : Message #{i+1}")
                await asyncio.sleep(1)

        async def recevoir():
            async for message in ws:
                data = json.loads(message)
                if data["type"] == "message":
                    print(f"Reçu de {data['de']} : {data['contenu']}")

        # Lancement concurrent de l'envoi et de la réception
        await asyncio.gather(envoyer(), recevoir())

asyncio.run(client_chat())

Mesure de latence WebSocket#

import asyncio
import websockets
import time
import statistics

async def mesurer_latence(n_mesures=100):
    """Mesure la latence aller-retour d'une connexion WebSocket."""
    async with websockets.connect("ws://localhost:8765") as ws:
        latences = []

        for i in range(n_mesures):
            debut = time.perf_counter()
            await ws.send(json.dumps({"type": "ping", "t": debut}))
            reponse = await ws.recv()
            fin = time.perf_counter()
            latences.append((fin - debut) * 1000)  # ms

        print(f"Latence sur {n_mesures} messages :")
        print(f"  Moyenne : {statistics.mean(latences):.2f} ms")
        print(f"  Médiane : {statistics.median(latences):.2f} ms")
        print(f"  P95     : {sorted(latences)[int(0.95*n_mesures)]:.2f} ms")
        print(f"  Min/Max : {min(latences):.2f} / {max(latences):.2f} ms")

asyncio.run(mesurer_latence())

Hide code cell source

# Simulation de latences WebSocket vs HTTP polling
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

np.random.seed(42)
n = 500

# Simulation de latences (ms)
latences_ws = np.abs(np.random.normal(2.5, 0.8, n))           # WebSocket local
latences_ws_reseau = np.abs(np.random.normal(15, 5, n))        # WebSocket réseau
latences_polling_1s = np.random.uniform(0, 1000, n)            # Polling 1s
latences_polling_5s = np.random.uniform(0, 5000, n)            # Polling 5s
latences_longpoll = np.abs(np.random.exponential(300, n))      # Long polling

ax1 = axes[0]
data_plot = {
    "WebSocket\n(local)": latences_ws,
    "WebSocket\n(réseau)": latences_ws_reseau,
    "Long polling": latences_longpoll[:n],
    "Polling 1s": latences_polling_1s,
    "Polling 5s": latences_polling_5s,
}

positions = range(len(data_plot))
colors = ["#4CAF50", "#8BC34A", "#FF9800", "#F44336", "#9C27B0"]

bp = ax1.boxplot(list(data_plot.values()), positions=list(positions),
                 patch_artist=True, showfliers=False,
                 medianprops=dict(color="white", linewidth=2))

for patch, color in zip(bp["boxes"], colors):
    patch.set_facecolor(color)
    patch.set_alpha(0.8)

ax1.set_xticks(list(positions))
ax1.set_xticklabels(list(data_plot.keys()), fontsize=9)
ax1.set_ylabel("Latence (ms)", fontsize=11)
ax1.set_title("Distribution des latences\npar méthode temps réel", fontsize=11)
ax1.set_yscale("log")
ax1.grid(True, alpha=0.3, axis="y")

# Graphique 2 : consommation bande passante
ax2 = axes[1]
clients = [10, 100, 1000, 10000]
# Messages/min par client
bw_polling = [c * 60 * 0.5 for c in clients]            # 60 req/min, 500 octets chacune
bw_longpoll = [c * 3 * 0.5 for c in clients]            # ~3 req/min
bw_ws = [c * 60 * 0.02 for c in clients]                 # Frame WebSocket ~20 octets keepalive

ax2.loglog(clients, bw_polling, "o-", label="Polling (1s)", color="#F44336", linewidth=2, markersize=7)
ax2.loglog(clients, bw_longpoll, "s-", label="Long polling", color="#FF9800", linewidth=2, markersize=7)
ax2.loglog(clients, bw_ws, "^-", label="WebSocket", color="#4CAF50", linewidth=2, markersize=7)

ax2.set_xlabel("Nombre de clients connectés", fontsize=11)
ax2.set_ylabel("Bande passante serveur (Ko/min)", fontsize=11)
ax2.set_title("Bande passante serveur\nselon la méthode et le nombre de clients", fontsize=11)
ax2.legend(fontsize=10)
ax2.grid(True, alpha=0.3, which="both")

# Conversion Ko
ax2_labels = ax2.get_yticks()
ax2.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"{x/1000:.0f}Mo" if x >= 1000 else f"{x:.0f}Ko"))

plt.tight_layout()
plt.show()
_images/7c655005e1e4aa2efd65857e2e3083665e6bb7288534e4673a9f7cee8eba8dda.png

Server-Sent Events (SSE)#

Principe et format#

Les Server-Sent Events (W3C, maintenant partie des standards HTML Living Standard) permettent au serveur d’envoyer un flux continu d’événements à un client HTTP. Contrairement à WebSocket, la communication est unidirectionnelle (serveur → client). Le client peut envoyer des données au serveur uniquement via des requêtes HTTP séparées.

Le client ouvre une requête HTTP avec Accept: text/event-stream et le serveur envoie un flux de données tant que la connexion est ouverte :

GET /events HTTP/1.1
Host: example.com
Accept: text/event-stream
Cache-Control: no-cache
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no

Format du flux SSE :

: commentaire (ignoré par le client, utile comme keepalive)\n
\n
id: 1\n
event: temperature\n
data: {"capteur": "A1", "valeur": 23.4}\n
\n
id: 2\n
event: temperature\n
data: {"capteur": "A1", "valeur": 23.6}\n
\n
retry: 3000\n
\n

Chaque événement est séparé par une ligne vide. Les champs possibles sont :

  • data: : données de l’événement (peut s’étendre sur plusieurs lignes)

  • event: : type de l’événement (si omis, type par défaut message)

  • id: : identifiant de l’événement

  • retry: : délai de reconnexion en ms

Reconnexion automatique et Last-Event-ID#

L’un des grands avantages de SSE est sa reconnexion automatique gérée par le navigateur. Si la connexion est perdue, le navigateur attend retry millisecondes (par défaut 3000 ms) puis se reconnecte automatiquement en envoyant l’en-tête Last-Event-ID avec le dernier identifiant reçu. Le serveur peut ainsi reprendre le flux depuis le bon point.

// Côté JavaScript (navigateur)
const evtSource = new EventSource("/events");

evtSource.addEventListener("temperature", (event) => {
    const data = JSON.parse(event.data);
    console.log(`Température : ${data.valeur}°C (capteur ${data.capteur})`);
});

evtSource.onerror = (err) => {
    console.error("Erreur SSE, reconnexion automatique dans 3s...");
};

Serveur SSE avec http.server#

import http.server
import threading
import time
import json
import random
from datetime import datetime

class SSEHandler(http.server.BaseHTTPRequestHandler):
    """Serveur SSE minimaliste avec la stdlib Python."""

    def log_message(self, format, *args):
        pass  # Silence des logs HTTP

    def do_GET(self):
        if self.path == "/events":
            self.send_sse_stream()
        elif self.path == "/":
            self.send_html_page()
        else:
            self.send_error(404)

    def send_sse_stream(self):
        """Envoie un flux SSE de mesures de température simulées."""
        self.send_response(200)
        self.send_header("Content-Type", "text/event-stream")
        self.send_header("Cache-Control", "no-cache")
        self.send_header("Connection", "keep-alive")
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()

        event_id = 0
        try:
            while True:
                event_id += 1
                temperature = 20 + random.gauss(0, 2)
                humidite = 50 + random.gauss(0, 5)

                # Format SSE : id, event, data, ligne vide
                evenement = (
                    f"id: {event_id}\n"
                    f"event: mesure\n"
                    f"data: {json.dumps({'t': temperature, 'h': humidite, 'ts': datetime.now().isoformat()})}\n"
                    f"\n"
                )
                self.wfile.write(evenement.encode("utf-8"))
                self.wfile.flush()

                # Commentaire keepalive toutes les 5 secondes
                if event_id % 5 == 0:
                    keepalive = ": keepalive\n\n"
                    self.wfile.write(keepalive.encode("utf-8"))
                    self.wfile.flush()

                time.sleep(1)

        except (BrokenPipeError, ConnectionResetError):
            print(f"Client déconnecté après {event_id} événements")

    def send_html_page(self):
        """Page HTML consommant le flux SSE."""
        html = """<!DOCTYPE html>
<html><head><title>SSE Demo</title></head>
<body>
<h1>Flux SSE temps réel</h1>
<div id="data">En attente...</div>
<script>
const es = new EventSource('/events');
es.addEventListener('mesure', e => {
    const d = JSON.parse(e.data);
    document.getElementById('data').innerHTML =
        `T: ${d.t.toFixed(1)}°C | H: ${d.h.toFixed(1)}% | ${d.ts}`;
});
</script>
</body></html>"""
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.end_headers()
        self.wfile.write(html.encode("utf-8"))

# Lancement du serveur dans un thread séparé
serveur = http.server.HTTPServer(("localhost", 8080), SSEHandler)
thread = threading.Thread(target=serveur.serve_forever, daemon=True)
thread.start()
print("Serveur SSE démarré sur http://localhost:8080")
print("Flux d'événements sur http://localhost:8080/events")
# serveur.shutdown()  # Pour arrêter

Client SSE en Python#

import httpx
import json

def consommer_sse(url: str, max_events: int = 10):
    """Consomme un flux SSE avec httpx."""
    print(f"Connexion au flux SSE : {url}")

    with httpx.stream("GET", url, headers={"Accept": "text/event-stream"}) as response:
        print(f"Statut : {response.status_code}")
        print(f"Content-Type : {response.headers.get('content-type')}")

        buffer = ""
        n_events = 0

        for chunk in response.iter_text():
            buffer += chunk

            # Traitement des événements complets (séparés par \n\n)
            while "\n\n" in buffer:
                evenement, buffer = buffer.split("\n\n", 1)
                if evenement.strip():
                    lignes = evenement.strip().split("\n")
                    event = {}
                    for ligne in lignes:
                        if ligne.startswith(":"):
                            continue  # Commentaire
                        if ":" in ligne:
                            cle, _, valeur = ligne.partition(":")
                            event[cle.strip()] = valeur.strip()

                    if "data" in event:
                        n_events += 1
                        print(f"Événement #{n_events} [{event.get('event', 'message')}] : {event['data']}")
                        try:
                            data = json.loads(event["data"])
                            print(f"  → T={data['t']:.1f}°C, H={data['h']:.1f}%")
                        except (json.JSONDecodeError, KeyError):
                            pass

                    if n_events >= max_events:
                        print(f"\nArrêt après {max_events} événements.")
                        return

consommer_sse("http://localhost:8080/events")

Hide code cell source

# Simulation d'un flux SSE et visualisation
import matplotlib.animation
from matplotlib.lines import Line2D

# Données simulées d'un flux SSE de capteurs
np.random.seed(42)
n_points = 60  # 60 secondes de données

temps = np.arange(n_points)
temp_a = 22 + np.cumsum(np.random.normal(0, 0.3, n_points))
temp_b = 19 + np.cumsum(np.random.normal(0, 0.2, n_points))
humidite = 55 + np.cumsum(np.random.normal(0, 1, n_points))
humidite = np.clip(humidite, 20, 90)

fig, axes = plt.subplots(2, 1, figsize=(13, 6), sharex=True)
fig.suptitle("Simulation d'un flux SSE : données de capteurs IoT (60s)", fontsize=12, fontweight="bold")

ax1, ax2 = axes

ax1.plot(temps, temp_a, "-", color="#F44336", linewidth=2, label="Capteur A (salon)")
ax1.plot(temps, temp_b, "-", color="#2196F3", linewidth=2, label="Capteur B (chambre)")
ax1.fill_between(temps, temp_a - 0.5, temp_a + 0.5, alpha=0.2, color="#F44336")
ax1.fill_between(temps, temp_b - 0.5, temp_b + 0.5, alpha=0.2, color="#2196F3")

# Annotation des événements SSE
for t in range(0, n_points, 5):
    ax1.axvline(x=t, color="gray", linestyle=":", alpha=0.3)
    ax1.text(t, ax1.get_ylim()[0] if ax1.get_ylim() else 18,
             f"id:{t//5+1}", fontsize=6, color="gray", ha="center")

ax1.set_ylabel("Température (°C)", fontsize=11)
ax1.legend(fontsize=10, loc="upper right")
ax1.grid(True, alpha=0.3)

ax2.plot(temps, humidite, "-", color="#4CAF50", linewidth=2, label="Humidité (%)")
ax2.fill_between(temps, humidite - 2, humidite + 2, alpha=0.2, color="#4CAF50")
ax2.axhline(y=60, color="#FF9800", linestyle="--", linewidth=1.5, alpha=0.7, label="Seuil alerte (60%)")
ax2.set_xlabel("Temps (secondes)", fontsize=11)
ax2.set_ylabel("Humidité (%)", fontsize=11)
ax2.legend(fontsize=10)
ax2.grid(True, alpha=0.3)

# Simuler des événements SSE dans le format texte
plt.tight_layout()
plt.show()

# Afficher un exemple de flux SSE
print("\nExemple de flux SSE généré :")
print("=" * 50)
for i in range(3):
    event_id = i + 1
    t = temp_a[i]
    h = humidite[i]
    print(f"id: {event_id}")
    print(f"event: mesure")
    print(f'data: {{"capteurA": {t:.1f}, "capteurB": {temp_b[i]:.1f}, "humidite": {h:.1f}}}')
    print()
_images/cbe867c8958f742c74c9ad38cce0a76fdb4f0d61182ad6e3752638e821d4b307.png
Exemple de flux SSE généré :
==================================================
id: 1
event: mesure
data: {"capteurA": 22.1, "capteurB": 18.9, "humidite": 55.8}

id: 2
event: mesure
data: {"capteurA": 22.1, "capteurB": 18.9, "humidite": 54.9}

id: 3
event: mesure
data: {"capteurA": 22.3, "capteurB": 18.6, "humidite": 56.3}

Comparaison WebSocket vs SSE vs Polling#

Hide code cell source

# Tableau de comparaison
fig, ax = plt.subplots(figsize=(14, 6))
ax.axis("off")

colonnes = ["Critère", "Polling", "Long Polling", "WebSocket", "SSE"]
lignes = [
    ["Direction", "Client → Serveur", "Client → Serveur", "Bidirectionnel", "Serveur → Client"],
    ["Protocole", "HTTP standard", "HTTP standard", "WS (Upgrade HTTP)", "HTTP standard"],
    ["Support navigateur", "Universel", "Universel", "Très large (IE10+)", "Large (pas IE natif)"],
    ["Latence", "Élevée (1-5s)", "Faible-moyenne", "Très faible (<10ms)", "Faible (<100ms)"],
    ["Connexions serveur", "Courtes, répétées", "Maintenues", "Maintenues (WS)", "Maintenues (HTTP)"],
    ["Reconnexion auto", "Par logique appli.", "Par logique appli.", "Bibliothèque", "Native (EventSource)"],
    ["Load balancer", "Facile (stateless)", "Difficile (sticky)", "Difficile (sticky)", "Possible (stateless)"],
    ["Cas d'usage", "Polling rare", "Notifications", "Chat, jeux, collab.", "Notifications, flux"],
    ["Complexité serveur", "Faible", "Moyenne", "Élevée", "Faible-Moyenne"],
    ["Overhead réseau", "Très élevé", "Moyen", "Très faible", "Faible"],
]

couleurs = []
for i, ligne in enumerate(lignes):
    row_colors = []
    for j, cell in enumerate(ligne):
        if j == 0:
            row_colors.append("#ECEFF1")
        elif j == 3:  # WebSocket
            row_colors.append("#E8F5E9")
        elif j == 4:  # SSE
            row_colors.append("#E3F2FD")
        elif i % 2 == 0:
            row_colors.append("#FAFAFA")
        else:
            row_colors.append("#F5F5F5")
    couleurs.append(row_colors)

table = ax.table(
    cellText=lignes,
    colLabels=colonnes,
    cellLoc="center",
    loc="center",
    cellColours=couleurs,
)
table.auto_set_font_size(False)
table.set_fontsize(8.5)
table.scale(1, 1.7)

col_colors = ["#37474F", "#F44336", "#FF9800", "#2E7D32", "#1565C0"]
for j, color in enumerate(col_colors):
    table[0, j].set_facecolor(color)
    table[0, j].set_text_props(color="white", fontweight="bold")

ax.set_title("Comparaison des méthodes de communication temps réel", fontsize=13, fontweight="bold", pad=20)
plt.tight_layout()
plt.show()
_images/8e29c3820d9baee4146fc08f5db5ba5f72393659ca06dde047f1a4a88e8ee082.png

Cas d’usage et choix architectural#

Quand choisir WebSocket ?#

WebSocket est le choix approprié quand :

  • La communication est bidirectionnelle avec un volume élevé de messages dans les deux sens (chat, collaboration en temps réel, jeux multijoueurs)

  • La latence est critique (trading, jeux de tir à la première personne)

  • Le serveur doit recevoir fréquemment des données du client (position du curseur, frappe au clavier)

Exemples : Slack, Discord, Google Docs (curseurs collaboratifs), jeux HTML5, plateformes de trading.

Quand choisir SSE ?#

SSE est idéal quand :

  • Le flux est unidirectionnel : le serveur envoie des mises à jour que le client consomme

  • La simplicité est prioritaire : SSE fonctionne sur HTTP/2 naturellement (pas d’upgrade nécessaire)

  • La robustesse aux déconnexions est critique (reconnexion automatique native)

  • Derrière un load balancer standard (pas besoin de sticky sessions)

Exemples : tableaux de bord de monitoring, flux de prix, notifications push, logs en direct, progression de tâches longues.

SSE sur HTTP/2

Sur HTTP/2, SSE bénéficie du multiplexage : plusieurs flux SSE de différentes sources peuvent partager la même connexion TCP. Sur HTTP/1.1, le navigateur est limité à 6 connexions par domaine, ce qui restreint le nombre de flux SSE simultanés. HTTP/2 lève cette limitation.

Quand garder le polling ?#

Le polling reste valide quand :

  • La fréquence de mise à jour est basse (toutes les minutes ou plus)

  • La simplicité d’implémentation prime

  • L’infrastructure ne supporte pas les connexions longues (certains proxys d’entreprise)

  • La logique est simple et sans état côté serveur

Hide code cell source

# Visualisation : arbre de décision pour le choix du protocole
fig, ax = plt.subplots(figsize=(12, 8))
ax.set_xlim(0, 12)
ax.set_ylim(0, 9)
ax.axis("off")
ax.set_title("Guide de choix : Polling / Long Polling / SSE / WebSocket", fontsize=12, fontweight="bold")

def noeud(ax, x, y, texte, couleur="#2196F3", radius=0.55, fontsize=8.5):
    cercle = plt.Circle((x, y), radius, color=couleur, alpha=0.85, zorder=5)
    ax.add_patch(cercle)
    ax.text(x, y, texte, ha="center", va="center", fontsize=fontsize,
            color="white", fontweight="bold", zorder=6, wrap=True,
            multialignment="center")

def fleche(ax, x1, y1, x2, y2, label="", couleur="gray"):
    ax.annotate("", xy=(x2, y2), xytext=(x1, y1),
                arrowprops=dict(arrowstyle="->", color=couleur, lw=1.5))
    if label:
        mx, my = (x1+x2)/2, (y1+y2)/2
        ax.text(mx + 0.1, my, label, fontsize=8, color=couleur)

def boite(ax, x, y, texte, couleur="#4CAF50", w=1.5, h=0.7):
    rect = mpatches.FancyBboxPatch((x - w/2, y - h/2), w, h,
                                    boxstyle="round,pad=0.1",
                                    facecolor=couleur, edgecolor="white",
                                    alpha=0.9, zorder=5)
    ax.add_patch(rect)
    ax.text(x, y, texte, ha="center", va="center", fontsize=9,
            color="white", fontweight="bold", zorder=6)

# Questions / noeuds
noeud(ax, 6, 8.2, "Besoin\ntemps réel ?", "#37474F", radius=0.7, fontsize=8)
noeud(ax, 2.5, 6.5, "Fréquence\nfaible ?\n(>1 min)", "#607D8B", fontsize=7.5)
noeud(ax, 9.5, 6.5, "Communication\nbidirectionnelle ?", "#607D8B", fontsize=7.5)
noeud(ax, 8, 4.5, "Volume élevé\nclient→serveur ?", "#607D8B", fontsize=7.5)

# Feuilles
boite(ax, 2.5, 4.8, "Polling", "#F44336")
boite(ax, 4.5, 4.8, "Long Polling", "#FF9800")
boite(ax, 7, 2.5, "SSE", "#4CAF50")
boite(ax, 10, 2.5, "WebSocket", "#2196F3")

# Flèches
fleche(ax, 6, 7.5, 2.5, 7.2, "Oui", "#4CAF50")
fleche(ax, 6, 7.5, 9.5, 7.2, "Non\n(rare)", "#F44336")

fleche(ax, 2.5, 5.8, 2.5, 5.3, "Oui", "#4CAF50")
fleche(ax, 2.5, 5.8, 4.5, 5.3, "Non", "#F44336")

fleche(ax, 9.5, 5.8, 8, 5.0, "Non", "#4CAF50")
fleche(ax, 9.5, 5.8, 10, 4.8, "Oui", "#F44336")

fleche(ax, 8, 4.0, 7, 2.9, "Non", "#4CAF50")
fleche(ax, 8, 4.0, 10, 2.9, "Oui", "#F44336")

ax.text(6, 0.5, "SSE : simple, HTTP natif, reconnexion auto  |  WebSocket : faible latence, bidirectionnel",
        ha="center", fontsize=9, color="#37474F", style="italic")

plt.tight_layout()
plt.show()
_images/eaa71391537880003196cc5719cc46461b42e8bdbf9c86d189e6149b82dc7af5.png

Résumé#

Ce chapitre a présenté les deux protocoles majeurs pour la communication temps réel sur le web.

WebSocket offre un canal bidirectionnel plein-duplex à faible latence, idéal pour les applications interactives. Son handshake HTTP Upgrade (101 Switching Protocols) permet le passage à travers les pare-feux. Le masquage des frames côté client protège contre les attaques par cache poisoning.

Server-Sent Events sont plus simples, natifs HTTP, avec reconnexion automatique intégrée. Ils excellent pour les flux unidirectionnels (notifications, dashboards) et fonctionnent mieux avec les load balancers standard et HTTP/2.

Le polling reste pertinent pour des cas simples à faible fréquence, mais consomme des ressources inutiles à haute fréquence. Le long polling améliore la latence mais complexifie la gestion des connexions côté serveur.