Solicitações de streaming com a API Fetch

Jake Archibald
Jake Archibald

A partir do Chromium 105, é possível 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 quando o usuário focar um campo de entrada de texto e remover todos os cabeçalhos. Em seguida, aguarde até que o usuário pressione "enviar" antes de enviar os dados inseridos.
  • Enviar gradualmente dados gerados no cliente, como áudio, vídeo ou dados de entrada.
  • Recrie os sockets da Web por HTTP/2 ou HTTP/3.

No entanto, 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 você pode transmitir dados do usuário para o servidor e enviar dados de volta que podem ser processados em tempo real.

Sim, não é o exemplo mais imaginativo, mas eu só queria manter as coisas simples, ok?

Como isso funciona?

Nas aventuras anteriores de streams de busca

As transmissões de resposta já estão disponíveis em todos os navegadores modernos há algum tempo. Elas permitem que você acesse partes de uma resposta conforme 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 dependem da velocidade da rede. Se você estiver em uma conexão rápida, receberá menos "porções" de dados maiores. Se você estiver em uma conexão lenta, vai receber mais blocos menores.

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

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

TextDecoderStream é um stream de transformação que extrai todos os fragmentos Uint8Array e os converte em strings.

As transmissões são ótimas, porque você pode começar a agir com os dados assim que eles chegam. Por exemplo, se você receber uma lista de 100 "resultados", poderá mostrar o primeiro assim que ele for recebido, em vez de esperar os 100.

De qualquer forma, essa é a resposta, a novidade que eu queria falar é sobre as solicitações.

Corpos de solicitação de streaming

As solicitações podem ter corpos:

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

Antes, era necessário ter o corpo inteiro pronto antes de iniciar a solicitação, mas agora, no Chromium 105, você pode fornecer seu próprio 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 código acima vai enviar "This is a slow request" para o servidor, uma palavra de cada vez, com uma pausa de um segundo entre cada palavra.

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

Restrições

As solicitações de streaming são um novo recurso da Web, então elas têm algumas restrições:

Meio-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 seja um comportamento padrão, dependendo de quem você perguntar) é que você pode começar a receber a resposta enquanto ainda está enviando a solicitação. No entanto, ela é tão pouco conhecida que não tem suporte de servidores nem de navegadores.

Nos navegadores, a resposta só fica disponível depois que o corpo da solicitação é totalmente enviado, mesmo que o servidor envie uma resposta antes. Isso é válido para todas as buscas do navegador.

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

Para contornar esse problema de compatibilidade, nos navegadores, duplex: 'half' precisa ser especificado em solicitações que têm um corpo de stream.

No futuro, o duplex: 'full' poderá ser aceito nos navegadores para solicitações de streaming e não streaming.

Enquanto isso, a melhor alternativa à comunicação duplex é fazer uma busca com uma solicitação de streaming e depois fazer outra busca para receber a resposta de streaming. O servidor vai precisar de uma 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 oferecer suporte a isso, o navegador precisaria armazenar em buffer o conteúdo do stream, o que é contraproducente, 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 será rejeitada e o redirecionamento não será seguido.

Os redirecionamentos 303 são permitidos, já que 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 têm um cabeçalho Content-Length. Esse é um novo tipo de solicitação, então o CORS é necessário, e essas solicitações sempre acionam uma simulação.

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

Não funciona no HTTP/1.x

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

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

A codificação em blocos é muito comum em respostas HTTP/1.1, mas muito rara em solicitações. Portanto, é um risco de compatibilidade muito grande.

Possíveis problemas

Esse é um recurso novo e pouco usado na Internet atualmente. Confira alguns problemas:

Incompatibilidade no lado do servidor

Alguns servidores de apps não oferecem suporte a solicitações de streaming e, em vez disso, aguardam que a solicitação completa seja recebida antes de mostrar qualquer parte dela, o que é contraproducente. Em vez disso, use um servidor de apps compatível com streaming, como o NodeJS ou o Deno.

Mas você ainda não saiu do problema. O servidor de aplicativos, como o NodeJS, geralmente fica atrás de outro servidor, muitas vezes chamado de "servidor de front-end", que pode ficar atrás de um CDN. Se qualquer um deles decidir armazenar a solicitação em buffer antes de enviá-la para o 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, 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 monitorar tudo o que passa entre o navegador e a rede. Pode haver casos em que esse software armazena em buffer os corpos de solicitação.

Para evitar isso, crie um "teste de recurso" semelhante à demonstração acima, em que você tenta transmitir alguns dados sem fechar a transmissão. Se o servidor receber os dados, ele poderá responder com uma busca diferente. Quando isso acontecer, você saberá 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 você tem curiosidade, saiba 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 streams de solicitação, o corpo da solicitação vai se tornar a string "[object ReadableStream]". Quando uma string é usada como um corpo, ela define convenientemente o cabeçalho Content-Type como text/plain;charset=UTF-8. Portanto, se esse cabeçalho estiver definido, saberemos que o navegador não oferece suporte a streams em objetos de solicitação, e podemos 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, o que o Safari não oferece no momento.

Como usar com streams graváveis

Às vezes, é mais fácil trabalhar com transmissões quando você tem um WritableStream. Você pode fazer isso usando um stream de "identidade", que é um par legível/gravável que recebe tudo o que é transmitido para o fim gravável e o envia para o fim legível. Você pode criar 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 vai fazer parte da solicitação. Isso permite que você combine streams. Por exemplo, aqui está um exemplo simples em que os dados são buscados 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 usando gzip.