Articolo originale: How to Build a Logging Web App with Server-Sent Events, RxJS, and Express

Diciamo che stai lavorando alla tua nuova grande idea - una app web o mobile e un server back-end. Fin qui niente di complicato. Fino a che non ti rendi conto che hai bisogno di inviare dati dal tuo server ai client.

Normalmente, per cose del genere, la prima cosa che ti salta alla mente è di usare strumenti affidabili, come WebSockets, SocketIO, o addirittura un servizio a pagamento che se ne occupa al posto tuo.

Ma esiste un altro metodo che normalmente viene lasciato da parte, di cui potresti anche non averne mai sentito parlare finora. Si chiama SSE, abbreviazione di Server-Sent Events (Eventi Inviati dal Server).

SSE occupa un posto speciale nel mio cuore, grazie alla sua semplicità. È leggero, efficiente e molto potente.

Per spiegare in dettaglio SSE e come lo utilizzo, esaminerò un mio piccolo progetto parallelo che penso sia un eccellente esempio di SSE. Utilizzerò Typescript, Express e RxJS, quindi prepara il tuo ambiente di lavoro e allaccia le cinture perché stiamo per tuffarci nel codice.

Prima di iniziare, c'è una cosa che devi sapere su SSE. Come suggerisce il suo nome, Server-Sent Events è unidirezionale dal server al client. Se il tuo client avesse bisogno di inviare dati in risposta al server, SSE non è una soluzione adatta alle tue esigenze. Ma nella maggior parte degli scenari non è un requisito necessario e possiamo usare REST per inviare dati in risposta al server.

Qual è il progetto?

L'idea di questo progetto è semplice: ho un sacco di script che girano su Raspberry Pis, droplet su Digital Ocean e altri posti che non mi sono facilmente accessibili. Quindi ho bisogno di un modo per salvare i log e accedervi da qualsiasi parte.

La soluzione che cerco è una semplice app web che riceva i log e un collegamento diretto alla mia sessione che possa aprire su qualsiasi dispositivo o anche condividere con altre persone.

Ci sono un paio di cose da tenere a mente prima di andare avanti.

Primo, i log che ricevo dai miei script non sono molto frequenti, e la complicazione di usare HTTP è trascurabile nel mio caso specifico. Per questo, ho deciso di pubblicare i miei log in una API REST di base e usare SSE nel lato client per registrare i log in arrivo.

Frame-8-1
Esempio di logging

Secondo, questo strumento serve per un debug rapido di cose su cui sto lavorando. Esistono svariati strumenti professionali che potrei utilizzare. Ma volevo qualcosa di veramente leggero e facile da usare.

Scriviamo un po' di codice lato Server

La configurazione lato server è semplice. Quindi iniziamo con un diagramma per darti un'idea del setup prima di spiegare tutto più dettagliatamente.

Frame-10-1
Diagramma del server

Se pensiamo al nostro server back-end come a una pipeline, da un lato abbiamo una serie di publisher (editori) - nel nostro caso gli script che pubblicano i log. Dall'altro abbiamo i client che registrano questi log.

Per connettere questi due vertici, userò un Soggetto RxJS. Questo mi permetterà di pubblicare qualsiasi cosa dall'editore tramite REST e in seguito registrare questi eventi e girare i messaggi ai client tramite SSE.

Per iniziare, definiamo la nostra interfaccia Log. Per mantenere le cose semplici, definirò solo il campo content che conterrà le informazioni del log.

interface Log {
  content: string;
}

Come configurare RxJS

Importiamo RxJS, creiamo un nuovo Subject per i nostri Log e definiamo una funzione per pubblicare i log su questo Subject.

Ovviamente, potremmo esportare il nostro Subject e chiamarlo direttamente dal nostro router, ma io preferisco astrarre l'implementazione e fornire solamente la funzione emit al resto del mio codice.

import { Subject } from 'rxjs';

// Log Subject
const NewLog$ = new Subject<Log>();

/**
 * Emit a new log to the RxJS subject
 * @param log
 */
export function emitNewLog(log: Log): void {
    NewLog$.next(log);
}

E per finire, definiamo una nuova rotta nel server di Express che accetti i nuovi log dai nostri client e li pubblichi tramite il metodo emitNewLog che abbiamo appena creato.

