Как делать11 октября 2026 г.8 мин чтения
Backend на Node.jsШаг 2 из 8

Node.js с нуля: модули, npm и первый сервер

Коротко

Node.js — среда выполнения JavaScript вне браузера: тот же язык, но вместо DOM и window у вас файловая система, сеть и процессы операционной системы. Проект на Node.js начинается с npm init и файла package.json, где включают ES-модули полем "type": "module". Первый сервер пишется на встроенном модуле node:http без зависимостей, настройки читаются из переменных окружения через флаг --env-file, а --watch перезапускает процесс при изменении кода.

Пошаговый урокУровень: НачальныйNode.jsПрактика: ~45–90 мин

После материала вы сможете

  • создать npm-проект с ES-модулями, скриптами и lock-файлом и объяснить разницу между npm install и npm ci
  • разбить код на модули с import/export и использовать node:fs/promises, node:path и node:events
  • написать HTTP-сервер на node:http с маршрутами GET /health и GET /services, кодами 404 и 405 и журналом запросов
  • читать настройки из переменных окружения через --env-file и запускать сервер в режиме --watch

Проверено на

Node.js 24 LTS (24.18)npm 11.16curl 8.21

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Что такое Node.js и чем он отличается от браузера
  2. 02npm-проект: package.json, зависимости и lock-файл
  3. 03ES-модули: import, export и что изменилось после require
  4. 04Встроенные модули: fs, path и events
  5. 05Первый HTTP-сервер и маршрутизация вручную
  6. 06Переменные окружения, --env-file и режим --watch
  7. 07Упражнения и почему дальше нужен фреймворк

Что такое Node.js и чем он отличается от браузера

В уроке «Что такое backend» мы разобрали, что делает сервер. Теперь — инструмент, на котором этот сервер можно написать, не уходя из JavaScript. Node.js — открытая кроссплатформенная среда выполнения JavaScript. Внутри неё работает V8 — тот же движок, что исполняет JavaScript в Chrome, — но снаружи нет браузера: нет document, window и DOM. Вместо них — стандартная библиотека для работы с файлами, сетью, процессами и потоками данных.

Документация Node.js подчёркивает главную особенность: приложение работает в одном процессе и не создаёт поток на каждый запрос. Операции ввода-вывода — чтение файла, запрос к базе, обращение к сети — неблокирующие: пока ответ не пришёл, Node.js обслуживает другие запросы, а затем возвращается к вашему коду через промис или колбэк. Это та же модель цикла событий, которую вы видели в уроке об асинхронном JavaScript, только применённая к серверу.

Что доступно

Браузер
DOM, window, fetch, localStorage, cookies
Node.js
fs, http, path, process, fetch, потоки и процессы ОС

Версия JavaScript

Браузер
Та, что у браузера посетителя
Node.js
Та, что вы установили на сервер

Модули

Браузер
Только import (ES-модули)
Node.js
ES-модули и CommonJS (require)

Доступ к ОС

Браузер
Изолирован песочницей
Node.js
Полный: файлы, сеть, переменные окружения

Важное следствие из последней строки таблицы: код на сервере видит ваши ключи, базу и файловую систему, поэтому всё, что вы читаете из запроса, нужно проверять. Для уроков используйте актуальную LTS-ветку — версию с долгосрочной поддержкой. На дату проверки это Node.js 24 (кодовое имя Krypton); код урока проверен на 24.18. Проверьте свои версии:

terminal
$ node -v
v24.18.0
$ npm -v
11.16.0
Node.js ставится вместе с npm — менеджером пакетов

npm-проект: package.json, зависимости и lock-файл

Любой проект на Node.js — это папка с файлом package.json: в нём имя, версия, скрипты запуска и список зависимостей. Создаёт его команда npm init, а ключ -y принимает значения по умолчанию без вопросов. Дальше удобно править файл не руками, а через npm pkg set — так не сломаете JSON. Создадим проект API со списком услуг:

bash
mkdir services-api
cd services-api
npm init -y
npm pkg set type=module
npm pkg set scripts.dev="node --watch --env-file=.env server.js" scripts.start="node server.js"
npm pkg set private=true --json
npm pkg delete scripts.test main
Создание проекта: ES-модули, скрипты dev/start и защита от случайной публикации
package.json
{
  "name": "services-api",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "dev": "node --watch --env-file=.env server.js",
    "start": "node server.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module",
  "private": true,
  "dependencies": {
    "zod": "^4.6.6"
  }
}
Итоговый package.json — реальный результат команд выше (npm 11.16)
  • "type": "module" — файлы .js в проекте считаются ES-модулями. npm 11 по умолчанию записывает "type": "commonjs" (настройка init-type), поэтому значение меняем явно. Документация Node.js советует всегда указывать поле type, а не полагаться на автоопределение
  • "private": true — npm откажется публиковать проект в реестр: для приложения, в отличие от библиотеки, это защита от ошибки
  • scripts — команды проекта. npm run dev запустит сервер для разработки, npm start — без флагов разработки. Начиная с Node.js 22 скрипты можно запускать и встроенной командой node --run dev

Пакеты из реестра npm ставит npm install <имя>. Установим Zod — библиотеку валидации, которая понадобится в следующем уроке про Express:

terminal
$ npm install zod

added 1 package, and audited 2 packages in 2s

1 package is looking for funding
  run `npm fund` for details

found 0 vulnerabilities

$ npm pkg get dependencies
{
  "zod": "^4.6.6"
}

$ npm ls
services-api@1.0.0 …/services-api
`-- zod@4.6.6
Реальный вывод; путь проекта сокращён

dependencies в package.json

Зачем
Список пакетов, нужных приложению в работе; devDependencies — только для разработки и тестов (npm install -D)
В Git?
Да

^4.6.6

Зачем
Диапазон версий: подойдёт любая совместимая 4.x не ниже 4.6.6, но не 5.0
В Git?
—

package-lock.json

Зачем
Точное дерево установленных версий с контрольными суммами. Документация npm: файл предназначен для коммита, чтобы команда, CI и сервер ставили одно и то же
В Git?
Да

node_modules/

Зачем
Сами пакеты. Восстанавливаются из lock-файла
В Git?
Нет, в .gitignore

ES-модули: import, export и что изменилось после require

Модуль — файл со своей областью видимости: наружу видно только то, что вы явно экспортировали. Node.js поддерживает две системы модулей. CommonJS с require() — историческая, её вы встретите в старых проектах и примерах. ES-модули с import и export — стандарт языка, тот же, что в браузере. Новые проекты пишут на ES-модулях. Вынесем настройки и загрузку данных в отдельные модули:

src/config.js
const port = Number(process.env.PORT ?? 8080);

if (!Number.isInteger(port) || port < 1 || port > 65535) {
  throw new Error(`PORT must be an integer from 1 to 65535, got "${process.env.PORT}"`);
}

export const config = {
  port,
  host: process.env.HOST ?? "127.0.0.1",
  appName: process.env.APP_NAME ?? "services-api",
};
Модуль настроек: читает переменные окружения один раз и экспортирует готовый объект
src/services.js
import { readFile } from "node:fs/promises";
import { join } from "node:path";

const file = join(import.meta.dirname, "..", "data", "services.json");

export async function loadServices() {
  const text = await readFile(file, "utf8");
  return JSON.parse(text);
}
Модуль данных: путь к файлу строится от папки модуля, а не от текущей папки терминала
data/services.json
[
  { "id": "web", "title": "Сайт для бизнеса", "category": "web" },
  { "id": "crm", "title": "CRM-система", "category": "software" },
  { "id": "bot", "title": "Telegram-бот", "category": "integrations" }
]
  • Встроенные модули импортируют с префиксом node: — node:fs/promises, node:http. Так сразу видно, что это не пакет из npm с похожим именем
  • В относительном импорте расширение файла обязательно: ./src/config.js, а не ./src/config — так требует документация ESM, как и в браузере
  • require, __dirname и __filename в ES-модулях не определены. Вместо двух последних — import.meta.dirname и import.meta.filename, стабильные с Node.js 24
  • await можно писать на верхнем уровне модуля, без обёртки в async-функцию — им мы загрузим данные до старта сервера
terminal
$ node a.mjs        # import { config } from "./config"
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '…/config' imported from …/a.mjs
Did you mean to import "./config.js"?

$ node b.mjs        # const fs = require("node:fs")
ReferenceError: require is not defined in ES module scope, you can use import instead

$ node c.mjs        # console.log(__dirname)
ReferenceError: __dirname is not defined in ES module scope
Три типичные ошибки при переходе с CommonJS — реальные сообщения Node.js 24.18, пути сокращены

Встроенные модули: fs, path и events

Стандартная библиотека Node.js покрывает большую часть задач простого сервера — без единой зависимости. В src/services.js уже работают два модуля: readFile из node:fs/promises читает файл и возвращает промис, а join из node:path склеивает путь с правильными разделителями для Windows и Unix. Используйте промисную версию fs: синхронные функции вроде readFileSync останавливают весь процесс, пока диск не ответит, и на сервере допустимы разве что при старте.

node:fs/promises

Для чего
Файлы и папки
Пример
await readFile(path, "utf8"), writeFile, mkdir

node:path

Для чего
Пути без ручной склейки строк
Пример
join(import.meta.dirname, "data")

node:http

Для чего
HTTP-сервер и клиент
Пример
createServer((req, res) => …)

node:events

Для чего
Подписка на события
Пример
emitter.on("name", handler)

node:process / process

Для чего
Окружение, аргументы, завершение
Пример
process.env.PORT, process.exit(1)

Модуль node:events стоит понять сразу, потому что на нём построена половина Node.js: HTTP-сервер, запрос, ответ, потоки — всё это объекты EventEmitter. Один объект генерирует событие методом emit, а подписчики, зарегистрированные через on, вызываются по очереди, синхронно. Так удобно развязывать код: модуль заказов не знает, кто будет слать письма и считать статистику.

events-demo.js
import { EventEmitter } from "node:events";

const orders = new EventEmitter();

orders.on("created", (order) => console.log(`email: order #${order.id} received`));
orders.on("created", (order) => console.log(`stats: +${order.total} KZT`));
orders.once("created", () => console.log("first order today!"));

