Overview
Server-Sent Events(SSE) technology allows sending data from server to clients in real-time, it's based on HTTP.
On the client side server-sent events provides EventSource
API (part of the HTML5 standard), that allows us to open a permanent connection to the HTTP server and receive messages(events) from it.
On the server side headers are required to keep the connection open. The Content-Type
header set to text/event-stream
and the Connection
header set to keep-alive
.
The connection remains open until closed by calling EventSource.close()
.
Restrictions:
- Allows only receive events from server (unidirectional data flow, unlike WebSockets);
- Events are limited to
UTF-8
(no binary data).
Possible benefits:
- Because SSE works via
HTTP
, it will be work on clients that use proxy, that not support other protocols (like WebSocket); - If connection use
HTTPS
then no need to think about traffic encryption.
Browsers support: https://caniuse.com/eventsource.
In this article we will develop Todo List
app, that allow us add, delete, mark as done tasks in the list.
The state of the Todo List will be shared among all connected users via Server-Sent Events.
Step 1 - Building Express Backend
# Create and go to project directory
mkdir sse
cd sse
# Create and go to subdirectory of backend part of project
mkdir server
cd server
# Initialize project and install required dependencies
npm init -y
npm install express@^4.18.1 body-parser@^1.20.0 compression@^1.7.4 cors@^2.8.5 --save
After installing required dependencies open package.json
and add "type": "module"
after project name. This is necessary so that NodeJS can work with javascript modules.
{
"name": "server",
"type": "module"
...
}
Create file server.js
and add some template code:
import express from 'express';
import compression from 'compression';
import bodyParser from 'body-parser';
import cors from 'cors';
const app = express();
app.use(compression());
app.use(cors());
app.use(bodyParser.json());
let clients = [];
let todoState = [];
app.get('/state', (req, res) => {
res.json(todoState);
});
const PORT = process.env.PORT || 3005;
app.listen(PORT, () => {
console.log(`Shared todo list server listening at http://localhost:${PORT}`);
});
Starting the server with the command npm start
. If everything is done correctly, then by making the request curl http://localhost:3005/state
you will see []
- an empty list of todo sheet.
Next, before the port declaration const PORT = process.env.PART || 3005;
add the code to connect the client via SSE:
app.get('/events', (req, res) => {
const headers = {
// The 'text/event-stream' connection type
// is required for SSE
'Content-Type': 'text/event-stream',
'Access-Control-Allow-Origin': '*',
// Setting the connection open 'keep-alive'
'Connection': 'keep-alive',
'Cache-Control': 'no-cache'
};
// Write successful response status 200 in the header
res.writeHead(200, headers);
/*
Data Shaping:
When the EventSource receives multiple consecutive
lines that begin with data:, it concatenates them,
inserting a newline character between each one.
Trailing newlines are removed.
Double trailing newline \n\n is mandatory to indicate
the end of an event
*/
const sendData = `data: ${JSON.stringify(todoState)}\n\n`;
res.write(sendData);
// If compression middleware is used, then res.flash()
// must be added to send data to the user
res.flush();
// Creating a unique client ID
const clientId = genUniqId();
const newClient = {
id: clientId,
res,
};
clients.push(newClient);
console.log(`${clientId} - Connection opened`);
req.on('close', () => {
console.log(`${clientId} - Connection closed`);
clients = clients.filter(client => client.id !== clientId);
});
});
function genUniqId(){
return Date.now() + '-' + Math.floor(Math.random() * 1000000000);
}
So, we wrote the code that allows the client to connect by establishing a permanent connection, and also saved the id
and res
in the array of clients so that in the future we could send data to connected clients.
To check that everything is working, we will add a code to transfer the unique ids
of the connected users.
app.get('/clients', (req, res) => {
res.json(clients.map((client) => client.id));
});
Starting the server npm start
.
We connect to the server in the new terminal:
curl -H Accept:text/event-stream http://localhost:3005/events
In different terminals, you can repeat the command several times to simulate the connection of several clients.
Checking the list of connected:
curl http://localhost:3005/clients
In the terminal, you should see an array of ids
of connected clients:
["1652948725022-121572961","1652948939397-946425533"]
Now let's start writing the business logic of the Todo List application, we need:
a) Add a task to the todo list;
b) Delete a task from the todo list;
c) Set/unset task completion;
d) After each action, send the state to all connected clients.
The todo list state will look like this:
[
{
id: "1652980545287-628967479",
text: "Task 1",
checked: true
},
{
id: "1652980542043-2529066",
text: "Task 2",
checked: false
},
...
]
Where id
is a unique identifier generated by the server, text
is the text of the task, checked
is the state of the task checkbox.
Let's start with d) - after each action, send the state to all connected clients:
function sendToAllUsers() {
for(let i=0; i<clients.length; i++){
clients[i].res.write(`data: ${JSON.stringify(todoState)}\n\n`);
clients[i].res.flush();
}
}
Then we implement a) b) and c):
// Add a new task to the list and
// send the state to all clients
app.post('/add-task', (req, res) => {
const addedText = req.body.text;
todoState = [
{ id: genUniqId(), text: addedText, checked: false },
...todoState
];
res.json(null);
sendToAllUsers();
});
// Change the state of the task in the list
// and send the result state to all clients
app.post('/check-task', (req, res) => {
const id = req.body.id;
const checked = req.body.checked;
todoState = todoState.map((item) => {
if(item.id === id){
return { ...item, checked };
}
else{
return item;
}
});
res.json(null);
sendToAllUsers();
});
// Remove the task from the list and
// send the new state of the list to all clients
app.post('/del-task', (req, res) => {
const id = req.body.id;
todoState = todoState.filter((item) => {
return item.id !== id;
});
res.json(null);
sendToAllUsers();
});
So, the server part is ready. Full code of the server:
import express from 'express';
import compression from 'compression';
import bodyParser from 'body-parser';
import cors from 'cors';
const app = express();
app.use(compression());
app.use(cors());
app.use(bodyParser.json());
let clients = [];
let todoState = [];
app.get('/state', (req, res) => {
res.json(todoState);
});
app.get('/events', (req, res) => {
const headers = {
'Content-Type': 'text/event-stream',
'Access-Control-Allow-Origin': '*',
'Connection': 'keep-alive',
'Cache-Control': 'no-cache'
};
res.writeHead(200, headers);
const sendData = `data: ${JSON.stringify(todoState)}\n\n`;
res.write(sendData);
res.flush();
const clientId = genUniqId();
const newClient = {
id: clientId,
res,
};
clients.push(newClient);
console.log(`${clientId} - Connection opened`);
req.on('close', () => {
console.log(`${clientId} - Connection closed`);
clients = clients.filter(client => client.id !== clientId);
});
});
function genUniqId(){
return Date.now() + '-' + Math.floor(Math.random() * 1000000000);
}
function sendToAllUsers() {
for(let i=0; i<clients.length; i++){
clients[i].res.write(`data: ${JSON.stringify(todoState)}\n\n`);
clients[i].res.flush();
}
}
app.get('/clients', (req, res) => {
res.json(clients.map((client) => client.id));
});
app.post('/add-task', (req, res) => {
const addedText = req.body.text;
todoState = [
{ id: genUniqId(), text: addedText, checked: false },
...todoState
];
res.json(null);
sendToAllUsers();
});
app.post('/check-task', (req, res) => {
const id = req.body.id;
const checked = req.body.checked;
todoState = todoState.map((item) => {
if(item.id === id){
return { ...item, checked };
}
else{
return item;
}
});
res.json(null);
sendToAllUsers();
});
app.post('/del-task', (req, res) => {
const id = req.body.id;
todoState = todoState.filter((item) => {
return item.id !== id;
});
res.json(null);
sendToAllUsers();
});
const PORT = process.env.PORT || 3005;
app.listen(PORT, () => {
console.log(`Shared todo list server listening at http://localhost:${PORT}`);
});
Then proceed to the second step - the client part.
Step 2 - Building the Client part: React application
Go to the previously created project folder sse
, then run the command to create the react application template:
npx create-react-app client
Next, go to the folder of the created application and launch it:
cd client
npm start
After that, the client application page should open in the browser http://localhost:3000.
Next, go to the file src/index.js
and remove React.StrictMode
from the application.
// Before
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// After
root.render(
<App />
);
The fact is that React StrictMode
renders components twice in development
mode to detect possible problems. But in our case, this is not necessary, otherwise the client will connect to the server twice and establish a permanent connection.
Remove all the contents from the App.css
file and insert our own styles:
h1 {
text-align: center;
}
main {
display: flex;
justify-content: center;
}
.l-todo {
max-width: 31.25rem;
}
form {
margin-bottom: 1rem;
}
form input[type="submit"] {
margin-left: 0.5rem;
}
.task-group {
margin-bottom: 0.125rem;
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
}
.task-group button {
padding: 0.25rem 0.5rem;
margin-left: 0.5rem;
border: none;
background-color: white;
}
Let's prepare the application template, remove from the App.js
all content and insert our:
import './App.css';
import { useState, useEffect, useRef } from 'react';
function App(){
return(
<main>
</main>
);
}
export default App;
Let's add a list state hook to our App
component:
const [tasks, setTasks] = useState([]);
Now let's add the useEffect
hook in which we will establish a permanent SSE connection:
useEffect(() => {
let mount = true;
let events;
let timer;
let createEvents = () => {
// Close connection if open
if(events){
events.close();
}
// Establishing an SSE connection
events = new EventSource(`http://localhost:3005/events`);
events.onmessage = (event) => {
// If the component is mounted, we set the state
// of the list with the received data
if(mount){
let parsedData = JSON.parse(event.data);
setTasks(parsedData);
}
};
// If an error occurs, we wait a second
// and call the connection function again
events.onerror = (err) => {
timer = setTimeout(() => {
createEvents();
}, 1000);
};
};
createEvents();
// Before unmounting the component, we clean
// the timer and close the connection
return () => {
mount = false;
clearTimeout(timer);
events.close();
}
}, []);
Now when opening the client site http://localhost:3000 a connection to the server will occur and the server will send the todo list state to the connected client. The client, after receiving the data, will set the state of the todo list.
Let's develop an interface component for adding a new task to the list.
Add a file to the project src/AddTask.js
function AddTask(props){
const { text, onTextChange, onSubmit, textRef } = props;
return(
<form onSubmit={onSubmit}>
<input
type="text"
name="add"
value={text}
onChange={onTextChange}
ref={textRef}
/>
<input
type="submit"
value="Add"
/>
</form>
);
}
export default AddTask;
Creating a list item element:
Add a file to the project src/Task.js
:
function Task(props){
const { id, text, checked, onCheck, onDel } = props;
return(
<div className="task-group">
<div>
<input
type="checkbox"
name={`chk${id}`}
id={`chk${id}`}
checked={checked}
onChange={onCheck}
/>
<label htmlFor={`chk${id}`}>{text}</label>
</div>
<button
id={`btn${id}`}
onClick={onDel}>x
</button>
</div>
);
}
export default Task;
Include the created files to App.js
:
import AddTask from './AddTask';
import Task from './Task';
In our application, we will transmit data to the server in JSON
format, so before moving on, we will write a small wrapper for the javascript fetch API to simplify the client code. Create a file /src/jsonFetch.js
:
function jsonFetch(url, data){
return new Promise(function(resolve, reject){
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
.then(function(res){
if(res.ok){
const contentType = res.headers.get('content-type');
if(contentType && contentType.includes('application/json')){
return res.json();
}
return reject(`Not JSON, content-type: ${contentType}`);
}
return reject(`Status: ${res.status}`);
})
.then(function(res){
resolve(res);
})
.catch(function(error){
reject(error);
});
});
}
export default jsonFetch;
Include created file in App.js
:
import jsonFetch from './jsonFetch';
Now let's add our addTask
and Task
components to the App
component:
function App(){
const [addTaskText, setAddTaskText] = useState('');
const [tasks, setTasks] = useState([]);
const addTextRef = useRef(null);
useEffect(() => {
// SSE code
...
},[]);
const tasksElements = tasks.map((item) => {
return(
<Task
key={item.id}
id={item.id}
text={item.text}
checked={item.checked}
onCheck={handleTaskCheck}
onDel={handleTaskDel}
/>
);
});
return (
<main>
<div className="l-todo">
<h1>Todo List</h1>
<AddTask
text={addTaskText}
onSubmit={handleAddTaskSubmit}
onTextChange={handleAddTaskTextChange}
textRef={addTextRef}
/>
{tasksElements}
</div>
</main>
);
}
Let's write user event handlers in the App
component:
function handleAddTaskTextChange(event){
setAddTaskText(event.target.value);
}
function handleAddTaskSubmit(event){
event.preventDefault();
let addedText = addTaskText.trim();
if(!addedText){
return setAddTaskText('');
}
jsonFetch('http://localhost:3005/add-task', {text: addedText})
.then(() => {
setAddTaskText('');
})
.catch((err) => {
console.log(err);
})
.finally(() => {
addTextRef.current.focus();
});
}
function handleTaskCheck(event){
const checked = event.target.checked;
const targetId = event.target.id.substring(3);
jsonFetch('http://localhost:3005/check-task', {id: targetId, checked})
.catch((err) => {
console.log(err);
});
}
function handleTaskDel(event){
let targetId = event.target.id.substring(3);
jsonFetch('http://localhost:3005/del-task', {id: targetId})
.catch((err) => {
console.log(err);
});
}
So, the logic of the client application: when component did mount, a SSE connection is created to the server, which transmits the state of the list when connected. After receiving the state of the list from the server, it is set to the client setTasks(parsedData)
.
Further, when adding, deleting and set/unset tasks, the changes are sending to the server, there they are recording to todoState
and transmitting to all connected users.
Full client application code:
import './App.css';
import { useState, useEffect, useRef } from 'react';
import AddTask from './AddTask';
import Task from './Task';
import jsonFetch from './jsonFetch';
function App(){
const [addTaskText, setAddTaskText] = useState('');
const [tasks, setTasks] = useState([]);
const addTextRef = useRef(null);
useEffect(() => {
let mount = true;
let events;
let timer;
let createEvents = () => {
if(events){
events.close();
}
events = new EventSource(`http://localhost:3005/events`);
events.onmessage = (event) => {
if(mount){
let parsedData = JSON.parse(event.data);
setTasks(parsedData);
}
};
events.onerror = (err) => {
timer = setTimeout(() => {
createEvents();
}, 1000);
};
};
createEvents();
return () => {
mount = false;
clearTimeout(timer);
events.close();
}
}, []);
const tasksElements = tasks.map((item) => {
return(
<Task
key={item.id}
id={item.id}
text={item.text}
checked={item.checked}
onCheck={handleTaskCheck}
onDel={handleTaskDel}
/>
);
});
return (
<main>
<div className="l-todo">
<h1>Todo List</h1>
<AddTask
text={addTaskText}
onSubmit={handleAddTaskSubmit}
onTextChange={handleAddTaskTextChange}
textRef={addTextRef}
/>
{tasksElements}
</div>
</main>
);
function handleAddTaskTextChange(event){
setAddTaskText(event.target.value);
}
function handleAddTaskSubmit(event){
event.preventDefault();
let addedText = addTaskText.trim();
if(!addedText){
return setAddTaskText('');
}
jsonFetch('http://localhost:3005/add-task', {text: addedText})
.then(() => {
setAddTaskText('');
})
.catch((err) => {
console.log(err);
})
.finally(() => {
addTextRef.current.focus();
});
}
function handleTaskCheck(event){
const checked = event.target.checked;
const targetId = event.target.id.substring(3);
jsonFetch('http://localhost:3005/check-task', {id: targetId, checked})
.catch((err) => {
console.log(err);
});
}
function handleTaskDel(event){
let targetId = event.target.id.substring(3);
jsonFetch('http://localhost:3005/del-task', {id: targetId})
.catch((err) => {
console.log(err);
});
}
}
export default App;
Please support me, like and write comments.
Top comments (1)
Nice man