Streaminganfragen mit der Fetch API

Jake Archibald
Jake Archibald

Ab Chromium 105 können Sie mithilfe der Streams API eine Anfrage starten, bevor der gesamte Textkörper verfügbar ist.

Sie können damit Folgendes tun:

  • Warm-up des Servers Mit anderen Worten: Sie können die Anfrage starten, sobald der Nutzer den Fokus auf ein Texteingabefeld legt, alle Überschriften ausblenden und dann warten, bis der Nutzer auf „Senden“ drückt, bevor Sie die eingegebenen Daten senden.
  • Senden Sie auf dem Client generierte Daten wie Audio-, Video- oder Eingabedaten nach und nach.
  • Web Sockets über HTTP/2 oder HTTP/3 neu erstellen

Da es sich jedoch um eine Low-Level-Funktion der Webplattform handelt, sollten Sie sich nicht von meinen Ideen einschränken lassen. Vielleicht können Sie sich einen viel spannenderen Anwendungsfall für das Streaming von Anfragen vorstellen.

Demo

Hier sehen Sie, wie Sie Daten vom Nutzer an den Server streamen und Daten zurücksenden, die in Echtzeit verarbeitet werden können.

Ja, okay, es ist nicht das einfallsreichste Beispiel, aber ich wollte es einfach halten, okay?

Wie funktioniert das?

Bisherige Abenteuer mit Abrufstreams

Response-Streams sind schon seit einiger Zeit in allen modernen Browsern verfügbar. Sie ermöglichen den Zugriff auf Teile einer Antwort, sobald sie vom Server empfangen werden:

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

Jede value ist ein Uint8Array Byte. Die Anzahl der Arrays und ihre Größe hängen von der Geschwindigkeit des Netzwerks ab. Bei einer schnellen Verbindung erhalten Sie weniger, dafür größere Datenblöcke. Bei einer langsamen Verbindung werden mehr kleinere Chunks übertragen.

Wenn Sie die Bytes in Text konvertieren möchten, können Sie TextDecoder oder den neueren Transform-Stream verwenden, sofern Ihre Zielbrowser ihn unterstützen:

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

TextDecoderStream ist ein Transformationsstream, der alle diese Uint8Array-Chunks erfasst und in Strings konvertiert.

Streams sind ideal, da Sie die Daten direkt verarbeiten können, sobald sie eintreffen. Wenn Sie beispielsweise eine Liste mit 100 Ergebnissen erhalten, können Sie das erste Ergebnis sofort anzeigen lassen, anstatt auf alle 100 zu warten.

Das war zu Antwortstreams. Jetzt möchte ich über Anfragestreams sprechen.

Anfragetexte für Streaming

Anfragen können Textkörper haben:

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

Bisher musste der gesamte Body fertig sein, bevor die Anfrage gestartet werden konnte. In Chromium 105 können Sie jetzt Ihre eigene ReadableStream mit Daten angeben:

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

Dadurch wird „Das ist eine langsame Anfrage“ nach und nach an den Server gesendet, wobei zwischen den einzelnen Wörtern eine Pause von einer Sekunde liegt.

Jeder Teil eines Anfragetexts muss eine Uint8Array Byte lang sein. Ich verwende pipeThrough(new TextEncoderStream()), um die Umwandlung für mich vorzunehmen.

Einschränkungen

Streaminganfragen sind eine neue Funktion für das Web und unterliegen daher einigen Einschränkungen:

Halbduplex?

Damit Streams in einer Anfrage verwendet werden können, muss die Anfrageoption duplex auf 'half' gesetzt sein.

Eine wenig bekannte Funktion von HTTP (ob dies Standardverhalten ist, hängt davon ab, wen Sie fragen) ist, dass Sie die Antwort bereits empfangen können, während Sie die Anfrage senden. Es ist jedoch so unbekannt, dass es von Servern nicht gut unterstützt wird und von keinem Browser unterstützt wird.

In Browsern ist die Antwort erst verfügbar, wenn der Anfragetext vollständig gesendet wurde, auch wenn der Server die Antwort früher sendet. Das gilt für alle Browserabrufe.

Dieses Standardmuster wird als „Halbduplex“ bezeichnet. Bei einigen Implementierungen, z. B. fetch in Deno, ist für Streamingabrufe standardmäßig „Full Duplex“ festgelegt. Das bedeutet, dass die Antwort verfügbar sein kann, bevor die Anfrage abgeschlossen ist.

Um dieses Kompatibilitätsproblem zu umgehen, muss duplex: 'half' in Browsern für Anfragen mit einem Stream-Textkörper angegeben werden.

In Zukunft wird duplex: 'full' möglicherweise in Browsern für Streaming- und Nicht-Streaming-Anfragen unterstützt.

In der Zwischenzeit ist die zweigleisige Kommunikation die beste Alternative. Dazu wird zuerst ein Abruf mit einer Streaminganfrage und dann ein weiterer Abruf zum Empfang der Streamingantwort durchgeführt. Der Server muss diese beiden Anfragen irgendwie verknüpfen können, z. B. über eine ID in der URL. So funktioniert die Demo.

Eingeschränkte Weiterleitungen

