Як влаштований бекенд
Перед читанням
Нічого знати не потрібно. Буде зручніше, якщо проєкт уже запущено за Швидким стартом: тоді приклади можна одразу перевірити.
Ця сторінка для тих, хто вперше відкрив шаблон. Тут без зайвих деталей пояснено, з чого складається бекенд, що відбувається з запитом і навіщо код розкладено саме так. Технічні подробиці кожної теми — на окремих сторінках розділу Бекенд, посилання наведено по ходу.
Коротко
Бекенд — це один Node.js-процес, який:
- приймає HTTP-запити (
/api/...) і WebSocket-з'єднання; - перевіряє, хто прийшов і чи можна йому це робити;
- виконує бізнес-логіку, читає й пише дані в PostgreSQL, тримає сесії в Redis;
- повертає 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, щоб дізнатися, хто зараз залогінений.
- Ядро знаходить маршрут і перевіряє ліміт запитів (rate limit). Якщо ліміт вичерпано, далі запит не йде.
- Валідація. Якщо маршрут описав схему query-параметрів, ядро їх перевіряє. Для
POST,PUTіPATCHядро ще й читає тіло запиту і перевіряє його за схемою маршруту. У нашогоGETтіла немає, тому цей крок для нього майже порожній. Некоректні дані отримують422ще до middleware і вашого коду. - Middleware по черзі перевіряють запит:
session_webпіднімає сесію з cookie,auth_guardвідсікає незалогінених з кодом401.csrf_guardтеж стоїть у ланцюжку, але дляGETнічого не перевіряє: CSRF-токен потрібен лише для запитів, що змінюють дані (POST,PUT,PATCH,DELETE). - Controller бере дані запиту й викликає потрібний сервіс.
- Service виконує логіку: перевіряє права, рахує, звертається до інших сервісів.
- Repository робить запит до бази.
- Transformer перетворює рядок з бази на безпечний об'єкт для клієнта (без пароля, токенів тощо).
- Controller повертає результат, ядро серіалізує його в JSON.
Спрощено
Тут опущено частину кроків: CORS-заголовки, перевірку Content-Type і розміру тіла, обробку помилок. Повний порядок з посиланнями на код — Життєвий цикл запиту.
Навіщо стільки шарів
Новачкам часто здається, що простіше написати все в одному обробнику. На маленькому проєкті це працює, але з ростом такий код стає важко тестувати й змінювати. Шари розділяють відповідальність:
| Шар | Відповідає за | Чого тут не має бути | Де лежить |
|---|---|---|---|
| Route | URL, метод, схема, 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:
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.ts | SQL-міграції, типи рядків UserRow / UserInsert у репозиторіях |
API: одна схема — і перевірка, і тип
Ось частина схеми логіну з shared/src/schemas/auth/login.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. Помилки з'являться з обох боків одразу (вивід скорочено):
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:
// 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):
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 символи. Повний довідник — Конфігурація.
Що робити далі
- Якщо ще не запускали проєкт, пройдіть Швидкий старт разом з першим сценарієм.
- Відкрийте
http-routes.tsі пройдіть поGET /api/main/initвід маршруту до репозиторію — це найкращий спосіб відчути структуру. - Тримайте під рукою Глосарій, якщо зустрінете незнайомий термін.
Наступний крок
Перший маршрут: додайте власний GET /api/hello і побачите всі шари на практиці.