フェッチ API を使用したストリーミング リクエスト

Chromium 105 以降では、Streams API を使用して、ボディ全体が利用可能になる前にリクエストを開始できます。

この機能を使用すると、次のことができます。

  • サーバーをウォームアップします。つまり、ユーザーがテキスト入力フィールドにフォーカスしたときにリクエストを開始し、すべてのヘッダーを非表示にして、ユーザーが [送信] を押すまで待機してから、入力されたデータを送信できます。
  • 音声、動画、入力データなど、クライアントで生成されたデータを段階的に送信します。
  • HTTP/2 または HTTP/3 を介してウェブソケットを再作成します。

ただし、これは低レベルのウェブ プラットフォーム機能であるため、私のアイデアに限定されることはありません。リクエスト ストリーミングのよりエキサイティングなユースケースを思いつくかもしれません。

フェッチ ストリームの冒険の続き

レスポンス ストリームは、すべての最新ブラウザでしばらく前から利用可能です。これらを使用すると、サーバーからレスポンスが届くたびに、レスポンスの一部にアクセスできます。

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

valueUint8Array バイトです。取得する配列の数と配列のサイズは、ネットワークの速度によって異なります。高速接続の場合、データはより大きな「チャンク」に分割されます。接続速度が遅い場合は、より多くの小さなチャンクが取得されます。

バイトをテキストに変換する場合は、TextDecoder を使用できます。また、対象ブラウザがサポートしている場合は、新しい変換ストリームを使用できます。

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

TextDecoderStream は、これらの Uint8Array チャンクをすべて取得して文字列に変換する変換ストリームです。

ストリームは、データが到着したときにデータの処理を開始できるため、非常に便利です。たとえば、100 件の「結果」のリストを受け取っている場合、100 件すべてが揃うのを待つのではなく、最初の結果を受け取った時点で表示できます。

さて、レスポンス ストリームについて説明しましたが、次にリクエスト ストリームについて説明します。

ストリーミング リクエストの本文

リクエストには本文を含めることができます。

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

以前は、リクエストを開始する前に全体を準備する必要がありましたが、Chromium 105 では、独自の ReadableStream のデータを提供できます。

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

上記のコマンドを実行すると、「This is a slow request」という文字列がサーバーに 1 語ずつ送信されます。各単語の間には 1 秒の遅延があります。

リクエスト本文の各チャンクは Uint8Array バイトである必要があるため、pipeThrough(new TextEncoderStream()) を使用して変換を行っています。

制限事項

ストリーミング リクエストはウェブの新しい機能ですが、いくつかの制限があります。

半二重通信?

リクエストでストリームを使用できるようにするには、duplex リクエスト オプションを 'half' に設定する必要があります。

HTTP のあまり知られていない機能(標準の動作かどうかは、誰に尋ねるかによって異なります)は、リクエストを送信しながらレスポンスの受信を開始できることです。ただし、この機能はほとんど知られていないため、サーバーで十分にサポートされておらず、どのブラウザでもサポートされていません。

ブラウザでは、サーバーがレスポンスを早く送信しても、リクエスト本文が完全に送信されるまでレスポンスは利用可能になりません。これはすべてのブラウザ フェッチに当てはまります。

このデフォルト パターンは「半二重」と呼ばれます。ただし、Deno の fetch など、一部の実装では、ストリーミング フェッチのデフォルトが「全二重」になっています。つまり、リクエストが完了する前にレスポンスが利用可能になる可能性があります。

この互換性の問題を回避するため、ブラウザでは、ストリーム本文を含むリクエストで duplex: 'half' を指定する必要があります。

今後、duplex: 'full' はストリーミング リクエストと非ストリーミング リクエストの両方でブラウザでサポートされる可能性があります。

それまでの間、双方向通信に次善の策として、ストリーミング リクエストで 1 回フェッチし、ストリーミング レスポンスを受信するために別のフェッチを行う方法があります。サーバーは、URL の ID など、これら 2 つのリクエストを関連付ける方法を必要とします。これがデモの仕組みです。

制限付きリダイレクト

HTTP リダイレクトの形式によっては、ブラウザがリクエストの本文を別の URL に再送信する必要があります。これをサポートするには、ブラウザがストリームの内容をバッファリングする必要がありますが、これは趣旨に反するため、ブラウザはバッファリングを行いません。

