成功レスポンスを1つ送り、本文を段階的に書き込み、メッセージを通常どおり完了します。このMacのターミナルを使った手順では、各チャンクを別々のレスポンスとして扱ったり、206 Partial Contentを使ったりせずに、合成ドキュメント全体を送信する方法を紹介します。

目的は、一般公開向けのホスティングを想定したHTMLページを1つ配信し、クライアントがその内容を完全に受け取ることを確認することです。デモはローカルで実行され、何も公開しません。必要なのは、ターミナル、curl、そしてNode.js公式ダウンロードページから入手できるサポート対象のNode.js LTSリリースです。

チャンクが部分レスポンスを意味しない理由

レスポンスの意味と、その配信フレーミングは、それぞれ異なる問いに答えます。通常の成功したGETでは、200 OKは取得が成功したことを示します。206 Partial Contentは、サーバーが範囲リクエストに応じて表現の一部を転送していることを意味します。配信を複数回の書き込みに分けても、範囲リクエストにはなりません。RFC 9110: HTTP Semantics、206 Partial Content。

たとえば、導入、説明、締めくくりの段落を含む合成ガイドは、1つのドキュメントのまま段階的に届きます。レスポンスヘッダーを1回送信し、その後に本文の各セクションを順番に送ります。各セクションの前に新しいHTTPステータス行を付けてはいけません。

HTTP/1.1のチャンク転送コーディングでは、本文のバイト列を16進数の長さで囲み、サイズ0のチャンクと必須の終端空行で終了します。これらのフレーミング用バイト列は配信を区切るものであり、記事の内容ではありません。送信側はContent-LengthとTransfer-Encodingを併用してはいけません。RFC 9112: Chunked Transfer Coding、Content-Length。

実践上のルールは、HTTPライブラリにメッセージのフレーミングを任せることです。アプリケーションはドキュメントの内容を渡し、その内容の送信完了を知らせます。

どのストリーミング方式を使うべきか?

利用できる方式はHTTPのバージョンによって異なります。完全な本文のストリーミングは以下の3つのバージョンすべてで可能ですが、フレーミング方法は異なります。

HTTPバージョン 本文の転送方法 完了を示す方法
チャンク転送コーディングを使うHTTP/1.1 長さを先頭に付けたチャンク サイズ0の終端チャンクと、完了したトレーラーセクション
HTTP/2 DATAフレーム ヘッダーブロックの完了を含め、最後のフレームにEND_STREAMを付ける
HTTP/3 DATAフレーム レスポンスを完了し、ストリームの送信方向を閉じる

HTTP/2ではHTTP/1.1のチャンク転送コーディングが明示的に禁止されています。HTTP/3では転送コーディングが定義されておらず、Transfer-Encodingヘッダーも禁止されています。すべての接続にTransfer-Encoding: chunkedを無理に適用せず、サーバーに各プロトコルのフレーミングを実装させてください。RFC 9113: HTTP Message Exchanges、RFC 9114: HTTP Message Framing。

すでに組み立て済みの小さなドキュメントなら、通常どおり送信するのが最も簡単な実装です。内容が時間をかけて生成される場合は、段階的な書き込みが役立ちます。以下の例では動作を確認できるよう、意図的に3回の書き込みの間隔を空けています。この遅延は説明用であり、公開時の推奨設定ではありません。

Macで完全なドキュメントをストリーミング配信する

ターミナルを開き、前提条件を確認します。

node --version
curl --version

nodeが利用できない場合は、Node.jsダウンロードページからサポート対象のLTSリリースをインストールし、新しいターミナルウィンドウを開いてください。このプログラムは組み込みモジュールだけを使い、npmパッケージは必要ありません。

以下のブロック全体をターミナルに貼り付けます。デモ用アドレスhttp://127.0.0.1:8080/documentでHTTP/1.1サーバーが起動します。3つの文字列を合わせると、完全な合成ページになります。

node <<'JS'
const http = require('node:http');

