Skip to content

Як влаштований бекенд ​

Перед читанням

Нічого знати не потрібно. Буде зручніше, якщо проєкт уже запущено за Швидким стартом: тоді приклади можна одразу перевірити.

Ця сторінка для тих, хто вперше відкрив шаблон. Тут без зайвих деталей пояснено, з чого складається бекенд, що відбувається з запитом і навіщо код розкладено саме так. Технічні подробиці кожної теми — на окремих сторінках розділу Бекенд, посилання наведено по ходу.

Коротко ​

Бекенд — це один Node.js-процес, який:

  1. приймає HTTP-запити (/api/...) і WebSocket-з'єднання;
  2. перевіряє, хто прийшов і чи можна йому це робити;
  3. виконує бізнес-логіку, читає й пише дані в PostgreSQL, тримає сесії в Redis;
  4. повертає JSON-відповідь або надсилає подію через WebSocket.

Він написаний на uWebSockets.js — дуже швидкому сервері, написаному на C++. Express, Fastify чи NestJS тут немає: замість них у каталозі src/vendor/ лежить невелике власне «ядро», яке робить роутинг, читання тіла запиту, сесії, ліміти й логування. Вам не треба його змінювати, щоб додати фічу: увесь ваш код живе в src/app/.

Чому не Express?

uWebSockets.js — один з найшвидших HTTP/WebSocket-серверів для Node.js за публічними бенчмарками автора. Власних вимірювань для цього шаблону в репозиторії немає, тож реальний виграш залежить від вашого навантаження: якщо більшість часу йде на запити до БД чи AI, швидкість сервера помітна менше. Друга перевага — HTTP і WebSocket працюють в одному процесі. Ціна — у нього немає екосистеми middleware, як у Express. Шаблон закриває цю прогалину: CORS, CSRF, сесії, rate limiting, валідація та статика вже написані й протестовані.

Шлях одного запиту ​

Уявімо, що фронтенд викликає GET /api/main/init, щоб дізнатися, хто зараз залогінений.

  1. Ядро знаходить маршрут і перевіряє ліміт запитів (rate limit). Якщо ліміт вичерпано, далі запит не йде.
  2. Валідація. Якщо маршрут описав схему query-параметрів, ядро їх перевіряє. Для POST, PUT і PATCH ядро ще й читає тіло запиту і перевіряє його за схемою маршруту. У нашого GET тіла немає, тому цей крок для нього майже порожній. Некоректні дані отримують 422 ще до middleware і вашого коду.
  3. Middleware по черзі перевіряють запит: session_web піднімає сесію з cookie, auth_guard відсікає незалогінених з кодом 401. csrf_guard теж стоїть у ланцюжку, але для GET нічого не перевіряє: CSRF-токен потрібен лише для запитів, що змінюють дані (POST, PUT, PATCH, DELETE).
  4. Controller бере дані запиту й викликає потрібний сервіс.
  5. Service виконує логіку: перевіряє права, рахує, звертається до інших сервісів.
  6. Repository робить запит до бази.
  7. Transformer перетворює рядок з бази на безпечний об'єкт для клієнта (без пароля, токенів тощо).
  8. Controller повертає результат, ядро серіалізує його в JSON.

Спрощено

Тут опущено частину кроків: CORS-заголовки, перевірку Content-Type і розміру тіла, обробку помилок. Повний порядок з посиланнями на код — Життєвий цикл запиту.

Навіщо стільки шарів ​

Новачкам часто здається, що простіше написати все в одному обробнику. На маленькому проєкті це працює, але з ростом такий код стає важко тестувати й змінювати. Шари розділяють відповідальність:

ШарВідповідає заЧого тут не має бутиДе лежить
RouteURL, метод, схема, middleware, лімітЛогікиapp/routes/http-routes.ts, ws-routes.ts
MiddlewareПеревірки перед обробникомБізнес-правил конкретної фічіapp/middlewares/, реєстр у kernel.ts
Controller«Перекласти» HTTP на виклик сервісуSQL, складних обчисленьapp/controllers/http, app/controllers/ws
ServiceБізнес-логіка, рішення, помилкиПрямого доступу до таблицьapp/services/
RepositoryЗапити до БДЛогування, бізнес-рішеньapp/repositories/
TransformerФорма даних для клієнтаЗвернень до БДapp/transformers/

