Отправка сообщений клиенту в реальном времени с помощью NodeJS и Server-Sent Events
Tags: node, javascript, realtime, messages
Published: 2022-05-19 20:08:21

## Обзор
Технология Server-Sent Events(SSE) позволяющая отправлять информацию с сервера клиенту в реальном времени, основана на HTTP протоколе. 

На клиентской стороне server-sent events API предоставляет `EventSource` интерфейс (часть HTML5 стандарта), посредством которого открывается постоянное соединение с HTTP сервером.

HTTP сервер отправляет события в `text/event-stream` формате. Соединение остаётся открытым до тех пор пока не будет вызван метод `EventSource.close()`.

Ограничения:
- Возможен только приём данных с сервера (однонаправленный поток данных, в отличии от WebSockets);
- Данные могут передаваться только в `UTF-8` формате (небинарные данные).

Возможные преимущества:
- Работает через `HTTP`, а значит у клиентов не возникнет проблем с подключением в случае, когда они подключены через прокси, не поддерживающие другие соединения (такие как WebSockets);
- Если соединение устанавливается через `HTTPS`, то трафик SSE защищён шифрованием.

Поддержка браузерами: [https://caniuse.com/eventsource](https://caniuse.com/eventsource).

В этой статье мы разработаем приложение Todo List, которое позволяет пользователям добавлять, удалять, помечать как выполенные задания в списке.

Обратите внимание, состояние списка с помощью Server-sent Events будет общим для всех пользователей:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8vvlljncvinca2trgk2m.gif)

## Шаг 1 - Разработка Express Backend

```console
# Создаём и заходим в каталог проекта
mkdir sse
cd sse

# Создаём и заходим в подкаталог для для backend части проекта
mkdir server
cd server

# Инициализируем проект и устанавливаем необходимые пакеты
npm init -y
npm install express@^4.18.1 body-parser@^1.20.0 compression@^1.7.4 cors@^2.8.5 --save
```
После установки пакетов в файл `package.json` необходимо добавить строку `"type": "module"` для того чтобы NodeJS мог работать с модулями.
```json
{
  "name": "server",
  "type": "module"
  ...
}
```
Создаём файл server.js и добавляем каркас приложения:
```javascript
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}`);
});
```
Запускаем сервер командой `npm start`. Если всё сделано правильно, то, сделав запрос `curl http://localhost:3005/state` вы увидете `[]` - пустой список todo листа.
Далее перед декларацией порта `const PORT = process.env.PORT || 3005;` добавляем код для подключения клиента через SSE:
```javascript
app.get('/events', (req, res) => {
    const headers = {
        // Тип соединения 'text/event-stream' необходим для SSE
        'Content-Type': 'text/event-stream',
        'Access-Control-Allow-Origin': '*',
        // Отставляем соединение открытым 'keep-alive'
        'Connection': 'keep-alive',
        'Cache-Control': 'no-cache'
    };
    // Записываем в заголовок статус успешного ответа 200
    res.writeHead(200, headers);
    
    /*
    Формирование данных:
    Когда EventSource получает множество последовательных
    строк, начинающихся с data: они объединяются, вставляя
    символ новой строки между ними. Завершающие символы
    новой строки удаляются.
    Двойные символы конца строки \n\n обозначают конец
    события.
    */
    const sendData = `data: ${JSON.stringify(todoState)}\n\n`;
    
    res.write(sendData);
    // Если используется compression middleware, то необходимо
    // добавить res.flush() для отправки данных пользователю
    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);
}
```
Итак, мы написали код, позволяющий клиенту подключиться, установив постоянное соединение, а так же сохранили `id` и `res` в массиве клиентов, чтобы в дальнейшем мы могли отправлять данные подключенным клиентам.

Чтобы проверить, что всё работает, добавим код для передачи уникальных `id` подключенных пользователей.
```javascript
app.get('/clients', (req, res) => {
    res.json(clients.map((client) => client.id));
});
```
Запускаем сервер `npm start`.
Подключаемся в новом терминале к серверу:
```console
curl -H Accept:text/event-stream http://localhost:3005/events
```
В разных терминалах можно несколько раз повторить команду, чтобы сымитировать подключение нескольких клиентов.
Проверяем список подключившихся:
```console
curl http://localhost:3005/clients
```
В терминале вы должны увидеть массив `id` подключенных клиентов:
```console
["1652948725022-121572961","1652948939397-946425533"]
```

Теперь приступим к написанию бизнес логики приложения Todo List, нам нужно:
**a)** Добавлять задачу в todo список;
**b)** Удалять задачу из todo списка;
**c)** Устанавливать/снимать галочку выполнения задачи;
**d)** После каждого действия отправлять состояние всем подключенным клиентам.

