HTTP — это текст: разбираем сырой запрос, приходящий кусками
Здесь будет встроен видео-разбор темы (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 с тем, что приехало по проводу?
// Один и тот же 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)}`);
— что пришло в сокет (\r\n показан, дальше настоящий перевод строки) —
POST /notes?draft=1 HTTP/1.1\r\n
host: 127.0.0.1:<порт>\r\n
connection: keep-alive\r\n
Content-Type: application/json\r\n
accept: */*\r\n
accept-language: *\r\n
sec-fetch-mode: cors\r\n
user-agent: node\r\n
accept-encoding: gzip, deflate\r\n
content-length: 38\r\n
\r\n
{"title":"HTTP — это текст"}
— те же байты глазами http.createServer —
req.method: POST
req.url: /notes?draft=1
req.httpVersion: 1.1
req.headers["content-type"]: application/json
req.headers["Content-Type"]: undefined
имена в req.headers: host, connection, content-type, accept, accept-language, sec-fetch-mode, user-agent, accept-encoding, content-length
имена в req.rawHeaders: host, connection, Content-Type, accept, accept-language, sec-fetch-mode, user-agent, accept-encoding, content-lengthПервая строка — 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.
Ответ устроен зеркально: вместо стартовой строки первой идёт строка состояния — версия, код, причина, — дальше те же заголовки, пустая строка и тело.
Текст мы прочитали целиком — но целиком его никто не приносит. Сокет отдаёт куски, и это те же куски, что у файлового потока: сколько накопилось в буфере ядра, столько и пришло. Протокол на границы не влияет никак. Один запрос приезжает в четырёх кусках; два запроса приезжают в одном.
Прогон показывает первое. Клиент нарочно пишет запрос в четыре приёма с паузами, чтобы куски ушли отдельными пакетами, а разрезы кладёт посреди слов — там же, где их положила бы сеть. Сервер печатает, что он знает после каждого куска. Прогноз: на каком куске у него появится целая голова и когда он сможет сказать, что запрос кончился?
// Один запрос, который клиент нарочно пишет в четыре приёма с паузами. Куски // выбирает не сервер: он видит ровно то, что отдала сеть, и склеивает сам. 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()); });
кусок 1: пустой строки в буфере нет, голова ещё не целая
кусок 2: пустой строки в буфере нет, голова ещё не целая
кусок 3: голова целая — «POST /notes?draft=1 HTTP/1.1», заголовков 3; тела собрано 12 из 38 байт
кусок 4: тела собрано 38 из 38 байт
запрос собран из 4 кусков: POST /notes?draft=1, тело {"title":"HTTP — это текст"}Первые два куска не дают ничего: пустой строки в буфере нет, значит голова не целая, и разбирать нечего. На третьем голова собралась — видны стартовая строка и три заголовка, — но тела приехало 12 байт из 38. И только четвёртый кусок закрывает запрос. Заметьте, чего сервер не делает ни на одном шаге: он не разбирает кусок. Он приклеивает кусок к буферу и заново спрашивает буфер, хватает ли уже.
Паузы в прогоне стоят не для красоты: без них TCP склеил бы четыре записи в одну, и вывод перестал бы воспроизводиться. По той же причине первый прогон число кусков не печатает — на живой сети оно каждый раз другое. Для разборщика это и есть главная новость: сколько будет кусков и где лягут разрезы — не его дело и не дело протокола.
Ошибки этого класса выглядят одинаково: на localhost запрос обычно приезжает одним куском, код работает, и рассыпается он только в проде.
Начнём с того, чего делать нельзя, — и убедимся, что нельзя. Пример берёт байты из прогона строковым литералом и режет их в трёх местах: посреди версии, посреди имени заголовка и посреди тела. Сначала он пробует прочитать «первую строку» каждого куска по отдельности, потом копит буфер и ищет в нём пустую строку. Прогноз: что окажется первой строкой у второго и третьего кусков и на каком куске в буфере найдётся пустая строка?
«Первая строка» второго куска — /1.1, третьего — Type: application/json. Ни то ни другое не строка протокола: это хвост версии и хвост имени заголовка, разрезанного пополам. Буфер же ведёт себя скучно и правильно: растёт, пока в нём не обнаружится \r\n\r\n — на 231-м символе, внутри третьего куска. Скучно — это хорошо: у разборщика, всё состояние которого сводится к буферу, заметно меньше поводов ошибиться.
Голова — всё, что до пустой строки. Разберём её на стартовую строку и заголовки. Имена заголовков сравниваются без учёта регистра, поэтому в словарь они лягут в нижнем, а как пришли — в отдельный массив. Прогноз: что напечатают две строки про content-type, отличающиеся только регистром ключа?
Получилось ровно то, что у 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, и остаётся отсчитать столько же от буфера. Прогноз: сколько в этом теле символов и сколько байт — и сойдётся ли хоть одно число с обещанным?
Тридцать восемь против двадцати восьми. content-length меряет байты, а length у строки считает единицы UTF-16: русская буква весит в UTF-8 два байта, длинное тире — три, и двадцать восемь символов набирают тридцать восемь. Разборщик, который сравнивает длину строки с content-length, на русском теле будет ждать вечно. Лечится это одной функцией — TextEncoder считает байты честно, — а что делать, когда буква разрезана границей кусков пополам, разбирает следующая статья: там тело перестанет быть строкой и станет настоящими байтами.
Теперь разборщик целиком. Он маленький: буфер, разобранная голова и один шаг, который пробует продвинуться — сначала до конца головы, потом до конца тела, — и говорит, удалось ли. Кормить его будем теми же четырьмя кусками, а следом пятым, в котором по тому же соединению приехали ещё два запроса. Прогноз: сколько раз сработает onRequest и что останется в буфере после третьего куска?
Первые два куска ничего не меняют. На третьем голова уходит из буфера в поле head, а в буфере остаются 12 символов начатого тела — потому и «осталось 12», хотя кусок был на 163. Четвёртый кусок закрывает запрос. А пятый — самое интересное: в нём два целых запроса, и оба разобраны за один вызов feed. Это и есть keep-alive: соединение после ответа не закрывается, и следующий запрос идёт по тому же сокету.
Отсюда правило, которое стоит запомнить раньше, чем оно понадобится: разбирать надо циклом, пока разбирается, а не «по запросу на кусок». Если бы feed останавливался после первого запроса, второй лёг бы в буфер и дождался бы там следующего куска — которого могло и не быть: клиент отправил всё, что хотел, и ждёт ответа. Такой сервер выглядит исправным и зависает через раз.
Буфер строкой — сознательное упрощение ради песочницы: в настоящем сервере там Buffer, и разница видна там, где байты считают и где их превращают обратно в текст.
Заголовок может прийти дважды, и это не ошибка клиента. Accept-Encoding: gzip и Accept-Encoding: br двумя строками значат то же самое, что gzip, br одной: RFC 9110 разрешает разворачивать список в отдельные строки, и обратная склейка через запятую — тоже по стандарту. Наш разборщик так и делает. Прогноз: что он напечатает для трёх заголовков, каждый из которых пришёл дважды, и что ответит на обращение с большой буквы?
Склейка работает, обращение с большой буквы даёт 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, и на одиноком\nNode рвёт разбор ещё до обработчика. - Цель запроса — не 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 глазами.
Строка состояния и заголовки собираются списком строк, склеиваются через \r\n, и перед телом ставится ещё одна пара — та самая пустая строка. content-length считается функцией byteLength из подложки: в теле есть кириллица и длинное тире, и длина строки тут не годится.
В ответе content-length: 48 при теле в 38 символов. Ошибись здесь на эти десять — и клиент либо обрежет ответ на середине буквы, либо будет ждать продолжения, которого нет.
Голова без конца
Клиент открыл соединение и шлёт заголовки, не собираясь останавливаться. Наш разборщик будет покорно копить буфер, пока не кончится память: пустой строки нет — значит, ждём ещё. Добавьте предел. Если головы всё ещё нет, а в буфере уже больше MAX_HEAD символов, разбор прекращается насовсем: об этом печатается строка, буфер очищается, следующие куски не разбираются. Предел — только на голову: длинное тело под него попадать не должно.
Проверка идёт после super.feed: если голова ушла в поле head, this.head уже не null, и длинное тело под предел головы не попадёт.
Без предела пример печатает «собран: GET /notes»: голова в 693 символа его нисколько не смущает. С пределом запрос до обработчика не доходит — и это то же самое, что делает node:http на своих 16 КиБ, отвечая 431 ещё до вызова обработчика.
Ровно один Host
HTTP/1.1 требует заголовок Host ровно один раз: без него сервер не знает, к какому из своих сайтов пришёл запрос, а два разных — попытка показать прокси один адрес, а приложению другой. RFC 9112 велит в обоих случаях отвечать 400. Добавьте проверку в onRequest. Подсказка: словарь заголовков тут не поможет — два Host в нём уже склеены в одно значение.
Считать надо по raw — списку имён в том виде, в каком они приехали, приведя каждое к нижнему регистру для сравнения.
Без проверки третий запрос печатает java-script.pro, evil.example — значение, которое выглядит как один хост с запятой и не совпадёт ни с одним настроенным доменом. Настоящий node:http ведёт себя не строже: на двух Host он оставляет первый и спокойно зовёт обработчик (проверено на 26.5.0), так что проверка эта — ваша, и её никто не сделает за вас.