Потоковые запросы с помощью API fetch

Джейк Арчибальд
Jake Archibald

Начиная с Chromium 105, вы можете начать запрос до того, как будет доступно все тело, используя API потоков .

Вы можете использовать это для:

  • Разогреть сервер. Другими словами, вы можете начать запрос, как только пользователь наведет фокус на поле ввода текста, и убрать все заголовки с дороги, а затем подождать, пока пользователь нажмет «отправить», прежде чем отправлять введенные им данные.
  • Постепенно отправляйте данные, сгенерированные на клиенте, такие как аудио, видео или входные данные.
  • Воссоздайте веб-сокеты по протоколу HTTP/2 или HTTP/3.

Но поскольку это низкоуровневая функция веб-платформы, не ограничивайтесь моими идеями. Возможно, вы сможете придумать гораздо более захватывающий вариант использования для потоковой передачи запросов.

Демо

Здесь показано, как можно передавать данные от пользователя на сервер и отправлять обратно данные, которые можно обрабатывать в режиме реального времени.

Да, ладно, это не самый изобретательный пример, я просто хотел упростить его, ладно?

Ну и как это работает?

Ранее о захватывающих приключениях Fetch-потоков

Потоки ответов уже некоторое время доступны во всех современных браузерах. Они позволяют вам получать доступ к частям ответа по мере их поступления с сервера:

const response = await fetch(url);
const reader = response.body.getReader();

while (true) {
  const {value, done} = await reader.read();
  if (done) break;
  console.log('Received', value);
}

console.log('Response fully received');

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

Если вы хотите преобразовать байты в текст, вы можете использовать TextDecoder или более новый поток преобразования, если ваши целевые браузеры его поддерживают :

const response = await fetch(url);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();

TextDecoderStream — это поток преобразования, который захватывает все эти фрагменты Uint8Array и преобразует их в строки.

Потоки великолепны, так как вы можете начать действовать на основе данных по мере их поступления. Например, если вы получаете список из 100 «результатов», вы можете отобразить первый результат сразу после его получения, а не ждать все 100.

Так или иначе, это потоки ответов, а вот о чем я хотел рассказать, так это о потоках запросов.

Потоковые тела запросов

Запросы могут иметь тела:

await fetch(url, {
  method: 'POST',
  body: requestBody,
});

Раньше для начала запроса требовалось, чтобы все тело было готово к работе, но теперь в Chromium 105 вы можете предоставить собственный ReadableStream данных:

function wait(milliseconds) {
  return new Promise(resolve => setTimeout(resolve, milliseconds));
}

const stream = new ReadableStream({
  async start(controller) {
    await wait(1000);
    controller.enqueue('This ');
    await wait(1000);
    controller.enqueue('is ');
    await wait(1000);
    controller.enqueue('a ');
    await wait(1000);
    controller.enqueue('slow ');
    await wait(1000);
    controller.enqueue('request.');
    controller.close();
  },
}).pipeThrough(new TextEncoderStream());

fetch(url, {
  method: 'POST',
  headers: {'Content-Type': 'text/plain'},
  body: stream,
  duplex: 'half',
});

Вышеуказанный код отправит на сервер сообщение «Это медленный запрос» по одному слову с паузой в одну секунду между каждым словом.

Каждый фрагмент тела запроса должен представлять собой массив байтов Uint8Array , поэтому я использую pipeThrough(new TextEncoderStream()) для выполнения преобразования.

Ограничения

Запросы на потоковую передачу данных — это новая возможность для Интернета, поэтому они имеют несколько ограничений:

Полудуплекс?

Чтобы разрешить использование потоков в запросе, необходимо установить параметр duplex запроса на значение 'half' .

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

В браузерах ответ никогда не становится доступным, пока тело запроса не будет полностью отправлено, даже если сервер отправляет ответ раньше. Это справедливо для всех браузерных выборок.

Этот шаблон по умолчанию известен как «полудуплекс». Однако некоторые реализации, такие как fetch в Deno , по умолчанию используют «полный дуплекс» для потоковых выборок, что означает, что ответ может стать доступным до завершения запроса.

Таким образом, чтобы обойти эту проблему совместимости, в браузерах необходимо указывать duplex: 'half' в запросах, имеющих тело потока.

В будущем duplex: 'full' может поддерживаться в браузерах для потоковых и непотоковых запросов.

В то же время, следующим лучшим способом для дуплексной коммуникации является выполнение одного запроса с потоковым запросом, а затем выполнение другого запроса для получения потокового ответа. Серверу понадобится какой-то способ связать эти два запроса, например, идентификатор в URL. Вот как работает демо .

Ограниченные перенаправления

Некоторые формы HTTP-редиректа требуют, чтобы браузер пересылал тело запроса на другой URL. Чтобы это поддерживать, браузеру пришлось бы буферизировать содержимое потока, что как бы противоречит сути, поэтому он этого не делает.