const parts = [
  '<!doctype html>\n<html lang="en"><head><meta charset="utf-8">' +
    '<title>Paper Shapes</title></head><body><main>\n' +
    '<h1>Paper Shapes</h1>\n',
  '<p>A circle, a square, and a triangle make this sample collection.</p>\n',
  '<p>End of the paper shapes guide.</p>\n</main></body></html>\n'
];

const server = http.createServer((req, res) => {
  res.on('error', error => console.error(error.message));
  if (req.method !== 'GET' || req.url !== '/document') {
    res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
    res.end('No sample document at this path.\n');
    return;
  }

  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  let index = 0;
  const timer = setInterval(() => {
    if (res.destroyed) {
      clearInterval(timer);
      return;
    }
    res.write(parts[index++]);
    if (index === parts.length) {
      clearInterval(timer);
      res.end();
    }
  }, 300);
  res.on('close', () => clearInterval(timer));
});

server.on('error', error => {
  console.error(error.message);
  process.exitCode = 1;
});
server.listen(8080, '127.0.0.1', () => {
  console.log('Sample: http://127.0.0.1:8080/document');
});
JS

アプリケーションはwriteHead()を1回、write()を3回、end()を1回呼び出します。Nodeのドキュメントでは、連続する書き込みは同じレスポンス本文の一部として説明されており、各レスポンスを完了するにはend()が必要です。HTTPのフレーミングはNodeが処理するため、文字列にチャンク長や終端のゼロを追加しないでください。Node.js HTTP documentation。

これは3つの小さな文字列を使った、範囲を限定したデモです。大きなデータや継続的なデータを生成する場合は、バックプレッシャーに従ってください。write()がfalseを返したら、drainが発生するまで生成を一時停止し、同時に接続の終了やエラーも処理します。Node.js response writing documentation。

このターミナルウィンドウは起動したままにしてください。EADDRINUSEと表示された場合は、ポート8080が使用中です。サーバーで別の利用可能なポートを選び、以下のすべてのクライアントコマンドでもポートを変更してください。終了したらControl-Cでデモを停止します。

デコード済みの内容とHTTPチャンクのフレーミングを確認する

2つ目のターミナルウィンドウで、ドキュメントをリクエストします。

curl --http1.1 --include --no-buffer --max-time 5 \
  http://127.0.0.1:8080/document

200 OKのレスポンスが1つ、Content-Typeがtext/html; charset=utf-8であること、そして組み立てられたページ内容が表示されるはずです。このHTTP/1.1の例では、レスポンスにTransfer-Encoding: chunkedが使われます。curlは通常、転送フレーミングをデコードするため、通常の出力には16進数のチャンクサイズではなくドキュメントが表示されます。

フレーミングを確認するには、--rawを付けてリクエストを繰り返します。

curl --http1.1 --include --no-buffer --raw --max-time 5 \
  http://127.0.0.1:8080/document

curlのマニュアルでは、--rawは内容と転送エンコーディングの内部デコードを無効にすると説明されています。--no-bufferはcurlの出力バッファリングを無効にし、--max-time 5は処理時間に上限を設けます。これらのオプションにより、いつまでも待ち続けるコマンドではなく、短時間の診断になります。curl manual。

生の出力では、サイズ行が内容を囲み、その後にサイズ0の終端チャンクが続きます。この終端がなければ、チャンク化されたHTTP/1.1の本文は未完了です。固定長フレーミングの場合は、宣言された長さより受信バイト数が少なければ未完了です。RFC 9112: Handling Incomplete Messages。

生の出力は転送の確認にだけ使ってください。サイズ行をページファイルにコピーしたり、ドキュメントのセクションとして扱ったりしないでください。

処理する前にドキュメント全体を受信する

次の手順でページ全体が必要な場合は、解析、保存、その他の処理を受け入れる前に、本文を最後まで読み取ってください。WHATWGではレスポンス本文をストリームとして定義しています。そのtext()メソッドは本文全体を読み取り、文字列を返します。本文の読み取りに失敗した場合は拒否されます。レスポンスヘッダーを受け取っただけでは、本文の受信が完了したとは判断できません。WHATWG Fetch Standard: Body mixin、Processing responses。

サーバーを起動したまま、以下のクライアント全体を2つ目のターミナルウィンドウに貼り付けてください。

