Enviando mensagens em tempo real para seu frontend com SSE - Server Sent Events
Descubra como utilizar Server-Sent Events (SSE) com Node.js para enviar mensagens em tempo real aos seus clientes de maneira eficiente e contínua.
![Enviando mensagens em tempo real para seu frontend com SSE - Server Sent Events](/content/images/size/w1200/2024/08/server-sent-events.jpg)
Quando precisamos enviar dados do servidor para o cliente em tempo real, como notificações ou painéis de controle, uma abordagem comum é o polling. No entanto, o polling pode gerar uma sobrecarga desnecessária na rede e no servidor, pois o cliente precisa constantemente perguntar se há novas informações.
Server-Sent Events (SSE) oferece uma alternativa mais eficiente. Com o SSE, o servidor pode enviar dados para o cliente através de uma conexão persistente. Isso elimina a necessidade do cliente ficar solicitando informações constantemente, reduzindo a latência e otimizando o uso de recursos.
Ao contrário do polling, que estabelece várias requisições HTTP, o SSE utiliza uma única conexão para enviar múltiplos eventos, tornando-o ideal para aplicações que exigem atualizações em tempo real e comunicação unidirecional do servidor para o cliente.
Neste texto será apresentado um exemplo prático de implementação de SSE utilizando Node.js e um pouco de HTML. O objetivo será enviar notificações para o frontend em tempo real, que por sua vez, irá exibir os detalhes para o usuário.
Server-Sent Events (SSE): uma introdução
O Server-Sent Events (SSE) é uma tecnologia que permite que servidores enviem dados em tempo real para clientes web de forma eficiente. Essa comunicação unidirecional ocorre através de uma única conexão HTTP persistente.
Como funciona o SSE
- Requisição: O cliente inicia uma requisição HTTP para um endpoint específico no servidor.
- Resposta: O servidor responde com um status 200 OK e um cabeçalho
Content-Type: text/event-stream
, indicando que se trata de uma conexão SSE. A conexão permanece aberta. - Eventos: O servidor pode enviar dados para o cliente a qualquer momento.
- Cliente: O cliente escuta esses eventos e os processa de acordo com sua lógica.
Este canal de comunicação permanece aberto, permitindo que o servidor envie mensagens para o cliente através de uma única requisição HTTP, diferentemente do polling, que exige múltiplas requisições. A seguir, apresentamos um projeto que demonstra essa funcionalidade na prática.
Exemplo de SSE com Node.js e Express
A título de estudo, criei um pequeno projeto que roda no Node.js com o framework Express. Veja a seguir os detalhes:
# Iniciando o projeto
npm init -y
# Instalando o Express
npm i express
Após criar a estrutura do projeto, adicionei 3 arquivos:
- index.js
- index.html
- notificacoes.js
Gerenciando as conexões dos usuários (notificacoes.js)
A classe Notificacoes
é a responsável por guardar as conexões de cada usuário e possibilitar o envio de mensagens para um usuário específico ou todos conectados.
const crypto = require('crypto');
class Notificacoes {
/**
* Para cada usuário, guarda as diversas conexões e seu
* respectivo objeto `response` utilizado para enviar
* mensagens para o cliente.
* @type {Map<string, { idConexao: string,
* response: Response }>}
*/
#conexoesDosUsuarios = new Map();
/** Retorna o id dos usuários conectados */
get usuariosConectados() {
return [...this.#conexoesDosUsuarios.keys()];
}
registrarConexao(idUsuario, response) {
if (!this.#conexoesDosUsuarios.has(idUsuario)) {
this.#conexoesDosUsuarios.set(idUsuario, []);
}
const idConexao = this.#gerarNovoIdDeConexao();
this.#conexoesDosUsuarios
.get(idUsuario)
.push({ idConexao, response });
return idConexao;
}
removerConexao(idUsuario, idConexao) {
if (!this.#conexoesDosUsuarios.has(idUsuario)) {
return;
}
// Remove a conexão da lista do usuário
const conexoes = this.#conexoesDosUsuarios
.get(idUsuario)
.filter((a) => a.idConexao !== idConexao);
if (conexoes.length) {
this.#conexoesDosUsuarios.set(idUsuario, conexoes);
} else {
this.#conexoesDosUsuarios.delete(idUsuario);
}
}
enviarMensagemParaTodos(mensagem) {
for (const idUsuario of this.usuariosConectados) {
this.enviarMensagemClienteEspecifico(
idUsuario,
mensagem
);
}
}
enviarMensagemClienteEspecifico(idUsuario, mensagem) {
if (!this.#conexoesDosUsuarios.has(idUsuario)) {
return;
}
const conexoesDoUsuario =
this.#conexoesDosUsuarios.get(idUsuario);
for (const conexoes of conexoesDoUsuario) {
const { response, idConexao } = conexoes;
this.#enviarMensagem(
response,
idUsuario,
idConexao,
mensagem
);
}
}
#enviarMensagem(res, idUsuario, idConexao, mensagem) {
const objMensagem = JSON.stringify({
idUsuario,
idConexao,
mensagem,
});
res.write(`data: ${objMensagem}\n\n`);
}
#gerarNovoIdDeConexao() {
return crypto.randomBytes(20).toString('hex');
}
}
module.exports = new Notificacoes();
notificacoes.js
#
nos métodos/campos acima, tornam o recurso privado em JavaScript. Para saber mais, acesse este link.Criando os endpoints e enviando mensagens (index.js)
No arquivo index.js, temos a configuração de 2 enpoints e também de um simulador de envio de mensagens. Deixei o código comentado para facilitar o entendimento:
const express = require('express');
const fs = require('fs');
const notificacoes = require('./notificacoes');
const path = require('path');
const app = express();
app.use(express.json());
// ******************************************************
// * GET /
// * Entrega o conteúdo do arquivo index.html
// ******************************************************
app.get('/', (_, res) => {
res.writeHead(200, {
'content-language': 'text/html',
});
const pathIndex = path.join(__dirname, 'index.html');
const streamIndexHtml = fs.createReadStream(pathIndex);
streamIndexHtml.pipe(res);
});
// ******************************************************
// * GET /events
// * Através do SSE, entrega mensagens em tempo real
// * ao usuários conectados
// ******************************************************
app.get('/events', (req, res) => {
res
.writeHead(200, {
'Cache-Control': 'no-cache',
'Content-Type': 'text/event-stream',
Connection: 'keep-alive',
})
.write('\n');
// Para fins de teste, o id do usuário é recebido
// através de uma query string.
// No mundo real, essa informação poderá ser obtida na
// sessão da aplicação ou através de um token de acesso
const idUsuario = req.query.usuario;
// Cada usuário pode ter várias conexões. Por exemplo,
// o mesmo usuário pode abrir várias abas, então cada
// aba será uma conexão vinculada ao usuário.
// Isto não é uma regra geral, é somente um requisito
// adotado neste exemplo
const idConexao = notificacoes.registrarConexao(
idUsuario,
res
);
// Quando o usuário encerra a conexão, por exemplo,
// fechando a aba do navegador, o método abaixo é
// executado
req.on('close', () => {
notificacoes.removerConexao(idUsuario, idConexao);
});
});
// ******************************************************
// * Inicia a escuta das requisições
// ******************************************************
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Servidor ouvindo na porta ${PORT}`);
});
// ******************************************************
// * O código abaixo simula a geração de notificações.
// * Em um sistema real, essas notificações seriam
// * enviadas por meio de mensagens de serviços como
// * Kafka, Azure Queue/Service Bus, RabbitMQ, etc.
// ******************************************************
const CINCO_SEGUNDOS_EM_MS = 5_000;
// Envia uma mensagem para todos usuários conectados a
// cada 5 segundos
setInterval(() => {
notificacoes.enviarMensagemParaTodos(
`Olá: ${Date.now()}`
);
}, CINCO_SEGUNDOS_EM_MS);
// Envia uma mensagem para um usuário aleatório a cada 5
// segundos
setInterval(() => {
const { usuariosConectados } = notificacoes;
const indiceAleatorio = Math.floor(
Math.random() * usuariosConectados.length
);
const idUsuario = usuariosConectados[indiceAleatorio];
notificacoes.enviarMensagemClienteEspecifico(
idUsuario,
`Mensagem exclusiva do usuário: ${Date.now()}`
);
}, CINCO_SEGUNDOS_EM_MS);
index.js
Construindo a página de testes (index.html)
Por fim, o arquivo index.html
serve para testar a funcionalidade. O código é básico e direto, sem incluir validações ou melhorias na experiência do usuário, pois o foco deste projeto é puramente didático.
<!doctype html>
<html>
<head>
<title>Exemplo SSE - consolelog.com.br</title>
<meta charset="UTF-8" />
</head>
<style>
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
tr:nth-child(even) {
background-color: #f2f2f2;
}
th {
background-color: #4caf50;
color: white;
}
input[type='button'] {
background-color: #4caf50;
border: none;
color: white;
padding: 15px 32px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
margin: 4px 2px;
cursor: pointer;
}
input[type='button']:disabled {
background-color: #787878;
}
input[type='text'] {
width: 100%;
padding: 12px 20px;
margin: 8px 0;
font-size: 16px;
box-sizing: border-box;
border: 2px solid #ccc;
border-radius: 4px;
}
</style>
<body>
<input
autofocus
type="text"
id="inputUsuario"
placeholder="id do usuário (qualquer valor)" />
<input
type="button"
id="btnIniciar"
value="Iniciar" />
<input
type="button"
id="btnParar"
value="Parar"
disabled />
<table id="mensagens">
<thead>
<tr>
<th>Usuário</th>
<th>Conexão</th>
<th>Mensagem</th>
</tr>
</thead>
<tbody></tbody>
</table>
<script>
let eventSource = null;
const inputUsuario =
document.querySelector('#inputUsuario');
const btnIniciar =
document.querySelector('#btnIniciar');
const btnParar =
document.querySelector('#btnParar');
btnIniciar.addEventListener(
'click',
iniciarConexaoSSE
);
btnParar.addEventListener(
'click',
fecharConexaoSSE
);
function iniciarConexaoSSE() {
const idUsuario = inputUsuario.value.trim();
if (!idUsuario) {
alert('Informe um id de usuário qualquer');
return;
}
btnIniciar.setAttribute('disabled', '');
btnParar.removeAttribute('disabled');
inputUsuario.setAttribute('disabled', '');
const endpoint = `/events?usuario=${idUsuario}`;
eventSource = new EventSource(endpoint);
// Sempre que o servidor envia uma mensagem, o
// código abaixo é executado:
eventSource.onmessage = (event) => {
const { idUsuario, idConexao, mensagem } =
JSON.parse(event.data);
const tr = document.createElement('tr');
const tdUsuario = document.createElement('td');
tdUsuario.textContent = idUsuario;
tr.appendChild(tdUsuario);
const tdConexao = document.createElement('td');
tdConexao.textContent = idConexao;
tr.appendChild(tdConexao);
const tdMsg = document.createElement('td');
tdMsg.textContent = mensagem;
tr.appendChild(tdMsg);
document
.querySelector('#mensagens tbody')
.appendChild(tr);
};
}
function fecharConexaoSSE() {
eventSource.close();
btnIniciar.removeAttribute('disabled');
btnParar.setAttribute('disabled', '');
inputUsuario.removeAttribute('disabled');
}
</script>
</body>
</html>
index.html
Executando e testando o projeto
No cenário simulado, há dois usuários conectados: USUARIO_AAA e USUARIO_BBB, sendo que o USUARIO_BBB tem duas abas do navegador abertas.
Na imagem abaixo, você pode ver que algumas mensagens são enviadas para todos os usuários e conexões, enquanto outras são direcionadas exclusivamente para um usuário específico.
Um outro ponto de observação é no DevTools. Na aba "Network" (Rede), você pode notar que uma requisição permanece aberta, sendo essa a requisição SSE, que permite ao backend enviar mensagens em tempo real.
![3 abas do navegador abertas no endereço localhost:3000 mostrando o projeto deste texto em execução](https://consolelog.com.br/content/images/2024/08/exemplo-server-sent-event-sse-com-node.jpeg)
Como exemplo do mundo real, podemos observar uma requisição desse tipo na versão web do X (Twitter, ago/2024). Para visualizar, basta abrir o site no navegador e inspecionar o DevTools, conforme ilustrado na imagem abaixo:
![Lista de requisições do DevTools. A última mostra uma requisição do tipo SSE do X (Twitter)](https://consolelog.com.br/content/images/2024/08/image.png)
Código fonte do exemplo
Abaixo deixo o link do projeto no Github. Após efetuar o clone e instalar as dependências, basta executar o comando npm start
para iniciar o projeto.
Repositório do exemplo deste texto
Conclusão
Podemos destacar a simplicidade de implementação. Tanto o servidor quanto o cliente podem ser implementados com relativa facilidade. Os SSE não requerem bibliotecas adicionais para muitas linguagens de programação, pois são baseados em tecnologias web padrão, como HTTP e JavaScript.
Embora os navegadores modernos suportem SSE, algumas versões mais antigas podem não ser totalmente compatíveis, então certifique-se nos seus requisitos quais serão as versões de navegadores atendidas.
Um outro ponto importante a considerar é que, durante os testes, percebi que a classe nativa EventSource
do JavaScript não permite o envio de cabeçalhos personalizados (ago/2024). Isso significa que, em cenários onde é necessário, por exemplo, incluir o cabeçalho Authorization
, essa classe não atenderá às necessidades. Nesses casos, é recomendável buscar bibliotecas alternativas que ofereçam suporte a essa funcionalidade ou alterar a forma como essas informações são enviadas.
Para finalizar, Server-Sent Events são uma excelente opção para casos de uso em que a comunicação unidirecional do servidor para o cliente é suficiente. Eles oferecem uma alternativa simples aos WebSockets e podem ser uma ótima escolha para implementações de tempo real que não requerem uma troca constante de dados entre servidor e cliente como no polling. Com a vantagem de serem nativamente suportados pelos navegadores, os SSE são uma ferramenta valiosa para adicionar atualizações em tempo real às aplicações web.