WebTransport入門|HTTP/3で双方向StreamとDatagramを使い分ける

WebTransportのHTTP/3通信入門。StreamとDatagram Web標準API
カテゴリー
Web標準API
公開日
2026.09.26

はじめに

WebTransportは、ブラウザとサーバーの間で複数の一方向・双方向StreamとDatagramを扱うAPIです。チャット、ゲーム、遠隔操作、メディア周辺データなど、信頼性と遅延の要件が混在する通信に向きます。

この記事ではブラウザ側の最小クライアントを作り、StreamとDatagramの選択基準を整理します。接続先にはWebTransport対応HTTP/3サーバーが必要です。

先に用語を整理する

  • HTTP/3:主にQUICを使って通信するHTTPの世代
  • セッション:ブラウザとサーバー間で確立した一連の接続
  • Stream:順序と到着を保証しながらバイト列を送る通信路
  • Datagram:到着や順序を保証せず、待ち時間の短さを優先できる小さなメッセージ
  • framing:連続したバイト列のどこからどこまでが1件のメッセージかを決める規則

たとえばゲームでは「購入確定」は欠落できないためStream、「現在のキャラクター位置」は次の位置情報ですぐ置き換わるためDatagram、という使い分けが考えられます。

試す前に必要なもの

この記事のコードだけでは接続テストは完結しません。通常の静的Webサーバーに加えて、WebTransportを受け付けるHTTP/3サーバーとTLS設定が必要です。まずは利用するサーバー実装の公式サンプルを起動し、そのサンプルが指定するURLをconnect()へ渡してください。

ブラウザの開発者ツールでConsoleとNetworkを開きます。接続成功時はtransport.readyが完了し、echoサーバーならStreamで送った文字列が返ります。https://example.com:4433/sessionは説明用のURLで、そのままでは動作しません。

ローカル検証で自己署名証明書を使う場合

WebTransportでは証明書エラーを警告画面で無視できません。公開環境では信頼された証明書を使います。ローカル検証では、短期間だけ有効な証明書のSHA-256ハッシュをserverCertificateHashesへ渡す方法があります。

この方法で使う証明書には、主に次の条件があります。

  • X.509 v3証明書である
  • 有効期間が2週間を超えず、現在時刻が期間内である
  • 相互運用性を考えるならECDSA P-256鍵を使う
  • DER形式の証明書全体をSHA-256でハッシュする
  • RSA鍵は使わない

serverCertificateHashesは開発時に証明書検証を無効化する設定ではなく、指定した証明書だけを信頼する仕組みです。ハッシュは改ざんされない経路でクライアントへ渡してください。

検証用HTTP/3サーバーを起動する

ここでは、PythonのHTTP/3実装であるaioquic 1.3.0の公式サーバー例を使います。

python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install aioquic==1.3.0 wsproto==1.3.2
git clone --branch 1.3.0 --depth 1 https://github.com/aiortc/aioquic.git

公式例のexamples/http3_server.pyへ渡す最小ASGIアプリをwebtransport_app.pyとして作ります。

次のecho処理はaioquic 1.3.0のサンプルを参考に、パスの確認と終了処理を加えた改変例です。BSD-3-Clauseライセンスの表示をコード内に保持しています。

# Copyright (c) Jeremy Lainé.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
#     * Redistributions of source code must retain the above copyright notice,
#       this list of conditions and the following disclaimer.
#     * Redistributions in binary form must reproduce the above copyright notice,
#       this list of conditions and the following disclaimer in the documentation
#       and/or other materials provided with the distribution.
#     * Neither the name of aioquic nor the names of its contributors may
#       be used to endorse or promote products derived from this software without
#       specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# Adapted from aioquic 1.3.0 examples/demo.py (wt).
# Changes: path guard, standalone app, close handling.
async def app(scope, receive, send):
    if scope["type"] != "webtransport" or scope["path"] != "/wt":
        return

    message = await receive()
    assert message["type"] == "webtransport.connect"
    await send({"type": "webtransport.accept"})

    while True:
        message = await receive()
        if message["type"] == "webtransport.datagram.receive":
            await send({
                "type": "webtransport.datagram.send",
                "data": message["data"],
            })
        elif message["type"] == "webtransport.stream.receive":
            await send({
                "type": "webtransport.stream.send",
                "stream": message["stream"],
                "data": message["data"],
            })
        elif message["type"] == "webtransport.close":
            return

