Transmite solicitudes con la API de fetch

Jake Archibald
Jake Archibald

A partir de Chromium 105, puedes iniciar una solicitud antes de tener todo el cuerpo disponible mediante la API de Streams.

Podría usar esto para:

  • Prepara el servidor. En otras palabras, podrías iniciar la solicitud una vez que el usuario enfoque un campo de entrada de texto y obtener todos los encabezados, y luego esperar hasta que el usuario presione "Enviar" antes de enviar los datos que ingresó.
  • Envía gradualmente los datos generados en el cliente, como los datos de audio, video o entrada.
  • Vuelve a crear sockets web a través de HTTP/2 o HTTP/3.

Pero como se trata de una función de plataforma web de bajo nivel, no te limites a mis ideas. Tal vez puedas pensar en un caso de uso mucho más emocionante para la transmisión de solicitudes.

Demostración

Aquí se muestra cómo puedes transmitir datos del usuario al servidor y enviar datos que se pueden procesar en tiempo real.

Bueno, no es el ejemplo más imaginativo, solo quería que fuera simple.

De todos modos, ¿cómo funciona?

Anteriormente, en las emocionantes aventuras de las transmisiones con recuperación

Las transmisiones de respuestas están disponibles desde hace un tiempo en todos los navegadores modernos. Te permiten acceder a partes de una respuesta a medida que llegan desde el 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 es un Uint8Array de bytes. La cantidad de arrays que obtienes y el tamaño de los arrays dependen de la velocidad de la red. Si tienes una conexión rápida, obtendrás menos “fragmentos” de datos, pero más grandes. Si la conexión es lenta, obtendrás más bloques más pequeños.

Si deseas convertir los bytes en texto, puedes usar TextDecoder o la transmisión de transformación más reciente si lo admiten tus navegadores de destino:

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

TextDecoderStream es una transmisión de transformación que toma todos esos fragmentos Uint8Array y los convierte en strings.

Las transmisiones son excelentes, ya que puedes comenzar a aprovechar los datos a medida que llegan. Por ejemplo, si recibes una lista de 100 "resultados", puedes mostrar el primer resultado en cuanto lo recibes, en lugar de esperar a que lleguen los 100.

De todos modos, se trata de flujos de respuestas, lo nuevo y emocionante de los que quería hablar son las transmisiones de solicitudes.

Cuerpos de solicitud de transmisión

Las solicitudes pueden tener cuerpos:

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

Anteriormente, se necesitaba todo el cuerpo listo para poder iniciar la solicitud, pero ahora en Chromium 105, puedes proporcionar tus propios ReadableStream de datos:

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',
});

Lo anterior enviará el mensaje "Esta es una solicitud lenta" al servidor, una palabra a la vez, con una pausa de un segundo entre cada palabra.

Cada fragmento del cuerpo de una solicitud debe ser un Uint8Array de bytes, por lo que usaré pipeThrough(new TextEncoderStream()) para realizar la conversión por mí.

Restricciones

Las solicitudes de transmisión son un nuevo poder para la Web, por lo que vienen con algunas restricciones:

¿La opción de dúplex medio?

Para permitir que se usen transmisiones en una solicitud, la opción de solicitud duplex debe establecerse en 'half'.

Una función poco conocida de HTTP (aunque si este es un comportamiento estándar depende de a quién se le pregunte) es que puedes comenzar a recibir la respuesta mientras aún envías la solicitud. Sin embargo, es tan poco conocida, no es compatible con los servidores ni es compatible con ningún navegador.

En los navegadores, la respuesta nunca está disponible hasta que el cuerpo de la solicitud se haya enviado por completo, incluso si el servidor envía una respuesta antes. Esto se aplica a todas las recuperaciones de navegadores.

Este patrón predeterminado se conoce como "half dúplex". Sin embargo, algunas implementaciones (como fetch en Deno) se configuraron como "dúplex completo" de forma predeterminada para las recuperaciones de transmisión, lo que significa que la respuesta puede estar disponible antes de que se complete la solicitud.

Por lo tanto, para solucionar este problema de compatibilidad, en los navegadores, se debe especificar duplex: 'half' en las solicitudes que tienen un cuerpo de transmisión.

En el futuro, es posible que duplex: 'full' sea compatible con navegadores para solicitudes de transmisión y de no transmisión.

Mientras tanto, la mejor opción para la comunicación dúplex es realizar una recuperación con una solicitud de transmisión y, luego, realizar otra recuperación para recibir la respuesta de transmisión. El servidor necesitará alguna forma de asociar estas dos solicitudes, como un ID en la URL. Así es como funciona la demostración.

Redireccionamientos restringidos

