Каждый фреймворк в экосистеме Node.js — от Express и Fastify до NestJS и сервера Next.js — под капотом использует низкоуровневый встроенный модуль node:http. Работа модуля опирается на быстрый парсер протокола HTTP llhttp, скомпилированный из C/C++ в нативный код, и абстракции потоков ввода-вывода (Streams).
Когда клиент устанавливает TCP-соединение с портом сервера, ядро ОС передает сокет в Node.js. Движок парсит заголовки HTTP/1.1 и генерирует два связанных объекта:
http.IncomingMessage (req): доступный только для чтения поток (Readable Stream), содержащий метод запроса, URL, заголовки и чанки бинарного тела.http.ServerResponse (res): поток для записи (Writable Stream), через который сервер формирует статусный код, заголовки ответа и отправляет тело клиенту.Понимание нативной работы node:http необходимо для создания высокопроизводительных микросервисов, безопасного парсинга полезной нагрузки (Payload) и предотвращения распространенных серверных сбоев, таких как ошибки ERR_HTTP_HEADERS_SENT или утечки открытых сокетов.
В отличие от традиционных серверов (PHP-FPM, Apache), где весь запрос буферизуется до вызова пользовательского скрипта, в Node.js тело запроса прибывает по частям (Stream Chunk). Обработчик http.createServer вызывается сразу после завершения парсинга заголовков, когда тело запроса (например, большой JSON или файл) может еще передаваться по сети.
[ Клиент (Браузер / cURL) ]
| HTTP/1.1 POST /api/users
v
[ TCP Socket (node:net) ]
|
v
[ llhttp C++ Parser ] ---> Разбор статусной строки и Headers
|
+---> Создание IncomingMessage (req) & ServerResponse (res)
|
v
[ http.createServer((req, res) => { ... }) ]
|
+-------+-------+
| |
[ req (Readable) ] [ res (Writable) ]
Чанки тела запроса res.writeHead(200, ...)
for await (chunk) res.end(JSON.stringify(payload))ОС принимает входящее TCP-соединение (3-way handshake) на прослушиваемом порту и передает дескриптор сокета в event loop libuv.
Нативный парсер C++ считывает метод (`POST`), путь (`/api/data`) и HTTP-заголовки. Генерируются объекты `req` и `res`.
Срабатывает колбэк `createServer(async (req, res) => ...)`. На этом этапе заголовки уже доступны, а тело запроса начинает поступать чанками.
Чтение стрима `req` через `for await (const chunk of req)` с контролем лимита байтов (защита от DoS/OOM).
Установка статус-кода и заголовка `Content-Type`, передача тела ответа через `res.end()` и освобождение ресурсов сокета.
Почему в нативном модуле node:http тело запроса (req.body) не доступно автоматически в виде готовой строки или объекта при старте обработчика createServer((req, res) => ...), как это происходит во фреймворках?
/healthz с частотой в несколько секунд. Использование чистого node:http запускает сервер за 10 мс с потреблением оперативной памяти менее 15 МБ.req в исходящий res методом pipeline(req, upstreamReq, res) без промежуточной сериализации и расхода кучи.Для корректной маршрутизации входящих запросов используется стандартный нативный класс URL (WHATWG URL Standard), который безопасно парсит путь и query-параметры.
import http from 'node:http';
import { Buffer } from 'node:buffer';
const PORT = 3000;
const MAX_BODY_SIZE = 1024 * 1024; // 1 МБ лимит на полезную нагрузку (защита от DoS)
// Функция безопасного чтения тела запроса через асинхронный итератор стрима
async function readJsonBody<T>(req: http.IncomingMessage): Promise<T> {
const chunks: Buffer[] = [];
let totalBytes = 0;
for await (const chunk of req) {
const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += bufferChunk.byteLength;
if (totalBytes > MAX_BODY_SIZE) {
throw new Error('PAYLOAD_TOO_LARGE');
}
chunks.push(bufferChunk);
}
if (chunks.length === 0) {
return {} as T;
}
const rawString = Buffer.concat(chunks).toString('utf-8');
try {
return JSON.parse(rawString) as T;
} catch {
throw new Error('INVALID_JSON');
}
}
// Создание HTTP-сервера
const server = http.createServer(async (req, res) => {
// Базовый URL для корректного разрешения относительного пути
const protocol = 'http';
const host = req.headers.host || 'localhost';
const parsedUrl = new URL(req.url || '/', `${protocol}://${host}`);
const { pathname } = parsedUrl;
const method = req.method?.toUpperCase();
// 1. Маршрут: GET /healthz
if (method === 'GET' && pathname === '/healthz') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store'
});
res.end(JSON.stringify({ status: 'ok', uptime: process.uptime() }));
return;
}
// 2. Маршрут: POST /api/users
if (method === 'POST' && pathname === '/api/users') {
try {
const body = await readJsonBody<{ username: string; email: string }>(req);
if (!body.username || !body.email) {
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: 'Поля username и email обязательны для заполнения' }));
return;
}
// Успешный ответ со статусом 201 Created
res.writeHead(201, {
'Content-Type': 'application/json; charset=utf-8',
'X-Service-Version': '1.0.0'
});
res.end(JSON.stringify({ id: crypto.randomUUID(), ...body, createdAt: Date.now() }));
return;
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : 'UNKNOWN_ERROR';
if (errMsg === 'PAYLOAD_TOO_LARGE') {
res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: 'Размер тела запроса превышает допустимый лимит 1 МБ' }));
return;
}
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: 'Некорректный синтаксис JSON в теле запроса' }));
return;
}
}
// 3. Fallback: 404 Not Found для всех неизвестных путей
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: `Маршрут ${method} ${pathname} не найден` }));
});
// Запуск прослушивания сокета
server.listen(PORT, '0.0.0.0', () => {
console.log(`HTTP Server successfully listening on http://0.0.0.0:${PORT}`);
});В объекте ServerResponse присутствуют два метода установки заголовков, имеющие разное поведение:
Вызов res.setHeader() накапливает заголовки в объекте метаданных до начала отправки тела.
Заголовки нормализуются к нижнему регистру согласно спецификации HTTP.
Генерируется строка HTTP/1.1 200 OK + CRLF-блоки заголовков. Устанавливается флаг res.headersSent = true.
Любая последующая попытка модификации заголовков вызовет ERR_HTTP_HEADERS_SENT.
Запись бинарных буферов через сокет операционной системы с контролем переполнения буфера сокета.
Если Content-Length не указан, включается Transfer-Encoding: chunked.
Финальный чанк отправлен. Если включен заголовок Connection: keep-alive, сокет возвращается в пул для следующих запросов.
Ресурсы потока освобождаются в памяти процесса.
Внутри асинхронного обработчика роута написан код: `res.writeHead(400, ...); res.end('Error'); res.writeHead(200, ...); res.end('Success');`. К какому результату приведет выполнение этого фрагмента при обработке запроса?
Классическая ошибка разработчиков:
if (userNotFound) {
res.writeHead(404);
res.end('Not Found');
// ОШИБКА: Забыт оператор return!
}
// Код продолжает выполняться дальше:
res.writeHead(200); // КРАХ: ERR_HTTP_HEADERS_SENT
res.end('OK');Всегда завершайте ветку выполнения оператором return после вызова res.end().
Если злоумышленник отправит запрос с заголовком Content-Type: application/json, но с поврежденным телом (например, { broken json), прямой вызов JSON.parse(rawString) выбросит SyntaxError. Если этот вызов не обернут в try...catch, процесс Node.js аварийно завершится с крахом всего сервера.
Чтение стрима req в бесконечный массив буферов без проверки суммарного объема байт позволяет атакующему отправить HTTP POST-запрос с телом в 10 ГБ. Память кучи Node.js будет мгновенно переполнена, что приведет к OOM-киллеру операционной системы. Всегда контролируйте суммарный размер поступающих чанков (как показано в примере с константой MAX_BODY_SIZE).
Если в одной из веток валидации разработчик забыл вызвать res.end(), TCP-сокет останется открытым. Браузер клиента будет бесконечно ожидать ответа вплоть до истечения таймаута соединения (Gateway Timeout), а на сервере возникнет утечка дескрипторов сокетов.