- カテゴリー
- Web標準API
- 公開日
- 2026.09.26
Contents
はじめに
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との違い
| 特性 | WebSocket | WebTransport |
|---|---|---|
| 信頼性 | 順序付き・信頼性あり | Streamは信頼性あり、Datagramは不確実 |
| 複数の独立Stream | アプリで多重化 | セッション内に作成可能 |
| head-of-line blocking | 1接続の順序の影響を受ける | 独立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で不足する要件が明確な場合に選びましょう。


コメント