Transmitir atualizações com eventos enviados pelo servidor

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

Você pode usar SSEs para enviar notificações push do seu app da Web. Os SSEs enviam informações em uma direção, portanto, você não receberá atualizações do cliente.

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

  • Pesquisa: o aplicativo pesquisa repetidamente um servidor em busca de dados. 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 espera o servidor responder com dados. Se nenhum estiver disponível, uma resposta vazia será retornada. A sondagem extra cria uma sobrecarga de HTTP maior.

  • Pesquisa longa (Suspensão de GET / COMET): 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 suspenso". Quando as informações ficam disponíveis, o servidor responde, encerra a conexão e o processo é repetido. Assim, o servidor responde constantemente com novos dados. Para configurar isso, os desenvolvedores costumam usar opções como anexar tags de script a um iframe "infinito".

Os eventos enviados pelo servidor foram projetados do zero para serem eficientes. Ao se comunicar com SSEs, um servidor pode enviar dados por push para o app sempre que quiser, sem a necessidade de fazer uma solicitação inicial. Em outras palavras, as atualizações podem ser transmitidas do servidor para o cliente à medida que acontecem. Os SSEs abrem um único canal unidirecional entre o servidor e o cliente.

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

Comparação entre eventos enviados pelo servidor e WebSockets

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

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

No entanto, às vezes você só precisa da comunicação unidirecional de um servidor. Por exemplo, quando um amigo atualiza o status, bolsa de valores, feeds de notícias ou outros mecanismos automáticos de push de dados. Em outras palavras, uma atualização em um banco de dados Web SQL ou armazenamento de objetos IndexedDB do lado do cliente. Se você precisar enviar dados para um servidor, XMLHttpRequest será sempre um amigo.

As SSEs são enviadas por HTTP. Não há um protocolo especial ou implementação de servidor para fazer o trabalho. Os WebSockets exigem conexões full-duplex e novos servidores WebSocket para lidar com o protocolo.

Além disso, os eventos enviados pelo servidor têm uma variedade de recursos que os WebSockets não têm por padrão, incluindo reconexão automática, IDs de evento 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. Opcionalmente, é possível 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 do servidor, o gerenciador onmessage é acionado e novos dados são disponibilizados 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é mesmo ter controle sobre esse tempo limite de reconexão.

Pronto. Agora seu cliente pode processar eventos de stream.php.

Formato do stream de eventos

Para enviar um stream de eventos da origem, basta construir uma resposta de texto simples, disponibilizada com um Content-Type text/event-stream, que siga o formato SSE. Na forma básica, a resposta precisa conter uma linha data:, seguida pela mensagem e por dois caracteres "\n" para encerrar o stream:

data: My message\n\n

Dados de várias linhas

Se a mensagem for mais longa, divida-a 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 é disparado.

Cada linha deve terminar em um único "\n" (exceto a última, 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 "primeira linha\nsegunda linha" em e.data. Em seguida, é possível usar e.data.split('\n').join('') para reconstruir a mensagem sem 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 possível código do lado do cliente para lidar com 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 que comece com id::

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

A definição de um ID permite que o navegador monitore o último evento acionado para que, se a conexão com o servidor for descartada, um cabeçalho HTTP especial (Last-Event-ID) seja 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 aproximadamente três segundos após cada conexão ser encerrada. 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 reconexão após 10 segundos:

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

Especificar um nome de evento

Uma única origem de eventos pode gerar diferentes tipos de eventos ao incluir um nome de evento. Se houver uma linha que começa com event:, seguida de um nome exclusivo, o evento é associado a esse nome. No cliente, é possível configurar um listener de eventos para detectar esse evento específico.

Por exemplo, a saída do servidor a seguir envia três tipos de eventos: "message", genérico 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 listeners de eventos configurados 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 de 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 stream de eventos

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

Para cancelar um stream no cliente, chame:

source.close();

Para cancelar um stream 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).

Ambos os métodos evitam que o navegador restabeleça a conexão.

Informações sobre segurança

As solicitações geradas pelo EventSource estão sujeitas às políticas de mesma origem que outras APIs de rede, como o fetch. Se você precisar que o endpoint SSE no servidor seja acessível de diferentes origens, leia como ativá-lo com o Compartilhamento de recursos entre origens (CORS).