Transmitir atualizações com eventos enviados pelo servidor

Os eventos enviados pelo servidor (SSEs) enviam atualizações automáticas para um cliente de um servidor com uma conexão HTTP. Depois que a conexão é estabelecida, os servidores podem iniciar a transmissão de dados.

Convém usar SSEs para enviar notificações push do seu app da Web. As SSEs enviam informações em uma direção. Portanto, você não receberá atualizações do cliente.

O conceito de SSEs pode ser familiar. Um app da Web "se inscreve" em um fluxo de atualizações geradas por um servidor e, sempre que um novo evento ocorre, uma notificação é enviada ao cliente. Mas, para entender os eventos enviados pelo servidor, precisamos entender as limitações dos antecessores do AJAX. Isso inclui:

  • Solicitação de dados: o aplicativo solicita dados repetidamente de um servidor. Essa técnica é usada pela maioria dos aplicativos AJAX. Com o protocolo HTTP, a busca de dados gira em torno de um formato de solicitação e resposta. O cliente faz uma solicitação e aguarda a resposta do servidor com os dados. Se nenhuma estiver disponível, uma resposta vazia será retornada. A pesquisa extra cria uma sobrecarga HTTP maior.

  • Long polling (GET / COMET pendente): se o servidor não tiver dados disponíveis, ele manterá a solicitação aberta até que novos dados sejam disponibilizados. Por isso, essa técnica é frequentemente chamada de "GET pendente". Quando as informações ficam disponíveis, o servidor responde, fecha a conexão e o processo é repetido. Assim, o servidor está constantemente respondendo com novos dados. Para configurar isso, os desenvolvedores geralmente usam hacks, como anexar tags de script a um iframe "infinito".

Os eventos enviados pelo servidor foram projetados desde o início para serem eficientes. Ao se comunicar com SSEs, um servidor pode enviar dados para seu app sempre que quiser, sem precisar fazer uma solicitação inicial. Em outras palavras, as atualizações podem ser transmitidas do servidor para o cliente conforme acontecem. SSEs abrem um único canal unidirecional entre o servidor e o cliente.

A principal diferença entre os eventos enviados pelo servidor e a pesquisa longa é que os SSEs são tratados diretamente pelo navegador, e o usuário só precisa detectar as mensagens.

Eventos enviados pelo servidor x WebSockets

Por que você escolheria eventos enviados pelo servidor em vez de WebSockets? Boa pergunta.

Os WebSockets têm um protocolo avançado com comunicação bidirecional full-duplex. Um canal de duas vias é melhor para jogos, apps de mensagens e qualquer caso de uso em que você precise de atualizações quase em tempo real em ambas as direções.

No entanto, às vezes você só precisa da comunicação unidirecional de um servidor. Por exemplo, quando um amigo atualiza o status, os tickers de ações, os feeds de notícias ou outros mecanismos de envio de dados automatizados. Em outras palavras, uma atualização em um banco de dados Web SQL ou repositório de objetos IndexedDB do lado do cliente. Se você precisar enviar dados para um servidor, o XMLHttpRequest sempre será um amigo.

Os SSEs são enviados por HTTP. Não há protocolo ou implementação de servidor especial para funcionar. Os WebSockets exigem conexões full-duplex e novos servidores WebSocket para processar o protocolo.

Além disso, os eventos enviados pelo servidor têm vários recursos que os WebSockets não têm por design, incluindo a reconexão automática, IDs de eventos e a capacidade de enviar eventos arbitrários.

Criar uma EventSource com JavaScript

Para se inscrever em um fluxo de eventos, crie um objeto EventSource e transmita o URL do seu fluxo:

const source = new EventSource('stream.php');

Em seguida, configure um gerenciador para o evento message. Você pode detectar open e error:

source.addEventListener('message', (e) => {
  console.log(e.data);
});

source.addEventListener('open', (e) => {
  // Connection was opened.
});

source.addEventListener('error', (e) => {
  if (e.readyState == EventSource.CLOSED) {
    // Connection was closed.
  }
});

Quando as atualizações são enviadas pelo servidor, o gerenciador onmessage é acionado e novos dados ficam disponíveis na propriedade e.data. O melhor de tudo é que, sempre que a conexão é encerrada, o navegador se reconecta automaticamente à origem após cerca de três segundos. A implementação do servidor pode até controlar esse tempo limite de reconexão.

É isso. Agora, seu cliente pode processar eventos de stream.php.

Formato do fluxo de eventos

Enviar um fluxo de eventos da origem é uma questão de construir uma resposta de texto simples, exibida com um text/event-stream Content-Type, que segue o formato SSE. Na forma básica, a resposta precisa conter uma linha data:, seguida da mensagem e de dois caracteres "\n" para encerrar o fluxo:

data: My message\n\n

Dados de várias linhas

Se a mensagem for mais longa, você pode dividi-la usando várias linhas data:. Duas ou mais linhas consecutivas que começam com data: são tratadas como um único dado, o que significa que apenas um evento message é acionado.

