Using Server-Sent Events in Node.js to Build a Realtime App

November 21, 2019 0 Comments

Using Server-Sent Events in Node.js to Build a Realtime App

 

 

The goal of this article is to present a complete solution for both the back-end and front-end to handle realtime information flowing from server to client.

The server will be in charge of dispatching new updates to all connected clients and the web app will connect to the server, receive these updates and present them in a nice way.

About Server-Sent Events

When we think about realtime apps, probably one of the first choices would be WebSockets, but we have other choices. If our project doesn’t need a complex real time feature but only receives something like stock prices or text information about something in progress, we can try another approach using Server-Sent Events (SSE).

Server-Sent Events is a technology based on HTTP so it’s very simple to implement on the server-side. On the client-side, it provides an API called EventSource (part of the HTML5 standard) that allows us to connect to the server and receive updates from it. Before making the decision to use server-sent events, we must take into account two very important aspects:

  • It only allows data reception from the server (unidirectional)
  • Events are limited to UTF-8 (no binary data)

These points should not be perceived as limitations, SSE was designed as a simple, text-based and unidirectional transport.

Here’s the current support in browsers

Prerequisites

  • Node.js
  • Express
  • Curl
  • React (and hooks)

Getting started

We will start setting up the requirements for our server. We’ll call our back-end app swamp-events:

$ mkdir swamp-events 
$ cd swamp-events
$ npm init -y
$ npm install --save express body-parser cors

Then we can proceed with the React front-end app:

$ npx create-react-app swamp-stats 
$ cd swamp-stats
$ npm start

The Swamp project will help us keep realtime tracking of alligator nests

SSE Express Backend

We’ll start developing the backend of our application, it will have these features:

  • Keeping track of open connections and broadcast changes when new nests are added
  • GET /events endpoint where we’ll register for updates
  • POST /nest endpoint for new nests
  • GET /status endpoint to know how many clients we have connected
  • cors middleware to allow connections from the front-end app

Here’s the complete implementation, you will find some comments throughout, but below the snippet I also break down the important parts in detail.

server.js

// Require needed modules and initialize Express app
const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors'); const app = express(); // Middleware for GET /events endpoint
function eventsHandler(req, res, next) { // Mandatory headers and http status to keep connection open const headers = { 'Content-Type': 'text/event-stream', 'Connection': 'keep-alive', 'Cache-Control': 'no-cache' }; res.writeHead(200, headers); // After client opens connection send all nests as string const data = data: ${JSON.stringify(nests)}\n\n; res.write(data); // Generate an id based on timestamp and save res // object of client connection on clients list // Later we'll iterate it and send updates to each client const clientId = Date.now(); const newClient = { id: clientId, res }; clients.push(newClient); // When client closes connection we update the clients list // avoiding the disconnected one req.on('close', () => { console.log(${clientId} Connection closed); clients = clients.filter(c => c.id !== clientId); });
} // Iterate clients list and use write res object method to send new nest
function sendEventsToAll(newNest) { clients.forEach(c => c.res.write(data: ${JSON.stringify(newNest)}\n\n))
} // Middleware for POST /nest endpoint
async function addNest(req, res, next) { const newNest = req.body; nests.push(newNest); // Send recently added nest as POST result res.json(newNest) // Invoke iterate and send function return sendEventsToAll(newNest);
} // Set cors and bodyParser middlewares
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: false})); // Define endpoints
app.post('/nest', addNest);
app.get('/events', eventsHandler);
app.get('/status', (req, res) => res.json({clients: clients.length})); const PORT = 3000; let clients = [];
let nests = []; // Start server on 3000 port
app.listen(PORT, () => console.log(Swamp Events service listening on port ${PORT}));

The most interesting part is the eventsHandler middleware, it receives the req and res objects that Express populates for us.

In order to establish a stream of events we must set a 200 HTTP status, in addition the Content-Type and Connection headers with text/event-stream and keep-alive values respectively are needed.

When I described SSE events, I noted that data is limited only to UTF-8, the Content-Type enforces it.

The Cache-Control header is optional, it will avoid client cache events. After the connection is set, we’re ready to send the first message to the client: the nests array.

Because this is a text-based transport we must stringify the array, also to fulfill the standard the message needs a specific format. We declare a field called data and set to it the stringified array, the last detail we should note is the double trailing newline \n\n, mandatory to indicate the end of an event.