Що це дає на практиці:

  • Сервіс можна протестувати без HTTP і без бази — підмінивши репозиторій.
  • Один сервіс використовують і HTTP, і WebSocket-контролери, логіка не дублюється.
  • Зміна схеми БД зачіпає лише репозиторій, а не весь код.
  • Пароль не потрапить у відповідь випадково, бо дані йдуть клієнту лише через трансформер.

Правила між шарами перевіряє тест layering.spec.ts: якщо контролер імпортує репозиторій напряму, pnpm test впаде. Детальніше — Архітектура.

Помилки без винятків ​

Сервіси не кидають throw на очікувані помилки («користувача не знайдено», «невірний пароль»). Вони повертають Result з бібліотеки better-result:

ts
if (user === null) return Result.err(notFound('User', 'User not found'))
return Result.ok({ user })

Контролер перевіряє результат і перетворює помилку на правильний HTTP-статус через mapControllerError. Так помилки видно в типах: TypeScript не дасть забути, що виклик може не вдатися. Детальніше — Контролери → Мапінг помилок.

Звідки беруться типи: єдине джерело істини ​

У шаблоні типи майже не пишуть вручну. Їх виводять з двох описів, кожен з яких — єдине джерело істини для свого контракту:

КонтрактДжерелоЩо з нього виходить
API: що клієнт надсилає й отримуєarktype-схема в packages/sharedвалідатор запиту на бекенді, тип у контролері, тип у фронтенді
База даних: які є таблиці й колонкиDrizzle-схема packages/backend/src/database/schema.tsSQL-міграції, типи рядків UserRow / UserInsert у репозиторіях

API: одна схема — і перевірка, і тип ​

Ось частина схеми логіну з shared/src/schemas/auth/login.ts:

ts
const IdentifierLoginInputSchema = type({
    identifier: 'string >= 3 & string <= 255',
    password: 'string >= 8 & string <= 32',
    token: 'string <= 60',
    '+': 'reject', // невідомі поля відхиляються
})

export const LoginInputSchema = type.or(LegacyLoginInputSchema, IdentifierLoginInputSchema)
export type LoginInput = typeof LoginInputSchema.infer // тип виведено зі схеми

Цей один файл використовують три місця:

  • маршрут POST /api/auth/login перевіряє тіло запиту: validator: LoginInputSchema у http-routes.ts;
  • контролер отримує вже типізовані дані: login(context: HttpContext<LoginInput>);
  • фронтенд надсилає запит того самого типу: login: async (body: LoginInput) у api.ts.

Що це дає: перейменуйте в схемі identifier на login, перезберіть shared і запустіть pnpm typecheck. Помилки з'являться з обох боків одразу (вивід скорочено):

text
packages/frontend typecheck: src/components/AuthModals.vue(222,13): error TS2353:
  Object literal may only specify known properties, and 'identifier' does not exist in type ...
packages/backend typecheck: src/app/services/auth/auth-service.ts(88,67): error TS2339:
  Property 'email' does not exist on type ...

Без спільної схеми фронтенд і далі надсилав би identifier, сервер відповідав би 422, і помилку знайшли б лише вручну.

База даних: типи рядків зі схеми Drizzle ​

Таблиця описана один раз у TypeScript:

ts
// packages/backend/src/database/schema.ts
export const users = pgTable('users', {
    id: bigserial('id', { mode: 'bigint' }).primaryKey(),
    name: varchar('name', { length: 100 }).notNull(),
    email: varchar('email', { length: 255 }),
    // ...
})

З неї db:generate генерує SQL-міграцію, а репозиторій виводить типи рядка (user-repository.ts):

ts
export type UserRow = InferSelectModel<typeof users> // те, що повертає SELECT
export type UserInsert = InferInsertModel<typeof users> // те, що можна передати в INSERT

