フェッチ 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');

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

上記のコードでは、「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,
});

これで、書き込み可能なストリームに送信するすべてのものがリクエストの一部になります。これにより、ストリームを組み合わせることができます。たとえば、次の例では、データが 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 で任意のデータを圧縮しています。