Enviando mensagens em tempo real para seu frontend com SSE - Server Sent Events

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.

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:

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.