🌐

deno で WebTransport(unstable) を触ってみた記事がなかったので自分でやってみた

に公開
2

Zenn の記事は途中で GitHub 連携したんですが、 vscode を開いてコミットしないといけないのがめんどすぎたので連携を解除して今ブラウザでこの記事を書いています。

最近仕事が忙しすぎてめっきり記事を書いていなかったんですが、ここ数週間 deno に目覚めて色々使っているので備忘録の一つとしておいておきます。

WebTransport?

普及率もかなり上がり一般的になってきた HTTP/3(QUIC) で、 UDP 通信を軸として双方向の通信を行える Web API です。

ブラウザ側の実装は(Safari は 26 以降かな...)比較的 stable になってきたんですが、サーバ側が C++/Rust/Go/python くらいしか選択肢がなかったというのが最近の話。

OpenSSL のバージョンもかなり上がってきて(今 3.5 くらい)、 QUIC に必要な暗号周りは 3.5 で揃うらしいので、サーバ側もようやく QUIC 通信が使える環境が増えてきました。 node.js は 2025 年 10 月くらいにリリース予定の v25 で対応予定だそうです(ほかの実装は別の SSL ライブラリ, BoringSSL などを使って実装していた模様)。

https://github.com/nodejs/node/pull/59249

その中で deno は v2.2 で試験的な WebTransport API をリリースし、現在に至ります。

ちなみに WebTransport は draft が最初に出てきたあたりからずっと注目していたので、数年前にも実験しています。

https://zenn.dev/yamayuski/scraps/1f2bc1588f422d

https://zenn.dev/yamayuski/scraps/f923ef3e776c13

Deno?

deno は node.js alternative な奴の一つで、サーバサイドの TypeScript/JavaScript ランタイムエンジンです。 Rust で実装されています。

node.js との違いは「権限を明確に指示出来る」という部分で、オプションなしで実行するとファイルの読み込みもネット通信も何もできません。代わりに、実行時に引数で --allow-read=sample.json などと指定することで許可できます(インタラクティブな場面では実行時に「こういう権限が必要ですが許可しますか?みたいなプロンプトが出ます)。

deno x WebTransport を試してみた

ということでこの二つを組み合わせて TypeScript で WebTransport サーバを立ててみました。

https://github.com/yamayuski/deno-webtransport-sample

コードはこちらです。

今回は deno 2.4.5 を WSL2(Ubuntu 24.04) 上で動かしただけで、他のプラットフォームでは未テストです。このサンプルコードを書くために丸二日くらいかかったのでメモがてらハマった点を残しておきます。

WSL2 はデフォルトでは udp をフォワーディングしない

WSL2 は便利なことに、 127.0.0.1 にバインドしたポートを、 Windows 側でも接続することが出来ます。これは裏で自動的にポートフォワーディングをしているらしいです。ただ、これが出来るのは WSL を起動している Windows だけなので、そのサーバに他マシンからアクセスしたい場合は別途フォワーディングする設定を Windows 側でする必要があります。ファイアウォールも許可しないといけません。

ここで一つ躓いたのは、 「WSL2 はデフォルトでは udp バインドをフォワーディングしない」 という部分です。

ほとんどのサーバは tcp ポートをリッスンするので気づかないことが多いのですが、 WSL は tcp ポートしか localhost フォワーディングしてくれません。切ない。

これは networkingMode=mirrored という設定を .wslconfig に追加することで回避可能です。デフォルトでは現在は NAT になっていますね。

ちなみに最近 WSL Settings というアプリが入るようになっているので、 GUI でもこの設定をすることが可能になっています。便利。 PowerToys を入れれば環境変数や hosts も GUI でいじれますよ。

これは WSL 内で deno server <-> deno client で WebTransport をつないでみたら普通につながることで発見しました。

https://github.com/denoland/deno/blob/main/tests/specs/run/webtransport/main.ts

公式のテストコードをローカルでも再現した形ですね。

https://github.com/denoland/deno/tree/main/tests/testdata/tls

ちなみに公式のテストでは TLS 自己署名証明書を RootCA.pem 作成から始めていて、この方式じゃないとダメなのかもと思って試してみましたが、別に mkcert の証明書でも問題なくいけました。

証明書作成の時に 127.0.0.1 を明示しないといけないかも

証明書といえばドメインに対して発行するものだと思っていたのですが、 IP アドレスに対しても発行出来ます。

で、 hostnamelocalhost にしても 127.0.0.1 としてバインドしてしまうらしいので、証明書は localhost だけでなく 127.0.0.1 も valid にしてやる必要があるっぽいです(未確定情報)。

$ mkcert -cert-file localhost.crt -key-file localhost.key localhost 127.0.0.1

こんな感じで生成できます。このあたりの挙動は WSL を使うかどうかや、 docker 経由するかどうかとかで変わりそうなので注意です。

