フェッチ 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 long 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 の背後に置かれていることもあります。 いずれか 1 つがリクエストをバッファリングしてからチェーン内の次のサーバーに渡すと、リクエスト ストリーミングのメリットを失います。

制御できない非互換性

この機能は 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 で任意のデータを圧縮しています。