23 gennaio 2021

Server Sent Events

La specifica Server-Sent Events descrive una classe built-in EventSource, che mantiene la connessione con il server e permette di ricevere eventi da esso.

In modo simile ai WebSocket, la connessione è persistente.

Ci sono però delle differenze sostanziali:

WebSocket EventSource
Bidirezionale: sia il client che il server possono scambiare messaggi Unidirezionale: solamente il server può inviare messaggi
Dati binari e testuali Solo testuali
Protocollo WebSocket HTTP standard

EventSource è un modo meno potente di comunicare con il server rispetto ai WebSocket.

Perché dovremmo usarli?

La ragione principale: è semplice da usare. In molte applicazioni, la potenza dei WebSocket è anche troppa.

Se abbiamo necessità di ricevere un flusso di dati da un server: che siano messaggi di chat o variazioni di prezzo dei mercati. Allora è ciò per cui EventSource è fatto. Supporta anche l’auto riconnessione, la qualcosa dovremmo invece implementare manualmente nei WebSocket. Oltretutto, è un normalissimo HTTP, e non un nuovo protocollo.

Ottenere i messaggi

Per cominciare a ricevere messaggi, dobbiamo solamente creare un new EventSource(url).

Il browser si connetterà all’url e terrà la connessione aperta, in attesa di eventi.

Il server dovrebbe rispondere con status 200 ed header Content-Type: text/event-stream, dopodiché mantenere aperta la connessione e scrivere i messaggi all’interno di esso in un formato speciale del tipo:

data: Message 1

data: Message 2

data: Message 3
data: of two lines
  • Un messaggio di testo segue la stringa data:, lo spazio dopo la virgola è opzionale.
  • I messaggi sono delimitati con un doppio line break \n\n.
  • Per inviare un line break \n, possiamo inviare immediatamente un altro data: (il terzo messaggio nell’esempio precedente).

In pratica, i messaggi complessi sono solitamente inviati tramite oggetti codificati in JSO. I Line-breaks sono codificati come \n, e in questo modo i messaggi data: multiriga non sono necessari

Ad esempio:

data: {"user":"John","message":"First line\n Second line"}

…In questo modo possiamo assumere che ogni data contenga esattamente un messaggio.

Per ognuno di questi messaggi, viene generato l’evento message:

let eventSource = new EventSource("/events/subscribe");

eventSource.onmessage = function(event) {
  console.log("New message", event.data);
  //logghera' 3 volte per il data stream poco sopra
};

// oppure eventSource.addEventListener('message', ...)

Richieste Cross-origin

EventSource supporta le richieste cross-origin, come fetch e qualunque altro metodo di rete. Possiamo usare qualunque URL:

let source = new EventSource("https://another-site.com/events");

Il server remoto otterrà l’header Origin e dovrà rispondere con Access-Control-Allow-Origin per continuare.

Per inviare credenziali, dovremmo impostare le opzioni aggiuntive withCredentials, in questo modo:

let source = new EventSource("https://another-site.com/events", {
  withCredentials: true
});

Si prega di guardare il capitolo Fetch: Cross-Origin Requests per maggiori informazioni sugli headers cross-origin.

Riconnessione

In fase di creazione, new EventSource si connette al server, e se la connessione si interrompe – si riconnette.

Ciò è molto conveniente, dal momento che non ci dobbiamo curare della cosa.

C’è un piccolo ritardo tra le riconnessioni, pochi secondi di default.

Il server può impostare il ritardo raccomandato usando retry: nella risposta (in millisecondi)

retry: 15000
data: Hello, I set the reconnection delay to 15 seconds

Il retry: può arrivare insieme ad altri dati, o come messaggio singolo.

Il browser dovrebbe attendere questi millisecondi prima di riconnettersi. O anche di più, ad esempio se il browser sa (dall’OS) che non c’è connessione in quel momento, può attendere fino a quando la connessione non ritorna, e successivamente riprovare.

  • Se il server vuole che il browser smetta di riconnettersi, dovrebbe rispondere con uno status HTTP 204.
  • Se il browser vuole chiudere la connessione, dovrebbe chiamare il metodo eventSource.close():
let eventSource = new EventSource(...);

eventSource.close();

Inoltre, non avverrà alcuna riconnessione se la risposta ha un Content-type non valido o se il suo HTTP status è diverso da 301, 307, 200 o 204. In questi casi verrà emesso l’evento "error", e il browser non si riconnetterà.

Da notare:

Quando una connessione è finalmente chiusa, non ci sarà modo di “riaprirla”. Se volessimo riconnetterci nuovamente, dovremmo ricreare un nuovo EventSource.

Message id

Quando una connessione si interrompe per motivi di problemi di rete, ogni lato non può essere sicuro di quale messaggi siano stati ricevuti, e quali no. Per riprendere correttamente la connessione, ogni messaggio dovrebbe avere un campo id, come questo:

data: Message 1
id: 1

data: Message 2
id: 2

data: Message 3
data: of two lines
id: 3

Quando viene ricevuto un messaggio con id:, il browser:

  • Imposta la proprietà eventSource.lastEventId su quel valore.
  • In fase di riconnessione invia l’header Last-Event-ID con quell’id, in modo da permettere al server di reinviare i messaggi successivi.
Inserisci id: dopo data:

Nota bene: l’id viene aggiunto dopo il messaggio data dal server, per assicurarsi che lastEventId venga aggiornato solamente dopo che il messaggio sia stato ricevuto.

Stato della conessione: readyState

L’oggetto EventSource possiede la proprietà readyState, che può assumere uno dei seguenti valori:

EventSource.CONNECTING = 0; // connessione o riconnessione
EventSource.OPEN = 1;       // connesso
EventSource.CLOSED = 2;     // connessione chiusa