ECDSA P-256鍵、有効期間10日の証明書、DER証明書のSHA-256ハッシュを作ります。次をgenerate_cert.pyとして保存します。

import base64
import datetime
import hashlib
import ipaddress
from pathlib import Path

from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID

output = Path(__file__).parent
key = ec.generate_private_key(ec.SECP256R1())
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
now = datetime.datetime.now(datetime.UTC)
certificate = (
    x509.CertificateBuilder()
    .subject_name(name)
    .issuer_name(name)
    .public_key(key.public_key())
    .serial_number(x509.random_serial_number())
    .not_valid_before(now - datetime.timedelta(minutes=5))
    .not_valid_after(now + datetime.timedelta(days=10))
    .add_extension(
        x509.SubjectAlternativeName([
            x509.DNSName("localhost"),
            x509.IPAddress(ipaddress.ip_address("127.0.0.1")),
        ]),
        critical=False,
    )
    .sign(key, hashes.SHA256())
)

(output / "localhost-key.pem").write_bytes(key.private_bytes(
    serialization.Encoding.PEM,
    serialization.PrivateFormat.PKCS8,
    serialization.NoEncryption(),
))
(output / "localhost-cert.pem").write_bytes(
    certificate.public_bytes(serialization.Encoding.PEM)
)
der = certificate.public_bytes(serialization.Encoding.DER)
(output / "certificate-sha256.txt").write_text(
    base64.b64encode(hashlib.sha256(der).digest()).decode("ascii"),
    encoding="ascii",
)

証明書を生成し、HTTP/3サーバーを起動します。

