Requêtes en streaming avec l'API fetch

Jake Archiibald
Jake Archibald

Depuis Chromium 105, vous pouvez envoyer une demande avant d'avoir tout le corps disponible à l'aide de l'API Streams.

Vous pouvez l'utiliser pour:

  • Préchauffez le serveur. En d'autres termes, vous pouvez lancer la requête une fois que l'utilisateur sélectionne un champ de saisie de texte et supprimer tous les en-têtes, puis attendre que l'utilisateur appuie sur « Envoyer » avant d'envoyer les données qu'il a saisies.
  • Envoyez progressivement les données générées sur le client, telles que les données audio, vidéo ou d'entrée.
  • Recréez des Web sockets via HTTP/2 ou HTTP/3.

Cependant, comme il s'agit d'une fonctionnalité de base de la plate-forme Web, ne soyez pas limité par mes idées. Vous pouvez peut-être imaginer un cas d'utilisation beaucoup plus intéressant pour le streaming de requêtes.

Démonstration

Vous y découvrirez comment diffuser les données de l'utilisateur vers le serveur et renvoyer des données pouvant être traitées en temps réel.

Ce n'est pas l'exemple le plus imaginatif. Je voulais juste faire simple, d'accord ?

Quoi qu'il en soit, comment ça marche ?

Vive les aventures passionnantes des flux de récupération

Les flux de réponse sont disponibles dans tous les navigateurs récents depuis un certain temps. Ils vous permettent d'accéder à certaines parties d'une réponse à mesure qu'elles arrivent du serveur:

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

Chaque value est une Uint8Array d'octets. Le nombre de tableaux obtenus et leur taille dépendent de la vitesse du réseau. Si vous utilisez une connexion haut débit, vous obtiendrez des fragments de données moins nombreux, mais plus volumineux. Si votre connexion est lente, vous obtiendrez des fragments de plus en plus petits.

Si vous souhaitez convertir les octets en texte, vous pouvez utiliser TextDecoder ou le flux de transformation plus récent si vos navigateurs cibles le permettent:

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

TextDecoderStream est un flux de transformation qui récupère tous ces fragments Uint8Array et les convertit en chaînes.

Les flux sont très bien, car vous pouvez commencer à agir sur les données dès qu'elles arrivent. Par exemple, si vous recevez une liste de 100 "résultats", vous pouvez afficher le premier résultat dès que vous le recevez, plutôt que d'attendre les 100 résultats.

Quoi qu'il en soit, les flux de réponse, la nouvelle chose passionnante dont je voulais parler est les flux de requêtes.

Corps des requêtes de streaming

Les requêtes peuvent avoir des corps:

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

Auparavant, tout le corps était nécessaire avant de pouvoir envoyer la requête, mais vous pouvez désormais fournir votre propre ReadableStream de données dans Chromium 105:

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

La requête ci-dessus envoie le message "Ceci est une requête lente" au serveur, mot par mot, avec une pause d'une seconde entre chaque mot.

Chaque fragment du corps de la requête doit être un Uint8Array d'octets. J'utilise donc pipeThrough(new TextEncoderStream()) pour effectuer la conversion à ma place.

Restrictions

Les requêtes en streaming représentent une nouvelle puissance pour le Web et sont donc soumises à quelques restrictions:

En semi-duplex ?

Pour autoriser l'utilisation de flux dans une requête, l'option de requête duplex doit être définie sur 'half'.

