Tutte le interazioni HTTP viste finora — il server statico del capitolo 3, le API REST del capitolo 5 — hanno una caratteristica comune che è così ovvia da passare inosservata: è sempre il client a iniziare. Il browser chiede, il server risponde. Il server, per quanto sappia benissimo che qualcosa è cambiato, non ha alcun modo di dirlo a nessuno: può soltanto aspettare che qualcuno passi a chiedere.
Questo è il modello pull. La comunicazione è inizializzata dal client, che richiede periodicamente informazioni al server; le tecniche che lo realizzano sono il polling e il long polling. È semplice da implementare, ed è inefficiente. L'esempio elementare è il browser che ricarica una pagina ogni dieci secondi per avere gli aggiornamenti: nove volte su dieci scarica di nuovo quello che aveva già.
Il problema si vede bene appena si prova a costruire qualcosa di vivo. Una chat in cui i messaggi arrivano quando l'utente si ricorda di premere F5 non è una chat. Una dashboard di sensori che si aggiorna al prossimo giro di polling non è tempo reale. E poiché il progetto d'esame richiede esplicitamente almeno una funzionalità in cui è il server a iniziare la comunicazione, questo capitolo non è un approfondimento facoltativo: è la parte dello stack che vale otto punti su ventisette.
La domanda che discrimina tutte le tecniche di questo capitolo è una sola: chi prende l'iniziativa. Nel pull il client interroga e il server subisce; nel push il server parla e il client ascolta. Tutto il resto — quante connessioni si aprono, quanto durano, in quante direzioni scorrono i dati — sono conseguenze di questa scelta.
Nel modello push il server invia i dati al client appena sono disponibili: la comunicazione è server-initiated. Le tecniche sono i Server-Sent Events e le WebSocket. Il prezzo da pagare è la complessità: è più complesso, ma è più efficiente, perché si trasmette solo quando c'è davvero qualcosa da trasmettere.
Gli scenari tipici elencati a lezione sono quattro, e conviene tenerli a mente perché sono anche i quattro modi in cui il push entra in un progetto d'esame:
| Aspetto | Pull | Push |
|---|---|---|
| Iniziativa | Client | Server |
| Efficienza | Bassa (molte richieste) | Alta (invio solo se serve) |
| Complessità | Bassa | Più alta |
| Reattività | Ritardo (polling interval) | Immediata |
Il polling è, in generale, il processo per cui un dispositivo viene controllato ripetutamente per verificarne la disponibilità e, in caso contrario, si torna a un'altra attività. Nel contesto di HTTP il polling è la tecnica in cui un client invia periodicamente richieste al server per verificare se ci sono aggiornamenti o nuovi dati disponibili.
La sequenza è quella che ci si aspetta: richiesta, risposta con il dato corrente, attesa di un intervallo fisso, di nuovo richiesta, di nuovo risposta — spesso con lo stesso dato di prima, che è esattamente lo spreco. Solo alla terza o quarta iterazione la risposta contiene finalmente un valore nuovo.
Lato client bastano una fetch e un timer; lato server non serve nulla di speciale, perché la rotta è una normale rotta Express che risponde e chiude:
// examples/example-01-polling/index.html
function polling() {
fetch("/events")
.then(response => response.text())
.then(data => {
console.log("Data received:", data);
document.body.innerHTML += "<p>" + data + "</p>";
})
}
// examples/example-01-polling/server.js
app.get("/events", (req, res) => {
res.send(`data: ${myEvent}\n\n`);
});
Il server, notatelo, non conserva alcuno stato relativo ai client: risponde e dimentica. È il modello HTTP puro del capitolo 3, applicato a ripetizione.
Il long polling è una tecnica che permette di ridurre il numero di richieste fatte dal client per ottenere nuovi dati dal server. Il meccanismo si descrive in tre passi:
Lato client la differenza rispetto al polling è una sola riga — la chiamata ricorsiva che riapre subito una nuova richiesta al posto del timer:
// examples/example-02-long-polling/index.html
function longPolling() {
fetch("/events")
.then((response) => response.text())
.then((data) => {
console.log("Data received:", data);
document.body.innerHTML += "<p>" + data + "</p>";
longPolling(); // Next request
});
}
Lato server, invece, cambia tutto: il server deve tenere da parte le risposte non ancora inviate. Compare per la prima volta la struttura che ritroveremo in tutte le tecniche push — un array di client, popolato all'arrivo della richiesta e ripulito quando la connessione si chiude:
// examples/example-02-long-polling/server.js
let clients = [];
let myEvent = 0;
app.get("/events", (req, res) => {
clients.push(res);
req.on("close", () => {
clients = clients.filter((client) => client !== res);
});
});
setInterval(() => {
clients.forEach((client) => {
client.send(`data: ${myEvent++}\n\n`);
});
}, 5000);
Il gestore req.on("close", …) non è un dettaglio di eleganza: senza di lui l'array clients cresce indefinitamente accumulando oggetti res di connessioni ormai morte, e il server prova a scrivere su socket chiusi. Vale per il long polling, per SSE e — nella forma ws.on("close", …) — anche per le WebSocket.
I Server-Sent Events (SSE) permettono al server di inviare aggiornamenti automatici al client tramite una connessione HTTP unidirezionale, e sono ideali per le notifiche in tempo reale senza bisogno di richieste continue da parte del client. La connessione viene aperta una volta sola e resta lì: il server ci scrive dentro un evento dopo l'altro finché uno dei due la chiude.
Il client non usa più fetch, ma l'oggetto nativo EventSource, che si occupa da solo di aprire la connessione e di consegnare ogni messaggio al callback:
// examples/example-03-sse/index.html
function setupSSE() {
const eventSource = new EventSource("/events");
eventSource.onmessage = function (event) {
console.log("Data received:", event.data);
document.body.innerHTML += "<p>" + event.data + "</p>";
};
}
Lato server la rotta diventa una rotta che non finisce mai. Gli header sono la parte che conta davvero, ed è quella su cui vale la pena soffermarsi:
SSE non è un protocollo nuovo: è HTTP usato in modo diverso. Una singola risposta che non viene mai terminata, con Content-Type: text/event-stream e un formato del corpo — data: … seguito da due a capo — che il browser sa spezzettare in eventi. Per questo funziona attraverso proxy e firewall che parlano solo HTTP, ed è la via più economica per il push quando i dati vanno in una sola direzione.
Le WebSocket forniscono una connessione bidirezionale persistente tra client e server, e consentono comunicazioni in tempo reale con bassa latenza. Sono la scelta indicata per le applicazioni interattive come le chat o i giochi online, cioè per tutti i casi in cui anche il client ha qualcosa da dire in qualsiasi momento, non solo da ascoltare.
Un punto che a lezione viene sottolineato: è tramite il protocollo HTTP che si instaura la socket. La connessione nasce come una normale richiesta HTTP che chiede un upgrade, e solo dopo l'handshake il canale diventa una socket bidirezionale. È il motivo per cui nel codice server compare un gestore dell'evento upgrade:
// examples/example-04-websockets/index.html
function setupWebSocket() {
const socket = new WebSocket("ws://localhost:3000");
socket.onmessage = function(event) {
console.log("Data received:", event.data);
document.body.innerHTML += "<p>" + event.data + "</p>";
};
}
document.addEventListener("DOMContentLoaded", (event) => {
setupWebSocket();
});
// examples/example-04-websockets/server.js
let myEvent = 0;
server.on('upgrade', (request, socket, head) => {
console.log('HTTP Upgrade request for WebSocket:', request.headers);
});
wss.on('connection', (ws) => {
ws.on('close', () => {
console.log('3) Client connection closed');
});
});
setInterval(() => {
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(`data: ${myEvent++}`);
}
});
}, 5000);
Notate il controllo client.readyState === WebSocket.OPEN: con una connessione persistente il client può trovarsi in stati intermedi (in apertura, in chiusura, chiusa) e scrivere su una socket che non è aperta è un errore. È la stessa preoccupazione del req.on("close") del long polling, espressa nel vocabolario delle WebSocket.
La tabella che chiude questa parte del laboratorio è la sintesi da tenere a mente: mette in fila tipo di connessione, direzione della comunicazione e uso tipico.
| Tecnica | Tipo di connessione | Direzione | Uso tipico |
|---|---|---|---|
| Polling | Richieste HTTP | Request-response | Aggiornamenti poco frequenti |
| Long Polling | Richieste HTTP prolungate | Request-response | Aggiornamenti in tempo reale, ma non frequenti |
| Server-Sent Events | Connessione HTTP persistente | Unidirezionale (server → client) | Aggiornamenti in tempo reale, streaming di dati leggeri |
| WebSocket | Connessione bidirezionale persistente | Bidirezionale | Applicazioni in tempo reale (chat, giochi, ecc.) |
Non basta sapere che le WebSocket sono "le più potenti": la domanda tipica chiede di motivare una scelta. Il criterio è duplice. Primo, la direzione: se i dati vanno solo dal server al client, SSE basta ed è più semplice, perché resta HTTP puro. Secondo, la frequenza: se gli aggiornamenti sono rari e non urgenti, il polling è legittimo e costa meno complessità. Le WebSocket si giustificano quando serve bidirezionalità con bassa latenza — la chat è l'esempio canonico, perché ogni partecipante è insieme sorgente e destinatario.
Socket.io è una libreria per la comunicazione a bassa latenza, bidirezionale e ad eventi tra client e server. Rispetto alle WebSocket "nude" cambia il modello mentale: non si ragiona più su messaggi da serializzare, ma su eventi con un nome, che si emettono e si ascoltano da entrambe le parti — la stessa idea dell'EventEmitter di Node, estesa attraverso la rete.
L'esercizio guidato è una chat in cui è possibile scambiare messaggi tra tutti gli utenti collegati. Si parte creando un progetto con npm init e aggiungendo le dipendenze:
npm install express
npm install socket.io
Lato server servono tre oggetti — l'applicazione Express, il server HTTP che la ospita e il server Socket.io agganciato a quest'ultimo:
// 1.
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
// 2.
const app = express();
const server = http.createServer(app);
const io = new Server(server);
Poi si gestisce la rotta radice restituendo l'index.html fornito e si avvia il server:
// 3.
app.get("/", (req, res) => {
res.sendFile(join(__dirname, "public", "index.html"));
});
// 4.
server.listen(3000, () => {
console.log("Server running at http://localhost:3000");
});
Ogni volta che un utente si connette — cioè ogni volta che un client chiama la funzione io() — viene generato un evento connection; quando si disconnette viene generato un evento disconnect. Si gestiscono così:
io.on('connection', (socket) => {
console.log('a user connected');
socket.on('disconnect', () => {
console.log('user disconnected');
});
});
Lato client basta una riga dentro il tag script: const socket = io();. Lanciando il server e visitando la radice deve comparire il messaggio di connessione; alla chiusura della tab, quello di disconnessione.
Qui sta il cuore dell'esercizio, ed è un ottimo esempio di come si ragiona con gli eventi. Al click sul bottone di submit il client emette l'evento, il server lo intercetta e lo ripropaga a tutti, ogni client lo riceve e lo appende alla lista.
$('form').submit((e)=>{
e.preventDefault();
socket.emit('chat message', $('#m').val());
$('#m').val('');
return false;
});
Il preventDefault() impedisce al form di ricaricare la pagina, che è esattamente ciò che stiamo cercando di evitare. Il messaggio parte come evento chat message.
socket.on('chat message', (msg)=>{
io.emit('chat message', msg);
});
Il codice va scritto all'interno della callback di connessione. Notate la differenza fondamentale tra socket.emit — che parla al singolo client — e io.emit — che parla a tutti i client collegati. È quest'ultimo a realizzare il broadcast.
socket.on('chat message', (msg)=>{
$('#messages').append($('<li>').text(msg));
});
Lo stesso nome di evento viaggia in entrambe le direzioni: il client lo emette verso il server e lo ascolta dal server. Il mittente riceve quindi anche il proprio messaggio, che è il comportamento atteso in una chat.
io.emit è la differenza fra un messaggio privato e un broadcast.A partire dal codice fornito in exercises/exercise-01, l'esercitazione chiede di implementare cinque funzionalità. Vale la pena leggerle non come una lista di compiti ma come una piccola tassonomia di problemi che ricompaiono in ogni applicazione real-time — e che ricompariranno nel progetto d'esame.
| Funzionalità richiesta | Il problema che nasconde |
|---|---|
| Mandare un messaggio in broadcast quando qualcuno si connette o disconnette | Trasformare gli eventi di sistema (connection, disconnect) in eventi applicativi visibili a tutti |
Mantenere la storia dei messaggi, servita su GET /messages | Far convivere il canale push con una normale API REST: lo stato non vive solo nel flusso di eventi |
| Aggiungere i nickname | Associare uno stato applicativo alla singola socket, cioè dare identità a una connessione |
| Visualizzare "utente sta scrivendo…" | Eventi effimeri che non fanno parte della storia e non vanno persistiti |
| Visualizzare la lista delle persone online | Mantenere sul server una vista aggregata e coerente dell'insieme dei client connessi |
La seconda riga della tabella è quella più istruttiva in vista del progetto. Una applicazione real-time seria non è "solo push": ha una API REST per lo stato consolidato (la storia dei messaggi, l'elenco delle risorse) e un canale push per gli aggiornamenti incrementali. Chi apre la pagina per la prima volta ha bisogno del primo; chi la tiene aperta ha bisogno del secondo.
Il regolamento del progetto chiede che almeno una funzionalità sia basata su uno scambio di dati iniziato dal server, e assegna a questa voce fino a 8 punti su 27. In sede di orale aspettatevi di dover dire quale tecnica avete scelto e perché — con il criterio direzione/frequenza della sezione 7 — e di dover mostrare dove nel codice il server decide di parlare per primo.
Sta in chi inizia la comunicazione. Nel pull è il client: invia richieste periodiche al server per verificare se ci sono aggiornamenti, con le tecniche di polling e long polling. È semplice da implementare ma inefficiente, e la reattività è limitata dall'intervallo di polling. Nel push è il server: invia i dati al client appena sono disponibili, con Server-Sent Events e WebSocket. È più complesso ma più efficiente, perché si trasmette solo quando serve, e la reattività è immediata.
Lato client la differenza è minima: invece di attendere un intervallo fisso, il client riapre subito una nuova richiesta appena riceve la risposta (nell'esempio, la chiamata ricorsiva longPolling() dentro il .then()). Lato server la differenza è radicale: il server non risponde subito, ma trattiene la richiesta finché non ha nuovi dati. Per farlo deve conservare gli oggetti res in un array di client e rimuoverli quando la connessione si chiude, con req.on("close", …). Il risultato è che si riduce il numero di richieste e sparisce il ritardo dell'intervallo.
Tre. Content-Type: text/event-stream dichiara che il corpo è un flusso di eventi e non un documento, ed è ciò che permette all'oggetto EventSource del browser di interpretarlo. Cache-Control: no-cache impedisce che i messaggi vengano serviti da una cache intermedia, cosa che ne annullerebbe il senso. Connection: keep-alive mantiene aperta la connessione. Nell'esempio si aggiunge poi res.flushHeaders(), che invia subito gli header al client evitando il buffering: senza, il client potrebbe non accorgersi che la connessione è pronta.
data: valore\n\n?Perché il flusso text/event-stream è una sequenza di campi testuali e il doppio a capo è il delimitatore che chiude un evento e lo consegna al gestore onmessage del client. Senza la seconda newline il browser continua ad accumulare il messaggio in attesa che finisca. È lo stesso motivo per cui nel codice server si usa client.write(...) e non client.end(...): la risposta non deve mai concludersi.
Tramite il protocollo HTTP. Il client apre una normale richiesta HTTP che chiede un upgrade del protocollo; il server la riconosce — nell'esempio con server.on('upgrade', …) — ed emette la conferma, dopo la quale la connessione smette di essere una richiesta HTTP e diventa una socket bidirezionale persistente. Da quel momento client e server possono scriversi in qualsiasi momento e in entrambe le direzioni, con bassa latenza.
client.readyState === WebSocket.OPEN?Perché una connessione persistente attraversa più stati (in apertura, aperta, in chiusura, chiusa) e il server mantiene una collezione di client che può contenere socket non più utilizzabili. Scrivere su una socket che non è nello stato OPEN è un errore. È la stessa preoccupazione che nel long polling e in SSE si risolve rimuovendo il client dall'array nel gestore dell'evento close: in tutte le tecniche push il server deve gestire esplicitamente il ciclo di vita delle connessioni, cosa che nel modello request-response non serve.
Server-Sent Events. Il criterio è duplice: la direzione è unidirezionale (server verso client), quindi la bidirezionalità delle WebSocket sarebbe complessità non utilizzata; la frequenza è alta e serve reattività immediata, quindi il polling introdurrebbe ritardo e richieste sprecate. In più SSE resta HTTP puro, quindi attraversa senza problemi proxy e infrastrutture che parlano solo HTTP, e il client si riduce a un EventSource con un onmessage.
socket.emit e io.emit in Socket.io?socket.emit invia l'evento a quella singola socket, cioè al client corrispondente; io.emit lo invia a tutti i client collegati. Nella chat di laboratorio è proprio io.emit('chat message', msg), chiamato dentro il gestore socket.on('chat message', …), a realizzare il broadcast: il server riceve da uno e ritrasmette a tutti, mittente compreso. Il server, in questo schema, non è il destinatario del messaggio ma un ripetitore.
e.preventDefault()?Perché il comportamento predefinito del submit di un form è inviare una richiesta HTTP e ricaricare la pagina, che è esattamente ciò che tutta l'architettura sta cercando di evitare: ricaricando si perderebbero la connessione socket e lo stato dell'interfaccia. Bloccato il default, il messaggio viene inviato via socket.emit e l'aggiornamento della lista avviene manipolando il DOM.
GET /messages?Perché servono a due cose diverse. Il canale push consegna gli aggiornamenti incrementali a chi è già collegato, ma non dice nulla a chi arriva dopo. La rotta REST restituisce lo stato consolidato — la storia dei messaggi — a chi apre la pagina per la prima volta o la ricarica. È per questo che l'esercizio chiede esplicitamente di mantenere la storia dei messaggi e di servirla su GET /messages: le due modalità sono complementari, non alternative.