Перейти к содержимому

Бэкенд на Fastify

До этого момента в нашей схеме был только фронтенд на React. В рамках одной из прошлых лабораторных мы для него подготовили моки данных, в другой — собрали Docker-окружение. Но реального бэкенда в схеме до сих пор не было. Настало время это исправить.

В этой лабораторной мы:

  • инициализируем Node.js-проект с Fastify v5;
  • подключим инфраструктурные плагины (security headers, CORS, rate-limit, OpenAPI/Swagger);
  • опишем типы и схемы валидации через TypeBox;
  • развернём app.ts с обработчиками ошибок и парой реальных маршрутов;
  • настроим server.ts с graceful shutdown;
  • свяжем backend с фронтендом через Vite-прокси и научимся переключаться между MSW и реальным API.

База данных, миграции, Docker-сборка backend и общий compose — это уже задача следующей лабораторной. Здесь данные хранятся прямо в памяти процесса, чтобы сосредоточиться на самом фреймворке.

Перенос фронтенда в frontend/

До этого момента весь наш проект жил в корне (room-assets/). Сейчас, когда у нас появится ещё и backend, удобно положить фронтенд и бэкенд в соседние папки. Сделаем простой ручной переезд:

Terminal
# В корне проекта (room-assets/)
mkdir frontend
git mv src public index.html package.json package-lock.json \
       tsconfig*.json vite.config.ts .gitignore eslint.config.* \
       Dockerfile .dockerignore nginx \
       frontend/ 2>/dev/null || true

# Старый docker-compose.dev.yml из лабы про Docker удаляем —
# в следующей лабе он будет заменён на compose.base.yaml + compose.dev.yaml
rm -f docker-compose.dev.yml docker-compose.yml
Если что-то из перечисленных файлов у вас отсутствует (например, nginx/ или Dockerfile ещё не созданы) — git mv пропустит недостающее. Главное, чтобы внутри frontend/ оказались все исходники Vite-проекта. Если проект пока не под git — используйте обычный mv.

После этого в корне останется только общая инфраструктура (она появится в следующей лабораторной), а собственно код фронтенда будет лежать внутри frontend/.

Настройка среды

В качестве основного фреймворка будем использовать Fastify v5 — быстрый backend-фреймворк на Node.js. Создадим рядом с frontend/ папку backend и проинициализируем там проект:

Terminal
mkdir backend && cd backend
npm init -y

# Fastify v5 + плагины
npm i fastify @fastify/swagger @fastify/helmet @fastify/rate-limit @fastify/cors @sinclair/typebox @fastify/type-provider-typebox

# Типы и TS-инфраструктура (tsx используем для dev-запуска ESM+TS без бубнов)
npm i -D typescript tsx @types/node
npx tsc --init --rootDir src --outDir dist --esModuleInterop

По умолчанию tsc --init сгенерирует tsconfig.json с "module": "commonjs". У нас же package.json будет помечен "type": "module", а в коде мы будем писать ESM-импорты вида import './app.js'. Чтобы и tsc, и tsx правильно их разрешали, в сгенерированном tsconfig.json подкрутим несколько опций (остальное, что добавил tsc --init, оставляем как есть):

backend/tsconfig.json (фрагмент compilerOptions)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "skipLibCheck": true
  }
}
С "module": "NodeNext" TypeScript обязывает указывать расширение .js в относительных импортах исходных .ts-файлов (import './app.js') — это не опечатка и не баг, так работает Node-ESM. После сборки tsc это превратится в честный ./app.js, а в dev tsx сам резолвит такой импорт в соседний app.ts.