python generate_cert.py
$env:PYTHONPATH = (Get-Location).Path
python .\aioquic\examples\http3_server.py `
  --certificate .\localhost-cert.pem `
  --private-key .\localhost-key.pem `
  --host 127.0.0.1 `
  --port 4433 `
  webtransport_app:app

秘密鍵localhost-key.pemは検証専用です。公開リポジトリへコミットせず、検証が終わったら安全に破棄してください。

WebSocketとの違い

特性WebSocketWebTransport
信頼性順序付き・信頼性ありStreamは信頼性あり、Datagramは不確実
複数の独立Streamアプリで多重化セッション内に作成可能
head-of-line blocking1接続の順序の影響を受ける独立Stream間で分離しやすい
導入の容易さ広い対応、サーバー多数HTTP/3等の対応サーバーが必要

テキストチャットだけならWebSocketの方が単純な場合があります。WebTransportを選ぶ理由を「新しいから」ではなく、複数StreamやDatagramが必要かで判断します。

接続

async function connect(url, certificateHash) {
  if (!("WebTransport" in window)) {
    throw new Error("このブラウザはWebTransportに対応していません");
  }

  const options = certificateHash
    ? {
        serverCertificateHashes: [
          { algorithm: "sha-256", value: certificateHash },
        ],
      }
    : undefined;
  const transport = new WebTransport(url, options);
  transport.closed.then(
    () => console.info("WebTransport closed"),
    (error) => console.error("WebTransport failed", error),
  );
  let timer;
  try {
    await Promise.race([
      transport.ready,
      new Promise((_, reject) => {
        timer = setTimeout(() => reject(new Error("接続がタイムアウトしました")), 10000);
      }),
    ]);
  } catch (error) {
    transport.close();
    throw error;
  } finally {
    clearTimeout(timer);
  }

  return transport;
}

// 公開環境では信頼された証明書を使い、第2引数を省略する
// const transport = await connect("https://example.com:4433/session");

先ほど生成したローカル証明書で試す場合は、Base64で保存したハッシュをbyte列へ戻して渡します。公開用とローカル用は別の選択肢なので、const transportを2回宣言しません。次のローカル用コードは、後述の起動手順に従いmain()内に置きます。

const response = await fetch("./certificate-sha256.txt");
if (!response.ok) throw new Error("証明書ハッシュを取得できません");
const base64 = (await response.text()).trim();
const certificateHash = Uint8Array.from(
  atob(base64),
  (character) => character.charCodeAt(0),
);
const transport = await connect(
  "https://127.0.0.1:4433/wt",
  certificateHash,
);

WebTransportはsecure context限定です。開発用自己署名証明書の扱いはブラウザとAPIの制約に従い、本番では信頼されたTLS証明書を使用します。

双方向Streamで要求と応答を送る

const encoder = new TextEncoder();

async function request(transport, message) {
  if (typeof message !== "string" || /[\r\n]/.test(message)) {
    throw new Error("改行を含まない文字列を送ってください");
  }
  const bytes = encoder.encode(`${message}\n`);
  if (bytes.length > 65536) throw new RangeError("要求が64 KiBを超えています");
  let timer;
  try {
    return await Promise.race([
      exchange(),
      new Promise((_, reject) => {
        timer = setTimeout(() => {
          transport.close(); // このデモではタイムアウト時にセッション全体を終了
          reject(new Error("応答がタイムアウトしました"));
        }, 10000);
      }),
    ]);
  } finally {
    clearTimeout(timer);
  }

  async function exchange() {
  const decoder = new TextDecoder("utf-8", { fatal: true });
  const stream = await transport.createBidirectionalStream();
  const writer = stream.writable.getWriter();
  const reader = stream.readable.getReader();
  try {
  await writer.write(bytes);
  await writer.close();
  let text = "";
  let size = 0;
  while (!text.includes("\n")) {
    const { value, done } = await reader.read();
    if (done) throw new Error("改行前に応答が終了しました");
    size += value.byteLength;
    if (size > 65536) throw new RangeError("応答が64 KiBを超えています");
    text += decoder.decode(value, { stream: true });
  }
  const end = text.indexOf("\n");
  return text.slice(0, end + 1);
  } finally {
    await Promise.allSettled([reader.cancel(), writer.abort()]);
    reader.releaseLock();
    writer.releaseLock();
  }
  }
}

Streamはbyte列であり、1回のwriteと1回のreadの境界が一致する保証はありません。この例は改行までを1件として読み、応答を受信したら読み取り側を終了します。複数メッセージを流す場合は、改行を残りのデータと分離して管理するか、長さprefixなどのframingを実装します。

デコーダーは要求ごとに作ります。送受信は64 KiB、待機は10秒に制限しています。この小さなデモのタイムアウトはセッション全体を閉じるため、同じセッションの他の処理も終了します。本格的な多重化では、Stream単位の中断とセッション全体の切断を分けて設計してください。

Datagramを送る

async function sendPosition(transport, position) {
  // 現行仕様のcreateWritable()と、実装移行中のwritable属性の両方を扱う
  const writable = transport.datagrams.createWritable?.()
    ?? transport.datagrams.writable;
  const bytes = encoder.encode(JSON.stringify(position));
  const maxSize = transport.datagrams.maxDatagramSize;
  if (Number.isFinite(maxSize) && bytes.byteLength > maxSize) {
    throw new RangeError(`Datagramが上限を超えています: ${bytes.byteLength} > ${maxSize}`);
  }
  const writer = writable.getWriter();
  try {
    await writer.write(bytes);
  } finally {
    writer.releaseLock();
  }
}

Datagramは失われる、重複する、順序が入れ替わる前提で設計します。writer.write()が成功しても、相手への到着を保証しません。検証環境ではmaxDatagramSizeが1024 bytesのときに65536 bytesを書き込むPromiseがresolveした一方、echoは返りませんでした。そのため上限は送信前に自分で確認します。最新の座標のように古い値を再送しても意味が薄いデータに向き、決済やチャット本文のように欠落できない情報にはStreamを使います。

終了とエラー

transport.close({ closeCode: 0, reason: "user signed out" });
await transport.closed;

ネットワーク切断、サーバー拒否、証明書、HTTP/3不通を区別してUIへ伝えます。再接続では指数バックオフを使い、同じ要求の二重送信を防ぐIDを付けます。UDP/HTTP3がネットワーク機器で制限される環境もあるため、WebSocket等へのフォールバックを検討します。

クライアントを組み立てて起動する

Pythonは3.11以上、クライアント配信用にはNode.js 22.12以上とnpmを用意します。秘密鍵のあるフォルダーとは別にclientフォルダーを作り、そこへ公開情報のcertificate-sha256.txtだけをコピーします。秘密鍵を静的配信しないでください。

client/index.htmlを次の内容で保存します。

<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>WebTransport echo検証</title>
<h1>WebTransport echo検証</h1>
<p>Consoleへ接続結果とechoを表示します。</p>
<script type="module" src="./app.js"></script>
</html>

app.jsには、まずconnect()、encoderとrequest()、sendPosition()の3ブロックを掲載順に保存します。その末尾に次の枠を追加し、コメント位置へ「接続」節のローカル用コード(const responseからconst transport = await connect(...)まで)を貼り付けます。

async function main() {
  // ここにローカル用コードを貼る
  try {
    console.log("echo:", await request(transport, "こんにちは"));
  } finally {
    transport.close();
    await transport.closed.catch(() => {});
  }
}
main().catch(error => console.error("検証失敗:", error.message));

HTTP/3サーバーを起動したまま、別の端末でclientフォルダーに移り、npx --yes vite@7.3.6 --host 127.0.0.1を実行します。表示されたURLをブラウザで開き、Consoleにecho: こんにちはが出ればStreamの往復成功です。Datagramの例は別の送信関数で、この起動例では呼びません。各サーバーの終了はCtrl+Cです。再接続、認証、WebSocketへの切替はこの最小例には未実装です。

動作確認

  • ready成功後にStreamのechoが返る
  • 日本語を複数chunkに分けてもTextDecoderで壊れない
  • 大きすぎるDatagramを送った場合の失敗を扱う
  • サーバー停止時にclosedのrejectを処理する
  • タブ終了とサインアウトでcloseする
  • HTTP/3が利用できない環境では接続エラーになる(代替手段への自動切替はこの例に含まない)

セキュリティ

TLSはアプリ利用者の認証や権限管理を代行しません。セッション確立時に認証し、各操作の認可、レート制限、サイズ上限を設けます。受信byte列は必ず不正入力として解析し、無制限バッファリングを避けます。

よくあるトラブル

  • WebTransport is not defined:ブラウザの対応状況とsecure contextで開いているか確認する
  • transport.readyが失敗する:接続URL、TLS証明書、HTTP/3サーバーの待受ポートを確認する
  • 社内ネットワークだけ接続できない:UDPやHTTP/3が制限されていないか調べ、WebSocketフォールバックを試す
  • 日本語が途中で文字化けする:受信chunkごとに単純変換せず、TextDecoderのstreamオプションを使う
  • Streamが終了しない:送信側のwriterを閉じ、サーバー側も応答Streamを完了しているか確認する
  • Datagramが届かない:maxDatagramSizeと送信byte数を確認する。上限内でも欠落を正常な可能性として扱い、重要データにはStreamを使う

関連記事

まとめ

WebTransportは、信頼性が必要なStreamと低遅延を優先するDatagramを一つのセッションで選べます。一方でサーバー、HTTP/3、証明書、対応ブラウザ、フォールバックまで含む導入コストがあります。WebSocketで不足する要件が明確な場合に選びましょう。

参考リンク

コメント

タイトルとURLをコピーしました