Quando viene creato un oggetto, o se la connessione è assente, viene valorizzato sempre a EventSource.CONNECTING (equivale a 0).

Possiamo interrogare questa proprietà per sapere lo stato di EventSource.

Tipi di evento

Di base l’oggetto EventSource genera tre eventi:

  • message – un messaggio ricevuto, disponibile come event.data.
  • open – la connessione è aperta.
  • error – la connessione non può essere stabilita, ad esempio, il server ha risposto con lo status HTTP 500.

Il server può specificare un altro tipo di evento con event: ... all’inizio dell’evento.

Per esempio:

event: join
data: Bob

data: Hello

event: leave
data: Bob

Per gestire eventi custom, dobbiamo usare addEventListener, e non onmessage:

eventSource.addEventListener('join', event => {
  alert(`Joined ${event.data}`);
});

eventSource.addEventListener('message', event => {
  alert(`Said: ${event.data}`);
});

eventSource.addEventListener('leave', event => {
  alert(`Left ${event.data}`);
});

Esempio completo

Qui c’è il server che invia messaggi con 1, 2, 3, ed infine bye interrompendo la connessione.

Dopo il browser si riconnette automaticamente.

Risultato
server.js
index.html
let http = require('http');
let url = require('url');
let querystring = require('querystring');
let static = require('node-static');
let fileServer = new static.Server('.');

function onDigits(req, res) {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream; charset=utf-8',
    'Cache-Control': 'no-cache'
  });

  let i = 0;

  let timer = setInterval(write, 1000);
  write();

  function write() {
    i++;

    if (i == 4) {
      res.write('event: bye\ndata: bye-bye\n\n');
      clearInterval(timer);
      res.end();
      return;
    }

    res.write('data: ' + i + '\n\n');

  }
}

function accept(req, res) {

  if (req.url == '/digits') {
    onDigits(req, res);
    return;
  }

  fileServer.serve(req, res);
}


if (!module.parent) {
  http.createServer(accept).listen(8080);
} else {
  exports.accept = accept;
}
<!DOCTYPE html>
<script>
let eventSource;

function start() { // when "Start" button pressed
  if (!window.EventSource) {
    // IE or an old browser
    alert("The browser doesn't support EventSource.");
    return;
  }

  eventSource = new EventSource('digits');

  eventSource.onopen = function(e) {
    log("Event: open");
  };

  eventSource.onerror = function(e) {
    log("Event: error");
    if (this.readyState == EventSource.CONNECTING) {
      log(`Reconnecting (readyState=${this.readyState})...`);
    } else {
      log("Error has occured.");
    }
  };

  eventSource.addEventListener('bye', function(e) {
    log("Event: bye, data: " + e.data);
  });

  eventSource.onmessage = function(e) {
    log("Event: message, data: " + e.data);
  };
}

function stop() { // when "Stop" button pressed
  eventSource.close();
  log("eventSource.close()");
}

function log(msg) {
  logElem.innerHTML += msg + "<br>";
  document.documentElement.scrollTop = 99999999;
}
</script>

<button onclick="start()">Start</button> Press the "Start" to begin.
<div id="logElem" style="margin: 6px 0"></div>

<button onclick="stop()">Stop</button> "Stop" to finish.

Riepilogo

L’oggetto EventSource stabilisce automaticamente una connessione persistente e permette al server di inviare dei messaggi attraverso di essa.

Offrendo:

  • Riconnessione automatica, con timeout di retry regolabili.
  • Id dei messaggi per riprendere gli eventi, l’ultimo id ricevuto viene inviato nell’header Last-Event-ID in fase di riconnessione.
  • Lo stato corrente è dentro la proprietà readyState.

Ciò rende EventSource una valida alternativa ai WebSocket, il quale è più a basso livello e manca di alcune funzionalità built-in (sebbene possano essere implementate).

In molte applicazioni reali, la potenza di EventSource è già sufficiente.

Supportato in tutti i browser moderni (non IE).

La sintassi è:

let source = new EventSource(url, [credentials]);

Il secondo argomento consta di una sola opzione possibile: { withCredentials: true }, la quale permette di inviare credenziali cross-origin.

Complessivamente la sicurezza del cross-origin è la stessa di fetch e altri metodi di rete.

Proprietà di un oggetto EventSource

readyState
Lo stato corrente della connessione: uno tra EventSource.CONNECTING (=0), EventSource.OPEN (=1) o EventSource.CLOSED (=2).
lastEventId
L’ultimo id ricevuto.In fase di riconnessione il browser lo invia nell’header Last-Event-ID.

Metodi

close()
Chiude la connessione.

Eventi

message
Messagio ricevuto, il dato è dentro event.data.
open
La connessione è stabilita.
error
In caso di errori, inclusi sia la connessione persa (con riconnessione automatica) che eventuali errori fatali. Possiamo controllare readyState per vedere se è stata tentata la riconnessione.

Il server può impostare un evento personalizzato dentro event:. Questi eventi andrebbero gestiti usando addEventListener, e non on<event>.

Formato della risposta del server

Il server invia messaggi, delimitati da \n\n.

Un messaggio può avere i seguenti campi:

  • data: – corpo del messaggio, una sequenza di data multipli viene interpretata come un messaggio singolo, con \n tra la parti.
  • id: – aggiorna lastEventId, inviato dentro Last-Event-ID in fase di riconnessione.
  • retry: – raccomanda una ritardo nel tentativo di riconessione in millisecondi. Non c’è modo di impostarlo da JavaScript.
  • event: – il nome dell’evento, deve necessariamente precedere data:.

Un messaggio può includere uno o più campi in qualunque ordine, ma l’id: solitamente va per ultimo.

Mappa del tutorial