orders.emit("created", { id: 1, total: 12000 });
orders.emit("created", { id: 2, total: 8500 });
console.log("listeners:", orders.listenerCount("created"));

orders.emit("error", new Error("payment service timeout"));
console.log("this line is never printed");
EventEmitter: два постоянных подписчика, один одноразовый (once) и событие error без обработчика
terminal
$ node events-demo.js
email: order #1 received
stats: +12000 KZT
first order today!
email: order #2 received
stats: +8500 KZT
listeners: 2
…/events-demo.js:13
orders.emit("error", new Error("payment service timeout"));
                     ^

Error: payment service timeout
    at …/events-demo.js:13:22
Emitted 'error' event at:
    at …/events-demo.js:13:8

Node.js v24.18.0
Реальный вывод: once сработал один раз, а событие error без подписчика завершило процесс

Первый HTTP-сервер и маршрутизация вручную

Устройство запросов и ответов, заголовки и коды мы подробно разобрали в уроке «HTTP и JSON», где на node:http уже был сервер заказов. Здесь сервер собирается из модулей проекта и получает две вещи, без которых не обходится ни один backend: журнал запросов и обработку ошибки запуска. Маршрутов два: GET /health — проверка, что процесс жив (её опрашивают хостинг и балансировщик), и GET /services — список услуг из файла.

server.js
import { createServer } from "node:http";
import { config } from "./src/config.js";
import { loadServices } from "./src/services.js";

const services = await loadServices();

function sendJson(res, status, data, headers = {}) {
  const body = JSON.stringify(data);
  res.writeHead(status, {
    "Content-Type": "application/json; charset=utf-8",
    "Content-Length": Buffer.byteLength(body),
    ...headers,
  });
  res.end(body);
}

const routes = {
  "/health": () => ({ status: "ok", uptime: Math.round(process.uptime()) }),
  "/services": () => services,
};