Une fonctionnalité peu connue du protocole HTTP (qui dépend de la personne interrogée pour savoir s'il s'agit d'un comportement standard) est que vous pouvez commencer à recevoir la réponse tout en continuant à envoyer la requête. Cependant, elle est si peu connue qu'elle n'est pas bien prise en charge par les serveurs et n'est prise en charge par aucun navigateur.

Dans les navigateurs, la réponse n'est jamais disponible tant que le corps de la requête n'a pas été entièrement envoyé, même si le serveur envoie une réponse plus tôt. Cela est vrai pour toutes les extractions dans le navigateur.

Ce modèle par défaut est appelé "demi-duplex". Cependant, certaines implémentations, telles que fetch dans Deno, sont définies par défaut sur "duplex intégral" pour les récupérations en flux continu, ce qui signifie que la réponse peut être disponible avant la fin de la requête.

Ainsi, pour contourner ce problème de compatibilité, dans les navigateurs, duplex: 'half' doit être spécifié pour les requêtes ayant un corps de flux.

À l'avenir, duplex: 'full' pourra être compatible avec les requêtes en streaming et autres que dans les navigateurs.

En attendant, la meilleure solution pour la communication duplex consiste à effectuer une extraction avec une requête de flux, puis une autre extraction pour recevoir la réponse en flux continu. Le serveur a besoin d'un moyen d'associer ces deux requêtes, par exemple un identifiant dans l'URL. C'est comme cela que fonctionne la démonstration.

Redirections restreintes

Certaines formes de redirection HTTP nécessitent que le navigateur renvoie le corps de la requête à une autre URL. Pour cela, le navigateur doit mettre en mémoire tampon le contenu du flux, ce qui va à l'encontre de ce point. Il ne procède donc pas à cette opération.

En revanche, si la requête comporte un corps de streaming et que la réponse est une redirection HTTP autre que 303, la récupération sera rejetée et la redirection ne sera pas suivie.

Les redirections 303 sont autorisées, car elles remplacent explicitement la méthode par GET et suppriment le corps de la requête.

Nécessite le CORS et déclenche une requête préliminaire

Les requêtes en flux continu ont un corps, mais pas d'en-tête Content-Length. Comme il s'agit d'un nouveau type de requête, CORS est requis et ces requêtes déclenchent toujours une requête préliminaire.

Les requêtes no-cors en flux continu ne sont pas autorisées.

Ne fonctionne pas sur HTTP/1.x

La récupération sera refusée si la connexion est HTTP/1.x.

En effet, selon les règles HTTP/1.1, le corps des requêtes et des réponses doit envoyer un en-tête Content-Length afin que l'autre partie sache la quantité de données qu'il recevra, ou modifier le format du message pour utiliser l'encodage fragmenté. Avec l'encodage fragmenté, le corps est divisé en plusieurs parties, chacune ayant sa propre longueur de contenu.

L'encodage fragmenté est assez courant pour les réponses HTTP/1.1, mais très rare dans le cas des requêtes. Il présente donc trop de risques de compatibilité.

Problèmes potentiels

Il s'agit d'une nouvelle fonctionnalité, qui est peu utilisée sur Internet aujourd'hui. Voici quelques problèmes à vérifier:

Incompatibilité côté serveur

Certains serveurs d'applications ne prennent pas en charge les requêtes de streaming et attendent la réception de la requête complète avant de vous laisser voir les données, ce qui va à l'encontre du principe. Utilisez plutôt un serveur d'applications compatible avec le streaming, comme NodeJS ou Deno.

Mais vous n'êtes pas encore sorti du bois ! Le serveur d'applications, tel que NodeJS, se trouve généralement derrière un autre serveur, souvent appelé "serveur frontal", qui peut lui-même se trouver derrière un CDN. Si l'un d'entre eux décide de mettre la requête en mémoire tampon avant de la transmettre au prochain serveur de la chaîne, vous perdez le bénéfice du streaming des requêtes.

Incompatibilité hors de votre contrôle

Étant donné que cette fonctionnalité ne fonctionne qu'avec HTTPS, vous n'avez pas à vous soucier des proxys entre vous et l'utilisateur, mais l'utilisateur peut exécuter un proxy sur sa machine. Certains logiciels de protection Internet le font pour surveiller tout ce qui va entre le navigateur et le réseau. Dans certains cas, ce logiciel met en mémoire tampon le corps des requêtes.

Pour éviter cela, vous pouvez créer un "test de fonctionnalité" semblable à la démonstration ci-dessus, dans laquelle vous essayez d'insérer des données en flux continu sans le fermer. Si le serveur reçoit les données, il peut répondre via une autre extraction. Vous savez alors que le client prend en charge les requêtes de streaming de bout en bout.

Détection de caractéristiques

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 vous êtes curieux, voici comment fonctionne la détection de fonctionnalités:

Si le navigateur n'accepte pas un type body particulier, il appelle toString() sur l'objet et utilise le résultat en tant que corps. Ainsi, si le navigateur n'est pas compatible avec les flux de requêtes, le corps de la requête devient la chaîne "[object ReadableStream]". Lorsqu'une chaîne est utilisée en tant que corps, il est pratique de définir l'en-tête Content-Type sur text/plain;charset=UTF-8. Ainsi, si cet en-tête est défini, nous savons que le navigateur n'accepte pas les flux dans les objets de la requête. Nous pouvons donc quitter prématurément.

Safari accepte les flux dans les objets de requête, mais ne permet pas leur utilisation avec fetch. L'option duplex est donc testée, ce qui n'est pas encore compatible avec Safari.

Utilisation avec des flux accessibles en écriture

Il est parfois plus facile de gérer des flux lorsque vous avez un WritableStream. Vous pouvez le faire à l'aide d'un flux "identité", qui est une paire lisible/écriture lisible qui prend tout ce qui est transmis à son extrémité accessible en écriture et l'envoie en lecture. Vous pouvez créer l'un de ces éléments en créant un TransformStream sans aucun argument:

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

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

Désormais, tout ce que vous envoyez au flux accessible en écriture fera partie de la requête. Cela vous permet de composer des flux ensemble. Voici un exemple loufoque où les données sont extraites d'une URL, compressées, puis envoyées à une autre 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,
});

L'exemple ci-dessus utilise des flux de compression pour compresser des données arbitraires à l'aide de gzip.