ローカル開発ではなく public なところで Let's encrypt の証明書が使えるならまあ問題はないでしょう。

deno 単体で TypeScript をサーブする機能がなさそう?

これはちょっとびっくりだったんですが、 Deno.serve で HTTP サーバを立てること自体は簡単なんですが、ファイルをディレクトリ単位でバッとサーブしたり、 TypeScript を自動的に JavaScript にトランスパイルして返却したりする API がなさそうです。

昔は Deno.emit なんて関数が生えてたみたいなんですが、今はないようで。

で、 denoland/deno-vite-plugin を使うことで deno run で Vite サーバを動かすことが出来るんですが、 HTTPS でサーブするためには node:http2 createSecureServer の実装が必要で、現在の deno ではまだ未実装なので Vite 動かせない~!となりました。

https://github.com/denoland/deno/blob/v2.4.5/ext/node/polyfills/http2.ts#L1756

https://chaika.hatenablog.com/entry/2023/08/10/083000

この通り、 local-ssl-proxy をかませればいけそうですが、あまりにめんどいのでもう JavaScript を index.html に全部書いてしまいました。サンプルだからいいよね。

自己署名証明書の場合で Google Chrome の場合はフラグ追加が必要

chrome://flags/#webtransport-developer-mode

Chrome のフラグには WebTransport Developer Mode なるものがあるらしいです。

WebTransport Developer Mode
When enabled, removes the requirement that all certificates used for WebTransport over HTTP/3 are issued by a known certificate root. – Mac, Windows, Linux, ChromeOS, Android

どうやら WebTransport over HTTP/3 を Chrome 上で利用するには、 issued by a known certificate root 、つまり Chrome が最初から持っている正式な証明書を必要とするらしいですね。 mkcert で作った自己署名証明書は弾かれると。

一応接続時のオプションに serverCertificateHashes というのがあり、昔は chrome の起動オプションにこのハッシュを渡したら動く!みたいな話があったと思うんですが、私の環境ではうまく動かず。この WebTransport Developer Mode を有効にすることでうまくいきました。

serve は簡単

const hostname = "localhost";
const port = 4433;
const cert = Deno.readTextFileSync("localhost.crt");
const key = Deno.readTextFileSync("localhost.key");

const server = new Deno.QuicEndpoint({
  hostname,
  port,
});
const listener = server.listen({
  alpnProtocols: ["h3"],
  cert,
  key,
});

これでとりあえず HTTP/3 サーバが 127.0.0.1:4433/udp で立ち上がります。 HTTP/2 までは Deno.serve でいけるんですが、まだ Unstable な API なので別で立ち上げる必要があるんだと思います。ちなみに実行時引数に --unstable-net を加えないと WebTransport クラスとかが生成すらされません。

Firefox はなんか動かない

今回は Chrome だけ検証予定で、 Firefox も一応つないでみたのですが、 Promise が reject されてしまって特に明確な理由もいまいちわからないので、放置しました。

通信は基本的に Stream で行われる

これまで WebSocket などで双方向通信を行う際はイベント駆動で、こんな感じだったと思います。

const ws = new WebSocket("wss://localhost:8080");
ws.addEventListener("message", (data) => {
  console.log("received", data.data);
});
ws.send("hello");

まあ JavaScript ライクでシンプルですね。

対して WebTransport は Stream になるのでちょっと難しいです。

const wt = new WebTransport("https://localhost:4433");
await wt.ready;
const { readable, writable } = await wt.createBidirectionalStream();
const writer = writable.getWriter();
await writer.ready;
await writer.write((new TextEncoder()).encode("Hello server!"));
writer.releaseLock();

const reader = readable.getReader();
const response = await reader.read();
console.log((new TextDecoder()).decode(response.value));
reader.releaseLock();
wt.close();

クライアント側は Stream を生成して送信・受信するだけなので簡単です(この Stream は使い捨てて良いらしいです)。

サーバ側はなんか新しい書き方になります。

await wt.ready;
for await (const { readable, writable } of wt.incomingBidirectionalStreams) {
  for await (const value of readable.pipeThrough(new TextDecoderStream())) {
    console.log("Received", value);
    const writer = writable.getWriter();
    await writer.write(textEncoder.encode(`Pong: ${value}`));
    writer.releaseLock();
    break;
  }
  break;
}
return wt.closed;

多分 pipe をもっとうまく使った方が良いと思うんですが、いったんこれで ping/pong が実現出来ました。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/for-await...of

for await という構文が比較的新しいですね。 AsyncIterator を受け取って順番に処理できるみたいです。

PHP でも最近 : array と書かないで : iterable と書いて、 yield $val1; みたいなイテレータを書くようになり始めました。特に PHPUnit で DataProvider 書く時とか便利ですよ(唐突に PHP の話題)。

