Solicitações de streaming com a API Fetch

Jake Archibald
Jake Archibald

No Chromium 105, é possível usar a API Streams para iniciar uma solicitação antes de todo o corpo estar disponível.

Você pode usar isso para:

  • Aqueça o servidor. Em outras palavras, é possível iniciar a solicitação quando o usuário focar um campo de entrada de texto, tirar todos os cabeçalhos do caminho e aguardar até que o usuário pressione "enviar" antes de enviar os dados inseridos.
  • Envie gradualmente os dados gerados no cliente, como dados de áudio, vídeo ou entrada.
  • Recrie os soquetes da Web por HTTP/2 ou HTTP/3.

Mas, como esse é um recurso de plataforma da Web de baixo nível, não se limite a minhas ideias. Talvez você possa pensar em um caso de uso muito mais interessante para o streaming de solicitações.

Demonstração

Isso mostra como você pode transmitir dados do usuário para o servidor e enviar dados que podem ser processados em tempo real.

Não é o exemplo mais imaginativo. Só queria simplificar.

De qualquer forma, como isso funciona?

Antes nas aventuras eletrizantes dos fluxos de busca

Os fluxos de resposta já estão disponíveis em todos os navegadores modernos há algum tempo. Elas permitem que você acesse partes de uma resposta à medida que elas chegam do servidor:

const response = await fetch(url);
const reader = response.body.getReader();

while (true) {
  const {value, done} = await reader.read();
  if (done) break;
  console.log('Received', value);
}

console.log('Response fully received');

Cada value é uma Uint8Array de bytes. O número de matrizes recebidas e o tamanho delas dependem da velocidade da rede. Se você estiver usando uma conexão rápida, terá "pedaços" de dados em menor quantidade. Se a conexão estiver lenta, você conseguirá fragmentos menores.

Se você quiser converter os bytes em texto, use TextDecoder ou o fluxo de transformação mais recente se os navegadores de destino forem compatíveis:

const response = await fetch(url);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();

O TextDecoderStream é um stream de transformação que pega todos esses blocos de Uint8Array e os converte em strings.

Os streams são ótimos porque você pode começar a agir com base nos dados assim que eles chegarem. Por exemplo, se estiver recebendo uma lista de 100 "resultados", você poderá exibir o primeiro resultado assim que recebê-lo, em vez de esperar todos os 100.

Enfim, esses são os fluxos de resposta. A novidade interessante que eu gostaria de falar são os streams de solicitações.

Corpos de solicitação de streaming

As solicitações podem ter corpos:

await fetch(url, {
  method: 'POST',
  body: requestBody,
});

Antes, você precisava de todo o corpo pronto para a ação antes de iniciar a solicitação, mas agora no Chromium 105, você pode fornecer seus próprios ReadableStream de dados:

function wait(milliseconds) {
  return new Promise(resolve => setTimeout(resolve, milliseconds));
}

const stream = new ReadableStream({
  async start(controller) {
    await wait(1000);
    controller.enqueue('This ');
    await wait(1000);
    controller.enqueue('is ');
    await wait(1000);
    controller.enqueue('a ');
    await wait(1000);
    controller.enqueue('slow ');
    await wait(1000);
    controller.enqueue('request.');
    controller.close();
  },
}).pipeThrough(new TextEncoderStream());

fetch(url, {
  method: 'POST',
  headers: {'Content-Type': 'text/plain'},
  body: stream,
  duplex: 'half',
});

O comando acima enviará "Esta é uma solicitação lenta" ao servidor, uma palavra por vez, com uma pausa de um segundo entre cada palavra.

Cada bloco do corpo da solicitação precisa ter uma Uint8Array de bytes. Por isso, estou usando pipeThrough(new TextEncoderStream()) para fazer a conversão.

Restrições

As solicitações de streaming são uma nova tecnologia na Web, portanto, apresentam algumas restrições:

Meio duplex?

Para permitir que streams sejam usados em uma solicitação, a opção de solicitação duplex precisa ser definida como 'half'.

Um recurso pouco conhecido de HTTP (embora o comportamento padrão dependa de quem você pergunta) é que você pode começar a receber a resposta enquanto ainda está enviando a solicitação. No entanto, é tão pouco conhecido que não tem suporte de servidores nem de nenhum navegador.

Nos navegadores, a resposta nunca fica disponível até que o corpo da solicitação seja totalmente enviado, mesmo que o servidor envie uma resposta antes disso. Isso é válido para todas as buscas por navegador.

Esse padrão é conhecido como "meio duplex". No entanto, algumas implementações, como fetch no Deno, usam "full duplex" por padrão nas buscas de streaming, o que significa que a resposta pode ser disponibilizada antes da conclusão da solicitação.

Portanto, para solucionar esse problema de compatibilidade, em navegadores, o duplex: 'half' precisa ser especificado nas solicitações que têm um corpo de stream.

No futuro, duplex: 'full' poderá ser compatível com navegadores para solicitações de streaming e não streaming.

Enquanto isso, a próxima melhor coisa para a comunicação duplex é fazer uma busca com uma solicitação de streaming e, em seguida, fazer outra busca para receber a resposta do streaming. O servidor precisará de alguma maneira para associar essas duas solicitações, como um ID no URL. É assim que a demonstração funciona.

Redirecionamentos restritos

