跳至主要内容

Socket.IO 与 WebTransport

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

这将生成一个私钥和一个证书,它们符合 此处 列出的要求

  • 有效期总长度不得超过两周
  • 允许的公钥算法的精确列表[...]必须包括使用 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.org.cn/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.cn/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

提示

如果有人知道这个问题,请告诉我们。

注意

即使 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 带来的性能改进中受益,**而无需担心浏览器兼容性**。

感谢您的阅读!