Skip to main content

Socket.IO with WebTransport

Support for WebTransport has been added in version 4.7.0 (June 2023).

info

In short, WebTransport is an alternative to WebSocket which fixes several performance issues that plague WebSockets like head-of-line blocking.

If you want more information about this new web API, please check:

In this guide, we will create a Socket.IO server that accepts WebTransport connections.

Here we go!

Requirements​

Please use at least Node.js 18 (the current LTS version at the time of writing).

SSL certificate​

First, let's create a new directory for our project:

mkdir webtransport-sample-project && cd webtransport-sample-project

WebTransport only works in secure contexts (HTTPS), so we will need an SSL certificate.

You can run the following command to issue a new certificate:

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

Reference: https://www.openssl.org/docs/man3.1/man1/openssl-req.html

This will generate a private key and a certificate which comply with the requirements listed here:

  • the total length of the validity period MUST NOT exceed two weeks
  • the exact list of allowed public key algorithms [...] MUST include ECDSA with the secp256r1 (NIST P-256) named group

OK, so you should now have:

.
β”œβ”€β”€ cert.pem
└── key.pem

Basic HTTPS server​

Then, let's create a basic Node.js HTTPS server:

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>

Nothing fancy here, we just serve the content of the index.html file at /, and return an HTTP 404 error code otherwise.

Reference: https://nodejs.org/api/https.html

You can start the server by running node index.js:

$ node index.js
server listening at https://localhost:3000

Now, let's open a new browser window:

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

The --ignore-certificate-errors-spki-list flag tells Chromium to accept our self-signed certificate without complaining:

Hello world displayed in the browser

Our SSL certificate is indeed deemed valid:

Browser indicating that our certificate is valid

Great! You should now have:

.
β”œβ”€β”€ cert.pem
β”œβ”€β”€ index.html
β”œβ”€β”€ index.js
β”œβ”€β”€ key.pem
β”œβ”€β”€ open_browser.sh
└── package.json

Socket.IO server​

Now, let's install the socket.io package:

npm i socket.io

We now create a Socket.IO server and attach it to our existing HTTPS server:

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

Let's update the client accordingly:

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>

A few explanations:

  • client bundle
<script src="/socket.io/socket.io.js"></script>

The Socket.IO client bundle is served by the server at /socket.io/socket.io.js.

We could also have used the minified bundle (/socket.io/socket.io.min.js, without debug logs) or a CDN (for example https://cdn.socket.io/4.7.0/socket.io.min.js).

  • transport
socket.on("connect", () => {
console.log(`connected with transport ${socket.io.engine.transport.name}`);
// ...
});

In the Socket.IO jargon, a Transport is a way to establish a connection between a client and a server. Since version 4.7.0, there are now 3 available transports:

By default, the Socket.IO client will always try HTTP long-polling first, since it is the transport which is the most likely to successfully establish a connection. It will then quietly upgrade to more performant transports, like WebSocket or WebTransport.

More about this upgrade mechanism here.

OK, so let's restart our server. You should now see:

Browser indicating that the connection is established with WebSocket

So far, so good.

WebTransport​

On the client side, WebTransport is currently available in all major browsers but Safari: https://caniuse.com/webtransport

On the server side, until support for WebTransport lands in Node.js (and in Deno), we can use the @fails-components/webtransport package maintained by Marten Richter.

npm i @fails-components/webtransport

Source: https://github.com/fails-components/webtransport

Let's create an HTTP/3 server and forward the WebTransport sessions to the Socket.IO server:

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,
});

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

h3Server.startServer();

This should have been sufficient, but there is an error in the browser nonetheless:

Browser indicating an error with WebTransport

tip

If someone has any clue about this, please ping us.

note

Even if WebTransport fails (which might also happen if something between the client and the server blocks the connection), the connection is successfully established with WebSocket.

A quick workaround is to use 127.0.0.1 instead of 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

And voilΓ !

Browser indicating that the connection is established with WebTransport

Conclusion​

Like WebSocket more than 10 years ago (!), Socket.IO now allows you to benefit from the performance improvements brought by WebTransport, without worrying about browser compatibility.

Thanks for reading!