Cours JavaScript et React · Module 10: Écosystème et avenir de React
WebSockets et Server-Sent Events dans React
Dans cette leçon3
Le radar de la station doit afficher la position des vaisseaux en direct. Une API REST classique fonctionne comme une lettre : le client demande, le serveur répond et la conversation s'arrête. Pour voir les changements, tu devrais demander chaque seconde, en gaspillant la bande passante et la batterie. La communication en temps réel inverse ce modèle : le serveur envoie lui-même les données quand quelque chose se produit. Dans le navigateur, tu disposes pour cela de deux technologies : WebSocket et Server-Sent Events (SSE).
WebSockets - communication bidirectionnelle en temps réel
Un WebSocket commence comme une requête HTTP ordinaire, puis passe à son propre protocole (ws:// ou sa version chiffrée wss://). À partir de ce moment, les deux côtés peuvent envoyer des messages texte ou binaires à tout instant. C'est le meilleur choix pour un chat, les notifications en direct, les données boursières et les jeux, c'est-à-dire partout où le client émet lui aussi souvent.
Implémentation de base d'un WebSocket
Le hook useWebSocket regroupe tout le cycle de vie de la connexion : connexion, réception, envoi, déconnexion et nouvelles tentatives :
1// hooks/useWebSocket.js
2import { useState, useEffect, useRef, useCallback } from 'react';
3
4const useWebSocket = (url, options = {}) => {
5 const [socket, setSocket] = useState(null);
6 const [lastMessage, setLastMessage] = useState(null);
7 const [readyState, setReadyState] = useState(0);
8 const [connectionError, setConnectionError] = useState(null);
9
10 // Socket, listeners et compteur de tentatives dans des refs - les modifier ne déclenche pas de rendu
11 const socketRef = useRef(null);
12 const messageListeners = useRef(new Set());
13 const retryTimeoutRef = useRef(null);
14 const reconnectAttemptsRef = useRef(0);
15
16 // Dernières options du dernier rendu : les callbacks passés en ligne
17 // ne changent pas connect, donc l'effet ne se reconnecte pas
18 const optionsRef = useRef(options);
19 useEffect(() => {
20 optionsRef.current = options;
21 });
22
23 const { heartbeatInterval = 30000 } = options;
24
25 // Fonction de création de la connexion - ne dépend que de url
26 const connect = useCallback(() => {
27 const { protocols, reconnectAttempts = 5, reconnectInterval = 3000 } = optionsRef.current;
28
29 try {
30 const ws = new WebSocket(url, protocols);
31 socketRef.current = ws;
32
33 ws.onopen = (event) => {
34 console.log('WebSocket connecté :', url);
35 setReadyState(1); // OPEN
36 setConnectionError(null);
37 reconnectAttemptsRef.current = 0;
38 optionsRef.current.onOpen?.(event);
39 };
40
41 ws.onmessage = (event) => {
42 let parsedData;
43 try {
44 parsedData = JSON.parse(event.data);
45 } catch {
46 parsedData = event.data;
47 }
48
49 setLastMessage({
50 data: parsedData,
51 timestamp: Date.now(),
52 raw: event.data
53 });
54
55 // Appeler tous les listeners enregistrés
56 messageListeners.current.forEach(listener => {
57 listener(parsedData, event);
58 });
59
60 optionsRef.current.onMessage?.(parsedData, event);
61 };
62
63 ws.onclose = (event) => {
64 // Ignorer les événements d'une ancienne socket (après reconnect ou démontage)
65 if (socketRef.current !== ws) return;
66
67 console.log('WebSocket déconnecté :', event.code, event.reason);
68 setReadyState(3); // CLOSED
69 setSocket(null);
70 optionsRef.current.onClose?.(event);
71
72 // Reconnexion automatique
73 if (!event.wasClean && reconnectAttemptsRef.current < reconnectAttempts) {
74 reconnectAttemptsRef.current++;
75 console.log(`Reconnexion (${reconnectAttemptsRef.current}/${reconnectAttempts})...`);
76
77 retryTimeoutRef.current = setTimeout(() => {
78 connect();
79 }, reconnectInterval * reconnectAttemptsRef.current);
80 }
81 };
82
83 ws.onerror = (error) => {
84 if (socketRef.current !== ws) return;
85 console.error('Erreur WebSocket :', error);
86 setConnectionError(error);
87 optionsRef.current.onError?.(error);
88 };
89
90 setSocket(ws);
91 setReadyState(0); // CONNECTING
92
93 } catch (error) {
94 console.error('Impossible de créer le WebSocket :', error);
95 setConnectionError(error);
96 }
97 }, [url]);
98
99 // Envoi des messages
100 const sendMessage = useCallback((message) => {
101 const ws = socketRef.current;
102 if (ws && ws.readyState === WebSocket.OPEN) {
103 const dataToSend = typeof message === 'string' ? message : JSON.stringify(message);
104 ws.send(dataToSend);
105 return true;
106 } else {
107 console.warn('Le WebSocket n’est pas connecté');
108 return false;
109 }
110 }, []);
111
112 // Ajout d'un listener de messages
113 const addMessageListener = useCallback((listener) => {
114 messageListeners.current.add(listener);
115 return () => messageListeners.current.delete(listener);
116 }, []);
117
118 // Reconnexion manuelle
119 const reconnect = useCallback(() => {
120 clearTimeout(retryTimeoutRef.current);
121 socketRef.current?.close();
122 reconnectAttemptsRef.current = 0;
123 connect();
124 }, [connect]);
125
126 // Déconnexion
127 const disconnect = useCallback(() => {
128 clearTimeout(retryTimeoutRef.current);
129 socketRef.current?.close(1000, 'Fermé par l’utilisateur');
130 }, []);
131
132 // Connexion automatique au montage
133 useEffect(() => {
134 connect();
135
136 return () => {
137 clearTimeout(retryTimeoutRef.current);
138 const ws = socketRef.current;
139 socketRef.current = null; // le onclose de l'ancienne socket ne fera plus rien
140 ws?.close();
141 };
142 }, [connect]);
143
144 // Heartbeat - ping/pong
145 useEffect(() => {
146 if (socket && readyState === 1 && heartbeatInterval > 0) {
147 const interval = setInterval(() => {
148 if (socket.readyState === WebSocket.OPEN) {
149 sendMessage({ type: 'ping', timestamp: Date.now() });
150 }
151 }, heartbeatInterval);
152
153 return () => clearInterval(interval);
154 }
155 }, [socket, readyState, heartbeatInterval, sendMessage]);
156
157 return {
158 socket,
159 lastMessage,
160 readyState,
161 connectionError,
162 sendMessage,
163 addMessageListener,
164 reconnect,
165 disconnect,
166 isConnecting: readyState === 0,
167 isOpen: readyState === 1,
168 isClosing: readyState === 2,
169 isClosed: readyState === 3
170 };
171};
172
173export default useWebSocket;readyState reflète les états de la socket (0 connexion, 1 ouverte, 2 fermeture, 3 fermée), donc tu obtiens un indicateur de statut gratuitement. sendMessage n'envoie que lorsque la connexion est ouverte, et après une fermeture anormale le hook réessaie avec un délai croissant. Un heartbeat toutes les 30 secondes envoie ping, pour que les intermédiaires ne coupent pas une liaison inactive.
Le hook évite deux pièges. Les callbacks comme onOpen, passé en ligne dans le chat ci-dessous, et le tableau protocols sont nouveaux à chaque rendu, donc si connect en dépendait, l'effet rouvrirait la connexion après chaque rendu. C'est pourquoi le hook lit les options dans optionsRef, mis à jour après chaque rendu, et connect ne dépend que de url. La socket elle-même se trouve dans socketRef, car sinon le cleanup verrait la socket du rendu dans lequel l'effet a démarré, c'est-à-dire null. Le hook ignore les événements d'une ancienne socket, fermée par reconnect ou au démontage, donc il ne relancera pas la connexion après la sortie du chat.
Chat en temps réel
Une fois la connexion ouverte, ChatRoom envoie un message join, et répartit chaque message reçu avec une instruction switch selon le champ type :
1// components/ChatRoom.jsx
2import React, { useState, useEffect, useRef } from 'react';
3import useWebSocket from '../hooks/useWebSocket';
4
5const ChatRoom = ({ roomId, userId, userName }) => {
6 const [messages, setMessages] = useState([]);
7 const [newMessage, setNewMessage] = useState('');
8 const [typingUsers, setTypingUsers] = useState(new Set());
9 const [onlineUsers, setOnlineUsers] = useState(new Map());
10 const messagesEndRef = useRef(null);
11 const typingTimeoutRef = useRef(null);
12
13 const {
14 sendMessage,
15 lastMessage,
16 isOpen,
17 connectionError,
18 addMessageListener
19 } = useWebSocket(`ws://localhost:8080/chat/${roomId}`, {
20 onOpen: () => {
21 // Rejoindre le salon
22 sendMessage({
23 type: 'join',
24 roomId,
25 userId,
26 userName
27 });
28 }
29 });
30
31 // Gestion des messages reçus
32 useEffect(() => {
33 if (!lastMessage) return;
34
35 const { data } = lastMessage;
36
37 switch (data.type) {
38 case 'message':
39 setMessages(prev => [...prev, {
40 id: data.id,
41 text: data.text,
42 userId: data.userId,
43 userName: data.userName,
44 timestamp: data.timestamp
45 }]);
46 break;
47
48 case 'typing':
49 setTypingUsers(prev => {
50 const newSet = new Set(prev);
51 if (data.isTyping && data.userId !== userId) {
52 newSet.add(data.userName);
53 } else {
54 newSet.delete(data.userName);
55 }
56 return newSet;
57 });
58 break;
59
60 case 'userJoined':
61 setOnlineUsers(prev => new Map(prev).set(data.userId, data.userName));
62 setMessages(prev => [...prev, {
63 id: `system-${Date.now()}`,
64 text: `${data.userName} a rejoint le chat`,
65 type: 'system',
66 timestamp: Date.now()
67 }]);
68 break;
69
70 case 'userLeft':
71 setOnlineUsers(prev => {
72 const newMap = new Map(prev);
73 newMap.delete(data.userId);
74 return newMap;
75 });
76 setMessages(prev => [...prev, {
77 id: `system-${Date.now()}`,
78 text: `${data.userName} a quitté le chat`,
79 type: 'system',
80 timestamp: Date.now()
81 }]);
82 break;
83
84 case 'messageDeleted':
85 setMessages(prev => prev.filter(msg => msg.id !== data.messageId));
86 break;
87
88 case 'messageEdited':
89 setMessages(prev => prev.map(msg =>
90 msg.id === data.messageId
91 ? { ...msg, text: data.newText, edited: true }
92 : msg
93 ));
94 break;
95 }
96 }, [lastMessage, userId]);
97
98 // Défilement automatique vers le dernier message
99 useEffect(() => {
100 messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
101 }, [messages]);
102
103 // Envoi des messages
104 const handleSendMessage = (e) => {
105 e.preventDefault();
106
107 if (newMessage.trim() && isOpen) {
108 sendMessage({
109 type: 'message',
110 text: newMessage.trim(),
111 roomId,
112 userId,
113 userName
114 });
115
116 setNewMessage('');
117 handleStopTyping();
118 }
119 };
120
121 // Gestion de la saisie
122 const handleTyping = (e) => {
123 setNewMessage(e.target.value);
124
125 if (!typingTimeoutRef.current) {
126 sendMessage({
127 type: 'typing',
128 isTyping: true,
129 userId,
130 userName,
131 roomId
132 });
133 }
134
135 clearTimeout(typingTimeoutRef.current);
136 typingTimeoutRef.current = setTimeout(() => {
137 handleStopTyping();
138 }, 2000);
139 };
140
141 const handleStopTyping = () => {
142 if (typingTimeoutRef.current) {
143 clearTimeout(typingTimeoutRef.current);
144 typingTimeoutRef.current = null;
145
146 sendMessage({
147 type: 'typing',
148 isTyping: false,
149 userId,
150 userName,
151 roomId
152 });
153 }
154 };
155
156 // Suppression d'un message
157 const handleDeleteMessage = (messageId) => {
158 sendMessage({
159 type: 'deleteMessage',
160 messageId,
161 userId
162 });
163 };
164
165 if (connectionError) {
166 return (
167 <div className="chat-error">
168 <h3>Erreur de connexion au chat</h3>
169 <p>{connectionError.message}</p>
170 <button onClick={() => window.location.reload()}>
171 Réessayer
172 </button>
173 </div>
174 );
175 }
176
177 return (
178 <div className="chat-room">
179 <div className="chat-header">
180 <h2>Salon : {roomId}</h2>
181 <div className="online-users">
182 Online ({onlineUsers.size}): {Array.from(onlineUsers.values()).join(', ')}
183 </div>
184 <div className="connection-status">
185 Statut : {isOpen ? 'Connecté' : 'Déconnecté'}
186 </div>
187 </div>
188
189 <div className="chat-messages">
190 {messages.map(message => (
191 <div
192 key={message.id}
193 className={`message ${message.type === 'system' ? 'system' : ''} ${message.userId === userId ? 'own' : ''}`}
194 >
195 {message.type !== 'system' && (
196 <div className="message-header">
197 <span className="username">{message.userName}</span>
198 <span className="timestamp">
199 {new Date(message.timestamp).toLocaleTimeString()}
200 </span>
201 {message.userId === userId && (
202 <button
203 onClick={() => handleDeleteMessage(message.id)}
204 className="delete-btn"
205 >
206 Supprimer
207 </button>
208 )}
209 </div>
210 )}
211 <div className="message-text">
212 {message.text}
213 {message.edited && <span className="edited"> (modifié)</span>}
214 </div>
215 </div>
216 ))}
217
218 {typingUsers.size > 0 && (
219 <div className="typing-indicator">
220 {Array.from(typingUsers).join(', ')} écrit...
221 </div>
222 )}
223
224 <div ref={messagesEndRef} />
225 </div>
226
227 <form onSubmit={handleSendMessage} className="chat-input">
228 <input
229 type="text"
230 value={newMessage}
231 onChange={handleTyping}
232 onBlur={handleStopTyping}
233 disabled={!isOpen}
234 maxLength={500}
235 />
236 <button type="submit" disabled={!isOpen || !newMessage.trim()}>
237 Envoyer
238 </button>
239 </form>
240 </div>
241 );
242};
243
244export default ChatRoom;Les types typing, userJoined et messageEdited forment un protocole convenu avec le serveur, pas une partie du standard WebSocket. L'indicateur de saisie envoie isTyping: false après deux secondes de silence, donc il n'inonde pas le serveur de messages.
Middleware et autorisation
À mesure que le chat grandit, les règles pour les messages se multiplient. WebSocketMiddleware les fait passer par une chaîne de fonctions, comme un douanier au sas :
1// utils/websocketMiddleware.js
2class WebSocketMiddleware {
3 constructor() {
4 this.middlewares = [];
5 }
6
7 use(middleware) {
8 this.middlewares.push(middleware);
9 }
10
11 async processIncoming(message, socket) {
12 let processedMessage = message;
13
14 for (const middleware of this.middlewares) {
15 try {
16 processedMessage = await middleware.incoming(processedMessage, socket);
17 if (!processedMessage) break; // Un middleware peut arrêter le traitement
18 } catch (error) {
19 console.error('Middleware error (incoming):', error);
20 break;
21 }
22 }
23
24 return processedMessage;
25 }
26
27 async processOutgoing(message, socket) {
28 let processedMessage = message;
29
30 for (const middleware of this.middlewares) {
31 try {
32 processedMessage = await middleware.outgoing(processedMessage, socket);
33 if (!processedMessage) break;
34 } catch (error) {
35 console.error('Middleware error (outgoing):', error);
36 break;
37 }
38 }
39
40 return processedMessage;
41 }
42}
43
44// Middleware d'authentification
45const authMiddleware = {
46 incoming: async (message, socket) => {
47 if (message.type === 'auth') {
48 const token = message.token;
49 const isValid = await validateToken(token);
50
51 if (isValid) {
52 socket.isAuthenticated = true;
53 socket.userId = getUserIdFromToken(token);
54 return { type: 'authSuccess', userId: socket.userId };
55 } else {
56 socket.close(4001, 'Unauthorized');
57 return null;
58 }
59 }
60
61 if (!socket.isAuthenticated && message.type !== 'auth') {
62 socket.send(JSON.stringify({
63 type: 'error',
64 message: 'Authentication required'
65 }));
66 return null;
67 }
68
69 return message;
70 },
71
72 outgoing: async (message, socket) => {
73 if (!socket.isAuthenticated && message.type !== 'authRequired') {
74 return null;
75 }
76 return message;
77 }
78};
79
80// Middleware de limitation du débit
81const rateLimitMiddleware = {
82 incoming: async (message, socket) => {
83 if (!socket.messageCount) {
84 socket.messageCount = 0;
85 socket.lastReset = Date.now();
86 }
87
88 const now = Date.now();
89 const timePassed = now - socket.lastReset;
90
91 // Réinitialisation toutes les minutes
92 if (timePassed > 60000) {
93 socket.messageCount = 0;
94 socket.lastReset = now;
95 }
96
97 socket.messageCount++;
98
99 // Au maximum 100 messages par minute
100 if (socket.messageCount > 100) {
101 socket.send(JSON.stringify({
102 type: 'error',
103 message: 'Rate limit exceeded'
104 }));
105 return null;
106 }
107
108 return message;
109 },
110
111 outgoing: async (message, socket) => message
112};
113
114// Hook avec middleware
115const useAdvancedWebSocket = (url, options = {}) => {
116 // Enregistrer les middlewares une seule fois, au premier rendu (StrictMode lancerait un effet deux fois)
117 const middleware = useRef(null);
118 if (middleware.current === null) {
119 middleware.current = new WebSocketMiddleware();
120 middleware.current.use(authMiddleware);
121 middleware.current.use(rateLimitMiddleware);
122 }
123
124 const { sendMessage: baseSendMessage, socket, ...rest } = useWebSocket(url, {
125 ...options,
126 onMessage: async (data, event) => {
127 const processed = await middleware.current.processIncoming(data, event.target);
128 if (processed) {
129 options.onMessage?.(processed, event);
130 }
131 }
132 });
133
134 const sendMessage = useCallback(async (message) => {
135 if (socket) {
136 const processed = await middleware.current.processOutgoing(message, socket);
137 if (processed) {
138 return baseSendMessage(processed);
139 }
140 }
141 return false;
142 }, [socket, baseSendMessage]);
143
144 return {
145 ...rest,
146 sendMessage,
147 socket
148 };
149};Un middleware qui renvoie null arrête le message. useAdvancedWebSocket enregistre les middlewares une seule fois, au premier rendu, et prend la socket dans useWebSocket. La vraie autorisation et la limitation du débit doivent cependant tourner sur le serveur, car l'utilisateur peut modifier le code dans le navigateur, et côté client un tel middleware ne fait que mettre de l'ordre dans le trafic.
Server-Sent Events (SSE) - communication unidirectionnelle
Le SSE est une réponse HTTP ordinaire de type text/event-stream, que le serveur ne ferme pas, mais à laquelle il ajoute de nouveaux événements. Les données circulent uniquement du serveur vers le client, et seulement sous forme de texte. En échange, l'EventSource du navigateur reprend tout seul une connexion coupée et envoie l'en-tête Last-Event-ID, pour ne perdre aucun événement. Le hook useServerSentEvents l'enveloppe et gère les événements nommés :
1// hooks/useServerSentEvents.js
2import { useState, useEffect, useRef, useCallback } from 'react';
3
4const useServerSentEvents = (url, options = {}) => {
5 const [data, setData] = useState(null);
6 const [readyState, setReadyState] = useState(0);
7 const [error, setError] = useState(null);
8 const eventSourceRef = useRef(null);
9 const listenersRef = useRef(new Map());
10
11 // Dernières options sans reconnexion, comme dans useWebSocket
12 const optionsRef = useRef(options);
13 useEffect(() => {
14 optionsRef.current = options;
15 });
16
17 const { withCredentials = false } = options;
18
19 const connect = useCallback(() => {
20 try {
21 const eventSource = new EventSource(url, { withCredentials });
22 eventSourceRef.current = eventSource;
23
24 eventSource.onopen = (event) => {
25 console.log('SSE connecté :', url);
26 setReadyState(1); // OPEN
27 setError(null);
28 optionsRef.current.onOpen?.(event);
29 };
30
31 eventSource.onmessage = (event) => {
32 let parsedData;
33 try {
34 parsedData = JSON.parse(event.data);
35 } catch {
36 parsedData = event.data;
37 }
38
39 setData({
40 data: parsedData,
41 timestamp: Date.now(),
42 id: event.lastEventId,
43 type: event.type
44 });
45
46 optionsRef.current.onMessage?.(parsedData, event);
47 };
48
49 eventSource.onerror = (event) => {
50 console.error('Erreur SSE :', event);
51 setReadyState(eventSource.readyState); // 0 = le navigateur se reconnecte, 2 = fermé
52 setError(event);
53 optionsRef.current.onError?.(event);
54 };
55
56 // Ajouter des event listeners personnalisés
57 listenersRef.current.forEach((listener, eventType) => {
58 eventSource.addEventListener(eventType, listener);
59 });
60
61 } catch (err) {
62 console.error('Impossible de créer l’EventSource :', err);
63 setError(err);
64 }
65 }, [url, withCredentials]);
66
67 const addEventListener = useCallback((eventType, listener) => {
68 listenersRef.current.set(eventType, listener);
69
70 if (eventSourceRef.current) {
71 eventSourceRef.current.addEventListener(eventType, listener);
72 }
73
74 return () => {
75 listenersRef.current.delete(eventType);
76 if (eventSourceRef.current) {
77 eventSourceRef.current.removeEventListener(eventType, listener);
78 }
79 };
80 }, []);
81
82 const close = useCallback(() => {
83 if (eventSourceRef.current) {
84 eventSourceRef.current.close();
85 eventSourceRef.current = null;
86 setReadyState(2);
87 }
88 }, []);
89
90 useEffect(() => {
91 connect();
92
93 return () => {
94 close();
95 };
96 }, [connect, close]);
97
98 return {
99 data,
100 readyState,
101 error,
102 addEventListener,
103 close,
104 reconnect: connect,
105 isConnecting: readyState === 0,
106 isOpen: readyState === 1,
107 isClosed: readyState === 2
108 };
109};
110
111export default useServerSentEvents;onmessage capte les événements sans nom, et addEventListener('metrics', ...) ceux qui ont le champ event: metrics. Dans onerror, le hook enregistre eventSource.readyState : 0 signifie que le navigateur se reconnecte tout seul, et 2 que la connexion est fermée pour de bon. Les nouvelles tentatives dépendent du navigateur et du champ retry: envoyé par le serveur, donc le hook ne compte pas lui-même les tentatives. Il lit les options dans optionsRef, comme useWebSocket, donc un onMessage passé en ligne ne rouvre pas la connexion à chaque rendu.
LiveDashboard écoute quatre événements nommés : les métriques, les notifications, le statut et les alertes :
1// components/LiveDashboard.jsx
2import React, { useState, useEffect } from 'react';
3import useServerSentEvents from '../hooks/useServerSentEvents';
4
5const LiveDashboard = () => {
6 const [metrics, setMetrics] = useState({
7 activeUsers: 0,
8 serverLoad: 0,
9 memoryUsage: 0,
10 requestsPerSecond: 0
11 });
12
13 const [notifications, setNotifications] = useState([]);
14 const [systemStatus, setSystemStatus] = useState('healthy');
15 const [lastUpdate, setLastUpdate] = useState(null);
16
17 const { data, isOpen, error, addEventListener } = useServerSentEvents(
18 '/api/dashboard/stream',
19 {
20 onMessage: (data) => {
21 setLastUpdate(new Date());
22 }
23 }
24 );
25
26 // Handler pour les différents types d'événements
27 useEffect(() => {
28 const removeMetricsListener = addEventListener('metrics', (event) => {
29 const metricsData = JSON.parse(event.data);
30 setMetrics(metricsData);
31 });
32
33 const removeNotificationListener = addEventListener('notification', (event) => {
34 const notification = JSON.parse(event.data);
35 setNotifications(prev => [notification, ...prev.slice(0, 9)]); // Au maximum 10 notifications
36 });
37
38 const removeStatusListener = addEventListener('status', (event) => {
39 const status = JSON.parse(event.data);
40 setSystemStatus(status.level);
41 });
42
43 const removeAlertListener = addEventListener('alert', (event) => {
44 const alert = JSON.parse(event.data);
45
46 // Afficher l'alerte comme notification du navigateur
47 if (Notification.permission === 'granted') {
48 new Notification(`System Alert: ${alert.type}`, {
49 body: alert.message,
50 icon: '/icons/alert.png'
51 });
52 }
53
54 setNotifications(prev => [{
55 ...alert,
56 type: 'alert',
57 timestamp: Date.now()
58 }, ...prev.slice(0, 9)]);
59 });
60
61 return () => {
62 removeMetricsListener();
63 removeNotificationListener();
64 removeStatusListener();
65 removeAlertListener();
66 };
67 }, [addEventListener]);
68
69 // Demander l'autorisation après un clic : Firefox et Safari ignorent une demande sans geste de l'utilisateur
70 const [notificationPermission, setNotificationPermission] = useState(Notification.permission);
71
72 const requestNotifications = async () => {
73 setNotificationPermission(await Notification.requestPermission());
74 };
75
76 const getStatusColor = (status) => {
77 switch (status) {
78 case 'healthy': return '#4CAF50';
79 case 'warning': return '#FF9800';
80 case 'critical': return '#F44336';
81 default: return '#9E9E9E';
82 }
83 };
84
85 const getMetricColor = (value, thresholds) => {
86 if (value >= thresholds.critical) return '#F44336';
87 if (value >= thresholds.warning) return '#FF9800';
88 return '#4CAF50';
89 };
90
91 if (error) {
92 return (
93 <div className="dashboard-error">
94 <h2>Erreur de connexion au tableau de bord</h2>
95 <p>Impossible d'établir la connexion avec le serveur de monitoring.</p>
96 <button onClick={() => window.location.reload()}>
97 Rafraîchir la page
98 </button>
99 </div>
100 );
101 }
102
103 return (
104 <div className="live-dashboard">
105 <header className="dashboard-header">
106 <h1>Live Dashboard</h1>
107 <div className="connection-info">
108 <span
109 className={`status-indicator ${isOpen ? 'connected' : 'disconnected'}`}
110 style={{ backgroundColor: isOpen ? '#4CAF50' : '#F44336' }}
111 />
112 {isOpen ? 'Connecté' : 'Déconnecté'}
113 {lastUpdate && (
114 <span className="last-update">
115 Dernière mise à jour : {lastUpdate.toLocaleTimeString()}
116 </span>
117 )}
118 </div>
119 {notificationPermission === 'default' && (
120 <button onClick={requestNotifications}>Activer les notifications</button>
121 )}
122 </header>
123
124 <div className="metrics-grid">
125 <div className="metric-card">
126 <h3>Utilisateurs actifs</h3>
127 <div
128 className="metric-value"
129 style={{ color: getMetricColor(metrics.activeUsers, { warning: 1000, critical: 1500 }) }}
130 >
131 {metrics.activeUsers.toLocaleString()}
132 </div>
133 </div>
134
135 <div className="metric-card">
136 <h3>Charge du serveur</h3>
137 <div
138 className="metric-value"
139 style={{ color: getMetricColor(metrics.serverLoad, { warning: 70, critical: 90 }) }}
140 >
141 {metrics.serverLoad}%
142 </div>
143 <div className="progress-bar">
144 <div
145 className="progress-fill"
146 style={{
147 width: `${metrics.serverLoad}%`,
148 backgroundColor: getMetricColor(metrics.serverLoad, { warning: 70, critical: 90 })
149 }}
150 />
151 </div>
152 </div>
153
154 <div className="metric-card">
155 <h3>Utilisation de la mémoire</h3>
156 <div
157 className="metric-value"
158 style={{ color: getMetricColor(metrics.memoryUsage, { warning: 80, critical: 95 }) }}
159 >
160 {metrics.memoryUsage}%
161 </div>
162 <div className="progress-bar">
163 <div
164 className="progress-fill"
165 style={{
166 width: `${metrics.memoryUsage}%`,
167 backgroundColor: getMetricColor(metrics.memoryUsage, { warning: 80, critical: 95 })
168 }}
169 />
170 </div>
171 </div>
172
173 <div className="metric-card">
174 <h3>Requêtes/s</h3>
175 <div
176 className="metric-value"
177 style={{ color: getMetricColor(metrics.requestsPerSecond, { warning: 1000, critical: 2000 }) }}
178 >
179 {metrics.requestsPerSecond}
180 </div>
181 </div>
182 </div>
183
184 <div className="system-status">
185 <h3>État du système</h3>
186 <div
187 className="status-badge"
188 style={{
189 backgroundColor: getStatusColor(systemStatus),
190 color: 'white',
191 padding: '8px 16px',
192 borderRadius: '20px',
193 display: 'inline-block'
194 }}
195 >
196 {systemStatus.toUpperCase()}
197 </div>
198 </div>
199
200 <div className="notifications-panel">
201 <h3>Notifications ({notifications.length})</h3>
202 <div className="notifications-list">
203 {notifications.map((notification, index) => (
204 <div
205 key={index}
206 className={`notification ${notification.type || 'info'}`}
207 >
208 <div className="notification-content">
209 <strong>{notification.title || notification.type}</strong>
210 <p>{notification.message}</p>
211 </div>
212 <div className="notification-time">
213 {new Date(notification.timestamp).toLocaleTimeString()}
214 </div>
215 </div>
216 ))}
217 </div>
218 </div>
219 </div>
220 );
221};
222
223export default LiveDashboard;Chaque listener renvoie une fonction de nettoyage, donc le cleanup de l'effet les détache tous. Une alerte part aussi vers une notification système, si l'utilisateur les a acceptées auparavant. Un bouton demande l'autorisation, car Firefox et Safari ignorent une demande d'autorisation qui ne vient pas d'un geste de l'utilisateur.
Choisir entre WebSockets et SSE
Quand utiliser les WebSockets
Un éditeur collaboratif est le cas d'école du WebSocket : chaque utilisateur émet des modifications de texte et des mouvements de curseur, et reçoit en même temps ceux des autres :
1// Exemple : application de collaboration en temps réel
2const CollaborativeEditor = () => {
3 const { sendMessage, lastMessage, isOpen } = useWebSocket('/ws/document/123');
4 const [documentContent, setDocumentContent] = useState('');
5 const [cursors, setCursors] = useState(new Map());
6
7 const handleTextChange = (newContent, position) => {
8 setDocumentContent(newContent);
9
10 // Envoyer la modification aux autres utilisateurs
11 sendMessage({
12 type: 'content_change',
13 content: newContent,
14 position,
15 userId: currentUser.id
16 });
17 };
18
19 const handleCursorMove = (position) => {
20 sendMessage({
21 type: 'cursor_move',
22 position,
23 userId: currentUser.id
24 });
25 };
26
27 useEffect(() => {
28 if (lastMessage) {
29 switch (lastMessage.data.type) {
30 case 'content_change':
31 setDocumentContent(lastMessage.data.content);
32 break;
33 case 'cursor_move':
34 setCursors(prev => new Map(prev).set(
35 lastMessage.data.userId,
36 lastMessage.data.position
37 ));
38 break;
39 }
40 }
41 }, [lastMessage]);
42
43 return (
44 <div className="collaborative-editor">
45 {/* Éditeur avec collaboration en temps réel */}
46 </div>
47 );
48};Le trafic circule dans les deux sens plusieurs fois par seconde, donc une seule connexion persistante l'emporte sur une série de requêtes HTTP séparées.
Quand utiliser le SSE
Un fil d'actualités est le cas inverse : le lecteur ne fait que recevoir les nouveaux articles, donc le SSE suffit :
1// Exemple : fil d'actualités en direct
2const NewsFeed = () => {
3 const [articles, setArticles] = useState([]);
4 const [newArticleCount, setNewArticleCount] = useState(0);
5
6 const { data, addEventListener } = useServerSentEvents('/api/news/stream');
7
8 useEffect(() => {
9 const removeArticleListener = addEventListener('new_article', (event) => {
10 const article = JSON.parse(event.data);
11 setArticles(prev => [article, ...prev]);
12 setNewArticleCount(prev => prev + 1);
13 });
14
15 const removeUpdateListener = addEventListener('article_update', (event) => {
16 const updatedArticle = JSON.parse(event.data);
17 setArticles(prev => prev.map(article =>
18 article.id === updatedArticle.id ? updatedArticle : article
19 ));
20 });
21
22 return () => {
23 removeArticleListener();
24 removeUpdateListener();
25 };
26 }, [addEventListener]);
27
28 return (
29 <div className="news-feed">
30 {newArticleCount > 0 && (
31 <div className="new-articles-banner">
32 {newArticleCount} nouveaux articles
33 <button onClick={() => setNewArticleCount(0)}>
34 Marquer comme lu
35 </button>
36 </div>
37 )}
38
39 {articles.map(article => (
40 <ArticleCard key={article.id} article={article} />
41 ))}
42 </div>
43 );
44};Il n'y a ici aucun sendMessage. Si le client envoie parfois quelque chose, par exemple marque un article comme lu, il le fait avec une requête HTTP ordinaire.
Comparaison et bonnes pratiques
Pour finir, un utilitaire qui choisit la technologie en fonction des besoins :
1// Choix de la technologie selon les besoins - une simple fonction, pas un hook
2const chooseTransport = ({
3 bidirectional = false,
4 authentication = false,
5 highFrequency = false,
6 binaryData = false
7} = {}) =>
8 bidirectional || authentication || highFrequency || binaryData ? 'websocket' : 'sse';
9
10// Chaque canal est un composant distinct qui appelle toujours le même hook
11function WebSocketChannel({ url }) {
12 const { sendMessage, lastMessage } = useWebSocket(url.replace('http', 'ws'));
13 // Logique pour WebSocket
14 return <div>Canal WebSocket</div>;
15}
16
17function SseChannel({ url }) {
18 const { data, addEventListener } = useServerSentEvents(url);
19 // Logique pour SSE
20 return <div>Canal SSE</div>;
21}
22
23// Exemple d'utilisation
24const SmartComponent = () => {
25 const transport = chooseTransport({
26 bidirectional: true, // Nécessite WebSocket
27 authentication: true
28 });
29
30 return transport === 'websocket'
31 ? <WebSocketChannel url="/api/data" />
32 : <SseChannel url="/api/data" />;
33};authentication désigne le WebSocket, car EventSource ne permet pas d'ajouter ses propres en-têtes, comme Authorization. Le choix lui-même est une simple fonction, et les hooks sont appelés par des composants distincts, donc chacun appelle toujours le même hook dans le même ordre, comme l'exigent les règles des hooks. Appeler useWebSocket ou useServerSentEvents de façon conditionnelle dans un seul composant enfreint ces règles.
Mon conseil : quand les données ne viennent que du serveur, commence par le SSE, car c'est du HTTP ordinaire avec reconnexion automatique. Passe au WebSocket quand le client émet lui aussi souvent. Dans l'exercice, tu construiras ton propre hook avec connexion, déconnexion, envoi et indicateur de statut.
Retiens : un WebSocket est un canal radio bidirectionnel, et le SSE un émetteur qui ne fait qu'émettre - choisis celui qui convient à la conversation.
Code de cette leçon : App.jsx
1import React, { useState, useEffect, useRef, useCallback } from 'react';
2
3// Simulation d’un serveur WebSocket : flux de télémétrie du vaisseau et réponses aux commandes du client
4// Un vrai code utiliserait new WebSocket(url) avec les gestionnaires onopen, onmessage et onclose
5class MockWebSocketServer {
6 constructor() {
7 this.handlers = null;
8 this.openTimer = null;
9 this.interval = null;
10 }
11
12 connect(handlers) {
13 this.handlers = handlers;
14 this.openTimer = setTimeout(() => {
15 handlers.onOpen();
16 this.interval = setInterval(() => {
17 handlers.onMessage({
18 type: 'TELEMETRY',
19 timestamp: Date.now(),
20 data: {
21 speed: (27600 + Math.random() * 400).toFixed(0),
22 altitude: (408 + Math.random() * 2).toFixed(1),
23 fuel: (85 - Math.random() * 0.5).toFixed(1),
24 temperature: (20 + Math.random() * 3).toFixed(1),
25 oxygen: (98.5 + Math.random() * 1.5).toFixed(1),
26 },
27 });
28 }, 1500);
29 }, 400);
30 }
31
32 // Le serveur répond à une commande du client - communication bidirectionnelle
33 send(message) {
34 setTimeout(() => {
35 this.handlers?.onMessage({ type: 'ACK', command: message.command, timestamp: Date.now() });
36 }, 300);
37 }
38
39 // Liaison coupée : fermeture sans arrêt propre (wasClean: false)
40 drop() {
41 const handlers = this.handlers;
42 this.stop();
43 handlers?.onClose({ wasClean: false });
44 }
45
46 close() {
47 const handlers = this.handlers;
48 this.stop();
49 handlers?.onClose({ wasClean: true });
50 }
51
52 stop() {
53 clearTimeout(this.openTimer);
54 clearInterval(this.interval);
55 this.handlers = null;
56 }
57}
58
59// Simulation de Server-Sent Events : le serveur envoie lui-même les notifications de mission, le client ne fait qu’écouter
60class MockSSEServer {
61 subscribe(onEvent) {
62 const events = [
63 { type: 'mission_update', data: 'Mission Nebula : étape 3 terminée' },
64 { type: 'alert', data: 'Anomalie gravitationnelle détectée dans le secteur 7' },
65 { type: 'crew_message', data: 'Dr. Stellar : analyse des échantillons terminée' },
66 { type: 'system', data: 'Mise à jour du logiciel de navigation disponible' },
67 { type: 'alert', data: 'Approche d’un champ d’astéroïdes - changement de cap' },
68 { type: 'mission_update', data: 'Nouvelle mission assignée : exploration d’Io' },
69 ];
70
71 let index = 0;
72 const interval = setInterval(() => {
73 const event = events[index % events.length];
74 index++;
75 onEvent({ ...event, id: index, timestamp: new Date().toLocaleTimeString('fr') });
76 }, 2500);
77
78 return () => clearInterval(interval);
79 }
80}
81
82const MAX_RETRIES = 3;
83const RETRY_DELAY = 2000;
84
85// Hook WebSocket : après une coupure de la liaison, il tente de se reconnecter
86// toutes les 2 secondes, 3 fois au maximum ; une fermeture par l’utilisateur ne relance pas la connexion
87function useWebSocket() {
88 const [status, setStatus] = useState('DISCONNECTED');
89 const [messages, setMessages] = useState([]);
90 const [attempt, setAttempt] = useState(0);
91 const serverRef = useRef(null);
92 const retryRef = useRef({ count: 0, timer: null });
93
94 const connect = useCallback(function openConnection() {
95 const server = new MockWebSocketServer();
96 serverRef.current = server;
97 setStatus('CONNECTING');
98 server.connect({
99 onOpen: () => {
100 retryRef.current.count = 0;
101 setAttempt(0);
102 setStatus('CONNECTED');
103 },
104 onMessage: (message) => setMessages((prev) => [message, ...prev].slice(0, 20)),
105 onClose: ({ wasClean }) => {
106 serverRef.current = null;
107 if (wasClean || retryRef.current.count >= MAX_RETRIES) {
108 setStatus('DISCONNECTED');
109 return;
110 }
111 // Fermeture anormale : nouvelle tentative après RETRY_DELAY
112 retryRef.current.count += 1;
113 setAttempt(retryRef.current.count);
114 setStatus('RECONNECTING');
115 retryRef.current.timer = setTimeout(openConnection, RETRY_DELAY);
116 },
117 });
118 }, []);
119
120 const disconnect = useCallback(() => {
121 clearTimeout(retryRef.current.timer);
122 retryRef.current.count = 0;
123 setAttempt(0);
124 if (serverRef.current) serverRef.current.close();
125 else setStatus('DISCONNECTED');
126 }, []);
127
128 const simulateDrop = useCallback(() => {
129 serverRef.current?.drop();
130 }, []);
131
132 const sendCommand = useCallback((command) => {
133 const message = { type: 'COMMAND', command, timestamp: Date.now() };
134 setMessages((prev) => [message, ...prev].slice(0, 20));
135 serverRef.current?.send(message);
136 }, []);
137
138 // Démontage : arrêter le serveur et la tentative de reconnexion prévue
139 useEffect(() => {
140 const retry = retryRef.current;
141 return () => {
142 clearTimeout(retry.timer);
143 serverRef.current?.stop();
144 };
145 }, []);
146
147 return { status, attempt, messages, connect, disconnect, simulateDrop, sendCommand };
148}
149
150const STATUS_LABELS = {
151 CONNECTING: 'Connexion...',
152 CONNECTED: 'Connecté',
153 RECONNECTING: 'Reconnexion',
154 DISCONNECTED: 'Déconnecté',
155};
156const STATUS_COLORS = { CONNECTING: '#ffa500', CONNECTED: '#00ff88', RECONNECTING: '#ffa500', DISCONNECTED: '#ff6b6b' };
157
158function TelemetryPanel() {
159 const { status, attempt, messages, connect, disconnect, simulateDrop, sendCommand } = useWebSocket();
160 const latest = messages.find((message) => message.type === 'TELEMETRY')?.data;
161 const lastAck = messages.find((message) => message.type === 'ACK');
162 const connected = status === 'CONNECTED';
163 const idle = status === 'DISCONNECTED';
164
165 const metrics = latest ? [
166 { label: 'Vitesse', value: `${latest.speed} km/h` },
167 { label: 'Altitude', value: `${latest.altitude} km` },
168 { label: 'Carburant', value: `${latest.fuel}%` },
169 { label: 'Température', value: `${latest.temperature} °C` },
170 { label: 'Oxygène', value: `${latest.oxygen}%` },
171 ] : [];
172
173 return (
174 <div style={styles.panel}>
175 <div style={styles.panelHeader}>
176 <h3 style={styles.heading}>WebSocket - télémétrie</h3>
177 <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
178 <span style={{ ...styles.dot, background: STATUS_COLORS[status] }} />
179 <span style={{ color: '#8892b0', fontSize: '12px' }}>
180 {STATUS_LABELS[status]}{status === 'RECONNECTING' ? ` (${attempt}/${MAX_RETRIES})` : ''}
181 </span>
182 </div>
183 </div>
184 <div style={styles.buttons}>
185 <button onClick={connect} disabled={!idle} style={{ ...styles.btn, opacity: idle ? 1 : 0.4 }}>Connecter</button>
186 <button onClick={disconnect} disabled={idle} style={{ ...styles.btnDanger, opacity: idle ? 0.4 : 1 }}>Déconnecter</button>
187 <button onClick={simulateDrop} disabled={!connected} style={{ ...styles.btnDanger, opacity: connected ? 1 : 0.4 }}>Couper la liaison</button>
188 <button onClick={() => sendCommand('PING')} disabled={!connected} style={{ ...styles.btn, opacity: connected ? 1 : 0.4 }}>Envoyer un ping</button>
189 </div>
190 {latest && (
191 <div style={styles.metrics}>
192 {metrics.map((metric) => (
193 <div key={metric.label} style={styles.metricCard}>
194 <div style={{ color: '#8892b0', fontSize: '11px' }}>{metric.label}</div>
195 <div style={{ color: '#00d4ff', fontSize: '17px', fontWeight: 'bold' }}>{metric.value}</div>
196 </div>
197 ))}
198 </div>
199 )}
200 {lastAck && <p style={{ color: '#00ff88', fontSize: '12px', margin: '12px 0 0' }}>Le serveur a confirmé la commande {lastAck.command} à {new Date(lastAck.timestamp).toLocaleTimeString('fr')}</p>}
201 {!latest && idle && <p style={styles.empty}>Clique sur « Connecter » pour lancer le flux de données.</p>}
202 </div>
203 );
204}
205
206const EVENT_LABELS = { mission_update: 'mission', alert: 'alerte', crew_message: 'équipage', system: 'système' };
207const EVENT_COLORS = { mission_update: '#00ff88', alert: '#ff6b6b', crew_message: '#ffa500', system: '#00d4ff' };
208
209function SSENotificationsPanel() {
210 const [events, setEvents] = useState([]);
211 const [listening, setListening] = useState(false);
212 const unsubscribeRef = useRef(null);
213
214 const startListening = () => {
215 unsubscribeRef.current = new MockSSEServer().subscribe((event) => {
216 setEvents((prev) => [event, ...prev].slice(0, 15));
217 });
218 setListening(true);
219 };
220
221 const stopListening = () => {
222 unsubscribeRef.current?.();
223 unsubscribeRef.current = null;
224 setListening(false);
225 };
226
227 // Fermer l’abonnement au démontage
228 useEffect(() => () => unsubscribeRef.current?.(), []);
229
230 return (
231 <div style={styles.panel}>
232 <div style={styles.panelHeader}>
233 <h3 style={styles.heading}>SSE - notifications</h3>
234 <button onClick={listening ? stopListening : startListening} style={listening ? styles.btnDanger : styles.btn}>
235 {listening ? 'Arrêter' : 'S’abonner'}
236 </button>
237 </div>
238 <div style={{ maxHeight: '300px', overflowY: 'auto' }}>
239 {events.length === 0 && <p style={styles.empty}>Aucune notification - clique sur « S’abonner ».</p>}
240 {events.map((event) => (
241 <div key={event.id} style={{ ...styles.event, borderLeftColor: EVENT_COLORS[event.type] }}>
242 <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
243 <span style={{ color: EVENT_COLORS[event.type], fontSize: '11px', textTransform: 'uppercase', fontWeight: 'bold' }}>{EVENT_LABELS[event.type]}</span>
244 <span style={{ color: '#8892b0', fontSize: '11px' }}>{event.timestamp}</span>
245 </div>
246 <div style={{ color: '#e0e1dd', fontSize: '13px' }}>{event.data}</div>
247 </div>
248 ))}
249 </div>
250 </div>
251 );
252}
253
254function App() {
255 return (
256 <div style={styles.container}>
257 <h1 style={styles.title}>Centre de communication en temps réel</h1>
258 <p style={styles.subtitle}>WebSockets et Server-Sent Events dans React</p>
259 <div style={styles.grid}>
260 <TelemetryPanel />
261 <SSENotificationsPanel />
262 </div>
263 <p style={styles.note}>Un WebSocket transporte les données dans les deux sens : le serveur envoie la télémétrie, et le client des commandes comme ping. « Couper la liaison » montre la reconnexion. Les SSE ne circulent que du serveur vers le client, donc le panneau de notifications n’envoie rien.</p>
264 </div>
265 );
266}
267
268const styles = {
269 container: { minHeight: '100vh', background: 'linear-gradient(135deg, #0d1b2a 0%, #1a1a3e 100%)', padding: '24px 16px', color: '#e0e1dd', fontFamily: 'system-ui, sans-serif', boxSizing: 'border-box' },
270 title: { textAlign: 'center', color: '#00d4ff', margin: '0 0 4px', fontSize: '24px' },
271 subtitle: { textAlign: 'center', color: '#8892b0', marginBottom: '20px' },
272 grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '16px' },
273 panel: { background: 'rgba(0,0,0,0.3)', borderRadius: '12px', padding: '16px', border: '1px solid rgba(0,212,255,0.2)' },
274 panelHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', flexWrap: 'wrap', marginBottom: '12px' },
275 heading: { color: '#00d4ff', margin: 0, fontSize: '17px' },
276 dot: { width: '10px', height: '10px', borderRadius: '50%', display: 'inline-block' },
277 buttons: { display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '12px' },
278 metrics: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(90px, 1fr))', gap: '8px' },
279 metricCard: { padding: '10px', background: 'rgba(0,212,255,0.05)', borderRadius: '8px', textAlign: 'center', border: '1px solid rgba(0,212,255,0.15)' },
280 event: { padding: '8px 12px', borderLeft: '3px solid', background: 'rgba(0,0,0,0.2)', marginBottom: '6px', borderRadius: '0 6px 6px 0' },
281 empty: { color: '#8892b0', textAlign: 'center', fontSize: '13px' },
282 btn: { padding: '8px 12px', background: 'rgba(0,212,255,0.15)', border: '1px solid #00d4ff', borderRadius: '6px', color: '#00d4ff', cursor: 'pointer', fontSize: '13px' },
283 btnDanger: { padding: '8px 12px', background: 'rgba(255,107,107,0.15)', border: '1px solid #ff6b6b', borderRadius: '6px', color: '#ff6b6b', cursor: 'pointer', fontSize: '13px' },
284 note: { maxWidth: '760px', margin: '16px auto 0', color: '#8892b0', fontSize: '13px', lineHeight: 1.5 },
285};
286
287export default App;Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Quand WebSocket est-il un meilleur choix que l'API REST dans une application React ?
2. En quoi les Server-Sent Events (SSE) diffèrent-ils de WebSocket ?
Tâches pratiques dans le jeu
- Éditeur de code
Termine le hook useMissionSocket qui gère le canal de communication avec la station. La socket est créée par createSocket(url) - dans l'aperçu, c'est un simulateur de station avec la même interface que WebSocket. ___BLANK1___ : dans onmessage, transforme le texte de event.data en objet message avec JSON.parse. ___BLANK2___ : la fonction de nettoyage de l'effet ferme la socket avec close() quand le composant disparaît (aussi quand StrictMode lance l'effet deux fois). ___BLANK3___ : send envoie l'objet { text } transformé en texte avec JSON.stringify. Après quelques secondes, la télémétrie apparaît dans l'aperçu, et chacun de tes messages revient en écho.