We can continue with the rest of the function that’s not related with SSE. We use a timestamp as a client id and save the res Express object on the clients array.

At last, to keep the client’s list updated we register the close event with a callback that removes the disconnected client.

The main goal of our server is to keep all clients connected, informed when new nests are added, so addNest and sendEvents are completely related functions. The addNest middleware simply saves the nest, returns it to the client which made POST request and invokes the sendEvents function. sendEvents iterates the clients array and uses the write method of each Express res object to send the update.

Before the web app implementation, we can try our server using cURL to check that our server is working correctly.

My recommendation is using a Terminal with three open tabs:

# Server execution 
$ node server.js
Swamp Events service listening on port 3000
# Open connection waiting updates 
$ curl -H Accept:text/event-stream http://localhost:3000/events
data: []
# POST request to add new nest 
$ curl -X POST \ -H "Content-Type: application/json" \ -d '{"momma": "swampprincess", "eggs": 40, "temperature": 31}'\ -s http://localhost:3000/nest
{"momma": "swamp
princess", "eggs": 40, "temperature": 31}

After the POST request we should see an update like this on the second tab:

data: {"momma": "swampprincess", "eggs": 40, "temperature": 31} 

Now the nests array is populated with one item, if we close the communication on second tab and open it again, we should receive a message with this item and not the original empty array:

$ curl -H Accept:text/event-stream http://localhost:3000/events 
data: [{"momma": "swamp
princess", "eggs": 40, "temperature": 31}]

Remember that we implemented the GET /status endpoint. Use it before and after the /events connection to check the connected clients.

The back-end is fully functional, and it’s now time to implement the EventSource API on the front-end.

React Web App Front-End

In this second and last part of our project we’ll write a simple React app that uses the EventSource API.

The web app will have the following set of features:

  • Open and keep a connection to our previously developed server
  • Render a table with the initial data
  • Keep the table updated via SSE

For the sake of simplicity, the App component will contain all the web app.

App.js

import React, { useState, useEffect } from 'react'; 
import './App.css'; function App() { const [ nests, setNests ] = useState([]); const [ listening, setListening ] = useState(false); useEffect( () => { if (!listening) { const events = new EventSource('http://localhost:3000/events'); events.onmessage = (event) => { const parsedData = JSON.parse(event.data); setNests((nests) => nests.concat(parsedData)); }; setListening(true); } }, [listening, nests]); return ( <table className="stats-table"> <thead> <tr> <th>Momma</th> <th>Eggs</th> <th>Temperature</th> </tr> </thead> <tbody> { nests.map((nest, i) => <tr key={i}> <td>{nest.momma}</td> <td>{nest.eggs}</td> <td>{nest.temperature}</td> </tr> ) } </tbody> </table> );
}

App.css

body { color: #555; margin: 0 auto; max-width: 50em; font-size: 25px; line-height: 1.5; padding: 4em 1em; 
} .stats-table { width: 100%; text-align: center; border-collapse: collapse;
} tbody tr:hover { background-color: #f5f5f5;
}

The useEffect function argument contains the important parts. There, we instance an EventSource object with the endpoint of our server and after that we declare an onmessage method where we parse the data property of the event.

Unlike the cURL event that was like this…

data: {"momma": "swamp_princess", "eggs": 40, "temperature": 31} 

…We now we have the event as an object, we take the data property and parse it giving as a result a valid JSON object.

Finally we push the new nest to our list of nests and the table gets re-rendered.

It’s time for a complete test, I suggest you restart the Node.js server. Refresh the web app and we should get an empty table.

Try adding a new nest:

$ curl -X POST \ -H "Content-Type: application/json" \ -d '{"momma": "lady.sharp.tooth", "eggs": 42, "temperature": 34}'\ -s http://localhost:3000/nest 
{"momma":"lady.sharp.tooth","eggs":42,"temperature":34}

The POST request added a new nest and all the connected clients should have received it, if you check the browser you will have a new row with this information.

Congratulations! You implemented a complete realtime solution with server-sent events.

Conclusion

As usual, the project has room for improvement. Server-sent events has a nice set of features that we didn’t cover and could use to improve our implementation. I would definitely take a look at the connection recovery mechanism that SSE provides out of the box.


Tag cloud