Вместо этого, если запрос имеет потоковое тело, а ответ представляет собой HTTP-перенаправление, отличное от 303, выборка будет отклонена, и перенаправление не будет выполнено.

Перенаправления 303 разрешены, поскольку они явно изменяют метод на GET и отбрасывают тело запроса.

Требует CORS и запускает предварительную проверку

У потоковых запросов есть тело, но нет заголовка Content-Length . Это новый тип запроса, поэтому требуется CORS, и эти запросы всегда запускают предварительную проверку.

Запросы на потоковую передачу no-cors не допускаются.

Не работает на HTTP/1.x

Если соединение HTTP/1.x, выборка будет отклонена.

Это связано с тем, что, согласно правилам HTTP/1.1, тела запросов и ответов должны либо отправлять заголовок Content-Length , чтобы другая сторона знала, сколько данных она получит, либо изменять формат сообщения для использования фрагментированного кодирования . При фрагментированном кодировании тело разделяется на части, каждая из которых имеет свою собственную длину содержимого.

Кодирование фрагментами довольно распространено, когда речь идет об ответах HTTP/1.1, но очень редко, когда речь идет о запросах , поэтому оно создает слишком большой риск совместимости.

Возможные проблемы

Это новая функция, которая сегодня недостаточно используется в Интернете. Вот несколько проблем, на которые следует обратить внимание:

Несовместимость на стороне сервера

Некоторые серверы приложений не поддерживают потоковые запросы и вместо этого ждут получения полного запроса, прежде чем позволять вам увидеть его, что как-то противоречит сути. Вместо этого используйте сервер приложений, который поддерживает потоковые запросы, например NodeJS или Deno .

Но вы еще не вышли из леса! Сервер приложений, такой как NodeJS, обычно находится за другим сервером, часто называемым «сервером переднего плана», который в свою очередь может находиться за CDN. Если кто-то из них решит буферизировать запрос перед передачей его следующему серверу в цепочке, вы потеряете преимущество потоковой передачи запросов.

Несовместимость вне вашего контроля

Поскольку эта функция работает только через HTTPS, вам не нужно беспокоиться о прокси между вами и пользователем, но пользователь может запустить прокси на своей машине. Некоторое программное обеспечение для интернет-защиты делает это, чтобы иметь возможность контролировать все, что происходит между браузером и сетью, и могут быть случаи, когда это программное обеспечение буферизует тела запросов.

Если вы хотите защититься от этого, вы можете создать «тест функции», аналогичный демо выше , где вы пытаетесь передать поток данных, не закрывая поток. Если сервер получает данные, он может ответить с помощью другой выборки. Как только это произойдет, вы будете знать, что клиент поддерживает потоковые запросы сквозного типа.

Обнаружение особенностей

const supportsRequestStreams = (() => {
  let duplexAccessed = false;

  const hasContentType = new Request('', {
    body: new ReadableStream(),
    method: 'POST',
    get duplex() {
      duplexAccessed = true;
      return 'half';
    },
  }).headers.has('Content-Type');

  return duplexAccessed && !hasContentType;
})();

if (supportsRequestStreams) {
  // …
} else {
  // …
}

Если вам интересно, вот как работает обнаружение признаков:

Если браузер не поддерживает определенный тип body , он вызывает toString() для объекта и использует результат в качестве тела. Таким образом, если браузер не поддерживает потоки запроса, тело запроса становится строкой "[object ReadableStream]" . Когда строка используется в качестве тела, она удобно устанавливает заголовок Content-Type в text/plain;charset=UTF-8 . Таким образом, если этот заголовок установлен, то мы знаем, что браузер не поддерживает потоки в объектах запроса, и мы можем выйти раньше.

Safari поддерживает потоки в объектах запроса, но не позволяет использовать их с fetch , поэтому тестируется duplex вариант, который Safari в настоящее время не поддерживает.

Использование с записываемыми потоками

Иногда проще работать с потоками, когда у вас есть WritableStream . Вы можете сделать это, используя поток 'identity', который является парой читаемый/записываемый, которая принимает все, что передается на его записываемый конец, и отправляет это на читаемый конец. Вы можете создать один из них, создав TransformStream без каких-либо аргументов:

const {readable, writable} = new TransformStream();

const responsePromise = fetch(url, {
  method: 'POST',
  body: readable,
});

Теперь все, что вы отправляете в записываемый поток, будет частью запроса. Это позволяет вам составлять потоки вместе. Например, вот глупый пример, где данные извлекаются из одного URL, сжимаются и отправляются на другой URL:

// Get from url1:
const response = await fetch(url1);
const {readable, writable} = new TransformStream();

// Compress the data from url1:
response.body.pipeThrough(new CompressionStream('gzip')).pipeTo(writable);

// Post to url2:
await fetch(url2, {
  method: 'POST',
  body: readable,
});

В приведенном выше примере используются потоки сжатия для сжатия произвольных данных с помощью gzip.