本文へ移動

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 サーバーを作成しましょう。

package.json
{
"name": "webtransport-sample-project",
"version": "0.0.1",
"description": "Socket.IO with WebTransport",
"private": true,
"type": "module"
}
index.js
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}`);
});
index.html
<!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

新しいブラウザウィンドウを開きましょう。

open_browser.sh
#!/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 に自己署名証明書を文句を言わずに受け入れるように指示します。

Hello world displayed in the browser

SSL証明書は確かに有効とみなされます。

Browser indicating that our certificate is valid

素晴らしい!これで次のものがあるはずです。

.
├── cert.pem
├── index.html
├── index.js
├── key.pem
├── open_browser.sh
└── package.json

Socket.IO サーバー

次に、socket.io パッケージをインストールしましょう。

npm i socket.io

既存の HTTPS サーバーに Socket.IO サーバーを作成してアタッチします。

index.js
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}`);
});
});

クライアントをそれに合わせて更新しましょう。

index.html
<!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 つのトランスポートが利用可能です。

デフォルトでは、Socket.IO クライアントは、接続を正常に確立する可能性が最も高いトランスポートである HTTP ロングポーリングを最初に試みます。その後、WebSocket や WebTransport のようなより高性能なトランスポートに静かにアップグレードします。

このアップグレードメカニズムの詳細については、こちらをご覧ください。

では、サーバーを再起動しましょう。次の表示されるはずです。

Browser indicating that the connection is established with WebSocket

今のところは順調です。

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 サーバーに転送しましょう。

index.js
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);
}
})();

これで十分のはずですが、それでもブラウザにエラーが表示されます。

Browser indicating an error with WebTransport

ヒント

この件について何かご存知の方がいらっしゃいましたら、お知らせください。

注意

WebTransport が失敗した場合でも(クライアントとサーバー間の接続をブロックするものが存在する場合も発生する可能性があります)、WebSocket で接続は正常に確立されます。

簡単な回避策は、localhostの代わりに127.0.0.1を使用することです。

index.html
<!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>
open_browser.sh
#!/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

そして、 *ほら!*

Browser indicating that the connection is established with WebTransport

結論

10年以上前の WebSocket と同様に (!)、Socket.IO は、**ブラウザの互換性を気にすることなく**、WebTransport がもたらすパフォーマンスの向上を利用できるようになりました。

読んでいただきありがとうございます!