const server = createServer((req, res) => {
  const started = performance.now();
  res.on("finish", () => {
    const ms = (performance.now() - started).toFixed(1);
    console.log(`${req.method} ${req.url} ${res.statusCode} ${ms}ms`);
  });

  const { pathname } = new URL(req.url, "http://localhost");
  const route = routes[pathname];
  if (!route) return sendJson(res, 404, { error: "Not found" });
  if (req.method !== "GET") {
    return sendJson(res, 405, { error: "Method not allowed" }, { Allow: "GET" });
  }
  sendJson(res, 200, route());
});

server.on("error", (error) => {
  console.error(`Cannot start server: ${error.code} ${error.message}`);
  process.exit(1);
});

server.listen(config.port, config.host, () => {
  console.log(`${config.appName}: http://${config.host}:${config.port}`);
});
Сервер без зависимостей: таблица маршрутов, 404 и 405, журнал через событие finish, обработка ошибки запуска
  1. Данные загружаются до старта

    await loadServices() на верхнем уровне: если файл битый, сервер не запустится вовсе — лучше, чем ответы с ошибкой.

  2. Маршруты — объект

    Ключ — путь, значение — функция, которая возвращает данные. Нет ключа — 404. Путь есть, а метод не GET — 405 с заголовком Allow, как требует RFC 9110.

  3. Журнал через событие

    res.on("finish") срабатывает, когда ответ передан операционной системе. В этот момент известны код ответа и длительность — одна строка лога на запрос.

  4. Ошибка запуска

    server.on("error") перехватывает, например, EADDRINUSE — порт занят — и завершает процесс с понятным сообщением и кодом 1.

Запустите npm run dev и во втором терминале отправьте запросы:

terminal
$ curl -si http://127.0.0.1:8080/health
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 26
Date: Sun, 11 Oct 2026 23:13:57 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"status":"ok","uptime":1}

$ curl -s http://127.0.0.1:8080/services
[{"id":"web","title":"Сайт для бизнеса","category":"web"},{"id":"crm","title":"CRM-система","category":"software"},{"id":"bot","title":"Telegram-бот","category":"integrations"}]

$ curl -si -X POST http://127.0.0.1:8080/services
HTTP/1.1 405 Method Not Allowed
Content-Type: application/json; charset=utf-8
Content-Length: 30
Allow: GET
Date: Sun, 11 Oct 2026 23:13:57 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"Method not allowed"}

$ curl -s http://127.0.0.1:8080/price
{"error":"Not found"}
Реальные ответы: 200, 405 с заголовком Allow и 404

Переменные окружения, --env-file и режим --watch

Порт, адрес базы, ключи сервисов отличаются между ноутбуком, тестовым и боевым сервером, поэтому их не пишут в код, а читают из переменных окружения — объекта process.env. Для разработки их удобно держать в файле .env, а Node.js умеет загрузить его сам, без пакета dotenv: флаг --env-file стабилен с версии 24.10.

.env
PORT=8080
HOST=127.0.0.1
APP_NAME=InterLAB services API
Локальные настройки — в Git не попадают
.gitignore
node_modules/
.env
В репозиторий кладут копию без секретов — .env.example, — чтобы коллега знал, какие переменные нужны
terminal
$ node server.js
services-api: http://127.0.0.1:8080

$ PORT=8081 node --env-file=.env server.js
InterLAB services API: http://127.0.0.1:8081

$ PORT=abc node server.js
Error: PORT must be an integer from 1 to 65535, got "abc"
    at …/src/config.js:4:9

$ node --env-file=.env.local server.js
C:\Program Files\nodejs\node.exe: .env.local: not found

$ node --env-file=.env server.js   # second instance, port already in use
Cannot start server: EADDRINUSE listen EADDRINUSE: address already in use 127.0.0.1:8080
Реальные запуски (вывод и пути сокращены): без файла, с приоритетом окружения, с неверным PORT, без файла .env.local и при занятом порте
  • Все значения из окружения — строки. PORT=8080 придёт как "8080", поэтому config.js переводит его в число и проверяет диапазон. Ошибку конфигурации лучше получить при старте, чем при первом запросе
  • Если переменная задана и в окружении, и в файле, побеждает окружение — так на сервере можно переопределить значение из .env
  • --env-file завершает процесс, если файла нет. Для необязательного файла есть --env-file-if-exists, а из кода — process.loadEnvFile()
  • Секреты в продакшене задают средствами хостинга или менеджера секретов, а не файлом рядом с кодом. Подробнее — в уроке о секретах и переменных окружения