app.post('/', (req: Request, res: Response) => {
  const content = req.body.content;
  const log: Log = { content: content };
  emitNewLog(log);
  return res.status(200).json({ ok: true });
});

Adesso siamo pronti con la parte riguardante la pubblicazione. Quello che rimane da fare è definire la rotta SSE, registrare il Subject RxJS e inviare i log ai nostri client.

Come configurare la rotta SSE

Iniziamo con la definizione di una nuova rotta per la nostra connessione SSE. Per abilitare SSE, abbiamo bisogno di inviare un paio di header ai client.

Vogliamo l'header ‘Connection’ impostato su ‘keep-alive’, ‘Cache-Control’ su ‘no-cache’ e ‘Content-Type’ su ‘text/event-stream’. In questa maniera il client saprà che questa è una rotta SSE.

Inoltre, ho aggiunto ‘Access-Control-Allow-Origin’ per CORS e ‘X-Accel-Buffering’ impostato su ‘no’ per evitare che Nginx si intrometta in questa rotta. E per finire possiamo reinviare gli header al nostro client per iniziare il flusso di eventi.

app.get('/', (req: Request, res: Response) => {
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('X-Accel-Buffering', 'no');
  res.flushHeaders();
});

Adesso possiamo iniziare a inviare dati inserendoli nella nostra risposta.

SSE utilizza un protocollo di testo che possiamo usare per aiutare il client a differenziare fra i vari tipi di eventi. Ognuno dei nostri eventi dovrebbe avere questa struttura:

event: ${event name}\n
data: ${event data}\n\n

Per facilitarmi la vita, ho creato una funzione che si occupa della serializzazione al posto mio.

/**
 * SSE message serializer
 * @param event: Event name
 * @param data: Event data
 */
function serializeEvent(event: string, data: any): string {
  const jsonString = JSON.stringify(data);
  return `event: ${event}\ndata: ${jsonString}\n\n`;
}

Ora possiamo registrare il Subject RxJS che abbiamo creato prima, serializzare ogni nuovo log e girarlo sulla nostra connessione come un evento NEW_LOG.

app.get('/', (req: Request, res: Response) => {
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('X-Accel-Buffering', 'no');
  res.flushHeaders();

  NewLog$.subscribe((log: Log) => {
    res.write(serializeEvent('NEW_LOG', log));
  });

}

Per ultimo dobbiamo assicurarci di cancellare la registrazione dell'osservatore quando la connessione SSE viene chiusa:

app.get('/', (req: Request, res: Response) => {
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('X-Accel-Buffering', 'no');
  res.flushHeaders();

  const stream$ = NewLog$.subscribe((log: Log) => {
    res.write(serializeEvent('NEW_LOG', log));
  });

  req.on('close', () => {
    stream$.unsubscribe();
  });
});

E questo è tutto! Abbiamo finito con il nostro server ed è ora di occuparci del codice front-end.

Scriviamo il codice del client

Registrare la rotta SSE dal browser è semplicissimo. Per prima cosa, spostiamoci sul codice del client e creiamo una nuova istanza dell'interfaccia EventSource e passiamo il nostro endpoint come argomento del constructor.

const eventSource = new EventSource("/");

Quindi, possiamo aggiungere gli event listeners per gli eventi che vogliamo registrare (nel nostro caso, NEW_LOG) e definiamo un metodo callback per gestire il nostro log.

eventSource.addEventListener(
   "NEW_LOG", (event) => {
       const log = JSON.parse(event.data);
       // use the data to update the UI
    }, false
);

E per finire,  possiamo chiudere la connessione appena abbiamo finito di osservare questi eventi.

eventSource.close();

Conclusione

Come puoi vedere, i Server-Sent Events rendono veramente facile inviare contenuto dal server verso il client. Sono particolarmente utili perché abbiamo un'interfaccia integrata nella maggior parte dei browser moderni, e possiamo facilmente usare un polyfill per quelli che non la forniscono.

Inoltre, SSE gestisce automaticamente le riconnessioni al posto nostro, nel caso in cui il client perdesse la connessione al server. Perciò, è una valida alternativa a SocketIO e WebSockets in vari scenari in cui si ha bisogno di un flusso di dati unidirezionale dal server.

Se sei interessato in questo progetto, ho aggiunto un paio di funzionalità extra al codice che abbiamo appena visto e un'interfaccia grafica che puoi vedere qui: LogSnag Console.

Frame-9-1
Console demo