跳到主要內容

Socket.IO 搭配 WebTransport

WebTransport 的支援已新增至 4.7.0 版本 (2023 年 6 月)。

資訊

簡而言之,WebTransport 是 WebSocket 的替代方案,可修正困擾 WebSocket 的多項效能問題,例如 佇列頭端封鎖

如果您想進一步了解此新的網路 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

這將產生一個符合列於此處需求的私密金鑰和憑證

  • 有效期間的總長度不得超過兩週
  • 允許的公開金鑰演算法的精確清單 [...] 必須包含具有 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://localhost:${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.dev.org.tw/api/https.html

你可以透過執行 node index.js 來啟動伺服器

$ node index.js
server listening at https://localhost: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://localhost: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

我們現在建立一個 Socket.IO 伺服器,並將它附加到我們現有的 HTTPS 伺服器

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://localhost:${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.dev.org.tw/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://localhost:${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

提示

如果有人對此有任何線索,請 ping 我們。

注意

即使 WebTransport 失敗(如果用戶端和伺服器之間的某個項目封鎖連線,也可能會發生),連線仍會透過 WebSocket 成功建立。

一個快速解決方法是使用 127.0.0.1 取代 localhost

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://localhost:3000

瞧!

Browser indicating that the connection is established with WebTransport

結論

就像 10 年前 (!) 的 WebSocket,Socket.IO 現在讓你可以受益於 WebTransport 帶來的效能提升,無需擔心瀏覽器相容性

感謝你的閱讀!