Флаг --watch перезапускает процесс, когда меняется точка входа или любой импортированный модуль, — в логе выше это строка Restarting 'server.js'. Проверка на Node.js 24.18 показала: перезапуск срабатывает и при изменении файла из --env-file, но не при правке data/services.json — файл читается через fs, а не импортируется. Чтобы следить за произвольными папками, используйте --watch-path=.; учтите, что этот флаг отключает автоматическое отслеживание импортов, поэтому указывайте папки целиком.

terminal
$ npm run dev

> services-api@1.0.0 dev
> node --watch --env-file=.env server.js

InterLAB services API: http://127.0.0.1:8080
GET /health 200 2.4ms
GET /services 200 0.3ms
POST /services 405 0.3ms
GET /price 404 0.3ms
Restarting 'server.js'
InterLAB services API: http://127.0.0.1:8080
GET /services 200 2.7ms
Журнал npm run dev: четыре запроса, затем правка src/services.js и автоматический перезапуск

Упражнения и почему дальше нужен фреймворк

Сервер работает, но посмотрите, сколько пришлось сделать руками: сравнивать пути, вытаскивать параметры регулярным выражением, собирать JSON-ответ, помнить про 405. Для POST добавятся чтение тела, разбор JSON, проверка Content-Type, валидация и единый формат ошибок — в уроке «HTTP и JSON» это заняло десятки строк на один маршрут. Именно это берёт на себя фреймворк. В следующем уроке перенесём API на Express: маршруты с параметрами, middleware, валидация через Zod и центральный обработчик ошибок.

  • В package.json указаны "type": "module" и "private": true, lock-файл в Git, node_modules — нет
  • Встроенные модули импортируются с node:, относительные импорты — с расширением .js
  • Файлы читаются через node:fs/promises, пути строятся через path.join и import.meta.dirname
  • Настройки читаются из process.env, приводятся к нужному типу и проверяются при старте
  • .env в .gitignore, рядом лежит .env.example без секретов
  • Для разработки — node --watch --env-file=.env, для продакшена — чистый node server.js
Как проверено9 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    package.json (npm init -y + npm pkg set)

    команды выполнены во временном проекте scratchpad/kb/verify/nodejs-osnovy/services-api, npm 11.16.0; package.json в статье — содержимое файла после команд

  • запущено, работает

    npm install zod / npm pkg get dependencies / npm ls

    реальный вывод, zod 4.6.6, package-lock.json lockfileVersion 3; путь проекта в выводе сокращён

  • запущено, работает

    src/config.js, src/services.js, data/services.json, server.js

    Node.js 24.18.0: node --check всех файлов; npm run dev; curl -si /health (200), /services (200), POST /services (405 + Allow), /price (404) — реальный вывод в статье; код в статье вставлен сборочным скриптом из проверенных файлов

  • запущено, работает

    Запуски с переменными окружения и ошибками

    node server.js без .env (значения по умолчанию), PORT=8081 поверх --env-file (окружение важнее), PORT=abc (ошибка конфигурации), --env-file=.env.local (not found, exit 9), второй экземпляр (EADDRINUSE, exit 1); вывод стека сокращён

  • запущено, работает

    --watch

    npm run dev: перезапуск при изменении src/services.js и .env; изменение data/services.json перезапуска не вызвало; --watch-path=. перезапускает при изменении data/services.json

  • запущено, работает

    Ошибки ESM (расширение, require, __dirname)

    три отдельных .mjs-файла, реальные сообщения Node.js 24.18, пути сокращены

  • запущено, работает

    events-demo.js

    node events-demo.js — вывод подписчиков, once, падение на error без обработчика (exit 1); стек сокращён

  • запущено, работает

    Решение упражнения GET /services/:id

    server-ex2.js: /services/crm → 200, /services/seo → 404, /services → 200; сборочный скрипт сверяет фрагмент с проверенным файлом

  • запущено, работает

    node --run start

    сервер стартовал через node --run start (Node.js 24.18)

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.