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).


Дополнение от 27.12


Обнаружил работоспособный WebTransport-сервер на Rust.


# Клонируем репозиторий
git clone https://github.com/BiagioFesta/wtransport.git
cd wtransport
# Запускаем полный пример использования сервера webtransport
# (разумеется, у вас должен быть установлен Rust)
cargo run --example full

Полный пример является ремейком официального демо.





Рассмотрим серверную и клиентскую части примера (обе части находятся в файле wtransport/examples/full.rs).


Основная функция, запускающая серверы HTTP и WebTransport:


#[tokio::main]
async fn main() -> Result<()> {
    // Включаем логгирование
    utils::init_logging();

    // Генерируем сертификат SSL
    let certificate = Certificate::self_signed(["localhost", "127.0.0.1", "::1"]);
    // Генерируем хэш сертификата
    // (он передается в клиентскую часть, в том числе)
    let cert_digest = certificate.hashes().pop().unwrap();

    // Создаем сервер WebTransport
    let webtransport_server = WebTransportServer::new(certificate)?;
    // Создаем HTTP сервер, использующий порт сервера WebTransport
    let http_server = HttpServer::new(&cert_digest, webtransport_server.local_port()).await?;

    info!(
        "Open Google Chrome and go to: http://127.0.0.1:{}",
        http_server.local_port()
    );

    // Запускаем серверы
    tokio::select! {
        result = http_server.serve() => {
            error!("HTTP server: {:?}", result);
        }
        result = webtransport_server.serve() => {
            error!("WebTransport server: {:?}", result);
        }
    }

    Ok(())
}

Серверная часть (нас в основном интересует функция handle_incoming_session() или, если точнее, handle_incoming_session_impl()):


mod webtransport {
    // Импорты...

    pub struct WebTransportServer {
        endpoint: Endpoint<Server>,
    }

    impl WebTransportServer {
        // Статический метод создания экземпляра сервера
        pub fn new(certificate: Certificate) -> Result<Self> {
            let config = ServerConfig::builder()
                .with_bind_default(0)
                .with_certificate(certificate)
                .keep_alive_interval(Some(Duration::from_secs(3)))
                .build();

            let endpoint = Endpoint::server(config)?;

            Ok(Self { endpoint })
        }

        // Метод, возвращающий порт сервера
        pub fn local_port(&self) -> u16 {
            self.endpoint.local_addr().unwrap().port()
        }

        // Метод обработки подключения
        pub async fn serve(self) -> Result<()> {
            info!("Server running on port {}", self.local_port());

            // Подключение остается открытым - бесконечный цикл
            for id in 0.. {
                // Принимаем входящую сессию
                let incoming_session = self.endpoint.accept().await;

                // Создаем поток для ее обработки
                tokio::spawn(
                    Self::handle_incoming_session(incoming_session)
                        .instrument(info_span!("Connection", id)),
                );
            }

            Ok(())
        }

        async fn handle_incoming_session(incoming_session: IncomingSession) {
            async fn handle_incoming_session_impl(incoming_session: IncomingSession) -> Result<()> {
                // Создаем буфер для чтения данных - Box[T]
                // Максимальная количество элементов - 65536, что соответствует u16
                let mut buffer = vec![0; 65536].into_boxed_slice();

                info!("Waiting for session request...");

                // Ожидаем запрос
                let session_request = incoming_session.await?;

                info!(
                    "New session: Authority: '{}', Path: '{}'",
                    session_request.authority(),
                    session_request.path()
                );

                // Ожидаем подключение
                let connection = session_request.accept().await?;

                info!("Waiting for data from client...");

                // Обработка подключения
                loop {
                    tokio::select! {
                        // Двунаправленный поток
                        stream = connection.accept_bi() => {
                            // Получаем поток - (SendStream, RecvStream)
                            let mut stream = stream?;
                            info!("Accepted BI stream");

                            // Читаем данные из RecvStream в буфер
                            let bytes_read = match stream.1.read(&mut buffer).await? {
                                Some(bytes_read) => bytes_read,
                                None => continue,
                            };

                            // Преобразуем буфер в строку
                            let str_data = std::str::from_utf8(&buffer[..bytes_read])?;

                            info!("Received (bi) '{str_data}' from client");

                            // Пишем данные в SendStream - отправляем клиенту
                            stream.0.write_all(b"ACK").await?;
                        }
                        // Однонаправленный поток
                        stream = connection.accept_uni() => {
                            // Получаем поток - RecvStream
                            let mut stream = stream?;
                            info!("Accepted UNI stream");

                            // Читаем данные в буфер
                            let bytes_read = match stream.read(&mut buffer).await? {
                                Some(bytes_read) => bytes_read,
                                None => continue,
                            };

                            // Преобразуем буфер в строку
                            let str_data = std::str::from_utf8(&buffer[..bytes_read])?;

                            info!("Received (uni) '{str_data}' from client");

                            // Открываем однонаправленный поток - SendStream
                            let mut stream = connection.open_uni().await?.await?;
                            // Пишем данные - отправляем клиенту
                            stream.write_all(b"ACK").await?;
                        }
                        // Датаграммы
                        dgram = connection.receive_datagram() => {
                            // Получаем датаграмму
                            let dgram = dgram?;
                            // Преобразуем датаграмму в строку
                            let str_data = std::str::from_utf8(&dgram)?;

                            info!("Received (dgram) '{str_data}' from client");

                            // Отправляем датаграмму клиенту
                            connection.send_datagram(b"ACK")?;
                        }
                    }
                }
            }

            // Обработка сессии
            let result = handle_incoming_session_impl(incoming_session).await;
            info!("Result: {:?}", result);
        }
    }
}