Кроме самого Fastify мы устанавливаем смежные плагины, которые будут использоваться в проекте:

  • swagger (генерация OpenAPI) — в рамках лабораторной с моками мы видели пример файла OpenAPI 3.1, к которому мы движемся. В следующей лабораторной к этой спецификации мы подключим Scalar — современный UI для интерактивного просмотра.
  • helmet — установка безопасных заголовков (headers) для ответа.
  • rate-limit — ограничение количества запросов к API и настройка политик безопасности с целью предотвращения спама запросами (DDoS-атаки).
  • cors — это механизм, который контролирует доступ к ресурсам веб-страницы по сети. Подробное и доступное описание можно посмотреть в этой статье на Хабре. Советую посмотреть, написано достаточно доступно и наглядно.
  • @sinclair/typebox + @fastify/type-provider-typebox — описание схем валидации запросов/ответов на TypeScript, с возможностью автоматически выводить типы.

Теперь настроим базовые скрипты в уже знакомом файле package.json:

backend/package.json
{
  ...
  "type": "module",
  "scripts": {
    "dev": "tsx src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js"
  }
  ...
}
Можно было бы вместо tsx использовать связку node --loader ts-node/esm src/server.ts, но в Node 22+ флаг --loader помечен как deprecated и выводит warning при каждом старте. tsx решает ровно ту же задачу — на лету компилирует TS и разруливает Node-ESM с расширениями .js в импортах — и при этом не требует ручной возни с --import/register.

Fastify-сервер

Теперь создадим базовый каркас сервера. Мы намеренно делим код на несколько модулей — даже если сейчас каждый из них короткий, это сразу задаёт правильную структуру для дальнейшего роста backend’а.

backend/src/types.ts
import type { FastifyError, FastifySchemaValidationError } from 'fastify'
import type { SchemaErrorDataVar } from 'fastify/types/schema.js'
import { Type as T, type Static } from '@sinclair/typebox'

// Этот модуль собирает переиспользуемые типы и схемы, которые нужны маршрутам Fastify и плагинам.

/**
 * Обёртка над стандартной ошибкой Fastify для случаев, когда схема запроса не проходит валидацию.
 * Мы расширяем Error, чтобы получить сообщение и stack trace, и одновременно реализуем FastifyError,
 * чтобы Fastify понимал код ошибки и корректно возвращал ответ клиенту.
 */
export class ValidationProblem extends Error implements FastifyError {
  public readonly name = 'ValidationError'
  public readonly code = 'FST_ERR_VALIDATION'
  public readonly statusCode = 400
  public readonly validation: FastifySchemaValidationError[]
  public readonly validationContext: SchemaErrorDataVar

  constructor(
    message: string,
    errs: FastifySchemaValidationError[],
    ctx: SchemaErrorDataVar,
    options?: ErrorOptions
  ) {
    super(message, options)
    this.validation = errs
    this.validationContext = ctx
  }
}

// Схема ответа в формате RFC 9457 / Problem Details — единый JSON-формат для сообщений об ошибках (ранее RFC 7807, заменён на 9457).
export const ProblemDetails = T.Object(
  {
    type: T.String({ description: 'URI с подробным описанием ошибки (по умолчанию about:blank)' }),
    title: T.String({ description: 'Короткое человекочитаемое резюме проблемы' }),
    status: T.Integer({ minimum: 100, maximum: 599, description: 'HTTP-статус, с которым был отправлен ответ' }),
    detail: T.Optional(T.String({ description: 'Дополнительные сведения о том, что пошло не так' })),
    instance: T.Optional(T.String({ description: 'URI запроса, в котором возникла проблема' })),
    errorsText: T.Optional(T.String({ description: 'Сводное описание всех ошибок, собранных валидацией Fastify' })),
  },
  { additionalProperties: true }
)
export type ProblemDetails = Static<typeof ProblemDetails>

// Схема и тип пользователя, которые используются и в валидаторах, и в ответах API.
export const User = T.Object({
  id: T.String({ description: 'Уникальный идентификатор пользователя' }),
  email: T.String({ format: 'email', description: 'Адрес электронной почты' }),
})
export type User = Static<typeof User>

