HTTP-сервер на Node.js: «Hello, World!» без фреймворков
✓ Все примеры выполнены на Node.js v22.23.1 (LTS), июль 2026
Содержание
Node.js создавался как способ обрабатывать сетевые запросы, поэтому HTTP-сервер здесь — не библиотека, а часть платформы. Ставить ничего не нужно: модуль node:http уже есть в дистрибутиве — как и fs, и child_process.
Разберём, как поднять сервер, ответить JSON-ом, отдать 404 и корректно всё закрыть — на встроенных средствах, без Express.
Минимальный HTTP-сервер
Сервер на Node — это функция, которую платформа вызывает на каждый запрос, передавая ей объект запроса и объект ответа.
import { createServer } from 'node:http';
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Привет, мир!\n');
});
server.listen(3000, '127.0.0.1', () => {
console.log('Сервер слушает http://127.0.0.1:3000');
});
Запускаем файл через node server.mjs и проверяем — прямо из Node, встроенным fetch:
const res = await fetch('http://127.0.0.1:3000/');
console.log('статус :', res.status);
console.log('тип :', res.headers.get('content-type'));
console.log('тело :', (await res.text()).trim());
статус : 200
тип : text/plain; charset=utf-8
тело : Привет, мир!
Что здесь происходит по шагам:
createServerпринимает функцию-обработчик и возвращает объект сервера. Пока не вызванlisten, сервер ничего не слушает.- Обработчик получает
req(входящий запрос) иres(ответ, который мы формируем). writeHeadотправляет код статуса и заголовки.res.endотправляет тело и закрывает ответ.listenпривязывает сервер к порту.
Почему charset=utf-8 обязателен
Это самая частая причина «кракозябр». Node отдаёт байты как есть, а браузер, не увидев кодировку в заголовке, угадывает её — и для кириллицы обычно угадывает неверно.
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); // ✅
res.writeHead(200, { 'Content-Type': 'text/plain' }); // ❌ рискуете кодировкой
Для JSON проблемы нет: application/json по спецификации всегда UTF-8.
Маршрутизация и 404 без фреймворка
Маршрутизация во встроенном http — это ваш собственный код: платформа даёт только req.url и req.method, разбирать их нужно самому. Именно эту рутину и убирает Express.
const server = createServer((req, res) => {
if (req.url === '/api/time' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ now: new Date().toISOString() }));
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Не найдено');
});
GET /api/time -> 200 {"now":"2026-07-16T12:00:00Z"}
GET /нет-такого -> 404 Не найдено
Два нюанса, на которых спотыкаются:
returnпослеres.end()обязателен. Без него выполнение пойдёт дальше и попытается отправить второй ответ — получитеERR_HTTP_HEADERS_SENT.- Проверяйте метод, а не только URL. Иначе
POST /api/timeпопадёт в обработчик, рассчитанный наGET.
Когда маршрутов становится больше десятка, разбор req.url вручную превращается в лапшу — вот тогда и берут Express или Fastify. Но до этого момента зависимость не нужна.
-
1
createServer(handler)Сервер создан, но пока ничего не слушает -
2
listen(port, host)Привязка к порту. Без host слушает все интерфейсы -
3
Пришёл запросПлатформа вызывает ваш обработчик и даёт
reqиres -
4
writeHead(200, …)Статус и заголовки. Кириллица требуетcharset=utf-8 -
5
res.end(body)Отправка тела и закрытие ответа. Забыли — браузер ждёт таймаут
Тело запроса приходит кусками
Это главное, что отличает встроенный http от Express: тела запроса как готовой строки не существует. req — это поток, и данные приходят частями.
const server = createServer(async (req, res) => {
if (req.method === 'POST') {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const raw = Buffer.concat(chunks).toString('utf8');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ получено: raw.length, кусков: chunks.length }));
return;
}
res.writeHead(405).end();
});
маленькое тело : {"получено":6,"кусков":1}
тело 200 КБ : {"получено":200000,"кусков":4}
Обратите внимание: одно и то же тело приходит разным числом кусков в зависимости от размера. Именно поэтому нельзя написать req.body — его собирают сами.
Buffer.concat здесь не случайность. Складывать куски в строку (str += chunk) нельзя: многобайтовый символ может разорваться между двумя кусками, и вы получите испорченную кириллицу. Склеивать нужно байты и декодировать один раз в конце.
Разбор JSON и защита от мусора
Собрать тело мало — его ещё нужно разобрать и не упасть. Два обязательных условия: лимит размера и обработка битого JSON.
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > 1024) { // лимит: иначе любой клиент съест память
res.writeHead(413).end('Слишком большое тело');
req.destroy();
return;
}
chunks.push(chunk);
}
try {
const data = JSON.parse(Buffer.concat(chunks).toString('utf8'));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, поля: Object.keys(data) }));
} catch {
res.writeHead(400).end('Невалидный JSON');
}
валидный JSON : 200 {"ok":true,"поля":["имя","возраст"]}
битый JSON : 400 Невалидный JSON
тело 5 КБ : 413 Слишком большое тело
Без лимита сервер беззащитен: клиент открывает POST и льёт данные, пока у процесса не кончится память. Express этим тоже не занимается сам — там лимит задаётся в express.json({ limit: '100kb' }), и его точно так же забывают поставить.
JSON.parse без try/catch — вторая классическая дыра: любой мусор в теле роняет обработчик, а вместе с ним и весь процесс.
Порт 0: как не ловить «порт занят»
Если передать listen(0), операционная система выдаст любой свободный порт. Это стандартный приём в тестах — параллельные прогоны не конфликтуют:
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const { port } = server.address();
console.log('слушает порт', port);
слушает порт 56701
Адрес 127.0.0.1 тоже важен: без него сервер слушает на всех интерфейсах и оказывается доступен из сети. Для локальной разработки это лишнее.
Корректное завершение
server.close() перестаёт принимать новые соединения и ждёт завершения текущих. Колбэк вызовется, когда всё закрылось:
await new Promise((r) => server.close(r));
console.log('сервер закрыт');
сервер закрыт
Просто убить процесс тоже можно, но тогда клиенты, которым сейчас отдаётся ответ, получат оборванное соединение. В продакшене на это вешают обработку SIGTERM.
Что было на этой странице в 2016 году
HackerX ведётся с 2015 года, и предыдущая версия этого урока вышла 14 января 2016-го. Её пример выглядел так:
const http = require('http');
http.createServer(function (request, response) {
response.writeHead(200, {'Content-Type': 'text/html'});
response.end('Hello, World!');
}).listen(8080);
Код рабочий — он и сегодня запустится. Но три детали в нём показывают, что изменилось:
requireвместоimport. В 2016 модулей ESM в Node ещё не было,requireбыл единственным вариантом. Сегодня стандарт —import, а префиксnode:явно отличает встроенный модуль от пакета.'Content-Type': 'text/html'без кодировки. Для «Hello, World!» на латинице это незаметно. Ровно поэтому проблема и всплывает позже — когда в ответ попадает первое русское слово..listen(8080)без адреса. Тогда на это не смотрели; сегодня привычка указывать127.0.0.1для локальной разработки экономит нервы — сервер не оказывается доступен всей сети.
Ещё одна деталь той версии: проверять сервер предлагалось браузером, потому что своего HTTP-клиента в Node не было. Сегодня fetch встроен, и проверить ответ можно прямо из Node — что мы и делаем выше.
Частые ошибки
| Симптом | Причина |
|---|---|
| Браузер грузится до таймаута | забыли res.end() |
ERR_HTTP_HEADERS_SENT |
ответ отправлен дважды — нет return после res.end() |
| Кракозябры вместо кириллицы | нет charset=utf-8 в Content-Type |
EADDRINUSE |
порт занят — используйте listen(0) или смените порт |
| Сервер «висит» и не отвечает другим | в обработчике синхронный тяжёлый код, блокирующий событийный цикл |
Последний пункт — концептуальный. Node обрабатывает запросы в одном потоке, поэтому синхронная нагрузка в обработчике останавливает весь сервер, а не только текущий запрос. Насколько именно — мы замерили на readFileSync: 269 мс полной остановки и ноль тиков таймера. Тяжёлые вычисления выносят в worker_threads, внешние программы — в дочерний процесс.
Смежные темы: потоки — как отдавать файлы и читать тело запроса; объект process — как корректно погасить сервер по сигналу; события — на чём построен сам http. Полный список — в справочнике по Node.js.
Частые вопросы
Нужен ли Express для простого API?
http достаточно, и это на одну зависимость меньше. Express оправдан, когда нужны middleware, разбор тела запроса и роутинг с параметрами.Чем `res.writeHead()` отличается от `res.setHeader()`?
setHeader задаёт один заголовок и допускает изменение до отправки. writeHead отправляет статус и заголовки разом — после него менять их уже нельзя.Как отдавать статические файлы?
createReadStream(path).pipe(res). Читать файл целиком в память ради отдачи не нужно — подробнее в статье про потоки и pipe.Это HTTP/1.1. А HTTP/2 и HTTPS?
node:https и node:http2 с похожим API. Но чаще HTTPS терминируют на nginx или облачном балансировщике, а Node внутри периметра оставляют на HTTP.