Шаблон HTTP-сервиса на Node.js 22, TypeScript и Fastify 5. Подходит для старта API или микросервиса: конфигурация проверяется до открытия порта, маршруты валидируют входные данные, ошибки имеют общий JSON-контракт, а приложение можно тестировать без сетевого соединения.
База данных, брокер, аутентификация и бизнес-логика выбираются под конкретный сервис.
В репозитории есть небольшой модуль greeting, показывающий связь маршрута и сервиса.
- Быстрый старт
- Команды разработки
- Структура и зависимости
- Конфигурация
- HTTP API и ошибки
- Health и readiness
- Логи и метрики
- Безопасность HTTP
- Добавление модуля
- Тестирование
- Запуск и остановка
- Docker
- CI и создание нового сервиса
- Диагностика
Требуются Node.js 22.13+ и npm 10+. Файл .nvmrc задаёт Node.js 22 для локальной
разработки и CI. Docker нужен только для контейнерного запуска.
# При использовании nvm:
nvm install
nvm use
npm ci
cp .env.example .env
npm run devnpm run dev запускает сервер через tsx watch с перезапуском при изменениях исходников.
Команды dev и start загружают .env из текущей директории штатным механизмом Node.js.
Файл необязателен; уже заданные переменные процесса имеют приоритет. После изменения .env
перезапустите команду.
Проверьте сервис из другого терминала:
curl -i http://localhost:3000/health
curl -i http://localhost:3000/ready
curl http://localhost:3000/api/v1/hello/World
# {"message":"Hello, World!"}Документация доступна на localhost:3000/docs/, метрики — на
localhost:3000/metrics. CORS в .env.example отключён;
для фронтенда на другом origin задайте, например, CORS_ORIGINS=http://localhost:5173.
| Команда | Назначение |
|---|---|
npm ci |
Установить точные версии из package-lock.json |
npm run dev |
Запустить TypeScript-сервер с наблюдением за файлами |
npm run build |
Скомпилировать src/ в dist/, включая source maps и типы |
npm start |
Запустить ранее собранный dist/server.js |
npm run typecheck |
Проверить типы исходников и тестов без генерации файлов |
npm run lint |
Проверить ESLint; предупреждения считаются ошибкой |
npm run format |
Отформатировать проект |
npm run format:check |
Проверить форматирование без изменения файлов |
npm test |
Однократно запустить Vitest |
npm run test:watch |
Перезапускать тесты при изменениях |
npm run test:coverage |
Запустить тесты и проверить пороги покрытия |
npm run audit:prod |
Проверить production-зависимости; порог отказа — high |
npm run check |
Форматирование → ESLint → типы → тесты с покрытием → сборка |
Перед pull request выполните npm run check. Аудит зависимостей запускается отдельно,
поскольку требует доступа к npm registry; CI выполняет обе команды.
src/
├── app.ts # сборка приложения и передача зависимостей
├── server.ts # запуск порта и обработка ошибок старта
├── config/
│ └── env.ts # Zod-схема и тип AppConfig
├── lib/
│ ├── errors.ts # контролируемая ошибка AppError
│ ├── error-response.ts # JSON-контракт ошибки и его схема
│ ├── readiness.ts # параллельные проверки с таймаутом
│ └── shutdown.ts # сигналы и ограничение времени остановки
├── modules/greeting/
│ ├── greeting.routes.ts # HTTP-схемы и вызов сервиса
│ └── greeting.service.ts # пример прикладной логики
├── plugins/
│ ├── documentation.ts # OpenAPI и Swagger UI
│ ├── error-handler.ts # перевод исключений в HTTP-ответы
│ ├── metrics.ts # отдельный Prometheus registry на приложение
│ ├── request-context.ts # request ID и настройки логирования
│ └── security.ts # Helmet, CORS и rate limit
└── routes/
└── health.ts # /health и /ready
test/
├── helpers/ # тестовая конфигурация и закрытие приложений
└── *.test.ts # HTTP-контракты, конфигурация и lifecycle
buildApp() создаёт приложение без открытия порта и без подписки на сигналы процесса.
Фабрика принимает config, readinessChecks и greetingService. Если конфигурация не
передана, используется loadConfig(). Для изоляции тестов передавайте её явно.
Направление зависимостей: маршрут вызывает прикладной сервис, а app.ts создаёт и передаёт
его зависимости. Прикладной сервис не импортирует Fastify и не читает process.env.
Функции register* подключают общие HTTP-hooks в корневой области Fastify; модули и
операционные маршруты регистрируются как отдельные плагины через app.register().
Источник правил и значений по умолчанию — src/config/env.ts.
.env.example содержит пример локальных настроек. loadConfig(environment)
проверяет переданный объект, не изменяя окружение и не читая файлы самостоятельно.
Неизвестные переменные игнорируются. Ошибка конфигурации перечисляет неверные поля и
останавливает запуск до открытия порта.
| Переменная | По умолчанию | Правила и назначение |
|---|---|---|
NODE_ENV |
development |
development, test, production |
HOST |
0.0.0.0 |
Непустой адрес для прослушивания |
PORT |
3000 |
Целое число от 1 до 65535 |
LOG_LEVEL |
info |
fatal, error, warn, info, debug, trace, silent |
SERVICE_NAME |
my-service |
Имя в health, логах и OpenAPI |
SERVICE_VERSION |
0.0.0 |
Версия в health, логах и OpenAPI |
CORS_ORIGINS |
пустая строка | Отключён, * или HTTP(S) origins через запятую |
TRUST_PROXY |
false |
Доверие к заголовкам прокси |
DOCS_ENABLED |
true вне production |
Публикация /docs/, /docs/json, /docs/yaml |
METRICS_ENABLED |
true |
Публикация /metrics и сбор метрик |
RATE_LIMIT_MAX |
100 |
Положительное безопасное целое; запросы на IP за минуту |
READINESS_TIMEOUT_MS |
2000 |
Таймаут каждой проверки зависимости |
SHUTDOWN_TIMEOUT_MS |
10000 |
Максимальное время закрытия приложения |
Булевы значения задаются только строками true и false; 1, yes и пустая строка
недопустимы. Таймауты — целые числа от 1 до 2147483647 миллисекунд. Значение
SERVICE_VERSION не берётся из package.json: задавайте его при выпуске; в примере .env
указано 1.0.0.
В CORS_ORIGINS пробелы вокруг элементов и дубликаты удаляются. Используйте origin в
каноническом виде, например https://app.example.com или http://localhost:5173, без пути,
завершающего /, query и учётных данных. * задаётся отдельно от списка.
Пример запуска собранного приложения:
npm run build
NODE_ENV=production SERVICE_NAME=orders SERVICE_VERSION=1.2.3 npm start| Метод | Путь | Результат |
|---|---|---|
| GET | /api/v1/hello/:name |
200, { "message": "Hello, World!" } |
| GET | /health |
200, состояние процесса |
| GET | /ready |
200 при готовности, иначе 503 |
| GET | /metrics |
Метрики Prometheus, если включены |
| GET | /docs/ |
Swagger UI, если включён |
| GET | /docs/json |
OpenAPI JSON, если документация включена |
| GET | /docs/yaml |
OpenAPI YAML, если документация включена |
Имя в greeting содержит от 1 до 80 символов; специальные символы в URL кодируются.
Fastify использует JSON Schema для валидации параметров и сериализации ответа. Те же схемы
попадают в OpenAPI. При отключённых docs и metrics соответствующие пути возвращают 404.
Ошибки маршрутов, валидации и HTTP-парсера используют общий формат:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "params/name must NOT have more than 80 characters",
"requestId": "example-request-id"
}
}| Ситуация | HTTP | error.code |
|---|---|---|
| Параметры не соответствуют схеме | 400 | VALIDATION_ERROR |
| Неизвестный маршрут | 404 | NOT_FOUND |
| Невалидный JSON | 400 | FST_ERR_CTP_INVALID_JSON_BODY |
| Тело превышает лимит Fastify | 413 | FST_ERR_CTP_BODY_TOO_LARGE |
| Неподдерживаемый Content-Type | 415 | FST_ERR_CTP_INVALID_MEDIA_TYPE |
| Превышен rate limit | 429 | RATE_LIMIT_EXCEEDED |
| Необработанное исключение | 500 | INTERNAL_ERROR |
В шаблоне действует стандартный лимит тела Fastify — 1 MiB. Другие HTTP-ошибки 4xx
сохраняют статус и код Fastify; если кода нет, используется HTTP_ERROR.
Для ожидаемого отказа используйте AppError:
throw new AppError('Order already exists', 409, 'ORDER_EXISTS', { orderId });statusCode должен быть целым от 400 до 599. message и необязательные details
считаются публичными и передаются клиенту, в том числе для AppError со статусом 5xx.
Не помещайте туда секреты. Неизвестные серверные ошибки записываются в лог; клиент получает
только Internal server error. Ответ /ready со статусом 503 имеет собственную схему
с состояниями зависимостей, описанную ниже.
/health возвращает status, service, version, timestamp и uptimeSeconds процесса.
Он не обращается к внешним системам: временная недоступность БД не означает, что процесс
нужно перезапустить.
/ready проверяет, способен ли экземпляр обслуживать запросы. Без зависимостей он возвращает
200 и пустой checks. Подключайте реальные проверки в месте сборки приложения:
const app = await buildApp({
config,
readinessChecks: {
database: async () => {
await database.ping();
},
catalog: async (signal) => {
const response = await fetch('http://catalog:3000/health', { signal });
if (!response.ok) throw new Error('Catalog unavailable');
},
},
});
app.addHook('onClose', async () => {
await database.close();
});database в примере — ваш предварительно созданный клиент. Проверка возвращает void или
Promise<void>; успешное завершение означает up, исключение или таймаут — down.
Возвращать false для обозначения ошибки не нужно: проверка должна выбросить исключение.
Все проверки запускаются параллельно при каждом запросе. Каждая ограничена
READINESS_TIMEOUT_MS; синхронное исключение не мешает остальным проверкам. При таймауте
переданный AbortSignal отменяется. HTTP-ответ ограничен по времени и для клиентов без
поддержки отмены, но их фоновая операция может продолжиться: задайте также таймаут драйвера.
Проверки не должны блокировать event loop синхронной работой.
Пример ответа 503:
{
"status": "not_ready",
"service": "my-service",
"version": "1.0.0",
"timestamp": "2026-09-10T12:00:00.000Z",
"checks": { "database": "down", "catalog": "up" }
}Тексты исключений не раскрываются через readiness. В оркестраторе используйте /health
для liveness и /ready для readiness; HTTP-таймаут readiness probe должен быть больше
READINESS_TIMEOUT_MS с запасом на сеть.
Логи — JSON в stdout, с полями service, version и идентификатором запроса reqId.
Fastify пишет начало и завершение запросов; обработчик ошибок добавляет серверные ошибки.
В маршрутах используйте request.log, вне запроса — app.log.
Входящий x-request-id принимается, если содержит от 1 до 128 символов из набора
A–Z, a–z, 0–9, ., _, :, -. В остальных случаях создаётся UUID. Идентификатор
возвращается заголовком x-request-id и в JSON ошибки. Это средство корреляции, не
подтверждение личности клиента.
Настроено редактирование req.headers.authorization, req.headers.cookie и
res.headers["set-cookie"]. Произвольные поля ваших сообщений и секреты в URL автоматически
не очищаются; не записывайте в логи токены и полные тела запросов.
При METRICS_ENABLED=true создаётся отдельный registry на экземпляр приложения:
| Метрика | Содержание |
|---|---|
service_http_requests_total |
Счётчик завершённых запросов |
service_http_request_duration_seconds |
Гистограмма времени обработки в секундах |
service_process_*, service_nodejs_* |
Стандартные метрики процесса и Node.js |
Метки HTTP-метрик: method, route, status_code. route содержит шаблон
/api/v1/hello/:name, для неизвестного маршрута — unknown; имена пользователей и request ID
в метки не попадают. Учитываются ошибки, 429 и операционные запросы. Текущий запрос
/metrics будет учтён после завершения ответа, поэтому виден в следующем scrape.
Пример конфигурации Prometheus в одной Docker-сети с сервисом:
scrape_configs:
- job_name: my-service
metrics_path: /metrics
static_configs:
- targets: ['service:3000']Helmet добавляет защитные заголовки. CORS отключён по умолчанию; явный allowlist разрешает браузерный доступ только перечисленным origins. CORS не заменяет аутентификацию и не запрещает прямые HTTP-запросы от других клиентов.
Rate limit действует по IP с минутным окном; превышение возвращает 429 и retry-after.
/health, /ready и /metrics исключены из ограничения. Состояние лимитера хранится в
памяти процесса и не разделяется между репликами; при масштабировании подключите общее
хранилище или ограничение на ingress. Для неизвестных маршрутов отдельный лимитер здесь
не подключён.
TRUST_PROXY=true включает доверие к заголовкам прокси, влияющим в том числе на IP клиента.
Используйте этот режим за контролируемым ingress с закрытым прямым доступом к сервису.
Для выборочного доверия конкретным адресам расширьте конфигурацию trustProxy в app.ts.
В production документация по умолчанию отключена. Доступ к метрикам и внутренним пробам ограничивайте на уровне сети или ingress. TLS и аутентификация в шаблон не включены.
- Создайте
src/modules/orders/orders.service.tsс прикладной логикой и явными зависимостями. - Создайте
orders.routes.ts: типы параметров, JSON Schema входа и ответа, вызов сервиса. - Создайте зависимости в
app.tsи зарегистрируйте маршруты с префиксом/api/v1. - Добавьте HTTP-тесты через
app.inject()и проверки важных ветвей прикладной логики. - Подключите readiness и
onCloseдля внешних клиентов, обновите конфигурацию и документацию.
Пример регистрации, когда orderRepository и классы модуля уже реализованы:
const orders = new OrdersService(orderRepository);
await app.register(ordersRoutes, { prefix: '/api/v1', service: orders });Маршруты получают service через options, как greetingRoutes. Это позволяет заменять
зависимости в тестах без моков импортов. Для схем ошибок используйте errorResponseSchema
из src/lib/error-response.ts, например в response: { '4xx': errorResponseSchema }.
Схемы маршрутов, добавленных после подключения Swagger, включаются в OpenAPI автоматически.
Сохраняйте ESM-импорты с расширением .js даже внутри .ts: после компиляции Node.js
запускает эти пути непосредственно. tsconfig.json включает строгий режим, проверку
неопределённых индексов и точную семантику optional-полей.
HTTP-тестам не нужны открытые порты, Docker или внешние сервисы:
import { expect, it } from 'vitest';
import { createTestApp } from './helpers/app.js';
it('returns a greeting', async () => {
const app = await createTestApp();
const response = await app.inject('/api/v1/hello/World');
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ message: 'Hello, World!' });
});createTestApp() передаёт тестовую конфигурацию и регистрирует закрытие приложения после
теста, включая случай падения assertions. Для собственных экземпляров также вызывайте
app.close(). Тесты охватывают конфигурацию, контракты HTTP-ошибок, CORS и proxy headers,
rate limit, readiness с отменой, изоляцию метрик и обработку сигналов остановки.
Пороги покрытия: 80% строк, функций и statements, 75% ветвей. Отчёты находятся в
coverage/, включая lcov.info. Из покрытия исключён только исполняемый src/server.ts;
логика завершения процесса в src/lib/shutdown.ts тестируется отдельно. При изменении
bootstrap дополнительно проверьте реальный запуск, занятый порт и остановку процесса.
Для запуска вне watch-режима выполните npm run build, затем npm start. Source maps
включены для стектрейсов собранного приложения. Изменения в src/ требуют повторной сборки.
При SIGINT или SIGTERM сервер вызывает app.close(): закрывает HTTP-сервер, дожидается
текущих запросов и выполняет onClose hooks. Повторные сигналы во время этой процедуры не
запускают её заново. После успешного завершения Node.js выходит естественным образом,
когда не остаётся активных ресурсов.
Ошибка закрытия или превышение SHUTDOWN_TIMEOUT_MS приводит к выходу с кодом 1.
Ваши подключения, интервалы и фоновые задачи должны освобождаться в onClose. При ошибке
открытия порта приложение также закрывается, процесс завершается с кодом 1.
docker compose up --build -d
docker compose logs -f service
curl http://localhost:3000/health
docker compose downDockerfile использует multi-stage build: зависимости устанавливаются по lockfile, TypeScript
компилируется, dev-зависимости удаляются. Runtime содержит dist/, production-зависимости
и запускается непривилегированным пользователем. Исходники и .env в образ не копируются.
Compose задаёт NODE_ENV=production, внутренний порт 3000 и отключает docs. Файловая
система контейнера доступна только для чтения; /tmp находится в tmpfs. Включены init,
no-new-privileges и ожидание остановки 15s, что превышает стандартный shutdown-таймаут
10s. При увеличении SHUTDOWN_TIMEOUT_MS увеличьте и stop_grace_period.
.env для локального Node.js-запуска не передаётся в контейнер автоматически.
Задайте нужные значения в environment файла compose.yaml либо подключите отдельный
env_file. Переменные из environment имеют приоритет над env_file.
Запуск образа без Compose, с другим внутренним и внешним портом:
docker build -t my-service:local .
docker run --rm --init -p 8080:8080 \
-e PORT=8080 -e SERVICE_NAME=my-service -e SERVICE_VERSION=1.0.0 \
my-service:localВстроенный Docker healthcheck обращается к /health на ${PORT:-3000}. Для контейнера
сохраняйте HOST=0.0.0.0; привязка к loopback сделает опубликованный порт недоступным.
Для readiness оркестратора настройте отдельную пробу /ready.
GitHub Actions выполняет две задачи при pull request и push в main:
quality: установка черезnpm ci,npm run check, аудит production-зависимостей, загрузка отчёта покрытия;container: сборка Docker-образа с BuildKit cache без публикации.
Dependabot проверяет npm-пакеты и GitHub Actions еженедельно. Формы issues и шаблон PR
находятся в .github/.
При использовании репозитория как шаблона:
- Включите Settings → General → Template repository, затем используйте Use this template.
- Замените имя и описание пакета в
package.json; синхронизируйте lockfile командойnpm install --package-lock-only. - Обновите
SERVICE_NAME,SERVICE_VERSION, название README, теги образа в CI и владельца лицензии. Проверьте defaults вsrc/config/env.tsи значения в.env.example. - Замените
greetingбизнес-модулями и их тестами; выберите хранилища и механизм авторизации. - Настройте branch protection для
mainс обязательными проверкамиqualityиcontainer. - Включите private vulnerability reporting, добавьте командный
CODEOWNERSи настройте публикацию образов и деплой под свою инфраструктуру.
Правила участия: CONTRIBUTING.md. Сообщение об уязвимостях: SECURITY.md. Лицензия: MIT.
| Симптом | Что проверить |
|---|---|
Invalid environment configuration |
Названные поля, формат boolean, диапазон порта и таймаутов |
Значения .env не применились |
Рабочую директорию, переменные shell с приоритетом, перезапуск команды |
EADDRINUSE |
Занятый порт; завершите другой процесс или задайте PORT |
npm start не находит dist/server.js |
Выполните npm run build |
/docs/ возвращает 404 |
NODE_ENV и DOCS_ENABLED; в production это поведение по умолчанию |
/ready возвращает 503 |
Поле checks, доступность зависимостей и их таймауты |
| Браузер блокирует запрос, а curl работает | Точный origin в CORS_ORIGINS, включая схему и порт |
Ответ 429 |
RATE_LIMIT_MAX, retry-after и корректность TRUST_PROXY |
| Контейнер недоступен или unhealthy | Логи, HOST, PORT и соответствие опубликованного порта внутреннему |
Процесс выходит с кодом 1 при остановке |
Ошибки onClose, незавершённые запросы и shutdown-таймаут |