// Минимальная схема для health-check запроса.
export const Health = T.Object({
  ok: T.Boolean({ description: 'Флаг готовности сервиса' }),
})
export type Health = Static<typeof Health>
Если помните, в прошлых лабораторных мы с вами смотрели на примерный вариант API-спецификации, который у нас должен получиться. Теперь сама эта спецификация (файл OAS 3.1 — openapi.json) будет генерироваться автоматически на основании схем и комментариев к маршрутам в коде. За это отвечает модуль Swagger. UI для просмотра спецификации (Scalar) подключим в следующей лабораторной — там же, где будем разворачивать backend в Compose.

Запуск и связка с фронтендом

Шаг 1 — Поднять backend локально

Из папки backend/ выполните:

Terminal — backend
npm run dev

В консоли вы увидите примерно следующее:

{"level":30,"msg":"Server listening at http://0.0.0.0:3000"}

Откройте в браузере:

Шаг 2 — Настроить Vite-прокси на фронтенде

Чтобы фронтенд (на :5173) обращался к backend (на :3000), настроим прокси в Vite: тогда /api/* запросы из браузера будут уходить на backend, как если бы они шли на тот же домен. В следующей лабораторной эту функцию возьмёт на себя NGINX.

Дополним frontend/vite.config.ts секцией server.proxy:

frontend/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
    },
  },
  server: {
    host: true,
    port: 5173,
    strictPort: true,
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
      },
    },
  },
})

Шаг 3 — Запустить фронт и бэк параллельно

В двух терминалах:

Terminal — frontend
cd frontend && npm run dev
Terminal — backend
cd backend && npm run dev

Откройте http://localhost:5173 — фронтенд работает как обычно, но запросы к /api/users уже идут на реальный Fastify-сервер.

Переключение между моками и реальным API

В лабораторной про моки мы регистрировали MSW прямо в main.tsx:

async function enableMocking() {
  if (import.meta.env.DEV) {
    const { worker } = await import('./mocks/browser')
    await worker.start({ onUnhandledRequest: 'bypass' });
  }
}

Пока MSW активен в dev-режиме, он перехватывает запросы на уровне Service Worker — до того, как они уйдут в Vite-прокси. Это значит, что /api/users сейчас всё ещё может ловиться MSW, а не уходить на наш Fastify.

Перепишем условие через переменную окружения, чтобы можно было осознанно переключаться:

frontend/src/main.tsx
async function enableMocking() {
  if (import.meta.env.DEV && import.meta.env.VITE_USE_MOCKS === 'true') {
    const { worker } = await import('./mocks/browser')
    await worker.start({
      onUnhandledRequest: 'bypass',
      serviceWorker: { url: '/mockServiceWorker.js' },
    });
  }
}

И заведём два варианта .env:

frontend/.env.development
VITE_USE_MOCKS=true
frontend/.env.local
VITE_USE_MOCKS=false
Файл .env.local Vite читает поверх .env.development и не коммитит его в git (.gitignore Vite его игнорирует по умолчанию). Это удобный способ держать локальные предпочтения отдельно от того, что лежит в репозитории. Подробнее — в документации Vite по env-файлам.

Теперь:

  • студент, у которого backend ещё не запущен, оставляет VITE_USE_MOCKS=true — и приложение работает на моках, как раньше;
  • когда нужно проверить связку с реальным backend — переключает на false (или просто запускает с VITE_USE_MOCKS=false npm run dev).
Если в браузере уже зарегистрирован Service Worker от прошлого запуска MSW, он продолжит перехватывать запросы даже после переключения. После смены VITE_USE_MOCKS сделайте hard reload (DevTools → Application → Service Workers → Unregister, затем перезагрузка страницы), либо зайдите на сайт в режиме инкогнито.

Что дальше

  • В следующей лабораторной мы перенесём данные из памяти в Postgres через Prisma, упакуем backend в Docker, опишем общий compose и поставим перед всем этим NGINX.
  • Маршруты /api/users и /api/health сейчас используют in-memory массив — это шаблон, по которому вы (уже в рамках самостоятельной части) сможете дописать собственные ручки /api/rooms, /api/bookings и т. п. под нужды итогового задания.