Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Hello world!
На днях я прочитал статью о WebTransport API как будущей альтернативе или даже замене WebSockets. Мне стало интересно, что это такое и с чем его едят. Давайте разбираться вместе.
Определение и особенности
WebTransport API
— это интерфейс/механизм передачи данных между клиентом и сервером с помощью протокола HTTP/3.
Он поддерживает надежную (гарантированную) упорядоченную (reliable) доставку данных с помощью одного или нескольких одно- или двунаправленных потоков (streams), а также ненадежную неупорядоченную (unreliable) доставку с помощью датаграмм (datagrams). В первом случае он действительно является альтернативой WebSockets, во втором — RTCDataChannel, предоставляемым WebRTC API.
Источник: WebTransport и его место среди других протоколов
HTTP/3
основан на протоколе QUIC от Google, который, в свою очередь, основан на протоколе UDP и призван решить несколько проблем, присущих протоколу TCP, таких как:
- head-of-line (HOL) blocking (блокировка очереди) —
HTTP/2
поддерживает мультиплексирование — через одно соединение одновременно могут передаваться несколько потоков данных. Но если один из потоков "упадет", другие будут ждать его восстановления и повторной отправки потерянных пакетов данных. ВQUIC
потоки не зависят друг от друга - более высокая производительность —
QUIC
является более производительным, чемTCP
по многим причинам. Одной из таких причин является то, чтоQUIC
самостоятельно реализует меры безопасности, а не полагается в этом на TLS, как это делаетTCP
, что означает меньшее количество запросов-ответов (round trips). Другой причиной является то, что потоки являются более эффективным транспортным механизмом, чем устаревшая пакетная передача данных. Особенно сильно это проявляется в высоконагруженных сетях - более легкая смена сети (network transition) —
QUIC
использует уникальный идентификатор подключения для обработки источника и получателя каждого запроса для обеспечения правильной доставки пакетов. Этот идентификатор может сохраняться между разными сетями. Это означает, что если мы во время скачивания файла переключились с Wi-Fi на мобильную сеть, скачивание продолжится (не будет прервано).HTTP/2
использует IP-адрес в качестве идентификатора запроса, поэтому переключение между сетями может быть проблематичным - ненадежная доставка —
HTTP/3
поддерживает ненадежную доставку, которая производительнее надежной доставки
Принципы работы
Подключение
Для открытия соединения с сервером HTTP/3
необходимо передать его URL в конструктор WebTransport(). Обратите внимание, что схема должна содержать HTTPS
и порт должен быть указан явно. Разрешение промиса WebTransport.ready означает установку подключения.
Закрытие соединения можно обработать с помощью промиса [WebTRansport.closed](). Ошибки WebTransport
являются экземплярами WebTransportError и содержат дополнительные данные поверх стандартного набора DOMException.
const url = "https://example.com:4999/wt";
async function initTransport(url) {
// Инициализируем подключение
const transport = new WebTransport(url);
// Разрешение этого промиса означает готовность подключения к обработке запросов
await transport.ready;
// ...
}
async function closeTransport(transport) {
// Обработка закрытия соединения
try {
await transport.closed;
console.log(`HTTP/3-подключение к ${url} закрыто мягко.`);
} catch (error) {
console.error(`HTTP/3-подключение к ${url} закрыто в результате ошибки: ${error}.`);
}
}
Ненадежная передача данных с помощью датаграмм
"Ненадежная" означает, что не гарантируется ни полная доставка данных, ни порядок их доставки. В некоторых случаях это вполне допустимо. Преимуществом является скорость передачи данных.
Ненадежная доставка обрабатывается с помощью свойства WebTransport.datagrams — оно возвращает объект WebTransportDatagramDuplexStream, содержащий все необходимое для отправки датаграмм на сервер и их получения на клиенте.
Свойство WebTransportDatagramDuplexStream.writable возвращает объект WritableStream, который позволяет отправлять (писать — write) данные на сервер:
const writer = transport.datagrams.writable.getWriter();
const data1 = new Uint8Array([65, 66, 67]);
const data2 = new Uint8Array([68, 69, 70]);
writer.write(data1);
writer.write(data2);
Свойство WebTransportDatagramDuplexStream.readable возвращает объект ReadableStream, который позволяет "читать" (read) данные, полученные от сервера:
async function readData() {
const reader = transport.datagrams.readable.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
// value - это Uint8Array
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array
console.log(value);
}
}
Надежная передача данных с помощью потоков
"Надежная" означает, что гарантируется полная и упорядоченная передача данных. Это означает более медленную доставку (но быстрее, чем с помощью WebSockets
), однако во многих случаях надежность является критичной (например, для приложений чата).
При использовании для передачи данных нескольких потоков возможно определение их приоритетов.
Однонаправленная передача данных
Для открытия однонаправленного потока используется метод WebTransport.createUnidirectionalStream(), возвращающий ссылку на WritableStream
. Данные отправляются на сервер с помощью writer
, возвращаемого методом getWriter:
async function writeData() {
const stream = await transport.createUnidirectionalStream();
const writer = stream.writable.getWriter();
const data1 = new Uint8Array([65, 66, 67]);
const data2 = new Uint8Array([68, 69, 70]);
writer.write(data1);
writer.write(data2);
try {
await writer.close();
console.log("Все данные были успешно отправлены");
} catch (error) {
console.error(`Во время отправки данных возникла ошибка: ${error}`);
}
}
Метод WritableStreamDefaultWriter.close() используется для закрытия HTTP/3-соединения
после отправки всех данных.
Извлечь данные на клиенте из однонаправленного потока, открытого на сервере, можно с помощью свойства WebTransport.incomingUnidirectionalStreams, который возвращает ReadableStream
объектов WebTransportReceiveStream.
Создаем функцию для чтения WebTransportReceiveStream
. Эти объекты наследуют от класса ReadableStream
, поэтому реализация функции нам уже знакома:
async function readData(receiveStream) {
const reader = receiveStream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
// value - это Uint8Array
console.log(value);
}
}
Получаем ссылку на reader
с помощью метода getReader()
и читаем incomingUnidirectionalStreams
по частям ("чанкам" — chunks) (каждый чанк — это WebTransportReceiveStream
):
async function receiveUnidirectional() {
const uds = transport.incomingUnidirectionalStreams;
const reader = uds.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
// value - это экземпляр WebTransportReceiveStream
await readData(value);
}
}
Двунаправленная передача данных
Для открытия двунаправленного потока используется метод WebTransport.createBidirectionalStream(), возвращающий ссылку на WebTransportBidirectionalStream. Он содержит свойства readable
и writable
, возвращающие ссылки на экземпляры WebTransportReceiveStream
и WebTransportSendStream
, которые могут использоваться для чтения данных, полученных от сервера, и отправки данных на сервер, соответственно.
async function setUpBidirectional() {
const stream = await transport.createBidirectionalStream();
// stream - это WebTransportBidirectionalStream
// stream.readable - это WebTransportReceiveStream
const readable = stream.readable;
// stream.writable - это WebTransportSendStream
const writable = stream.writable;
// ...
}
Чтение из WebTransportReceiveStream
может быть реализовано следующим образом:
async function readData(readable) {
const reader = readable.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
// value - это Uint8Array.
console.log(value);
}
}
Запись в WebTransportSendStream
может быть реализована следующим образом:
async function writeData(writable) {
const writer = writable.getWriter();
const data1 = new Uint8Array([65, 66, 67]);
const data2 = new Uint8Array([68, 69, 70]);
writer.write(data1);
writer.write(data2);
}
Извлечь данные на клиенте из двунаправленного потока, открытого на сервере, можно с помощью свойства WebTransport.incomingBidirectionalStreams, которое возвращает ReadableStream
объектов WebTransportBidirectionalStream
. Каждый поток может быть использован для чтения и записи экземпляров Uint8Array
. Разумеется, нам нужна функция чтения самого двунапраленного потока:
async function receiveBidirectional() {
const bds = transport.incomingBidirectionalStreams;
const reader = bds.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
// value - это экземпляр WebTransportBidirectionalStream
await readData(value.readable);
await writeData(value.writable);
}
}
Поддержка
Для того, чтобы включить поддержку HTTP/3
(QUIC
) в Google Chrome, необходимо перейти на chrome://flags
и включить Experimental QUIC protocol
:
По данным Can I Use, WebTransport API
в той или иной степени поддерживается всеми современными браузерами, но это не совсем так, как мы скоро увидим.
Что касается сервера HTTP/3
, то найти работоспособную реализацию в сети довольно сложно.
В официальном демо используется сервер на Python, исходный код которого можно найти здесь. Демо работает нестабильно, в частности, не работает при использовании прокси и VPN.
Существует также реализация на C#, которую по словам спикеров доклада WebTransport и его место среди других протоколов нужно немного "допиливать", чтобы заставить нормально работать.
Ни в Node.js, ни в Deno, ни в Bun
поддержка WebTransport API
пока не реализована.
В июне 2023 поддержка WebTransport
была добавлена в Socket.io v4.7.0. Однако для обеспечения такой поддержки используется пакет @fails-components/webtransport, который выглядит как чей-то эксперимент, не рассчитанный для использования в продакшне. Тем не менее рассмотрим этот вариант подробнее, поскольку socket.io
— это что называется battle tested библиотека для обмена данными в реальном времени.
Пример
Исходный код проект можно найти здесь.
В процессе разработки и тестирования я использовал следующее:
- Node.js 20.10.0
- Google Chrome 120.0.6099.110
- Windows 10 Pro
Создаем новую директорию, переходим в нее и инициализируем Node.js-проект:
mkdir webtransport-socket.io
cd webtransport-socket.io
npm init -yp
webtransport
может функционировать только в безопасном контексте (HTTPS) (даже localhost
не является исключением), поэтому нам необходимо сгенерировать SSL-сертификат и ключ. Создаем файл create_cert.sh
следующего содержания:
#!/bin/bash
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
О openssl-req
можно почитать здесь, а о требованиях к сертификату — здесь.
Выполняем команду bash create_cert.sh
. Это приводит к генерации файлов cert.pem
и key.pem
.
Установим несколько пакетов:
npm i express socket.io @fails-components/webtransport
npm i -D nodemon
Пропишем тип кода сервера и скрипт для его запуска в файле package.json
:
"main": "server.js",
"scripts": {
"start": "nodemon"
},
"type": "module",
Создаем файл server.js
с кодом запуска HTTPS-сервера с помощью Express:
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { createServer } from 'node:https'
import express from 'express'
// Читаем ключ и сертификат SSL
const key = readFileSync('./key.pem')
const cert = readFileSync('./cert.pem')
// Создаем приложение
const app = express()
// Возвращаем файл `index.html` в ответ на все запросы
app.use('*', (req, res) => {
res.sendFile(path.resolve('./index.html'))
})
// Создаем сервер
const httpsServer = createServer({ key, cert }, app)
const port = process.env.PORT || 443
// Запускаем сервер
httpsServer.listen(port, () => {
console.log(`Server listening at https://localhost:${port}`)
})
Создаем файл index.html
следующего содержания:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebTransport</title>
<link rel="icon" href="data:." />
<script src="/socket.io/socket.io.js"></script>
</head>
<body>
<h1>WebTransport</h1>
<p>Подключение: <span id="connection">Отсутствует</span></p>
<p>Транспорт: <span id="transport">Не определен</span></p>
</body>
</html>
У нас имеется два параграфа: для статуса подключения и механизма, используемого для передачи данных (транспорта).
Выполняем команду npm start
для запуска сервера для разработки, переходим по адресу https://localhost:3000
и соглашаемся с использованием самоподписанного сертификата.
Редактируем server.js
для добавления поддержки websockets
на сервере с помощью socket.io
:
// ...
import { Server } from 'socket.io'
// ...
const io = new Server(httpsServer)
// Обработка подключения
io.on('connection', (socket) => {
// Название транспорта: pooling, websocket, webtransport (которого пока нет)
console.log(`connected with transport ${socket.conn.transport.name}`)
// Обновление подключения: pooling -> websocket -> webtransport
socket.conn.on('upgrade', (transport) => {
console.log(`transport upgraded to ${transport.name}`)
})
// Обработка отключения
socket.on('disconnect', (reason) => {
console.log(`disconnected due to ${reason}`)
})
})
Редактируем index.html
для добавления поддержки websockets
на клиенте:
<!-- перед тегом </head> -->
<script src="/socket.io/socket.io.js"></script>
<!-- перед тегом </body> -->
<script>
const $connection = document.getElementById('connection')
const $transport = document.getElementById('transport')
const socket = io()
// Обработка подключения
socket.on('connect', () => {
// Название механизма передачи данных
console.log(
`connected with transport ${socket.io.engine.transport.name}`,
)
$connection.textContent = 'Подключение установлено'
$transport.textContent = socket.io.engine.transport.name
// Обновление подключения
socket.io.engine.on('upgrade', (transport) => {
console.log(`transport upgraded to ${transport.name}`)
$transport.textContent = 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}`)
$connection.textContent = 'Подключение отсутствует'
$transport.textContent = 'Отсутствует'
})
</script>
Перезапускаем сервер:
Видим, что транспорт был успешно обновлен до websocket
. Отлично, двигаемся дальше.
Редактируем server.js
— добавляем поддержку webtransport
:
// ...
import { Http3Server } from '@fails-components/webtransport'
// ...
const io = new Server(httpsServer, {
// `webtransport` должен быть указан явно
transports: ['polling', 'websocket', 'webtransport'],
})
// ...
// Создаем сервер HTTP/3
const h3Server = new Http3Server({
port,
host: '0.0.0.0',
secret: 'changeit',
cert,
privKey: key,
})
// Запускаем его
h3Server.startServer()
// Создаем поток и передаем его в `socket.io`
;(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)
}
})()
Редактируем index.html
:
<script>
// ...
const socket = io({
transportOptions: {
// `webtransport` должен быть указан явно
webtransport: {
hostname: '127.0.0.1',
},
},
})
// ..
</script>
Перезапускаем сервер:
И получаем ошибку, связанную с неизвестным сертификатом. Погуглив, я нашел эту заметку о запуске сервера и клиента QUIC
. Из большого количества флагов Chrome, указанных в заметке, нам необходимы 3:
--ignore-certificate-errors-spki-list
— игнорировать ошибки, связанные с сертификатом SSL для определенного сертификата (указывается хэш сертификата, см. ниже)--origin-to-force-quic-on
— принудительный обмен данными по протоколу QUIC--user-data-dir
— директория с данными профиля пользователя (я не знаю, почему этот флаг является обязательным)
Создаем файл generate_hash.sh
следующего содержания:
#!/bin/bash
openssl x509 -pubkey -noout -in cert.pem |
openssl pkey -pubin -outform der |
openssl dgst -sha256 -binary |
base64
Выполняем команду bash generate_hash.sh
. Получаем хэш нашего сертификата SSL.
Создаем файл open_chrome.sh
следующего содержания:
chrome --ignore-certificate-errors-spki-list=AbpC9VJaXAcTrUG38g2lcCqobfGecqNmdIvLV1Ukkf8= --origin-to-force-quic-on=127.0.0.1:443 --user-data-dir=quic-user-data https://localhost:443
Обратите внимание:
- для того, чтобы иметь возможность запускать Chrome с помощью команды
chrome
, необходимо указать путь кchrome.exe
в переменной средыPath
(в моем случае это —C:\Program Files\Google\Chrome\Application
) - мы передаем сгенерированный ранее хэш сертификата в
ignore-certificate-errors-spki-list
Перезапускаем сервер и выполняем команду bash open_chrome.sh
(при возникновении ошибки chrome: command not found
просто выполните команду chrome ...
в терминале):
Видим, что транспорт был успешно обновлен до webtransport
.
Открываем вкладку Network
инструментов разработчика в браузере и выбираем WS
:
Видим, что типом нашего подключения к https://127.0.0.1/socket.io/
является webtransport
(протокол отсутствует, хотя должен быть h3
).
Хотел бы я сказать, что мы добились желаемого результата, но, к сожалению, соединение прерывается вскоре после обновления до webtransport
:
Решения этой проблемы я пока не нашел. Если вы знаете, в чем причина, поделитесь, пожалуйста, в комментариях.
Таким образом, WebTransport API
— многообещающая технология, которая со временем может полностью заменить WebSockets
и частично WebRTC
, однако говорить о возможности ее применения даже в личных проектах пока не приходится. Будем надеяться, что внедрение HTTP/3
и webtransport
не затянется на десятилетия, как это иногда происходит с некоторыми технологиями и даже отдельными фичами (намек на декораторы в JavaScript
).
Happy coding!
Основные источники:
- Раздел о WebTransport API на MDN
- Основной репозиторий webtransport на GitHub
- Черновик спецификации WebTransport
- Using WebTransport
- WebTransport: The Future of Real-Time Communication, Bridging the Gap Beyond WebRTC & Websockets
- WebTransport: A new way to communicate over HTTP/3
- WebTransport и его место среди других протоколов (к сожалению, доступно только участникам HollyJS)
- Socket.IO with WebTransport
- Playing with QUIC