Далі тип іде ланцюжком: user-transformer.ts описує публічну форму як Omit<UserRow, 'password' | 'sessionToken' | ...>. Додали колонку в schema.ts — вона з'явиться в UserRow і в серіалізованому користувачі без жодного ручного типу. А пароль туди не потрапить, бо його явно виключено.

Чого цей підхід не робить ​

  • API і БД — різні контракти, і це навмисно. Тип відповіді не виводиться з таблиці автоматично: між ними стоїть transformer, який прибирає секрети і перетворює дати та bigint на рядки.
  • Захист працює там, де тип справді імпортовано. Якщо компонент оголосить власний тип замість імпорту зі shared, зміну схеми TypeScript у ньому не помітить. Виняток у самому shared один: у CanonicalErrorResponse поле details має точніший TypeScript-тип, ніж може описати схема (error-response.ts).
  • Перевірка відповіді під час виконання залежить від режиму. Крім TypeScript під час компіляції, сервер може перевіряти відповідь контролера за ResponseSchema: за замовчуванням strict поза production (розбіжність → 500) і off у production. Перевіряється об'єкт до серіалізації, а зайві поля перевірка не ловить. Див. Перевірка відповідей.

Детальніше — Валідація, Пакет shared, База даних.

Де зберігаються дані ​

СховищеЩо тамСторінка
PostgreSQLКористувачі, push-підписки, база знань, промпти тощоБаза даних
RedisСесії, спільні rate limits, тимчасові кодиАвтентифікація
S3 / MinIOФайли: аватари, скриншотиСховище файлів
Пам'ять процесуАктивні WebSocket-з'єднання, кеш статикиКешування та ETag

Схема БД описана в коді через Drizzle ORM (src/database/schema.ts), а зміни схеми застосовуються міграціями.

Як працюють сесії ​

Після логіну сервер створює сесію в Redis і ставить браузеру HttpOnly-cookie з підписаним ідентифікатором. JavaScript на сторінці цю cookie прочитати не може, тож XSS-скрипт не вкраде її і не використає сесію з іншого комп'ютера. Але HttpOnly не робить XSS безпечним: шкідливий скрипт, що виконується на вашій сторінці, все одно може надсилати запити від імені користувача, поки той на сайті. Тому шаблон додатково обмежує, які скрипти можуть виконуватися, через Content-Security-Policy (CSP). На кожен запит middleware session_web знаходить сесію в Redis. Вихід — це просто видалення сесії з Redis, тому logout-all миттєво розлогінює всі пристрої. Для запитів, що змінюють дані, фронтенд додатково надсилає CSRF-токен у заголовку x-csrf-token; токен він отримує з /api/main/init. Запит без правильного токена або з чужого сайту сервер відхиляє з 403 — навіщо це і як працює, пояснено на сторінці CSRF простими словами. Детальніше — Автентифікація і Безпека.

WebSocket простими словами ​

HTTP — це «запитав і отримав відповідь». WebSocket — постійне з'єднання, через яке сервер сам може надіслати повідомлення: «новий користувач онлайн», «ось наступний шматок відповіді AI». Фронтенд отримує короткоживучий токен з /api/main/init, відкриває з'єднання, і далі обидві сторони обмінюються JSON-повідомленнями. WS-маршрути описуються так само, як HTTP: URL-подія, обробник, схема. Детальніше — WebSocket.

Конфігурація ​

Усі налаштування — змінні оточення у файлі packages/backend/.env (зразок — .env.example). Під час старту validateRuntimeConfig() перевіряє, що конфігурація безпечна. Наприклад, сервер не запуститься, якщо секрет для хешування OTP-кодів коротший за 32 символи. Повний довідник — Конфігурація.

Що робити далі ​

  1. Якщо ще не запускали проєкт, пройдіть Швидкий старт разом з першим сценарієм.
  2. Відкрийте http-routes.ts і пройдіть по GET /api/main/init від маршруту до репозиторію — це найкращий спосіб відчути структуру.
  3. Тримайте під рукою Глосарій, якщо зустрінете незнайомий термін.

Наступний крок

Перший маршрут: додайте власний GET /api/hello і побачите всі шари на практиці.

Автор: Олександр Блажейко · blazheiko@gmail.com