WebTransport を使用した Socket.IO
WebTransport のサポートはバージョン 4.7.0 (2023年6月) で追加されました。
簡単に言うと、WebTransport は WebSocket の代替手段であり、ヘッドオブラインブロッキングなど、WebSocket に影響を与えるいくつかのパフォーマンスの問題を解決します。
この新しい Web API の詳細については、以下を参照してください。
このガイドでは、WebTransport 接続を受け入れる Socket.IO サーバーを作成します。
始めましょう!
要件
Node.js 18 以上を使用してください(執筆時点での現在のLTSバージョン)。
SSL証明書
まず、プロジェクト用の新しいディレクトリを作成しましょう。
mkdir webtransport-sample-project && cd webtransport-sample-project
WebTransport はセキュアコンテキスト (HTTPS) でのみ機能するため、SSL 証明書が必要です。
新しい証明書を発行するには、次のコマンドを実行できます。
openssl req -new -x509 -nodes \
-out cert.pem \
-keyout key.pem \
-newkey ec \
-pkeyopt ec_paramgen_curve:prime256v1 \
-subj '/CN=127.0.0.1' \
-days 14
参考: https://www.openssl.org/docs/man3.1/man1/openssl-req.html
これにより、ここに記載されている要件を満たす秘密鍵と証明書が生成されます。
- 有効期間の総長は2週間を超えてはいけません。
- 許可される公開鍵アルゴリズムの正確なリスト。[...]secp256r1 (NIST P-256) 名前付きグループを使用する ECDSA を含む必要があります。
これで、次のものがあるはずです。
.
├── cert.pem
└── key.pem
基本的な HTTPS サーバー
次に、基本的な Node.js HTTPS サーバーを作成しましょう。
{
"name": "webtransport-sample-project",
"version": "0.0.1",
"description": "Socket.IO with WebTransport",
"private": true,
"type": "module"
}
import { readFile } from "node:fs/promises";
import { createServer } from "node:https";
const key = await readFile("./key.pem");
const cert = await readFile("./cert.pem");
const httpsServer = createServer({
key,
cert
}, async (req, res) => {
if (req.method === "GET" && req.url === "/") {
const content = await readFile("./index.html");
res.writeHead(200, {
"content-type": "text/html"
});
res.write(content);
res.end();
} else {
res.writeHead(404).end();
}
});
const port = process.env.PORT || 3000;
httpsServer.listen(port, () => {
console.log(`server listening at https://:${port}`);
});
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Socket.IO WebTransport example</title>
</head>
<body>
Hello world!
</body>
</html>
ここでは、index.html
ファイルの内容を/
で提供し、それ以外の場合は HTTP 404 エラーコードを返します。
参考: https://node.dokyumento.jp/api/https.html
node index.js
を実行してサーバーを起動できます。
$ node index.js
server listening at https://:3000
新しいブラウザウィンドウを開きましょう。
#!/bin/bash
HASH=`openssl x509 -pubkey -noout -in cert.pem |
openssl pkey -pubin -outform der |
openssl dgst -sha256 -binary |
base64`
chromium \
--ignore-certificate-errors-spki-list=$HASH \
https://:3000
--ignore-certificate-errors-spki-list
フラグは、Chromium に自己署名証明書を文句を言わずに受け入れるように指示します。
SSL証明書は確かに有効とみなされます。
素晴らしい!これで次のものがあるはずです。
.
├── cert.pem
├── index.html
├── index.js
├── key.pem
├── open_browser.sh
└── package.json
Socket.IO サーバー
次に、socket.io
パッケージをインストールしましょう。
npm i socket.io
既存の HTTPS サーバーに Socket.IO サーバーを作成してアタッチします。
import { readFile } from "node:fs/promises";
import { createServer } from "node:https";
import { Server } from "socket.io";
const key = await readFile("./key.pem");
const cert = await readFile("./cert.pem");
const httpsServer = createServer({
key,
cert
}, async (req, res) => {
if (req.method === "GET" && req.url === "/") {
const content = await readFile("./index.html");
res.writeHead(200, {
"content-type": "text/html"
});
res.write(content);
res.end();
} else {
res.writeHead(404).end();
}
});
const port = process.env.PORT || 3000;
httpsServer.listen(port, () => {
console.log(`server listening at https://:${port}`);
});
const io = new Server(httpsServer);
io.on("connection", (socket) => {
console.log(`connected with transport ${socket.conn.transport.name}`);
socket.conn.on("upgrade", (transport) => {
console.log(`transport upgraded to ${transport.name}`);
});
socket.on("disconnect", (reason) => {
console.log(`disconnected due to ${reason}`);
});
});
クライアントをそれに合わせて更新しましょう。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Socket.IO WebTransport example</title>
</head>
<body>
<p>Status: <span id="status">Disconnected</span></p>
<p>Transport: <span id="transport">N/A</span></p>
<script src="/socket.io/socket.io.js"></script>
<script>
const $status = document.getElementById("status");
const $transport = document.getElementById("transport");
const socket = io();
socket.on("connect", () => {
console.log(`connected with transport ${socket.io.engine.transport.name}`);
$status.innerText = "Connected";
$transport.innerText = socket.io.engine.transport.name;
socket.io.engine.on("upgrade", (transport) => {
console.log(`transport upgraded to ${transport.name}`);
$transport.innerText = transport.name;
});
});
socket.on("connect_error", (err) => {
console.log(`connect_error due to ${err.message}`);
});
socket.on("disconnect", (reason) => {
console.log(`disconnect due to ${reason}`);
$status.innerText = "Disconnected";
$transport.innerText = "N/A";
});
</script>
</body>
</html>
いくつかの説明
- クライアントバンドル
<script src="/socket.io/socket.io.js"></script>
Socket.IO クライアントバンドルは、サーバーによって/socket.io/socket.io.js
で提供されます。
最小化されたバンドル (/socket.io/socket.io.min.js
、デバッグログなし) または CDN (例: https://cdn.socket.io/4.7.2/socket.io.min.js) を使用することもできます。
- トランスポート
socket.on("connect", () => {
console.log(`connected with transport ${socket.io.engine.transport.name}`);
// ...
});
Socket.IO の専門用語では、トランスポートとは、クライアントとサーバー間の接続を確立する方法です。バージョン 4.7.0 以降、3 つのトランスポートが利用可能です。
- HTTP ロングポーリング
- WebSocket
- WebTransport
デフォルトでは、Socket.IO クライアントは、接続を正常に確立する可能性が最も高いトランスポートである HTTP ロングポーリングを最初に試みます。その後、WebSocket や WebTransport のようなより高性能なトランスポートに静かにアップグレードします。
このアップグレードメカニズムの詳細については、こちらをご覧ください。
では、サーバーを再起動しましょう。次の表示されるはずです。
今のところは順調です。
WebTransport
クライアント側では、WebTransport は現在、Safari を除くすべての主要なブラウザで利用可能です: https://caniuse.dokyumento.jp/webtransport
サーバー側では、WebTransport のサポートが Node.js に(そして Deno に)入るまで、Marten Richter によって維持されている@fails-components/webtransport
パッケージを使用できます。
npm i @fails-components/webtransport @fails-components/webtransport-transport-http3-quiche
ソース: https://github.com/fails-components/webtransport
HTTP/3 サーバーを作成し、WebTransport セッションを Socket.IO サーバーに転送しましょう。
import { readFile } from "node:fs/promises";
import { createServer } from "node:https";
import { Server } from "socket.io";
import { Http3Server } from "@fails-components/webtransport";
const key = await readFile("./key.pem");
const cert = await readFile("./cert.pem");
const httpsServer = createServer({
key,
cert
}, async (req, res) => {
if (req.method === "GET" && req.url === "/") {
const content = await readFile("./index.html");
res.writeHead(200, {
"content-type": "text/html"
});
res.write(content);
res.end();
} else {
res.writeHead(404).end();
}
});
const port = process.env.PORT || 3000;
httpsServer.listen(port, () => {
console.log(`server listening at https://:${port}`);
});
const io = new Server(httpsServer, {
transports: ["polling", "websocket", "webtransport"]
});
io.on("connection", (socket) => {
console.log(`connected with transport ${socket.conn.transport.name}`);
socket.conn.on("upgrade", (transport) => {
console.log(`transport upgraded to ${transport.name}`);
});
socket.on("disconnect", (reason) => {
console.log(`disconnected due to ${reason}`);
});
});
const h3Server = new Http3Server({
port,
host: "0.0.0.0",
secret: "changeit",
cert,
privKey: key,
});
h3Server.startServer();
(async () => {
const stream = await h3Server.sessionStream("/socket.io/");
const sessionReader = stream.getReader();
while (true) {
const { done, value } = await sessionReader.read();
if (done) {
break;
}
io.engine.onWebTransportSession(value);
}
})();
これで十分のはずですが、それでもブラウザにエラーが表示されます。
この件について何かご存知の方がいらっしゃいましたら、お知らせください。
WebTransport が失敗した場合でも(クライアントとサーバー間の接続をブロックするものが存在する場合も発生する可能性があります)、WebSocket で接続は正常に確立されます。
簡単な回避策は、localhost
の代わりに127.0.0.1
を使用することです。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Socket.IO WebTransport example</title>
</head>
<body>
<p>Status: <span id="status">Disconnected</span></p>
<p>Transport: <span id="transport">N/A</span></p>
<script src="/socket.io/socket.io.js"></script>
<script>
const $status = document.getElementById("status");
const $transport = document.getElementById("transport");
const socket = io({
transportOptions: {
webtransport: {
hostname: "127.0.0.1"
}
}
});
socket.on("connect", () => {
console.log(`connected with transport ${socket.io.engine.transport.name}`);
$status.innerText = "Connected";
$transport.innerText = socket.io.engine.transport.name;
socket.io.engine.on("upgrade", (transport) => {
console.log(`transport upgraded to ${transport.name}`);
$transport.innerText = transport.name;
});
});
socket.on("connect_error", (err) => {
console.log(`connect_error due to ${err.message}`);
});
socket.on("disconnect", (reason) => {
console.log(`disconnect due to ${reason}`);
$status.innerText = "Disconnected";
$transport.innerText = "N/A";
});
</script>
</body>
</html>
#!/bin/bash
HASH=`openssl x509 -pubkey -noout -in cert.pem |
openssl pkey -pubin -outform der |
openssl dgst -sha256 -binary |
base64`
chromium \
--ignore-certificate-errors-spki-list=$HASH \
--origin-to-force-quic-on=127.0.0.1:3000 \
https://:3000
そして、 *ほら!*
結論
10年以上前の WebSocket と同様に (!)、Socket.IO は、**ブラウザの互換性を気にすることなく**、WebTransport がもたらすパフォーマンスの向上を利用できるようになりました。
読んでいただきありがとうございます!