Для удобства изучения я вынес код клиентской части в отдельный файл HTML (без стилей, в них нет ничего интересного). В исходном коде примера HTML, CSS и JavaScript представлены в виде строк и генерируются сервером перед передачей в браузер (см. функцию build_router() модуля http). Это обусловлено тем, что в HTML передается порт сервера для подключения (WEBTRANSPORT_PORT), а в JavaScript передается хэш сертификата SSL (CERT_DIGEST).


<!DOCTYPE html>
<html lang="en">
  <title>WTransport-Example</title>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <body>
    <h1>WTransport Example</h1>

    <!-- Подключение -->
    <div>
      <h2>Establish WebTransport connection</h2>
      <div class="input-line">
        <label for="url">URL:</label>
        <input
          type="text"
          name="url"
          id="url"
          value="https://localhost:${WEBTRANSPORT_PORT}/"
        />
        <input type="button" id="connect" value="Connect" onclick="connect()" />
      </div>
    </div>

    <div>
      <h2>Send data over WebTransport</h2>
      <form name="sending">
        <!-- Данные для отправки -->
        <textarea name="data" id="data"></textarea>
        <!-- Тип отправки -->
        <div>
          <!-- Датаграмма -->
          <input
            type="radio"
            name="sendtype"
            value="datagram"
            id="datagram"
            checked
          />
          <label for="datagram">Send a datagram</label>
        </div>
        <div>
          <!-- Однонаправленный поток -->
          <input type="radio" name="sendtype" value="unidi" id="unidi-stream" />
          <label for="unidi-stream">Open a unidirectional stream</label>
        </div>
        <div>
          <!-- Двунаправленный поток -->
          <input type="radio" name="sendtype" value="bidi" id="bidi-stream" />
          <label for="bidi-stream">Open a bidirectional stream</label>
        </div>
        <input
          type="button"
          id="send"
          name="send"
          value="Send data"
          disabled
          onclick="sendData()"
        />
      </form>
    </div>

    <!-- Отчеты/логи -->
    <div>
      <h2>Event log</h2>
      <ul id="event-log"></ul>
    </div>

    <script>
      // Хэш сертификата
      const HASH = new Uint8Array(${CERT_DIGEST});

      let currentTransport, streamNumber, currentTransportDatagramWriter;

      // Обработка подключения
      async function connect() {
        // Адрес сервера
        const url = document.getElementById('url').value;
        try {
          // Создаем WebTransport
          var transport = new WebTransport(url, { serverCertificateHashes: [ { algorithm: "sha-256", value: HASH.buffer } ] } );
          addToEventLog('Initiating connection...');
        } catch (e) {
          addToEventLog('Failed to create connection object. ' + e, 'error');
          return;
        }

        // Подключаемся
        try {
          await transport.ready;
          addToEventLog('Connection ready.');
        } catch (e) {
          addToEventLog('Connection failed. ' + e, 'error');
          return;
        }

        // Обработка отключения
        transport.closed
            .then(() => {
              addToEventLog('Connection closed normally.');
            })
            .catch(() => {
              addToEventLog('Connection closed abruptly.', 'error');
            });

        // Текущий транспорт
        currentTransport = transport;
        // Счётчик потоков
        streamNumber = 1;
        try {
          // Текущий "писатель" датаграмм
          currentTransportDatagramWriter = transport.datagrams.writable.getWriter();
          addToEventLog('Datagram writer ready.');
        } catch (e) {
          addToEventLog('Sending datagrams not supported: ' + e, 'error');
          return;
        }
        // Читаем датаграммы
        readDatagrams(transport);
        // Читаем однонаправленные потоки
        acceptUnidirectionalStreams(transport);
        // Снимаем блокировку с кнопки для отправки данных
        document.forms.sending.elements.send.disabled = false;
        // Блокируем кнопку для подключения
        document.getElementById('connect').disabled = true;
      }

      // Обработка отправки данных
      async function sendData() {
        // Форма
        let form = document.forms.sending.elements;
        // Кодировщик текстовых данных
        let encoder = new TextEncoder('utf-8');
        // Сырые данные
        let rawData = sending.data.value;
        // Закодированные данные - Uint8Array
        let data = encoder.encode(rawData);
        // Текущий транспорт
        let transport = currentTransport;
        try {
          switch (form.sendtype.value) {
            // Датаграмма
            case 'datagram':
              // Пишем датаграмму
              await currentTransportDatagramWriter.write(data);
              addToEventLog('Sent datagram: ' + rawData);
              break;
            // Однонаправленный поток
            case 'unidi': {
              // Открываем однонаправленный поток
              let stream = await transport.createUnidirectionalStream();
              // Получаем ссылку на "писателя"
              let writer = stream.getWriter();
              // Пишем данные - отправляем серверу
              await writer.write(data);
              // Закрываем поток
              await writer.close();
              addToEventLog('Sent a unidirectional stream with data: ' + rawData);
              break;
            }
            // Двунаправленный поток
            case 'bidi': {
              // Открываем двунаправленный поток
              let stream = await transport.createBidirectionalStream();
              // Увеличиваем значение счётчика потоков
              let number = streamNumber++;
              // Читаем данные из входящего потока
              readFromIncomingStream(stream, number);

              // Получаем ссылку на "писателя"
              let writer = stream.writable.getWriter();
              // Пишем данные - отправляем серверу
              await writer.write(data);
              // Закрываем поток
              await writer.close();
              addToEventLog(
                  'Opened bidirectional stream #' + number +
                  ' with data: ' + rawData);
              break;
            }
          }
        } catch (e) {
          addToEventLog('Error while sending data: ' + e, 'error');
        }
      }

      // Читаем датаграммы
      async function readDatagrams(transport) {
        try {
          // Получаем ссылку на "читателя"
          var reader = transport.datagrams.readable.getReader();
          addToEventLog('Datagram reader ready.');
        } catch (e) {
          addToEventLog('Receiving datagrams not supported: ' + e, 'error');
          return;
        }
        // Декодер текстовых данных
        let decoder = new TextDecoder('utf-8');
        try {
          while (true) {
            // Читаем датаграмму
            const { value, done } = await reader.read();
            if (done) {
              addToEventLog('Done reading datagrams!');
              return;
            }
            // Декодируем данные - преобразуем Uint8Array в текст
            let data = decoder.decode(value);
            addToEventLog('Datagram received: ' + data);
          }
        } catch (e) {
          addToEventLog('Error while reading datagrams: ' + e, 'error');
        }
      }

      // Читаем однонаправленные потоки
      async function acceptUnidirectionalStreams(transport) {
        // Получаем ссылку на "читателя" потоков
        let reader = transport.incomingUnidirectionalStreams.getReader();
        try {
          while (true) {
            // value - это однонаправленный поток
            const { value, done } = await reader.read();
            if (done) {
              addToEventLog('Done accepting unidirectional streams!');
              return;
            }
            let stream = value;
            // Увеличиваем значение счётчика потоков
            let number = streamNumber++;
            addToEventLog('New incoming unidirectional stream #' + number);
            // Читаем данные из потока
            readFromIncomingStream(stream, number);
          }
        } catch (e) {
          addToEventLog('Error while accepting streams: ' + e, 'error');
        }
      }

      // Читаем данные из потока
      async function readFromIncomingStream(stream, number) {
        // Декодер потоковых текстовых данных - первый раз встречаю :)
        let decoder = new TextDecoderStream('utf-8');
        // Получаем ссылку на "читателя" декодированных данных
        // (данные декодируются, проходя через конвейер `pipeThrough(decoder)`)
        let reader = stream.pipeThrough(decoder).getReader();
        try {
          while (true) {
            // Читаем данные
            const { value, done } = await reader.read();
            if (done) {
              addToEventLog('Stream #' + number + ' closed');
              return;
            }
            let data = value;
            addToEventLog('Received data on stream #' + number + ': ' + data);
          }
        } catch (e) {
          addToEventLog(
              'Error while reading from stream #' + number + ': ' + e, 'error');
          addToEventLog('    ' + e.message);
        }
      }

      // Добавляем запись в журнал
      function addToEventLog(text, severity = 'info') {
        let log = document.getElementById('event-log');
        let mostRecentEntry = log.lastElementChild;
        let entry = document.createElement('li');
        entry.innerText = text;
        entry.className = 'log-' + severity;
        log.appendChild(entry);

        // Прокрутка до последнего (нижнего) элемента
        if (mostRecentEntry != null &&
            mostRecentEntry.getBoundingClientRect().top <
                log.getBoundingClientRect().bottom) {
          entry.scrollIntoView();
        }
      }
    </script>
  </body>
</html>

Happy coding!


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





Комментарии (3)


  1. ShADAMoV
    26.12.2023 22:16
    +3

    Огромное спасибо за статью! К сожалению, наличие крутой технологии - не всегда гарантирует ее успех в ближайшее будущее (даже если область, в которой она используется, остро в ней нуждается)... Многое будет зависеть от глобализации, но, надеюсь, что Google не подкачает с этим)


  1. Master255
    26.12.2023 22:16

    Quic ограничен по скорости во всём Android. Из-за малого буфера передачи данных скорость в 4g не превышает 20Мегабит.

    Тушите свет. Всё что вы тут писали не актуально для Android. А для стационарников и TCP норм.

    Не плывёт ваш QUIC и всё что на нём построено.


  1. Safort
    26.12.2023 22:16
    +4

    Шикарная статья, спасибо! Сам давно хотел написать, но руки не доходили.

    Честно говоря, не очень понятно, почему не форсят это использовать, особенно в трейдинговых веб-терминалах.