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
тело   : Привет, мир!

Что здесь происходит по шагам:

  1. createServer принимает функцию-обработчик и возвращает объект сервера. Пока не вызван listen, сервер ничего не слушает.
  2. Обработчик получает req (входящий запрос) и res (ответ, который мы формируем).
  3. writeHead отправляет код статуса и заголовки.
  4. res.end отправляет тело и закрывает ответ.
  5. 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. Но до этого момента зависимость не нужна.

Жизненный цикл HTTP-запроса в Node
  1. 1 createServer(handler)Сервер создан, но пока ничего не слушает
  2. 2 listen(port, host)Привязка к порту. Без host слушает все интерфейсы
  3. 3 Пришёл запросПлатформа вызывает ваш обработчик и даёт req и res
  4. 4 writeHead(200, …)Статус и заголовки. Кириллица требует charset=utf-8
  5. 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);

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

  1. require вместо import. В 2016 модулей ESM в Node ещё не было, require был единственным вариантом. Сегодня стандарт — import, а префикс node: явно отличает встроенный модуль от пакета.
  2. 'Content-Type': 'text/html' без кодировки. Для «Hello, World!» на латинице это незаметно. Ровно поэтому проблема и всплывает позже — когда в ответ попадает первое русское слово.
  3. .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.