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
Um pouco sobre Server Sent Events - SSE

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

  1. Requisição: O cliente inicia uma requisição HTTP para um endpoint específico no servidor.
  2. 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.
  3. Eventos: O servidor pode enviar dados para o cliente a qualquer momento.
  4. 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

💡
A aplicação do prefixo # 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
Exemplo do SSE com Node.js

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)
Exemplo do uso de SSE no X

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.

GitHub - marcelovismari/exemplo-server-sent-events-com-nodejs: Este repositório demonstra uma implementação prática de Server-Sent Events (SSE) com Node.js e HTML. Ele envia notificações em tempo real para o frontend, que as exibe ao usuário. O exemplo foi publicado no site consolelog.com.br.
Este repositório demonstra uma implementação prática de Server-Sent Events (SSE) com Node.js e HTML. Ele envia notificações em tempo real para o frontend, que as exibe ao usuário. O exemplo foi pub…

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.