Cada linha deve terminar com um único "\n" (exceto o último, que deve terminar com dois). O resultado transmitido ao gerenciador message é uma única string concatenada por caracteres de nova linha. Exemplo:

data: first line\n
data: second line\n\n</pre>

Isso produz "first line\nsecond line" em e.data. É possível usar e.data.split('\n').join('') para reconstruir a mensagem sem os caracteres "\n".

Enviar dados JSON

O uso de várias linhas ajuda a enviar JSON sem quebrar a sintaxe:

data: {\n
data: "msg": "hello world",\n
data: "id": 12345\n
data: }\n\n

E um possível código do lado do cliente para processar esse fluxo:

source.addEventListener('message', (e) => {
  const data = JSON.parse(e.data);
  console.log(data.id, data.msg);
});

Associar um ID a um evento

É possível enviar um ID exclusivo com um evento de stream incluindo uma linha iniciada com id::

id: 12345\n
data: GOOG\n
data: 556\n\n

A configuração de um ID permite que o navegador acompanhe o último evento acionado. Assim, se a conexão com o servidor for perdida, um cabeçalho HTTP especial (Last-Event-ID) será definido com a nova solicitação. Isso permite que o navegador determine qual evento é apropriado para disparar. O evento message contém uma propriedade e.lastEventId.

Controlar o tempo limite de reconexão

O navegador tenta se reconectar à origem cerca de três segundos após o fechamento de cada conexão. Para alterar esse tempo limite, inclua uma linha que comece com retry:, seguida pelo número de milissegundos a aguardar antes de tentar se reconectar.

O exemplo a seguir tenta uma nova conexão após 10 segundos:

retry: 10000\n
data: hello world\n\n

Especifique um nome de evento

Uma única fonte de eventos pode gerar diferentes tipos de eventos incluindo um nome de evento. Se houver uma linha que começa com event:, seguida por um nome exclusivo, o evento será associado a esse nome. No cliente, um listener de eventos pode ser configurado para detectar esse evento específico.

Por exemplo, a saída do servidor a seguir envia três tipos de eventos: um evento genérico de "mensagem", "userlogon" e "update":

data: {"msg": "First message"}\n\n
event: userlogon\n
data: {"username": "John123"}\n\n
event: update\n
data: {"username": "John123", "emotion": "happy"}\n\n

Com a configuração de listeners de eventos no cliente:

source.addEventListener('message', (e) => {
  const data = JSON.parse(e.data);
  console.log(data.msg);
});

source.addEventListener('userlogon', (e) => {
  const data = JSON.parse(e.data);
  console.log(`User login: ${data.username}`);
});

source.addEventListener('update', (e) => {
  const data = JSON.parse(e.data);
  console.log(`${data.username} is now ${data.emotion}`);
};

Exemplos de servidor

Confira uma implementação básica do servidor em PHP:

<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache'); // recommended to prevent caching of event data.

/**
* Constructs the SSE data format and flushes that data to the client.
*
* @param string $id Timestamp/id of this connection.
* @param string $msg Line of text that should be transmitted.
**/

function sendMsg($id, $msg) {
  echo "id: $id" . PHP_EOL;
  echo "data: $msg" . PHP_EOL;
  echo PHP_EOL;
  ob_flush();
  flush();
}

$serverTime = time();

sendMsg($serverTime, 'server time: ' . date("h:i:s", time()));
?>

Confira uma implementação semelhante no Node JS usando um gerenciador Express:

app.get('/events', (req, res) => {
    // Send the SSE header.
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });

    // Sends an event to the client where the data is the current date,
    // then schedules the event to happen again after 5 seconds.
    const sendEvent = () => {
        const data = (new Date()).toLocaleTimeString();
        res.write("data: " + data + '\n\n');
        setTimeout(sendEvent, 5000);
    };

    // Send the initial event immediately.
    sendEvent();
});

sse-node.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
  </head>
  <body>
    <script>
    const source = new EventSource('/events');
    source.onmessage = (e) => {
        const content = document.createElement('div');
        content.textContent = e.data;
        document.body.append(content);
    };
    </script>
  </body>
</html>

Cancelar um fluxo de eventos

Normalmente, o navegador se reconecta automaticamente ao evento quando a conexão é fechada, mas esse comportamento pode ser cancelado pelo cliente ou pelo servidor.

Para cancelar uma transmissão do cliente, chame:

source.close();

Para cancelar uma transmissão do servidor, responda com um Content-Type diferente de text/event-stream ou retorne um status HTTP diferente de 200 OK, como 404 Not Found.

Os dois métodos impedem que o navegador restabeleça a conexão.

Sobre a segurança

As solicitações geradas pelo EventSource estão sujeitas às políticas de mesma origem de outras APIs de rede, como fetch. Se você precisar que o endpoint SSE no servidor possa ser acessado de origens diferentes, leia como ativar com o Compartilhamento de recursos entre origens (CORS, na sigla em inglês).