Requêtes en streaming avec l'API fetch

Jake Archibald
Jake Archibald

À partir de Chromium 105, vous pouvez envoyer une requête avant que l'ensemble du corps de la demande 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 sélectionne un champ de saisie de texte, supprimer tous les en-têtes, puis attendre que l'utilisateur appuie sur "Envoyer". avant d'envoyer les données saisies.
  • Envoyez progressivement les données générées sur le client, telles que des données audio, vidéo ou d'entrée.
  • Recréez les sockets Web via HTTP/2 ou HTTP/3.

Mais comme il s'agit d'une fonctionnalité de bas niveau de la plate-forme Web, ne vous limitez pas à mes idées. Vous pouvez peut-être penser à 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 qui peuvent être traitées en temps réel.

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

Bref, comment ça marche ?

Précédemment dans les aventures passionnantes des flux de récupération,

Les flux de réponse sont disponibles depuis un certain temps dans tous les navigateurs modernes. 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. Avec une connexion rapide, vous obtiendrez moins de fragments, mais de plus grands de données. Si votre connexion est lente, vous obtiendrez davantage de fragments 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 fragments Uint8Array et les convertit en chaînes.

Les flux sont excellents, 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 qu'il est reçu, au lieu d'attendre les 100.

Quoi qu'il en soit, il s'agit des flux de réponse. Je voudrais maintenant vous parler des flux de requêtes.

Corps de requête en streaming

Les requêtes peuvent avoir des corps:

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

Auparavant, l'ensemble du corps devait être prêt avant de pouvoir lancer la requête. Désormais, dans Chromium 105, vous pouvez fournir vos propres 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',
});

La commande ci-dessus renvoie le message "This is alow request" (Cette requête est lente). au serveur, un mot à la fois, avec une pause d'une seconde entre chaque mot.

Chaque fragment du corps d'une requête doit être une Uint8Array d'octets. J'utilise donc pipeThrough(new TextEncoderStream()) pour effectuer la conversion pour moi.

Restrictions

Les requêtes en streaming constituent une nouvelle puissance pour le Web. Elles 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 (bien que ce comportement standard dépende de la personne interrogée) est que vous pouvez commencer à recevoir la réponse pendant que vous envoyez la requête. Cependant, il est si peu connu, qu’il n’est pas bien pris en charge par les serveurs et n’est pris en charge par aucun navigateur.

Dans les navigateurs, la réponse ne devient 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 valable pour toutes les récupérations par navigateur.

Ce modèle par défaut est connu sous le nom de « half-duplex ». Cependant, certaines implémentations, telles que fetch dans Deno, ont défini par défaut le duplex intégral. pour les récupérations en flux continu, ce qui signifie que la réponse peut être disponible avant que la requête ne soit terminée.

Pour contourner ce problème de compatibilité, duplex: 'half' doit avoir besoin dans les navigateurs à spécifier pour les requêtes comportant un corps de flux.

À l'avenir, duplex: 'full' sera peut-être compatible avec les navigateurs pour les requêtes en streaming ou non.

En attendant, la meilleure chose à faire pour la communication duplex consiste à effectuer une récupération avec une requête de flux, puis à effectuer une autre extraction pour recevoir la réponse en flux continu. Le serveur doit trouver un moyen d'associer ces deux requêtes, comme un identifiant dans l'URL. Voilà comment fonctionne la démonstration.

Redirections restreintes

Certaines formes de redirection HTTP nécessitent que le navigateur renvoie le corps de la requête vers une autre URL. Pour prendre en charge cela, le navigateur doit mettre en mémoire tampon le contenu du flux, ce qui déjoue le point. Il ne fait donc pas cela.

À la place, si la requête comporte un corps de flux et que la réponse est une redirection HTTP autre que 303, la récupération rejette la requête et la redirection n'est 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 CORS et déclenche une requête préliminaire

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

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

Ne fonctionne pas avec 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 soit envoyer un en-tête Content-Length pour que l'autre partie connaisse la quantité de données qu'il recevra, soit modifier le format du message pour utiliser l'encodage fragmenté. Avec l'encodage bloc, 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 pour les requêtes. Il présente donc un risque trop important de compatibilité.

Problèmes potentiels

Il s'agit d'une nouvelle fonctionnalité, qui est sous-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 plutôt la réception de la demande complète avant de vous laisser voir l'un des éléments, ce qui va à l'encontre de l'objectif. Utilisez plutôt un serveur d'application compatible avec le streaming, comme NodeJS ou Deno.

Mais vous n'êtes pas encore sorti de la forêt ! Le serveur d'applications, tel que NodeJS, se trouve généralement derrière un autre serveur, souvent appelé "serveur d'interface", qui peut à son tour 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 serveur suivant de la chaîne, vous perdez l'avantage du streaming de requêtes.

Incompatibilité hors de votre contrôle

Étant donné que cette fonctionnalité ne fonctionne que sur HTTPS, vous n'avez pas à vous soucier des proxys entre vous et l'utilisateur, mais celui-ci exécute peut-être un proxy sur sa machine. Certains logiciels de protection Internet le font pour lui permettre de surveiller tout ce qui va entre le navigateur et le réseau. Dans certains cas, ils peuvent mettre en mémoire tampon les corps de requête.

Pour éviter cela, vous pouvez créer un "test de fonctionnalité" semblable à la démonstration ci-dessus, dans laquelle 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 extraction différente. Une fois que cela se produit, vous savez que le client prend en charge les requêtes en flux continu 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 {
  // …
}

Voici comment fonctionne la détection de caractéristiques:

Si le navigateur n'accepte pas un type de body particulier, il appelle toString() sur l'objet et utilise le résultat comme corps. Ainsi, si le navigateur n'accepte pas 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, l'en-tête Content-Type est facilement défini 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 requête et que nous pouvons fermer la session plus tôt.

Safari accepte les flux dans les objets de requête, mais ne permet pas de les utiliser avec fetch. Par conséquent, l'option duplex est testée, ce qui n'est actuellement pas compatible avec Safari.

Utiliser avec des flux accessibles en écriture

Il est parfois plus facile de travailler avec des diffusions si vous avez un WritableStream. Pour ce faire, vous pouvez utiliser une "identité" stream, qui est une paire accessible en lecture/écriture qui récupère tout ce qui est transmis à sa fin accessible en écriture et l'envoie à la fin lisible. Vous pouvez en créer un 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 enverrez au flux accessible en écriture fera partie de la requête. Cela vous permet de composer des diffusions ensemble. Voici un exemple loufoque où des données sont récupérées à partir d'une URL, compressées, puis envoyées vers 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.