node --input-type=module <<'JS'
try {
  const response = await fetch('http://127.0.0.1:8080/document', {
    signal: AbortSignal.timeout(5000)
  });
  if (response.status !== 200) {
    throw new Error(`Expected 200; received ${response.status}`);
  }

  const html = await response.text();
  const marker = 'End of the paper shapes guide.';
  if (!html.includes(marker) || !html.endsWith('</html>\n')) {
    throw new Error('Expected sample ending is missing');
  }

  console.log(html);
  console.log('Complete sample document received.');
} catch (error) {
  console.error('Document not accepted:', error.message);
  process.exitCode = 1;
}
JS

Nodeにはブラウザー互換のfetch()実装があります。そのAbortSignal.timeout()は、指定したミリ秒が経過すると中断するシグナルを作成します。ここでは、5秒をこの小さなローカル例の説明用の上限として使っています。Node.js global objects documentation、AbortSignal timeout documentation。

このクライアントは異なる2点を確認します。本文を正常に読み取れたことと、想定したサンプルの末尾があることです。後者はアプリケーション固有の診断です。アプリケーションがend()を呼び出す前に誤って本文を短くしてしまっても、HTTPによるドキュメントの転送自体は成功することがあります。終了マーカーはその特定のミスの検出に役立ちますが、すべての段落を検証したり、一般的なHTML検証を行ったりするものではありません。

このクライアントはページ全体をメモリにバッファリングします。非常に大きなドキュメントには、明確なドキュメント形式と完了ルールを定めたうえで、逐次処理を選んでください。各読み取りで完全な段落が届くとも、それぞれが独立して意味のあるレスポンスだとも想定しないでください。

ステータスコードを変える前に完了を診断する

問題の箇所を特定するには、次の手順を使ってください。転送の完了とアプリケーションが意図した内容を切り分けることで、ダウンロードが成功したように見えても末尾が抜けている状態を防ぎます。

状況 次の対応
ローカルコマンドが接続できない サーバーが動作していて、ポートが一致していることを確認します。
curlがタイムアウトする データ生成処理が完了してend()を呼び出すことを確認し、エラー出力を残します。
転送は完了したが末尾の確認に失敗する 生成された内容と、アプリケーションがレスポンスを終了する箇所を調べます。
ドキュメント全体が一度に届く 生の出力とデコード済みの出力を比較します。表示のタイミングだけで複数のレスポンスだと判断しないでください。
通常のページ全体のリクエストで206が返る 範囲処理が意図せず適用されていないか確認します。段階的な配信だけでは、このステータスコードを使う理由になりません。

完了確認は、別個のHTTP機能ではなく、プロトコルとFetchの動作から導かれる推奨事項です。本文読み取り処理に失敗した場合、上記のクライアントは失敗を報告して異常終了し、受信できた内容だけを成功として表示することはありません。

公開ページやGoogle検索にとって、これは何を意味するか?

有用なドキュメント内容はHTMLレスポンスそのものに含めてください。Googleは、レスポンスHTMLにすべての内容が含まれているページではクロールと解析が適切に機能すると説明し、すべてのボットがJavaScriptを実行するわけではないため、サーバーサイドレンダリングまたはプリレンダリングを推奨しています。このサンプルはその方針に沿っており、テキストがドキュメント本文から直接届きます。Google Search Central: Understand the JavaScript SEO basics。

Googleのクローラーに関するドキュメントによると、200レスポンスは、受信した内容を次の処理段階に渡しますが、インデックス登録を保証するものではありません。引用したドキュメントには、ページのインデックス登録を確実にする特別なチャンクサイズや遅延時間は記載されていません。ストリーミングは配信の実装方法として扱い、完成した内容は別途確認してください。Google: How HTTP status codes affect Google's crawlers。

このデモを一般公開向けのホスティングに適用するときは、実際のドキュメントエンドポイントに対して確認を繰り返してください。成功ステータスを確認し、本文全体を読み込み、最終的な内容を意図したページと比較します。ドキュメントを運んだ書き込み、フレーム、表示更新の回数とは無関係に、合格基準を保ってください。

参考資料