Solicitações de streaming com a API Fetch

Jake Archibald
Jake Archibald

No Chromium 105, você pode iniciar uma solicitação antes de ter todo o corpo disponível usando a API Streams.

Você pode usar isso para:

  • Aqueça o servidor. Em outras palavras, você pode iniciar a solicitação assim que o usuário focar em um campo de entrada de texto, remover todos os cabeçalhos e aguardar até que o usuário pressione "send" antes de enviar os dados inseridos.
  • Enviar gradualmente os dados gerados no cliente, como áudio, vídeo ou dados de entrada.
  • Recriar 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 às 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 é possível transmitir dados do usuário para o servidor e enviar dados que podem ser processados em tempo real.

Tá, ok. Não é o exemplo mais imaginativo, eu só queria ser simples, certo?

Enfim, como isso funciona?

As aventuras emocionantes dos streams de busca

Os streams de resposta (link em inglês) já estão disponíveis em todos os navegadores modernos há algum tempo. Eles 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 é um Uint8Array de bytes. O número e o tamanho das matrizes geradas dependem da velocidade da rede. Se sua conexão for rápida, você vai receber "blocos" maiores e em menor quantidade. de dados. Se sua conexão for lenta, você obterá pedaços maiores e menores.

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

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

TextDecoderStream é um fluxo de transformação que pega todos os blocos 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 chegam. Por exemplo, se você está recebendo uma lista de 100 "resultados", pode exibir o primeiro resultado assim que recebê-lo, em vez de esperar todos os 100.

De qualquer forma, são os fluxos de resposta. Uma novidade interessante sobre os quais eu gostaria de falar são os fluxos de solicitações.

Corpos de solicitações de streaming

As solicitações podem ter corpos:

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

Antes, era necessário ter todo o corpo pronto para 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',
});

A mensagem acima vai enviar a mensagem "Esta é uma solicitação lenta". ao servidor, uma palavra por vez, com uma pausa de um segundo entre cada palavra.

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

Restrições

As solicitações de streaming são uma nova tecnologia da Web, por isso vêm com algumas restrições:

Half-duplex?

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

Um recurso pouco conhecido do HTTP (embora esse comportamento seja padrão depende de quem você perguntar) é que você pode começar a receber a resposta enquanto ainda a envia. No entanto, é tão pouco conhecido que não é bem suportado por servidores nem por 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. Isso é verdadeiro para todas as buscas no navegador.

Esse padrão padrão é conhecido como "half-duplex". No entanto, algumas implementações, como fetch em Deno, adotam o padrão "full-duplex" para buscas de streaming, o que significa que a resposta pode ficar disponível antes que a solicitação seja concluída.

Portanto, para contornar esse problema de compatibilidade, nos navegadores, o duplex: 'half' precisa seja especificado em solicitações que tenham um corpo de stream.

No futuro, duplex: 'full' poderá ser compatível com navegadores para solicitações de streaming e sem 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 de streaming. O servidor vai precisar de alguma forma de 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 que isso aconteça, o navegador teria que armazenar em buffer o conteúdo do stream, o que invalida o argumento e não faz isso.

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

Os redirecionamentos 303 são permitidos, porque 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 um corpo, mas não um cabeçalho Content-Length. Como esse é um novo tipo de solicitação, o CORS é obrigatório, e essas solicitações sempre acionam uma simulação.

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 ocorre 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 quantos dados receberá ou alterar o formato da mensagem para usar a codificação em partes. Com a codificação em partes, o corpo é dividido em partes, cada uma com seu próprio tamanho de conteúdo.

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

Possíveis problemas

Esse é um recurso novo e pouco usado na Internet atualmente. Fique atento aos seguintes problemas:

Incompatibilidade no lado do servidor

Alguns servidores de apps não oferecem suporte a solicitações de streaming e aguardam o recebimento completo da solicitação para que ela apareça, o que invalida o propósito. Em vez disso, use um servidor de apps compatível com streaming, como o NodeJS ou o Deno.

Mas você ainda não está fora da floresta! O servidor de aplicativos, como o NodeJS, geralmente fica atrás de outro servidor, geralmente chamado de "servidor front-end", que pode estar por trás de um 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 em HTTPS, você não precisa se preocupar com proxies entre você e o usuário, mas o usuário pode estar executando um proxy na máquina. Alguns softwares de proteção da Internet fazem isso para permitir que eles monitorem tudo o que está entre o navegador e a rede, e pode haver casos em que esse software armazena em buffer os corpos de solicitações.

Para se proteger contra isso, crie um "teste de recurso" 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 acontece, você sabe que o cliente oferece suporte a 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 {
  // …
}

Se tiver curiosidade, saiba como a detecção de recursos funciona:

Se o navegador não oferecer suporte a um tipo body específico, ele chamará toString() no objeto e usará o resultado como o corpo. Portanto, se o navegador não oferecer suporte a streams de solicitações, 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 streams em objetos de solicitação e poderemos sair antecipadamente.

O Safari oferece suporte a streams em objetos de solicitação, mas não permite que eles sejam usados com fetch. Portanto, a opção duplex é testada, que não é compatível com o Safari no momento.

Como usar com streams graváveis

Às vezes, é mais fácil trabalhar com streams quando você tem um WritableStream. Você pode fazer isso usando uma "identidade" stream, que é um par legível/gravável que usa tudo o que é transmitido para a extremidade gravável e o envia para o final legível. Você pode criar um deles criando um TransformStream sem nenhum argumento:

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ês criem streams juntos. Por exemplo, aqui está um exemplo divertido em que os dados são recuperados de um URL, compactados e enviados para outro:

// 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 streams de compactação para compactar dados arbitrários com o gzip.