Streaminganfragen mit der Fetch API

Jake Archibald
Jake Archibald

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

Damit können Sie:

  • Bereiten Sie den Server vor. Das heißt, Sie könnten die Anforderung starten, sobald der Benutzer ein Texteingabefeld fokussiert hat, und alle Header wegräumen und dann warten, bis der Benutzer auf „Senden“ drückt, bevor Sie die eingegebenen Daten senden.
  • Auf dem Client generierte Daten wie Audio-, Video- oder Eingabedaten werden nach und nach gesendet.
  • WebSockets über HTTP/2 oder HTTP/3 neu erstellen

Da es sich hierbei aber um eine untergeordnete Webplattformfunktion handelt, solltest du dich nicht durch meine Ideen einschränken. Vielleicht fällt Ihnen ein viel aufregender Anwendungsfall für das Anfragestreaming ein.

Demo

Hier sehen Sie, wie Sie Daten vom Nutzer zum Server streamen und zurücksenden können, um sie in Echtzeit zu verarbeiten.

Ja, ok, das ist nicht das fantasievollste Beispiel. Ich wollte es einfach halten, okay?

Wie funktioniert das?

Zuvor waren die aufregenden Abenteuer des Abruf-Streams

Antwortstreams sind bereits seit geraumer Zeit in allen modernen Browsern verfügbar. Sie ermöglichen es Ihnen, auf Teile einer Antwort zuzugreifen, sobald sie vom Server eingehen:

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

Jedes value ist ein Uint8Array von Byte. Die Anzahl der Arrays, die Sie erhalten, und die Größe der Arrays hängt von der Geschwindigkeit des Netzwerks ab. Bei einer schnellen Verbindung erhalten Sie weniger, aber größere Datenblöcke. Bei einer langsamen Verbindung erhalten Sie mehr, kleinere Blöcke.

Wenn Sie die Byte in Text umwandeln möchten, können Sie TextDecoder oder den neueren Transformationsstream verwenden, sofern Ihre Zielbrowser dies unterstützen:

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

TextDecoderStream ist ein Transformationsstream, der alle Uint8Array-Chunks abruft und in Strings umwandelt.

Streams sind eine tolle Sache, denn Sie können direkt auf die Daten reagieren, sobald sie eintreffen. Wenn Sie beispielsweise eine Liste mit 100 Ergebnissen erhalten, können Sie das erste Ergebnis anzeigen, sobald Sie es erhalten, anstatt auf alle 100 zu warten.

Das sind die Antwort-Streams. Die interessante neue Sache, über die ich sprechen möchte, sind die Anfrage-Streams.

Text von Streaminganfragen

Anfragen können folgenden Text haben:

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

Früher konnte die Anfrage erst gestartet werden, wenn der gesamte Text bereit war. In Chromium 105 kannst du jetzt deine eigenen ReadableStream an 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',
});

Mit dem Code oben wird jeweils ein Wort für die Anfrage „Dies ist eine langsame Anfrage“ an den Server gesendet. Zwischen den einzelnen Wörtern wird jeweils eine Sekunde Pause gemacht.

Jeder Chunk eines Anfragetexts muss Uint8Array Byte umfassen. Ich verwende pipeThrough(new TextEncoderStream()), um die Konvertierung für mich durchzuführen.

Einschränkungen

Streaminganfragen sind ein neues Potenzial im Web und bergen deshalb einige Einschränkungen:

Halbduplex?

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

Eine wenig bekannte HTTP-Funktion (ob dies das Standardverhalten ist, hängt davon ab, wen Sie fragen) ist, dass Sie die Antwort erhalten können, während Sie die Anfrage noch senden. Er ist jedoch so wenig bekannt, dass er weder von Servern noch 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 früher eine Antwort sendet. Dies gilt für alle Browserabrufe.

Dieses Standardmuster wird als Halbduplex bezeichnet. Einige Implementierungen wie fetch in Deno sind jedoch für Streamingabrufe standardmäßig auf „Vollduplex“ eingestellt. Das bedeutet, dass die Antwort verfügbar werden kann, bevor die Anfrage abgeschlossen ist.

Zur Umgehung dieses Kompatibilitätsproblems muss also in Browsern duplex: 'half' für Anfragen mit Streamtext angegeben werden.

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

In der Zwischenzeit empfiehlt es sich bei der Duplexkommunikation, einen Abruf mit einer Streaminganfrage und dann einen weiteren Abruf durchzuführen, um die Streamingantwort zu erhalten. Der Server benötigt eine Methode, um diese beiden Anfragen zu verknüpfen, z. B. eine ID in der URL. So funktioniert die Demo.

Eingeschränkte Weiterleitungen

Bei einigen Formen von HTTP-Weiterleitungen muss der Browser den Text der Anfrage an eine andere URL senden. Um dies zu unterstützen, müsste der Browser den Inhalt des Streams puffern, was den Punkt quasi zunichte macht, sodass er dies nicht tut.