Algumas formas de redirecionamento HTTP exigem que o navegador reenvie o corpo da solicitação para outro URL. Para oferecer suporte a isso, o navegador teria que armazenar em buffer o conteúdo do stream, o que meio invalida o ponto, então ele não faz isso.

Em vez disso, se a solicitação tiver um corpo de streaming e a resposta for um redirecionamento HTTP diferente de 303, a busca vai ser rejeitada e o redirecionamento não será seguido.

Os redirecionamentos 303 são permitidos, já que eles mudam explicitamente o método para GET e descartam o corpo da solicitação.

Requer CORS e aciona uma simulação

As solicitações de streaming têm corpo, mas não têm um cabeçalho Content-Length. Esse é um novo tipo de solicitação, portanto, o CORS é obrigatório, e essas solicitações sempre acionam uma simulação.

As solicitações no-cors de streaming não são permitidas.

Não funciona em HTTP/1.x

A busca será rejeitada se a conexão for HTTP/1.x.

Isso acontece porque, de acordo com as regras do HTTP/1.1, os corpos de solicitação e resposta precisam enviar um cabeçalho Content-Length, para que o outro lado saiba a quantidade de dados que receberá, ou mudar o formato da mensagem para usar a codificação fragmentada. Com a codificação em partes, o corpo é dividido em partes, cada uma com um comprimento de conteúdo próprio.

A codificação fragmentada é bastante comum quando se trata de respostas de HTTP/1.1, mas muito rara quando se trata de solicitações, por isso é um risco de compatibilidade muito alto.

Possíveis problemas

Esse é um recurso novo e pouco usado na Internet hoje. Confira a seguir alguns problemas:

Incompatibilidade do lado do servidor

Alguns servidores de aplicativos não oferecem suporte a solicitações de streaming. Em vez disso, aguardam o recebimento da solicitação completa antes de exibir qualquer conteúdo, o que invalida o ponto. Em vez disso, use um servidor de apps compatível com streaming, como NodeJS ou Deno.

Mas você ainda não está fora! O servidor de aplicativos, como o NodeJS, geralmente fica atrás de outro servidor, também chamado de "servidor front-end", que, por sua vez, pode ficar atrás de uma CDN. Se algum deles decidir armazenar a solicitação em buffer antes de fornecê-la ao próximo servidor na cadeia, você perderá o benefício do streaming de solicitações.

Incompatibilidade fora do seu controle

Como esse recurso só funciona por HTTPS, não é preciso se preocupar com proxies entre você e o usuário, mas o usuário pode estar executando um proxy na máquina dele. Alguns softwares de proteção de Internet fazem isso para permitir que eles monitorem tudo o que acontece entre o navegador e a rede, e pode haver casos em que esse software armazena os corpos de solicitação em buffer.

Para se proteger contra isso, crie um "teste de recursos" semelhante à demonstração acima, em que você tenta transmitir alguns dados sem fechar o stream. Se o servidor receber os dados, ele poderá responder com uma busca diferente. Quando isso acontecer, você saberá que o cliente suporta solicitações de streaming de ponta a ponta.

Detecção de recursos

const supportsRequestStreams = (() => {
  let duplexAccessed = false;

  const hasContentType = new Request('', {
    body: new ReadableStream(),
    method: 'POST',
    get duplex() {
      duplexAccessed = true;
      return 'half';
    },
  }).headers.has('Content-Type');

  return duplexAccessed && !hasContentType;
})();

if (supportsRequestStreams) {
  // …
} else {
  // …
}

Confira como a detecção de recursos funciona:

Se o navegador não oferecer suporte a um tipo específico de body, ele vai chamar toString() no objeto e usar o resultado como o corpo. Portanto, se o navegador não oferecer suporte a fluxos de solicitação, o corpo da solicitação se tornará a string "[object ReadableStream]". Quando uma string é usada como um corpo, ela define o cabeçalho Content-Type como text/plain;charset=UTF-8 de maneira conveniente. Portanto, se esse cabeçalho estiver definido, saberemos que o navegador não oferece suporte a fluxos em objetos de solicitação e podemos sair antecipadamente.

O Safari oferece suporte para fluxos em objetos de solicitação, mas não permite que eles sejam usados com fetch. Portanto, a opção duplex foi testada, e o Safari não tem suporte no momento.

Como usar com streams graváveis

Às vezes, é mais fácil trabalhar com streams quando você tem um WritableStream. É possível fazer isso usando um stream de "identidade", que é um par legível/gravável que recebe tudo o que foi transmitido à extremidade gravável dele e o envia para a extremidade legível. Crie um deles criando um TransformStream sem argumentos:

const {readable, writable} = new TransformStream();

const responsePromise = fetch(url, {
  method: 'POST',
  body: readable,
});

Agora, tudo o que você enviar para o stream gravável fará parte da solicitação. Isso permite que você crie streams juntos. Veja um exemplo simples em que os dados são obtidos de um URL, compactados e enviados para outro URL:

// Get from url1:
const response = await fetch(url1);
const {readable, writable} = new TransformStream();

// Compress the data from url1:
response.body.pipeThrough(new CompressionStream('gzip')).pipeTo(writable);

// Post to url2:
await fetch(url2, {
  method: 'POST',
  body: readable,
});

O exemplo acima usa fluxos de compactação para compactar dados arbitrários com o gzip.