HTTP — это текст: разбираем сырой запрос, приходящий кусками

HTTP≈ 15 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Пять статей подряд собирали механику: излучатель, читающий и пишущий потоки, обратное давление, трубу с уборкой и, наконец, настоящие потоки fs и сокета. Всё это было про то, как данные едут. Сегодня — про то, что в них едет. HTTP/1.1 устроен до неприличия просто: это текст, который читается глазами, — стартовая строка, заголовки, пустая строка, тело; те же метод, заголовки и тело возят и HTTP/2 с HTTP/3, только не текстом, а двоичными кадрами. Сложность начинается там, где его нужно прочитать программой: сокет отдаёт не строки протокола, а куски произвольного размера, и границы между ними кладёт сеть. Соберём разборщик, который с этим справляется: дальше на нём встанут маршрутизатор, конвейер и адаптер под свой каркас.

Разделы темы:·

Сокет — такой же читающий поток, как файл: запрос в node:http оказался обычным Readable, и в куске у него байты. Но node:http эти байты уже разобрал и отдал готовыми полями req.method, req.url, req.headers. Чтобы понять, откуда они берутся, надо спуститься на этаж ниже — к node:net, который не разбирает ничего и отдаёт ровно то, что пришло по проводу.

Спускаться страшно только на словах. HTTP/1.1 — текстовый протокол (RFC 9112), и его целиком можно напечатать. Первая строка — стартовая: метод, цель запроса и версия через пробел. Дальше заголовки, по одному на строку, в виде «имя: значение». Потом пустая строка. Потом тело. Перевод строки здесь всегда пара символов \r\n, а конец головы — две такие пары подряд.

Прогон снимает один и тот же POST дважды: сырыми байтами из сокета node:net и им же, разобранным http.createServer. Клиент — настоящий fetch, чтобы заголовки писал не автор примера. Прогноз: сколько заголовков клиент допишет сам и совпадёт ли регистр имён в req.headers с тем, что приехало по проводу?

прогон node 26.5.0 · 21 сентября 2026
Один POST: сырые байты из сокета и те же байты глазами http.createServer
// Один и тот же POST, снятый дважды: сырыми байтами из сокета и уже разобранным
// у node:http. Клиент — настоящий fetch, чтобы заголовки писал не автор примера.
import { createServer as createSocketServer } from "node:net";
import { createServer } from "node:http";

const BODY = JSON.stringify({ title: "HTTP — это текст" });
const TARGET = "/notes?draft=1";

