Requêtes en streaming avec l'API fetch

Jake Archibald
Jake Archibald

À partir de Chromium 105, vous pouvez démarrer une requête avant que l'intégralité du corps ne soit 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 a sélectionné un champ de saisie de texte, puis 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 sockets Web via HTTP/2 ou HTTP/3.

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

Démo

Cela montre comment diffuser des données de l'utilisateur vers le serveur et renvoyer des données pouvant être traitées en temps réel.

Oui, ce n'est pas l'exemple le plus imaginatif, mais je voulais garder les choses simples.

Comment ça marche ?

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

Les flux Response sont disponibles depuis un certain temps dans tous les navigateurs modernes. Ils vous permettent d'accéder à des 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 un Uint8Array d'octets. Le nombre et la taille des tableaux dépendent de la vitesse du réseau. Si vous disposez d'une connexion rapide, vous recevrez moins de "blocs" de données, mais de plus grande taille. Si votre connexion est lente, vous recevrez davantage de blocs 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 prennent en charge:

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 segments Uint8Array et les convertit en chaînes.

Les flux sont très utiles, car vous pouvez commencer à agir sur les données dès leur arrivée. Par exemple, si vous recevez une liste de 100 "résultats", vous pouvez afficher le premier résultat dès que vous le recevez, au lieu d'attendre les 100 résultats.

C'est tout pour les flux de réponses. Je voulais vous parler d'une nouvelle fonctionnalité intéressante : les flux de requêtes.

Corps de requêtes en streaming

Les requêtes peuvent avoir un corps:

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

Auparavant, vous deviez préparer l'intégralité du corps avant de pouvoir démarrer la requête. Mais dans Chromium 105, vous pouvez fournir votre propre ReadableStream de données:

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

Le code ci-dessus envoie "This is a slow request" au serveur, un mot à la fois, avec une pause d'une seconde entre chaque mot.

Chaque segment d'un corps de requête doit être un Uint8Array d'octets. Je vais donc utiliser pipeThrough(new TextEncoderStream()) pour effectuer la conversion.

Restrictions

Les requêtes de streaming sont une nouvelle fonctionnalité du Web. Elles sont donc soumises à quelques restrictions:

Half-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 (bien que ce comportement standard dépende de la personne à qui vous vous adressez) est que vous pouvez commencer à recevoir la réponse pendant que vous envoyez la requête. Cependant, elle est si peu connue qu'elle n'est pas bien prise en charge par les serveurs et 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 s'applique à toutes les récupérations du navigateur.

Ce schéma par défaut est appelé "half-duplex". Toutefois, certaines implémentations, telles que fetch dans Deno, utilisent par défaut le mode "full-duplex" pour les récupérations en streaming, ce qui signifie que la réponse peut être disponible avant la fin de la requête.

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

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

En attendant, la meilleure alternative à la communication duplex consiste à effectuer une récupération avec une requête de streaming, puis une autre pour recevoir la réponse de streaming. Le serveur doit pouvoir associer ces deux requêtes, par exemple à l'aide d'un ID dans l'URL. C'est ainsi que fonctionne la démo.

Redirections limitées

Certaines formes de redirection HTTP nécessitent que le navigateur renvoie le corps de la requête vers une autre URL. Pour ce faire, le navigateur devrait mettre en mémoire tampon le contenu du flux, ce qui va à l'encontre de l'objectif. Il ne le fait donc pas.

Au lieu de cela, 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 est rejetée et la redirection n'est pas suivie.

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

Nécessite CORS et déclenche une vérification préliminaire

Les requêtes de streaming comportent un corps, mais pas d'en-tête Content-Length. Il s'agit d'un nouveau type de requête. CORS est donc obligatoire, et ces requêtes déclenchent toujours une pré-vérification.

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

Ne fonctionne pas avec HTTP/1.x

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

En effet, selon les règles HTTP/1.1, les corps de requête et de réponse doivent soit envoyer un en-tête Content-Length pour que l'autre partie sache combien de données elle recevra, soit modifier le format du message pour utiliser l'encodage par blocs. Avec l'encodage par blocs, le corps est divisé en parties, chacune ayant sa propre longueur de contenu.

L'encodage par blocs est assez courant pour les réponses HTTP/1.1, mais très rare pour les requêtes. Il présente donc un risque de compatibilité trop élevé.

Problèmes éventuels

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

Incompatibilité côté serveur

Certains serveurs d'applications ne prennent pas en charge les requêtes de streaming et attendent que la requête complète soit reçue avant de vous permettre de la voir, ce qui est un peu contre-productif. Utilisez plutôt un serveur d'application compatible avec le streaming, comme NodeJS ou Deno.

Mais vous n'êtes pas encore sorti d'affaire. Le serveur d'application, tel que NodeJS, se trouve généralement derrière un autre serveur, souvent appelé "serveur frontend", qui peut à son tour se trouver derrière un CDN. Si l'un d'eux décide de mettre en mémoire tampon la requête avant de la transmettre au serveur suivant de la chaîne, vous ne bénéficiez plus du streaming de requêtes.

Incapacité à résoudre l'incompatibilité

Étant donné que cette fonctionnalité ne fonctionne que sur HTTPS, vous n'avez pas à vous soucier des proxys entre vous et l'utilisateur. Toutefois, l'utilisateur peut exécuter un proxy sur sa machine. Certains logiciels de protection Internet le font pour pouvoir surveiller tout ce qui se passe entre le navigateur et le réseau. Il est possible que ce logiciel mette en mémoire tampon les corps de requête.

Pour vous protéger contre ce problème, vous pouvez créer un "test de fonctionnalité" semblable à la démo ci-dessus, dans lequel vous essayez de diffuser des données sans fermer le flux. Si le serveur reçoit les données, il peut répondre via une autre récupération. Vous saurez alors que le client prend en charge les requêtes de streaming de bout en bout.

Détection de fonctionnalités

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

Voici comment fonctionne la détection de fonctionnalités:

Si le navigateur n'est pas compatible avec un type body particulier, il appelle toString() sur l'objet et utilise le résultat comme corps. Par conséquent, 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 comme corps, l'en-tête Content-Type est défini sur text/plain;charset=UTF-8. Par conséquent, si cet en-tête est défini, nous savons que le navigateur n'est pas compatible avec les flux dans les objets de requête, et nous pouvons quitter le programme plus tôt.

Safari prend en charge les flux dans les objets de requête, mais ne les autorise pas à être utilisés avec fetch. L'option duplex est donc testée, mais Safari ne la prend pas en charge pour le moment.

Utiliser avec des flux enregistrables

Il est parfois plus facile de travailler avec des flux lorsque vous disposez d'un WritableStream. Pour ce faire, vous pouvez utiliser un flux d 'identité, qui est une paire de lecture/écriture qui prend tout ce qui est transmis à son extrémité en écriture et l'envoie à l'extrémité en lecture. Pour créer l'un de ces éléments, créez un TransformStream sans argument:

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

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

Tout ce que vous envoyez au flux en écriture fera désormais partie de la requête. Vous pouvez ainsi composer des flux ensemble. Par exemple, voici un exemple absurde dans lequel des données sont extraites d'une URL, compressées et 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.