Algunas formas de redireccionamiento HTTP requieren que el navegador reenvíe el cuerpo de la solicitud a otra URL. Para ello, el navegador tendría que almacenar en búfer el contenido de la transmisión, lo que derrocha el punto, por lo que no hace eso.

En cambio, si la solicitud tiene un cuerpo de transmisión y la respuesta es un redireccionamiento HTTP distinto del 303, se rechazará la recuperación y no se seguirá el redireccionamiento.

Se permiten los redireccionamientos 303, ya que cambian explícitamente el método a GET y descartan el cuerpo de la solicitud.

Requiere CORS y activa una solicitud preliminar

Las solicitudes de transmisión tienen un cuerpo, pero no un encabezado Content-Length. Este es un nuevo tipo de solicitud, por lo que se requiere CORS, y estas solicitudes siempre activan una comprobación previa.

No se permiten las solicitudes de transmisión no-cors.

No funciona en HTTP/1.x

La recuperación se rechazará si la conexión es HTTP/1.x.

Esto se debe a que, de acuerdo con las reglas de HTTP/1.1, los cuerpos de solicitud y respuesta deben enviar un encabezado Content-Length para que la parte del otro lado sepa cuántos datos recibirá o cambiar el formato del mensaje para usar la codificación fragmentada. Con la codificación fragmentada, el cuerpo se divide en partes, cada una con su propia longitud de contenido.

La codificación fragmentada es bastante común cuando se trata de respuestas HTTP/1.1, pero es muy poco frecuente cuando se trata de solicitudes, por lo que es demasiado riesgo de compatibilidad.

Posibles problemas

Esta es una función nueva que actualmente se usa poco en Internet. A continuación, se incluyen algunos problemas que debes tener en cuenta:

Incompatibilidad del servidor

Algunos servidores de apps no admiten solicitudes de transmisión y, en su lugar, esperan a que se reciba la solicitud completa antes de permitirte ver cualquiera de ellas, lo que desafía el punto. En su lugar, usa un servidor de apps que admita la transmisión, como NodeJS o Deno.

Pero aún no sales del bosque. El servidor de aplicaciones, como Node.js, generalmente se ubica detrás de otro servidor, a menudo denominado “servidor de frontend”, que a su vez puede ubicarse detrás de una CDN. Si alguno de ellos decide almacenar la solicitud en búfer antes de entregársela al siguiente servidor de la cadena, perderás el beneficio de la transmisión de solicitudes.

Incompatibilidad fuera de tu control

Dado que esta función solo funciona a través de HTTPS, no tienes que preocuparte por los proxies que hay entre tú y el usuario. Sin embargo, es posible que el usuario esté ejecutando un proxy en su máquina. Algunos software de protección de Internet hacen esto para permitir que supervise todo lo que se interpone entre el navegador y la red, y, en algunos casos, este software almacena en búfer los cuerpos de solicitud.

Si quieres protegerte contra esto, puedes crear una “prueba de funciones” similar a la demostración anterior, en la que intentas transmitir algunos datos sin cerrar la transmisión. Si el servidor recibe los datos, puede responder con una recuperación diferente. Una vez que esto sucede, sabes que el cliente admite solicitudes de transmisión de extremo a extremo.

Detección de funciones

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 {
  // …
}

Si tienes curiosidad, a continuación te mostramos cómo funciona la detección de funciones:

Si el navegador no admite un tipo de body en particular, llama a toString() en el objeto y usa el resultado como cuerpo. Por lo tanto, si el navegador no admite transmisiones de solicitudes, el cuerpo de la solicitud se convierte en la string "[object ReadableStream]". Cuando se usa una string como cuerpo, establece el encabezado Content-Type en text/plain;charset=UTF-8 de forma conveniente. Por lo tanto, si ese encabezado está configurado, sabemos que el navegador no admite transmisiones en objetos de solicitud, y podemos salir antes.

Safari admite transmisiones en objetos de solicitud, pero no permite que se usen con fetch, por lo que se prueba la opción duplex, que Safari no admite actualmente.

Uso con transmisiones que admiten escritura

A veces, es más fácil trabajar con transmisiones cuando tienes un WritableStream. Puedes hacerlo con un flujo de “identidad”, que es un par que se puede leer y escribir, y que toma todo lo que se pasa a su extremo de escritura y lo envía al extremo legible. Puedes crear uno de estos si creas un TransformStream sin ningún argumento:

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

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

Ahora, todo lo que envíes a la transmisión que admite escritura formará parte de la solicitud. Esto te permite componer transmisiones en vivo. Por ejemplo, a continuación se incluye un ejemplo simple en el que los datos se obtienen de una URL, se comprimen y se envían a otra 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,
});

En el ejemplo anterior, se usan flujos de compresión para comprimir datos arbitrarios con gzip.