代わりに、リクエストにストリーミング本文があり、レスポンスが 303 以外の HTTP リダイレクトの場合、フェッチは拒否され、リダイレクトはフォローされません。

303 リダイレクトは、メソッドを明示的に GET に変更し、リクエスト本文を破棄するため、許可されます。

CORS が必要で、プリフライトをトリガーする

ストリーミング リクエストには本文がありますが、Content-Length ヘッダーはありません。これは新しい種類のリクエストであるため、CORS が必要であり、これらのリクエストは常にプリフライトをトリガーします。

ストリーミング no-cors リクエストは許可されていません。

HTTP/1.x では動作しない

接続が HTTP/1.x の場合、フェッチは拒否されます。

これは、HTTP/1.1 のルールに従って、リクエストとレスポンスの本文で Content-Length ヘッダーを送信して、受信するデータ量を相手側に知らせるか、メッセージの形式をチャンク エンコードに変更する必要があるためです。チャンク エンコードでは、本文が複数の部分に分割され、それぞれに独自のコンテンツ長が設定されます。

チャンク エンコードは HTTP/1.1 のレスポンスでは一般的ですが、リクエストでは非常にまれであるため、互換性のリスクが大きすぎます。

発生しうる問題

これは新しい機能であり、現在のインターネットではあまり使用されていません。以下に、注意すべき点をいくつか示します。

サーバー側の非互換性

一部のアプリサーバーはストリーミング リクエストをサポートしていません。代わりに、リクエスト全体を受信してから、その一部を表示します。これは、ストリーミングの目的を損なうものです。代わりに、NodeJSDeno などのストリーミングをサポートするアプリサーバーを使用します。

しかし、まだ安心はできません。NodeJS などのアプリケーション サーバーは通常、別のサーバー(フロントエンド サーバーと呼ばれることが多い)の背後に置かれます。このサーバーは、CDN の背後に置かれることもあります。これらのいずれかが、チェーン内の次のサーバーにリクエストを渡す前にバッファリングすることにした場合、リクエスト ストリーミングのメリットは失われます。

制御不能な非互換性

この機能は HTTPS 経由でのみ動作するため、ユーザーと自分の間のプロキシを気にする必要はありませんが、ユーザーが自分のマシンでプロキシを実行している可能性があります。一部のインターネット保護ソフトウェアは、ブラウザとネットワーク間のすべての通信をモニタリングするために、この処理を行います。このソフトウェアがリクエスト本文をバッファリングする場合があります。

このような事態を防ぐには、上記のデモと同様の「機能テスト」を作成し、ストリームを閉じずにデータをストリーミングしてみてください。サーバーがデータを受信すると、別のフェッチを介して応答できます。この状態になったら、クライアントがストリーミング リクエストをエンドツーエンドでサポートしていることがわかります。

特徴検出

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

機能検出の仕組みは次のとおりです。

ブラウザが特定の body 型をサポートしていない場合、オブジェクトの toString() を呼び出し、その結果を本文として使用します。そのため、ブラウザがリクエスト ストリームをサポートしていない場合、リクエスト本文は文字列 "[object ReadableStream]" になります。文字列が本文として使用される場合、Content-Type ヘッダーは text/plain;charset=UTF-8 に設定されます。したがって、このヘッダーが設定されている場合、ブラウザがリクエスト オブジェクトのストリームをサポートしていないことがわかるため、早期に終了できます。

Safari はリクエスト オブジェクトのストリームをサポートしていますが、fetch での使用は許可していません。そのため、duplex オプションがテストされますが、Safari は現在このオプションをサポートしていません。

書き込み可能なストリームでの使用

WritableStream があると、ストリームの操作が簡単になることがあります。これを行うには、書き込み可能なエンドに渡されたものを読み取り可能なエンドに送信する読み取り/書き込み可能なペアである「ID」ストリームを使用します。引数なしで TransformStream を作成すると、これらのいずれかを作成できます。

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

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

書き込み可能なストリームに送信したものはすべてリクエストの一部になります。これにより、ストリームを一緒に構成できます。たとえば、次の例は、ある URL からデータを取得し、圧縮して別の 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,
});

上記の例では、圧縮ストリームを使用して、gzip で任意のデータを圧縮しています。