// тот же самый запрос, одинаковый для обоих серверов
const send = (port) =>
  fetch(`http://127.0.0.1:${port}${TARGET}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: BODY,
  });

// 1. node:net ничего не разбирает: в сокет приходят байты, и это весь протокол
const raw = await new Promise((resolve) => {
  const server = createSocketServer((socket) => {
    const chunks = [];
    socket.on("data", (chunk) => {
      chunks.push(chunk);
      const text = Buffer.concat(chunks).toString("utf8");
      const headEnd = text.indexOf("\r\n\r\n");
      if (headEnd === -1) return;
      // ждём всё тело: сколько кусков придёт и где лягут их границы — дело сети
      const length = Number(/\r\ncontent-length: *(\d+)/i.exec(text)?.[1] ?? 0);
      if (Buffer.byteLength(text.slice(headEnd + 4), "utf8") < length) return;
      socket.end("HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n");
      server.close();
      resolve(text);
    });
  });
  server.listen(0, "127.0.0.1", () => send(server.address().port).catch(() => {}));
});

console.log("— что пришло в сокет (\\r\\n показан, дальше настоящий перевод строки) —");
process.stdout.write(`${raw.replaceAll("\r\n", "\\r\\n\n")}\n`);

// 2. те же байты, но их уже разобрал node:http
const request = await new Promise((resolve) => {
  const server = createServer((req, res) => {
    resolve({
      method: req.method,
      url: req.url,
      httpVersion: req.httpVersion,
      headers: req.headers,
      rawHeaders: req.rawHeaders,
    });
    res.writeHead(204).end();
    server.close();
  });
  server.listen(0, "127.0.0.1", () => send(server.address().port).catch(() => {}));
});

const names = (list) => list.filter((_, i) => i % 2 === 0).join(", ");
console.log("— те же байты глазами http.createServer —");
console.log(`req.method: ${request.method}`);
console.log(`req.url: ${request.url}`);
console.log(`req.httpVersion: ${request.httpVersion}`);
console.log(`req.headers["content-type"]: ${request.headers["content-type"]}`);
console.log(`req.headers["Content-Type"]: ${request.headers["Content-Type"]}`);
console.log(`имена в req.headers: ${Object.keys(request.headers).join(", ")}`);
console.log(`имена в req.rawHeaders: ${names(request.rawHeaders)}`);
node capture.mjs

Первая строка — POST /notes?draft=1 HTTP/1.1. Цель запроса — не URL: ни схемы, ни хоста в ней нет, только путь и строка запроса. Куда клиент шёл, сказано отдельным заголовком host, и ровно поэтому один сервер на одном адресе обслуживает десяток доменов: разбирается он не по сокету, а по строчке текста. Версия тоже часть стартовой строки, и это не украшение: от неё зависит, закроется соединение после ответа или останется жить.

Заголовков девять, а fetch просили ровно об одном. Остальные восемь — host, connection, accept, accept-encoding, user-agent и соседи — клиент дописал сам. И один из девяти написан иначе, чем остальные: Content-Type с большими буквами, потому что таким его передали в fetch. А node:http положил его в req.headers под ключом content-type: имена он приводит к нижнему регистру, и обращение req.headers["Content-Type"] честно отвечает undefined. Регистр и порядок не потеряны — они лежат в req.rawHeaders, плоском массиве, где имена и значения чередуются.

Дальше пустая строка, и после неё тело. Где тело кончается, из самого текста не видно: это просто байты, никакого признака конца у них нет. Поэтому длину объявляют заранее — заголовком content-length. Здесь он обещает 38. Запомните это число, оно ещё покажет характер.

Пара CR LF досталась HTTP по наследству от телетайпов — через SMTP, FTP и прочих ровесников. RFC 9112 разрешает получателю принимать и одинокий \n, но не обязывает, и Node этим разрешением не пользуется. Проверено на 26.5.0: стартовая строка, оканчивающаяся голым переводом строки, даёт HPE_INVALID_VERSION («Expected CRLF after version»), голый перевод строки после значения заголовка — HPE_CR_EXPECTED, а пустая строка из одного \n — HPE_INVALID_HEADER_TOKEN. Во всех трёх случаях обработчик запроса не вызывается вовсе: разбор ломается раньше. Зато пробел после двоеточия необязателен — host:java-script.pro Node принимает молча, и наш разборщик будет так же обрезать значение через trim.

Ответ устроен зеркально: вместо стартовой строки первой идёт строка состояния — версия, код, причина, — дальше те же заголовки, пустая строка и тело.

Текст мы прочитали целиком — но целиком его никто не приносит. Сокет отдаёт куски, и это те же куски, что у файлового потока: сколько накопилось в буфере ядра, столько и пришло. Протокол на границы не влияет никак. Один запрос приезжает в четырёх кусках; два запроса приезжают в одном.

Прогон показывает первое. Клиент нарочно пишет запрос в четыре приёма с паузами, чтобы куски ушли отдельными пакетами, а разрезы кладёт посреди слов — там же, где их положила бы сеть. Сервер печатает, что он знает после каждого куска. Прогноз: на каком куске у него появится целая голова и когда он сможет сказать, что запрос кончился?

прогон node 26.5.0 · 21 сентября 2026
Один запрос, написанный в четыре приёма: что сервер знает после каждого куска
// Один запрос, который клиент нарочно пишет в четыре приёма с паузами. Куски
// выбирает не сервер: он видит ровно то, что отдала сеть, и склеивает сам.
import { connect, createServer } from "node:net";

const BODY = '{"title":"HTTP — это текст"}';

const server = createServer((socket) => {
  let buffer = Buffer.alloc(0);
  let start = null;
  let bodyAt = 0;
  let bodyLength = 0;
  let number = 0;

  socket.on("data", (chunk) => {
    buffer = Buffer.concat([buffer, chunk]);
    number += 1;
    const report = [];

    if (start === null) {
      const headEnd = buffer.indexOf("\r\n\r\n");
      if (headEnd === -1) {
        console.log(`кусок ${number}: пустой строки в буфере нет, голова ещё не целая`);
        return;
      }
      const head = buffer.subarray(0, headEnd).toString("utf8");
      const lines = head.split("\r\n");
      start = lines[0];
      bodyAt = headEnd + 4;
      bodyLength = Number(/\r\ncontent-length: *(\d+)/i.exec(head)?.[1] ?? 0);
      report.push(`голова целая — «${start}», заголовков ${lines.length - 1}`);
    }

    const got = buffer.length - bodyAt;
    report.push(`тела собрано ${got} из ${bodyLength} байт`);
    console.log(`кусок ${number}: ${report.join("; ")}`);
    if (got < bodyLength) return;

    const [method, target] = start.split(" ");
    console.log(`запрос собран из ${number} кусков: ${method} ${target}, тело ${buffer.subarray(bodyAt).toString("utf8")}`);
    socket.end("HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n");
    server.close();
  });
});

server.listen(0, "127.0.0.1", () => {
  const { port } = server.address();
  const request = Buffer.from(
    `POST /notes?draft=1 HTTP/1.1\r\nhost: 127.0.0.1:${port}\r\ncontent-type: application/json\r\n` +
      `content-length: ${Buffer.byteLength(BODY)}\r\n\r\n${BODY}`,
    "utf8",
  );
  // границы нарочно посреди токенов: сеть режет там, где ей удобно
  const cuts = [request.indexOf("HTTP/1.1") + 4, request.indexOf("\r\ncontent-type") + 10, request.indexOf("\r\n\r\n") + 4 + 12];
  const parts = [
    request.subarray(0, cuts[0]),
    request.subarray(cuts[0], cuts[1]),
    request.subarray(cuts[1], cuts[2]),
    request.subarray(cuts[2]),
  ];
  const client = connect(port, "127.0.0.1", async () => {
    for (const part of parts) {
      client.write(part);
      // пауза, чтобы части ушли отдельными пакетами: без неё их склеит сам TCP
      await new Promise((resolve) => setTimeout(resolve, 60));
    }
  });
  client.on("data", () => client.end());
});
node socket.mjs

Первые два куска не дают ничего: пустой строки в буфере нет, значит голова не целая, и разбирать нечего. На третьем голова собралась — видны стартовая строка и три заголовка, — но тела приехало 12 байт из 38. И только четвёртый кусок закрывает запрос. Заметьте, чего сервер не делает ни на одном шаге: он не разбирает кусок. Он приклеивает кусок к буферу и заново спрашивает буфер, хватает ли уже.

Паузы в прогоне стоят не для красоты: без них TCP склеил бы четыре записи в одну, и вывод перестал бы воспроизводиться. По той же причине первый прогон число кусков не печатает — на живой сети оно каждый раз другое. Для разборщика это и есть главная новость: сколько будет кусков и где лягут разрезы — не его дело и не дело протокола.

Ошибки этого класса выглядят одинаково: на localhost запрос обычно приезжает одним куском, код работает, и рассыпается он только в проде.

Начнём с того, чего делать нельзя, — и убедимся, что нельзя. Пример берёт байты из прогона строковым литералом и режет их в трёх местах: посреди версии, посреди имени заголовка и посреди тела. Сначала он пробует прочитать «первую строку» каждого куска по отдельности, потом копит буфер и ищет в нём пустую строку. Прогноз: что окажется первой строкой у второго и третьего кусков и на каком куске в буфере найдётся пустая строка?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

«Первая строка» второго куска — /1.1, третьего — Type: application/json. Ни то ни другое не строка протокола: это хвост версии и хвост имени заголовка, разрезанного пополам. Буфер же ведёт себя скучно и правильно: растёт, пока в нём не обнаружится \r\n\r\n — на 231-м символе, внутри третьего куска. Скучно — это хорошо: у разборщика, всё состояние которого сводится к буферу, заметно меньше поводов ошибиться.

Голова — всё, что до пустой строки. Разберём её на стартовую строку и заголовки. Имена заголовков сравниваются без учёта регистра, поэтому в словарь они лягут в нижнем, а как пришли — в отдельный массив. Прогноз: что напечатают две строки про content-type, отличающиеся только регистром ключа?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Получилось ровно то, что у node:http: headers["content-type"] отвечает, headers["Content-Type"] молчит. При этом регистр не потерян — массив raw держит имена как они приехали, и Content-Type стоит в нём с большими буквами. Это наш rawHeaders, и он ещё пригодится: по словарю нельзя отличить один заголовок от двух одинаковых, а по списку — можно.

Словарь заголовков — объект без прототипа, и это не педантизм. Имя заголовка приходит из сети, а у обычного {} уже заняты constructor, toString и ещё десяток имён: запрос с заголовком constructor: 1 на проверке key in headers сошёл бы за повтор и склеился бы с функцией. Object.create(null) закрывает весь этот класс сюрпризов одной строкой.

Повтор заголовка у нашего разборщика — не замена, а склейка через запятую: RFC 9110 разрешает так делать там, где значение и есть список. У node:http правило тоньше — о нём раздел про регистр и повторы.

Голова разобрана, дальше тело. Его длину обещал content-length, и остаётся отсчитать столько же от буфера. Прогноз: сколько в этом теле символов и сколько байт — и сойдётся ли хоть одно число с обещанным?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тридцать восемь против двадцати восьми. content-length меряет байты, а length у строки считает единицы UTF-16: русская буква весит в UTF-8 два байта, длинное тире — три, и двадцать восемь символов набирают тридцать восемь. Разборщик, который сравнивает длину строки с content-length, на русском теле будет ждать вечно. Лечится это одной функцией — TextEncoder считает байты честно, — а что делать, когда буква разрезана границей кусков пополам, разбирает следующая статья: там тело перестанет быть строкой и станет настоящими байтами.

Теперь разборщик целиком. Он маленький: буфер, разобранная голова и один шаг, который пробует продвинуться — сначала до конца головы, потом до конца тела, — и говорит, удалось ли. Кормить его будем теми же четырьмя кусками, а следом пятым, в котором по тому же соединению приехали ещё два запроса. Прогноз: сколько раз сработает onRequest и что останется в буфере после третьего куска?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первые два куска ничего не меняют. На третьем голова уходит из буфера в поле head, а в буфере остаются 12 символов начатого тела — потому и «осталось 12», хотя кусок был на 163. Четвёртый кусок закрывает запрос. А пятый — самое интересное: в нём два целых запроса, и оба разобраны за один вызов feed. Это и есть keep-alive: соединение после ответа не закрывается, и следующий запрос идёт по тому же сокету.

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

Буфер строкой — сознательное упрощение ради песочницы: в настоящем сервере там Buffer, и разница видна там, где байты считают и где их превращают обратно в текст.

Заголовок может прийти дважды, и это не ошибка клиента. Accept-Encoding: gzip и Accept-Encoding: br двумя строками значат то же самое, что gzip, br одной: RFC 9110 разрешает разворачивать список в отдельные строки, и обратная склейка через запятую — тоже по стандарту. Наш разборщик так и делает. Прогноз: что он напечатает для трёх заголовков, каждый из которых пришёл дважды, и что ответит на обращение с большой буквы?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Склейка работает, обращение с большой буквы даёт undefined, а raw помнит все семь строк в порядке прихода. Настоящий node:http ведёт себя тоньше, и разницу стоит знать (проверено на 26.5.0): обычные повторы он тоже склеивает через запятую, cookie — через точку с запятой, а для «одиночных» заголовков вроде content-type и host оставляет первое значение, а остальные молча выбрасывает — в req.rawHeaders при этом лежат все. Исключение одно: на двух content-length разбор обрывается ошибкой HPE_UNEXPECTED_CONTENT_LENGTH, и обработчик не вызывается. Разница понятна: от выбора зависит, где кончится тело, и угадывать тут нельзя.

«Молча выбрасывает» — не мелочь. Расхождение в том, какой из двух одинаковых заголовков считать настоящим, — известный способ развести прокси и приложение: один читает первый, другой последний, и запрос по дороге меняет смысл. Вывод для своего сервера простой: если два одинаковых имени означают разное, отвечать надо отказом, а не догадкой. Отличить один заголовок от двух можно только по raw — в словаре они уже склеены в одно значение, и это ровно тот случай, ради которого список сохраняется.

Есть и заголовки, которые вписывает не клиент. X-Forwarded-For добавляет обратный прокси, и доверять ему можно настолько, насколько вы уверены, что запрос пришёл именно через ваш прокси: клиенту ничто не мешает прислать такой заголовок самому, и тогда его выдумка встанет в начале списка, а справа прокси допишет единственный адрес, который видел своими глазами. Как с этим жить, разбирает статья про обратный прокси и заголовки.

У node:http есть предел на голову — maxHeaderSize, 16 КиБ: на голове длиннее Node отвечает 431 сам, не доходя до обработчика (проверено на 26.5.0).

Полусотни строк хватило, чтобы превратить поток байтов в объект с методом, целью, заголовками и телом. Соблазн решить, что сервер написан, велик — поэтому перечислим поимённо, чего в нём нет. Список не в укор разборщику: это оглавление остатка главы.

Чего нетКак это выглядитГде закрывается
Тела в настоящих байтахбуфер у нас строка; буква, разрезанная границей кусков, портит текст молчаследующая статья главы
Пределов и терпенияголова без конца, тело без конца, клиент, замолчавший на серединеона же: лимит тела и таймаут медленному клиенту
Строгостимусор вместо стартовой строки мы примем за метод; transfer-encoding: chunked не поймём вовсенастоящий node:http и восемь дыр
АдресацииPOST /notes и GET /notes/17 обязаны попасть в разные обработчикисвой маршрутизатор
Общего для всех запросовжурнал, разбор JSON, проверка входа — пока это копия в каждом обработчикеконвейер и свой next()

Но главное разборщик уже умеет, и умеет по-настоящему: он не верит куску, помнит состояние между кусками и отдаёт наружу целый запрос. Всё, что дальше в главе, — надстройки над этим объектом, а не другая механика. И когда в главе 7 на месте вашего сервера окажется Nest, узнать в его внутренностях ровно этот цикл будет куда проще, чем кажется сейчас.

  • HTTP/1.1 — это текст: стартовая строка (метод, цель, версия), заголовки «имя: значение» по одному на строку, пустая строка, тело. Перевод строки — всегда \r\n, и на одиноком \n Node рвёт разбор ещё до обработчика.
  • Цель запроса — не URL: ни схемы, ни хоста в ней нет. Куда шёл клиент, сказано отдельным заголовком host, и на этой строчке текста держится виртуальный хостинг.
  • Сокет отдаёт куски, а не строки протокола: один запрос приезжает в четырёх кусках, два запроса — в одном. Разбирать кусок нельзя: его приклеивают к буферу и заново спрашивают буфер.
  • Голова кончается на \r\n\r\n, тело — по content-length, а он считает байты, а не символы: на русском теле длина строки окажется меньше обещанной.
  • Имена заголовков сравниваются без учёта регистра, поэтому словарь держат в нижнем, а имена как пришли — отдельным списком (rawHeaders). Повтор имени — склейка через запятую, а не замена.
  • Разбирать надо циклом, пока разбирается: при keep-alive в одном куске бывает полтора запроса, и остаток обязан дождаться продолжения в буфере, а не пропасть.

1/4Сервер получил из сокета кусок, который обрывается на Content-. Что должен сделать разборщик?

2/4Клиент прислал заголовок Content-Type: application/json. Что вернёт req.headers["Content-Type"] у node:http?

3/4Тело запроса — {"title":"Привет"}, в нём 18 символов. Что поставит клиент в content-length?

4/4В одном куске приехали два целых запроса подряд. Что будет, если feed разбирает не больше одного запроса за вызов?

Ответ — тоже текст

Разбирать научились — теперь ответим. Напишите formatResponse: строка состояния (версия, код, причина), заголовки из переданного объекта, обязательный content-length, пустая строка и тело. Разделитель тот же \r\n, и одна ловушка уже знакома: длину тела клиент отсчитает в байтах, а не в символах. Пример печатает ответ, показывая \r\n глазами.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Строка состояния и заголовки собираются списком строк, склеиваются через \r\n, и перед телом ставится ещё одна пара — та самая пустая строка. content-length считается функцией byteLength из подложки: в теле есть кириллица и длинное тире, и длина строки тут не годится.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В ответе content-length: 48 при теле в 38 символов. Ошибись здесь на эти десять — и клиент либо обрежет ответ на середине буквы, либо будет ждать продолжения, которого нет.

Голова без конца

Клиент открыл соединение и шлёт заголовки, не собираясь останавливаться. Наш разборщик будет покорно копить буфер, пока не кончится память: пустой строки нет — значит, ждём ещё. Добавьте предел. Если головы всё ещё нет, а в буфере уже больше MAX_HEAD символов, разбор прекращается насовсем: об этом печатается строка, буфер очищается, следующие куски не разбираются. Предел — только на голову: длинное тело под него попадать не должно.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Проверка идёт после super.feed: если голова ушла в поле head, this.head уже не null, и длинное тело под предел головы не попадёт.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Без предела пример печатает «собран: GET /notes»: голова в 693 символа его нисколько не смущает. С пределом запрос до обработчика не доходит — и это то же самое, что делает node:http на своих 16 КиБ, отвечая 431 ещё до вызова обработчика.

Ровно один Host

HTTP/1.1 требует заголовок Host ровно один раз: без него сервер не знает, к какому из своих сайтов пришёл запрос, а два разных — попытка показать прокси один адрес, а приложению другой. RFC 9112 велит в обоих случаях отвечать 400. Добавьте проверку в onRequest. Подсказка: словарь заголовков тут не поможет — два Host в нём уже склеены в одно значение.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Считать надо по raw — списку имён в том виде, в каком они приехали, приведя каждое к нижнему регистру для сравнения.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Без проверки третий запрос печатает java-script.pro, evil.example — значение, которое выглядит как один хост с запятой и не совпадёт ни с одним настроенным доменом. Настоящий node:http ведёт себя не строже: на двух Host он оставляет первый и спокойно зовёт обработчик (проверено на 26.5.0), так что проверка эта — ваша, и её никто не сделает за вас.