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

Jake Archibald 氏
Jake Archibald

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

用途は次のとおりです。

  • サーバーをウォームアップします。 つまり、ユーザーがテキスト入力フィールドにフォーカスをあてたらリクエストを開始し、ヘッダーはすべて取り除いてから、ユーザーが「送信」を押すまで待ってから、入力したデータを送信します。
  • クライアントで生成されたデータ(音声、動画、入力データなど)を段階的に送信する。
  • HTTP/2 または HTTP/3 経由でソケットを再作成します。

とはいえ、これはウェブ プラットフォームのローレベルの機能なので、私のアイデアに縛られる必要はありません。 リクエスト ストリーミングには、よりエキサイティングなユースケースがあるかもしれません。

デモ

これは、ユーザーからサーバーにデータをストリーミングし、リアルタイムで処理可能なデータを返す方法を示しています。

これは最も想像力に富んだ例ではありませんが、シンプルにしたいと思いませんか?

いずれにしろ、どのような仕組みなのでしょうか。

以前、フェッチ ストリームのエキサイティングな冒険を紹介

Response ストリームは、すべての最新ブラウザで利用できるようになりました。この API を使用すると、サーバーから受信したレスポンスの一部にアクセスできます。

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

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

バイトをテキストに変換するには、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',
});

上記のコマンドは、1 単語ずつ、各単語の間に 1 秒空けてサーバーへ「This is a 低速 request」を送信します。

リクエスト本文の各チャンクは Uint8Array バイトでなければならないため、pipeThrough(new TextEncoderStream()) を使用して変換を行います。

制限

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

半二重ですか?

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

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

ブラウザでは、リクエスト本文が完全に送信されるまでは、サーバーがレスポンスをそれより早く送信したとしても、レスポンスが利用されることはありません。これは、すべてのブラウザの取得に当てはまります。

このデフォルトのパターンは「半二重」と呼ばれます。 ただし、一部の実装(Deno の fetch など)は、ストリーミング取得でデフォルトで「全二重」に設定されており、リクエストが完了する前にレスポンスが利用可能になる場合があります。

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

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

一方、二重通信における次の最適な方法は、ストリーミング リクエストで 1 回の取得を行い、次にストリーミング レスポンスを受信するために別のフェッチを行うことです。サーバーでは、この 2 つのリクエストを関連付ける方法(URL 内の ID など)が必要になります。これがデモの仕組みです。

制限付きのリダイレクト

一部の形式の HTTP リダイレクトでは、ブラウザがリクエストの本文を別の URL に再送信する必要があります。これをサポートするには、ブラウザでストリームのコンテンツをバッファリングする必要がありますが、これはいわばポイント獲得の妨げとなるので、これは行いません。

代わりに、リクエストにストリーミング本文があり、レスポンスが 303 以外の HTTP リダイレクトである場合、取得は拒否され、リダイレクトがたどられることはありません

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

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

これで、書き込み可能なストリームに送信するものがすべてリクエストの一部になります。これにより、ストリームをまとめて作成できます。 以下に、データが 1 つの 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 で任意のデータを圧縮しています。