Wenn die Anfrage einen Streamingtext enthält und als Antwort eine andere HTTP-Weiterleitung als 303 lautet, wird der Abruf abgelehnt und der Weiterleitung nicht gefolgt.

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

Erfordert CORS und löst einen Preflight aus

Streaminganfragen haben einen Text, aber keinen Content-Length-Header. Da es sich um eine neue Art von Anfrage handelt, ist CORS erforderlich. Diese Anfragen lösen immer einen Preflight aus.

Streaminganfragen für no-cors sind nicht zulässig.

Funktioniert nicht mit HTTP/1.x

Der Abruf wird abgelehnt, wenn die Verbindung über HTTP/1.x erfolgt.

Dies liegt daran, dass nach den Regeln von HTTP/1.1 entweder der Anfrage- und Antworttext einen Content-Length-Header senden muss, damit die andere Seite weiß, wie viele Daten sie empfangen wird, oder das Format der Nachricht ändern, um die aufgeteilte Codierung zu verwenden. Bei der aufgeteilten Codierung wird der Text in Teile mit jeweils eigener Inhaltslänge unterteilt.

Eine Chunk-Codierung kommt bei HTTP/1.1-Antworten sehr häufig vor, aber bei Anfragen nur sehr selten, sodass sie ein zu hohes Kompatibilitätsrisiko darstellt.

Mögliche Probleme

Diese neue Funktion wird im Internet kaum genutzt. Achten Sie auf Folgendes:

Inkompatibilität auf Serverseite

Einige Anwendungsserver unterstützen keine Streaminganfragen. Sie warten stattdessen, bis die vollständige Anfrage eingegangen ist, bevor Sie eine davon sehen können. Das ist jedoch nicht ganz einfach. Verwenden Sie stattdessen einen Anwendungsserver wie NodeJS oder Deno, der Streaming unterstützt.

Aber du bist noch nicht ganz ausgereift! Der Anwendungsserver, z. B. NodeJS, befindet sich normalerweise hinter einem anderen Server, der auch als „Front-End-Server“ bezeichnet wird und sich wiederum hinter einem CDN befinden kann. Wenn eine dieser Anfragen gepuffert wird, bevor sie an den nächsten Server in der Kette weitergeleitet wird, verlieren Sie den Vorteil des Anfragestreamings.

Inkompatibilität außerhalb Ihrer Kontrolle

Da diese Funktion nur über HTTPS funktioniert, müssen Sie sich keine Gedanken über Proxys zwischen Ihnen und dem Nutzer machen. Möglicherweise führt der Nutzer jedoch einen Proxy auf seinem Computer aus. Einige Internetschutzprogramme tun dies, um alles zwischen dem Browser und dem Netzwerk zu überwachen. Es kann Fälle geben, in denen diese Software Anforderungstexte zwischenspeichert.

Wenn Sie sich davor schützen möchten, können Sie einen Funktionstest ähnlich wie in der Demo oben erstellen. Dabei wird versucht, einige Daten zu streamen, ohne den Stream zu schließen. Wenn der Server die Daten empfängt, kann er über einen anderen Abruf antworten. Sobald dies geschieht, wissen Sie, dass der Client End-to-End-Streaminganfragen 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 {
  // …
}

Hier erfahren Sie, wie die Funktionserkennung funktioniert:

Wenn der Browser einen bestimmten body-Typ nicht unterstützt, wird toString() für das Objekt aufgerufen und das Ergebnis als Text verwendet. Wenn der Browser keine Anfragestreams unterstützt, wird der Anfragetext zum String "[object ReadableStream]". Wenn ein String als Text verwendet wird, wird der Header Content-Type praktisch 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 den Vorgang frühzeitig beenden.

Safari unterstützt Streams in Anfrageobjekten, lässt jedoch nicht zu, dass sie mit fetch verwendet werden. Daher wird die Option duplex getestet, die von Safari derzeit nicht unterstützt wird.

Mit beschreibbaren Streams verwenden

Manchmal ist es einfacher, mit Streams zu arbeiten, wenn du ein WritableStream hast. Dazu können Sie einen „Identity“-Stream verwenden. Dabei handelt es sich um ein lesbares/beschreibbares Paar, das alles, was an sein beschreibbares Ende übergeben wird, an das lesbare Ende sendet. Sie können eines davon erstellen, indem Sie ein TransformStream ohne Argumente erstellen:

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

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

Jetzt ist alles, was Sie an den beschreibbaren Stream senden, Teil der Anfrage. So können Sie Streams gemeinsam erstellen. Hier ist zum Beispiel ein albernes 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 Beispiel oben werden Komprimierungsstreams verwendet, um beliebige Daten mit gzip zu komprimieren.