Знакомство с WebTransport API

mapolvqq4uunxfqoaviv3g9km9y.jpeg


Hello world!

На днях я прочитал статью о WebTransport API как будущей альтернативе или даже замене WebSockets. Мне стало интересно, что это такое и с чем его едят. Давайте разбираться вместе.


Определение и особенности

WebTransport API — это интерфейс/механизм передачи данных между клиентом и сервером с помощью протокола HTTP/3.

Он поддерживает надежную (гарантированную) упорядоченную (reliable) доставку данных с помощью одного или нескольких одно- или двунаправленных потоков (streams), а также ненадежную неупорядоченную (unreliable) доставку с помощью датаграмм (datagrams). В первом случае он действительно является альтернативой WebSockets, во втором — RTCDataChannel, предоставляемым WebRTC API.


8sqpnhibmrbwsy0afbaonq3tmzi.png
bfauy2tjnrgblhy0sshpcdzrmco.png

Источник: 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:


bhuahgzxriv9csxoupf4zvds_6w.png

По данным 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 следующего содержания:



  
    
    
    WebTransport
    
    
  
  
    

WebTransport

Подключение: Отсутствует

Транспорт: Не определен

У нас имеется два параграфа: для статуса подключения и механизма, используемого для передачи данных (транспорта).

Выполняем команду npm start для запуска сервера для разработки, переходим по адресу https://localhost:3000 и соглашаемся с использованием самоподписанного сертификата.


ixf5rfcnrzeclmbbwslzlcuytzm.png

Редактируем 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 на клиенте:





Перезапускаем сервер:


awmhp_zsxfzui7paynqmcdgyswk.png

Видим, что транспорт был успешно обновлен до 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:

Перезапускаем сервер:


otglvx7koh-ljkptav1odogejeg.png

И получаем ошибку, связанную с неизвестным сертификатом. Погуглив, я нашел эту заметку о запуске сервера и клиента 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 ... в терминале):


wv34itlw-o3uezxtbvzx8fqzqom.png

Видим, что транспорт был успешно обновлен до webtransport.

Открываем вкладку Network инструментов разработчика в браузере и выбираем WS:


dvc8sw_n5sefy1v-pmonxfqpjte.png

Видим, что типом нашего подключения к https://127.0.0.1/socket.io/ является webtransport (протокол отсутствует, хотя должен быть h3).

Хотел бы я сказать, что мы добились желаемого результата, но, к сожалению, соединение прерывается вскоре после обновления до webtransport:


dwadpvf4x8fzyxanoxswiytjxc4.png

Решения этой проблемы я пока не нашел. Если вы знаете, в чем причина, поделитесь, пожалуйста, в комментариях.

Таким образом, WebTransport API — многообещающая технология, которая со временем может полностью заменить WebSockets и частично WebRTC, однако говорить о возможности ее применения даже в личных проектах пока не приходится. Будем надеяться, что внедрение HTTP/3 и webtransport не затянется на десятилетия, как это иногда происходит с некоторыми технологиями и даже отдельными фичами (намек на декораторы в JavaScript).

Happy coding!

Основные источники:


b5pjofdoxth14ro-rjsrn7sbmiy.png

© Habrahabr.ru