Bei einigen Formen der HTTP-Weiterleitung muss der Browser den Textkörper der Anfrage an eine andere URL zurücksenden. Dazu müsste der Browser den Inhalt des Streams puffern, was den Sinn der Sache etwas verfehlt.

Wenn die Anfrage einen Streaming-Textkörper enthält und die Antwort eine andere HTTP-Weiterleitung als 303 ist, wird der Abruf abgelehnt und die Weiterleitung nicht befolgt.

303-Weiterleitungen sind zulässig, da sie die Methode explizit in GET ändern und den Anfragetext verwerfen.

Erfordert CORS und löst einen Preflight aus

Streaminganfragen haben einen Textkörper, aber keinen Content-Length-Header. Das ist eine neue Art von Anfrage, daher ist CORS erforderlich. Diese Anfragen lösen immer einen Preflight aus.

Streaming-no-cors-Anfragen sind nicht zulässig.

Funktioniert nicht mit HTTP/1.x

Der Abruf wird abgelehnt, wenn die Verbindung HTTP/1.x ist.

Das liegt daran, dass gemäß den HTTP/1.1-Regeln Anfrage- und Antwortkörper entweder einen Content-Length-Header senden müssen, damit die andere Seite weiß, wie viele Daten sie erhält, oder das Format der Nachricht in Chunked-Codierung ändern müssen. Bei der Chunk-Codierung wird der Text in Teile mit jeweils eigener Inhaltslänge unterteilt.

Die Chunk-Codierung ist bei HTTP/1.1-Antworten recht häufig, aber bei Anfragen sehr selten. Daher ist das Kompatibilitätsrisiko zu hoch.

Potenzielle Probleme

Das ist eine neue Funktion, die im Internet derzeit noch nicht ausreichend genutzt wird. Achten Sie auf Folgendes:

Inkompatibilität auf Serverseite

Einige App-Server unterstützen keine Streaminganfragen und warten stattdessen, bis die vollständige Anfrage eingegangen ist, bevor sie dir etwas davon anzeigen. Das macht den Sinn dieser Funktion ein wenig zunichte. Verwenden Sie stattdessen einen App-Server, der Streaming unterstützt, z. B. NodeJS oder Deno.

Aber Sie sind noch nicht am Ziel. Der Anwendungsserver, z. B. NodeJS, befindet sich in der Regel hinter einem anderen Server, der oft als „Front-End-Server“ bezeichnet wird und der wiederum hinter einem CDN liegen kann. Wenn einer dieser Server die Anfrage puffert, bevor er sie an den nächsten Server in der Kette weitergibt, entgeht Ihnen der Vorteil des Anfragestreamings.

Inkompatibilität, die nicht in Ihrem Einflussbereich liegt

Da diese Funktion nur über HTTPS funktioniert, müssen Sie sich keine Gedanken über Proxys zwischen Ihnen und dem Nutzer machen. Der Nutzer kann jedoch einen Proxy auf seinem Computer verwenden. Einige Internetschutzprogramme tun dies, um alles zu überwachen, was zwischen dem Browser und dem Netzwerk passiert. In einigen Fällen puffert diese Software Anfragekörper.

Wenn Sie dies verhindern möchten, können Sie einen Funktionstest ähnlich der Demo oben erstellen, bei dem Sie versuchen, einige Daten zu streamen, ohne den Stream zu schließen. Wenn der Server die Daten empfängt, kann er über einen anderen Abruf antworten. In diesem Fall weißt du, dass der Client Streaminganfragen Ende-zu-Ende unterstützt.

Funktionserkennung

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

So funktioniert die Funktion:

Wenn der Browser einen bestimmten body-Typ nicht unterstützt, ruft er toString() auf dem Objekt auf und verwendet das Ergebnis als Textkörper. Wenn der Browser also keine Anfragestreams unterstützt, wird der Anfragetext zum String "[object ReadableStream]". Wenn ein String als Text verwendet wird, wird der Content-Type-Header automatisch auf text/plain;charset=UTF-8 gesetzt. Wenn dieser Header festgelegt ist, wissen wir, dass der Browser Streams in Anfrageobjekten nicht unterstützt, und können frühzeitig beenden.

Safari unterstützt Streams in Anfrageobjekten, ermöglicht aber nicht ihre Verwendung mit fetch. Daher wird die Option duplex getestet, die in Safari derzeit nicht unterstützt wird.

Mit beschreibbaren Streams verwenden

Manchmal ist es einfacher, mit Streams zu arbeiten, wenn du eine WritableStream hast. Dazu kannst du einen Identitätsstream verwenden. Das ist ein les-/schreibbares Paar, das alles, was an sein Schreibende übergeben wird, an das Leseende sendet. Sie können eine davon erstellen, indem Sie ein TransformStream ohne Argumente erstellen:

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

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

Alles, was Sie an den beschreibbaren Stream senden, ist jetzt Teil der Anfrage. So kannst du Streams zusammenstellen. Hier ist beispielsweise ein einfaches Beispiel, bei dem Daten von einer URL abgerufen, komprimiert und an eine andere URL gesendet werden:

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

Im obigen Beispiel werden Komprimierungsstreams verwendet, um beliebige Daten mit gzip zu komprimieren.