Состояние todo списка будет выглядеть следующим образом:
```javascript
[
   {
      id: "1652980545287-628967479",
      text: "Task 1",
      checked: true
   },
   {
      id: "1652980542043-2529066",
      text: "Task 2",
      checked: false
   },
   ...
]
```
Где `id` - уникальный идентификатор, генерируемый сервером, `text` - текст задачи, `checked` - состояние галочки задачи.

Начнём с **d)** - после каждого действия отправлять состояние всем подключенным клиентам:
```javascript
function sendToAllUsers() {
    for(let i=0; i<clients.length; i++){
        clients[i].res.write(`data: ${JSON.stringify(todoState)}\n\n`);
        clients[i].res.flush();
    }
}
```
Затем реализуем **a)** **b)** и **c)**:
```javascript
// Добавляем новую задачу в список и отправляем
// состояние всем клиентам
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();
});
```
Итак, серверная часть готова. Полный код серверной части:
```js
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}`);
});
```
Приступаем ко второму шагу - клиентской части.

## Шаг 2 - Клиентская часть: React приложение

Перейдём в ранее созданную папку проекта `sse`, затем запустим команду создания шаблона react приложения:
```console
npx create-react-app client
```
Далее перейдём в папку созданного приложения и запустим его:
```console
cd client
npm start
```
После чего в браузере должна открыться страница клиентского приложения [http://localhost:3000](http://localhost:3000).

Далее переходим к файлу `src/index.js` и удаляем `React.StrictMode` из приложения.
```javascript
// Было
root.render(
   <React.StrictMode>
      <App />
   </React.StrictMode>
);
// Стало
root.render(
   <App />
);
```
Дело в том что `React StrictMode` дважды рендерит компоненты в режиме `development` для обнаружения возможных проблем. Но в нашем случае это не нужно, иначе клиент будет дважды подключаться к серверу и устанавливать постоянное соединение.

Удалим из файла `App.css` всё содержимое и вставим свои стили:
```css
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;
}
```
Подготовим каркас приложения, удалим из файла `App.js` всё содержимое и вставим свой код:
```jsx
import './App.css';
import { useState, useEffect, useRef } from 'react';

function App(){
   return(
      <main>
      </main>
   );
}

export default App;
```
Добавим в наш `App` компонент хук состояния списка:
```jsx
const [tasks, setTasks] = useState([]);
```
Теперь добавим хук `useEffect` в котором мы будем устанавливать постоянное SSE соединение:
```jsx
useEffect(() => {
        let mount = true;
        let events;
        let timer;
        let createEvents = () => {
            // Закрываем соединение если открыто
            if(events){
                events.close();
            }
            // Устанавливаем SSE соединение
            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();
        }
    }, []);
```
Теперь при открытии клиентского сайта [http://localhost:3000](http://localhost:3000) произойдёт соединение с сервером и сервер отправит состояние todo списка подключившемуся клиенту. Клиент, получив данные установит состояние todo списка.

Разработаем компонент интерфейса для добавления новой задачи в список.
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1dx00d2328xcy8mmcdel.png)
Добавим в проект файл `src/AddTask.js`
```jsx
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="Добавить"
            />
        </form>
    );
}

export default AddTask;
```
Создадим элемент списка:
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ikzs8ioq4bfeusz8btmx.png)
Добавим в проект файл `src/Task.js`:
```jsx
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;
```
Подключим файлы созданные файлы в `App.js`:
```jsx
import AddTask from './AddTask';
import Task from './Task';
```
В нашем приложении мы будем передавать данные на сервер в `JSON` формате, поэтому прежде чем двигаться дальше мы напишем небольшую обёртку для javascript fetch API для упрощения клиентского кода. Создадим файл `/src/jsonFetch.js`:
```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(`Не JSON, content-type: ${contentType}`);
            }
            return reject(`Статус: ${res.status}`);
        })
        .then(function(res){
            resolve(res);
        })
        .catch(function(error){
            reject(error);
        });
    });
}

export default jsonFetch;
```
Подключим созданный файл в `App.js`:
```jsx
import jsonFetch from './jsonFetch';
```
Теперь добавим наши компоненты `AddTask` и `Task` в `App` компонент:
```jsx
function App(){
    const [addTaskText, setAddTaskText] = useState('');
    const [tasks, setTasks] = useState([]);
    const addTextRef = useRef(null);
    
    useEffect(() => {
      // Код SSE
       ...
    },[]);
    
    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>
    );
}
```
Напишем обработчики событий пользователя в `App` компоненте:
```jsx
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);
   });
}
```
Итак, логика работы приложения: при монтировании компонента создаётся SSE подключение к серверу, который при подключении передаёт состояние списка. После получения состояние списка с сервера оно устанавливается клиенту `setTasks(parsedData)`.
Далее, при добавлении, удалении и установке/снятии галочки задачи - изменения отправляются на сервер, там записываются в `todoState` и передаются всем подключенным пользователям.

Полный код клиентского приложения:
```jsx
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;
```

Ставьте лайки, пишите комментарии.