で、この WebTransport の Stream は一定時間(結構短そう)使わないとタイムアウトして途切れてしまうので、その場合はクライアントが新しい Stream を生成して再接続する、みたいなのを実装する必要がありそうです。

また、この通信は UDP にもかかわらず TCP 的な信頼性のある通信(なお順序は保証されていないのでアプリ側で頑張って、だそう)が出来ますが、 wt.datagrams を使えば、 UDP ライクな再送制御も順序制御もない Unreliable な通信を行うことが出来るようです。

この Unreliable な通信と Reliable な通信を同時に行える特徴を活かして、ライブ配信やゲーム、ビデオ通話などで活用する Media over QUIC(moq) という仕様も活発に議論されています。仕様が比較的複雑な WebRTC や RUDP(要するに自分で頑張って Reliable にした、標準規格のない独自実装 UDP) を置き換える存在として今注目されています。


ということで、今回は deno を使って WebTransport 通信をしてみる、をやってみました。 Stream-based な通信を上手に扱うライブラリは多分まだそんなにないと思うので、今作れば先駆者になれるかもしれません。

追記: 2025/08/28 Let's encrypt を使って正規の証明書を取得してみた

最初は mkcert で自己署名の証明書をブラウザに信頼させて正しい証明書としていましたが、その方法だと他のマシンで見たりすることが出来ません。

そこで、 certbot と自分のドメインを使って Let's encrypt の証明書を使えるか試してみました。

certbot は様々な方法で証明書を取ることが可能ですが、今回は Cloudflare でドメインを持っていたので certbot-dns-cloudflare プラグインを使ってコマンド一発で生成してみました。

まずサブドメインで localhost.foo.bar という DNS の A レコードとして 127.0.0.1 に転送するように設定します。

その後、 Cloudflare のダッシュボードから API トークンを生成します。 Zone:DNS:Edit というテンプレートをそのまま使えばよいです(IPの制限等をしようとすると逆に面倒なので、ドメインだけ指定してそのまま ok するのが良いです)。

トークンを生成したら ini ファイルとして保存します。

$ mkdir -pm 700 ~/.secrets/certbot
$ echo "dns_cloudflare_api_token = $API_TOKEN" > ~/.secrets/certbot/cloudflare.ini
$ chmod 600 ~/.secrets/certbot/cloudflare.ini

certbot コマンドとプラグインは Ubuntu 24.04 なら apt で一発です。

$ sudo apt install -y certbot python3-certbot-dns-cloudflare

で、生成もワンコマンド。

$ sudo certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /home/username/.secrets/certbot/cloudflare.ini \
  -d localhost.foo.bar

今回は 3 か月期限の証明書が生成されましたが、噂によるとどんどん短くして一週間とかにする予定らしいので、自動で証明書を更新する cron 等を用意しておくと良いと思います。

これで生成された /etc/letsencrypt/live/localhost.foo.bar/fullchain.pem/etc/letsencrypt/live/localhost.foo.bar/privkey.pem/certs の中に入れて、 WebTransport のサーバと node-ssl-proxy に渡すようにします。

この方法を用いることで、 WebTransport Developer Mode 実験フラグを Disable にした状態でも正常に WebTransport 接続が出来ることが確認できました。実際にデプロイする際はこのような形で証明書を指定すれば良さそうです。

※Let's encrypt 自身は、ローカルでの開発において正規の証明書は必須ではなく、必要があれば自己署名証明書を作ろう、と言っています(要約)。確かに、ローカル開発するだけなら自分のブラウザさえ信頼されればよいので mkcert などで十分かなと思います。 Let's encrypt で生成された秘密鍵が漏洩することは絶対に避けるべきでセキュリティリスクととられかねないので、本当に必要な場合のみ Let's encrypt の正規証明書を使うようにしましょう。

Discussion

petamorikenpetamoriken

Deno.serve で HTTP サーバを立てること自体は簡単なんですが、ファイルをディレクトリ単位でバッとサーブしたり、 TypeScript を自動的に JavaScript にトランスパイルして返却したりする API がなさそうです。

ファイルをディレクトリ単位でサーブするのには jsr:@std/http の file-server を使えばうまくいきそうです。また TypeScript のトランスパイルですが、experimental な deno bundle を使うのはいかがでしょうか?

https://docs.deno.com/runtime/reference/bundling/

やまゆやまゆ

serveDir まさにこれですね。

ただ、今回に関してはやはり Vite の開発体験(主に Hot Module Reload)が良いなーということで、 Vite + @deno/vite-plugin + local-ssl-proxy を使って HTTPS + Secure な Header 系も追加してレスポンスするようにしました。 await Deno.bundle({ output: "main.js", main: "./main.ts" }) 風な API が生えてたら良かったんですが、 --watch 等でファイル監視してプロセスを毎回立ち上げなおすのはなんかなーという感じでした。